detached 638943e

Merge pull request #90 from bnema/fix/registry-shutdown-resilience

fix: registry push resilience during Gordon restart
commit
638943e8476a88218f55cf53b0532ae736fc7cd0
author
bnema · · 2026-03-01 14:02 UTC
committer
GitHub · · 2026-03-01 14:02 UTC
merge of
d786451 + a00b616 · diff against the first parent
signature
unsigned
4 files · +122 -15
M internal/app/migrate_env_test.go +6 -3
@@ -110,11 +110,14 @@ func TestMigrateAttachmentEnvFile(t *testing.T) {
110110 require.NoError(t, err)
111111
112112 containerName := fmt.Sprintf("gitea-postgres-%d", time.Now().UnixNano())
113+ envFileName := "gordon-" + containerName + ".env"
114+ // extractContainerNameFromAttachmentFile strips the .env suffix, so the
115+ // stored name includes the "gordon-" prefix from the filename.
116+ storedContainerName := extractContainerNameFromAttachmentFile(envFileName)
113117 keys := []string{"POSTGRES_USER", "POSTGRES_PASSWORD"}
114- defer cleanupPassAttachment(t, containerName, keys)
118+ defer cleanupPassAttachment(t, storedContainerName, keys)
115119
116120 envContent := "POSTGRES_USER=gitea\nPOSTGRES_PASSWORD=secret123\n"
117- envFileName := "gordon-" + containerName + ".env"
118121 envFilePath := filepath.Join(tmpDir, envFileName)
119122 err = os.WriteFile(envFilePath, []byte(envContent), 0600)
120123 require.NoError(t, err)
@@ -129,7 +132,7 @@ func TestMigrateAttachmentEnvFile(t *testing.T) {
129132 _, err = os.Stat(migratedPath)
130133 assert.NoError(t, err, ".env.migrated file should exist")
131134
132- values, err := store.GetAllAttachment(containerName)
135+ values, err := store.GetAllAttachment(storedContainerName)
133136 require.NoError(t, err)
134137 assert.Equal(t, "gitea", values["POSTGRES_USER"])
135138 assert.Equal(t, "secret123", values["POSTGRES_PASSWORD"])
@@ -110,11 +110,14 @@ func TestMigrateAttachmentEnvFile(t *testing.T) {
110 require.NoError(t, err)110 require.NoError(t, err)
111 111
112 containerName := fmt.Sprintf("gitea-postgres-%d", time.Now().UnixNano())112 containerName := fmt.Sprintf("gitea-postgres-%d", time.Now().UnixNano())
113+ envFileName := "gordon-" + containerName + ".env"
114+ // extractContainerNameFromAttachmentFile strips the .env suffix, so the
115+ // stored name includes the "gordon-" prefix from the filename.
116+ storedContainerName := extractContainerNameFromAttachmentFile(envFileName)
113 keys := []string{"POSTGRES_USER", "POSTGRES_PASSWORD"}117 keys := []string{"POSTGRES_USER", "POSTGRES_PASSWORD"}
114- defer cleanupPassAttachment(t, containerName, keys)118+ defer cleanupPassAttachment(t, storedContainerName, keys)
115 119
116 envContent := "POSTGRES_USER=gitea\nPOSTGRES_PASSWORD=secret123\n"120 envContent := "POSTGRES_USER=gitea\nPOSTGRES_PASSWORD=secret123\n"
117- envFileName := "gordon-" + containerName + ".env"
118 envFilePath := filepath.Join(tmpDir, envFileName)121 envFilePath := filepath.Join(tmpDir, envFileName)
119 err = os.WriteFile(envFilePath, []byte(envContent), 0600)122 err = os.WriteFile(envFilePath, []byte(envContent), 0600)
120 require.NoError(t, err)123 require.NoError(t, err)
@@ -129,7 +132,7 @@ func TestMigrateAttachmentEnvFile(t *testing.T) {
129 _, err = os.Stat(migratedPath)132 _, err = os.Stat(migratedPath)
130 assert.NoError(t, err, ".env.migrated file should exist")133 assert.NoError(t, err, ".env.migrated file should exist")
131 134
132- values, err := store.GetAllAttachment(containerName)135+ values, err := store.GetAllAttachment(storedContainerName)
133 require.NoError(t, err)136 require.NoError(t, err)
134 assert.Equal(t, "gitea", values["POSTGRES_USER"])137 assert.Equal(t, "gitea", values["POSTGRES_USER"])
135 assert.Equal(t, "secret123", values["POSTGRES_PASSWORD"])138 assert.Equal(t, "secret123", values["POSTGRES_PASSWORD"])
M internal/app/run.go +19 -3
@@ -1750,7 +1750,7 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, registryHandler
17501750 syncAndAutoStart(ctx, svc, log)
17511751
17521752 waitForShutdown(ctx, errChan, reloadChan, deployChan, eventBus, log)
1753- gracefulShutdown(registrySrv, proxySrv, tlsSrv, containerSvc, log)
1753+ gracefulShutdown(registrySrv, proxySrv, tlsSrv, containerSvc, svc.proxySvc, log)
17541754 return nil
17551755 }
17561756
@@ -1950,13 +1950,14 @@ func waitForShutdown(ctx context.Context, errChan <-chan error, reloadChan, depl
19501950
19511951 // gracefulShutdown stops HTTP servers with a 30s timeout, then shuts down
19521952 // the container service and cleans up runtime files.
1953-func gracefulShutdown(registrySrv, proxySrv, tlsSrv *http.Server, containerSvc *container.Service, log zerowrap.Logger) {
1953+func gracefulShutdown(registrySrv, proxySrv, tlsSrv *http.Server, containerSvc *container.Service, proxySvc *proxy.Service, log zerowrap.Logger) {
19541954 log.Info().Msg("shutting down Gordon...")
19551955
19561956 shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
19571957 defer shutdownCancel()
19581958
1959- for _, srv := range []*http.Server{registrySrv, proxySrv, tlsSrv} {
1959+ // Phase 1: Stop ingress frontends (TLS, then proxy) — no new traffic accepted
1960+ for _, srv := range []*http.Server{tlsSrv, proxySrv} {
19601961 if srv == nil {
19611962 continue
19621963 }
@@ -1965,6 +1966,21 @@ func gracefulShutdown(registrySrv, proxySrv, tlsSrv *http.Server, containerSvc *
19651966 }
19661967 }
19671968
1969+ // Phase 2: Drain in-flight registry push sessions before stopping the backend
1970+ if proxySvc != nil {
1971+ log.Info().Msg("draining in-flight registry requests...")
1972+ if drained := proxySvc.DrainRegistryInFlight(25 * time.Second); !drained {
1973+ log.Warn().Int64("in_flight", proxySvc.RegistryInFlight()).Msg("registry drain timed out; some in-flight pushes may be interrupted")
1974+ }
1975+ }
1976+
1977+ // Phase 3: Stop the registry backend
1978+ if registrySrv != nil {
1979+ if err := registrySrv.Shutdown(shutdownCtx); err != nil {
1980+ log.Warn().Err(err).Str("addr", registrySrv.Addr).Msg("server shutdown error")
1981+ }
1982+ }
1983+
19681984 containerSvc.StopMonitor()
19691985
19701986 if err := containerSvc.Shutdown(shutdownCtx); err != nil {
@@ -1750,7 +1750,7 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, registryHandler
1750 syncAndAutoStart(ctx, svc, log)1750 syncAndAutoStart(ctx, svc, log)
1751 1751
1752 waitForShutdown(ctx, errChan, reloadChan, deployChan, eventBus, log)1752 waitForShutdown(ctx, errChan, reloadChan, deployChan, eventBus, log)
1753- gracefulShutdown(registrySrv, proxySrv, tlsSrv, containerSvc, log)1753+ gracefulShutdown(registrySrv, proxySrv, tlsSrv, containerSvc, svc.proxySvc, log)
1754 return nil1754 return nil
1755 }1755 }
1756 1756
@@ -1950,13 +1950,14 @@ func waitForShutdown(ctx context.Context, errChan <-chan error, reloadChan, depl
1950 1950
1951 // gracefulShutdown stops HTTP servers with a 30s timeout, then shuts down1951 // gracefulShutdown stops HTTP servers with a 30s timeout, then shuts down
1952 // the container service and cleans up runtime files.1952 // the container service and cleans up runtime files.
1953-func gracefulShutdown(registrySrv, proxySrv, tlsSrv *http.Server, containerSvc *container.Service, log zerowrap.Logger) {1953+func gracefulShutdown(registrySrv, proxySrv, tlsSrv *http.Server, containerSvc *container.Service, proxySvc *proxy.Service, log zerowrap.Logger) {
1954 log.Info().Msg("shutting down Gordon...")1954 log.Info().Msg("shutting down Gordon...")
1955 1955
1956 shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)1956 shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
1957 defer shutdownCancel()1957 defer shutdownCancel()
1958 1958
1959- for _, srv := range []*http.Server{registrySrv, proxySrv, tlsSrv} {1959+ // Phase 1: Stop ingress frontends (TLS, then proxy) — no new traffic accepted
1960+ for _, srv := range []*http.Server{tlsSrv, proxySrv} {
1960 if srv == nil {1961 if srv == nil {
1961 continue1962 continue
1962 }1963 }
@@ -1965,6 +1966,21 @@ func gracefulShutdown(registrySrv, proxySrv, tlsSrv *http.Server, containerSvc *
1965 }1966 }
1966 }1967 }
1967 1968
1969+ // Phase 2: Drain in-flight registry push sessions before stopping the backend
1970+ if proxySvc != nil {
1971+ log.Info().Msg("draining in-flight registry requests...")
1972+ if drained := proxySvc.DrainRegistryInFlight(25 * time.Second); !drained {
1973+ log.Warn().Int64("in_flight", proxySvc.RegistryInFlight()).Msg("registry drain timed out; some in-flight pushes may be interrupted")
1974+ }
1975+ }
1976+
1977+ // Phase 3: Stop the registry backend
1978+ if registrySrv != nil {
1979+ if err := registrySrv.Shutdown(shutdownCtx); err != nil {
1980+ log.Warn().Err(err).Str("addr", registrySrv.Addr).Msg("server shutdown error")
1981+ }
1982+ }
1983+
1968 containerSvc.StopMonitor()1984 containerSvc.StopMonitor()
1969 1985
1970 if err := containerSvc.Shutdown(shutdownCtx); err != nil {1986 if err := containerSvc.Shutdown(shutdownCtx); err != nil {
M internal/usecase/proxy/service.go +33 -9
@@ -70,15 +70,16 @@ type Config struct {
7070
7171 // Service implements the ProxyService interface.
7272 type Service struct {
73- runtime out.ContainerRuntime
74- containerSvc in.ContainerService
75- configSvc in.ConfigService
76- config Config
77- targets map[string]*domain.ProxyTarget
78- mu sync.RWMutex
79- activeConns atomic.Int64 // Atomic counter for concurrent connection limiting
80- inFlight map[string]int
81- inFlightMu sync.Mutex
73+ runtime out.ContainerRuntime
74+ containerSvc in.ContainerService
75+ configSvc in.ConfigService
76+ config Config
77+ targets map[string]*domain.ProxyTarget
78+ mu sync.RWMutex
79+ activeConns atomic.Int64 // Atomic counter for concurrent connection limiting
80+ inFlight map[string]int
81+ inFlightMu sync.Mutex
82+ registryInFlight atomic.Int64 // active registry proxy requests, for graceful drain
8283 }
8384
8485 // NewService creates a new proxy service.
@@ -574,6 +575,26 @@ func (s *Service) trackInFlight(containerID string) func() {
574575 }
575576 }
576577
578+// RegistryInFlight returns the current count of active registry proxy requests.
579+func (s *Service) RegistryInFlight() int64 {
580+ return s.registryInFlight.Load()
581+}
582+
583+// DrainRegistryInFlight blocks until all in-flight registry proxy requests
584+// complete or the timeout elapses. Returns true if drained cleanly, false if
585+// timed out with requests still in flight. Call this before shutting down the
586+// registry server.
587+func (s *Service) DrainRegistryInFlight(timeout time.Duration) bool {
588+ deadline := time.Now().Add(timeout)
589+ for time.Now().Before(deadline) {
590+ if s.registryInFlight.Load() == 0 {
591+ return true
592+ }
593+ time.Sleep(10 * time.Millisecond)
594+ }
595+ return false
596+}
597+
577598 // proxyToRegistry forwards requests to the local registry HTTP server.
578599 // SECURITY: This uses http://localhost:{port} which is safe because the registry
579600 // runs on the same host and traffic never leaves the loopback interface. If Gordon
@@ -585,6 +606,9 @@ func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) {
585606 cfg := s.config
586607 s.mu.RUnlock()
587608
609+ s.registryInFlight.Add(1)
610+ defer s.registryInFlight.Add(-1)
611+
588612 log := zerowrap.FromCtx(r.Context())
589613
590614 targetURL, err := url.Parse(fmt.Sprintf("http://localhost:%d", cfg.RegistryPort))
@@ -70,15 +70,16 @@ type Config struct {
70 70
71 // Service implements the ProxyService interface.71 // Service implements the ProxyService interface.
72 type Service struct {72 type Service struct {
73- runtime out.ContainerRuntime73+ runtime out.ContainerRuntime
74- containerSvc in.ContainerService74+ containerSvc in.ContainerService
75- configSvc in.ConfigService75+ configSvc in.ConfigService
76- config Config76+ config Config
77- targets map[string]*domain.ProxyTarget77+ targets map[string]*domain.ProxyTarget
78- mu sync.RWMutex78+ mu sync.RWMutex
79- activeConns atomic.Int64 // Atomic counter for concurrent connection limiting79+ activeConns atomic.Int64 // Atomic counter for concurrent connection limiting
80- inFlight map[string]int80+ inFlight map[string]int
81- inFlightMu sync.Mutex81+ inFlightMu sync.Mutex
82+ registryInFlight atomic.Int64 // active registry proxy requests, for graceful drain
82 }83 }
83 84
84 // NewService creates a new proxy service.85 // NewService creates a new proxy service.
@@ -574,6 +575,26 @@ func (s *Service) trackInFlight(containerID string) func() {
574 }575 }
575 }576 }
576 577
578+// RegistryInFlight returns the current count of active registry proxy requests.
579+func (s *Service) RegistryInFlight() int64 {
580+ return s.registryInFlight.Load()
581+}
582+
583+// DrainRegistryInFlight blocks until all in-flight registry proxy requests
584+// complete or the timeout elapses. Returns true if drained cleanly, false if
585+// timed out with requests still in flight. Call this before shutting down the
586+// registry server.
587+func (s *Service) DrainRegistryInFlight(timeout time.Duration) bool {
588+ deadline := time.Now().Add(timeout)
589+ for time.Now().Before(deadline) {
590+ if s.registryInFlight.Load() == 0 {
591+ return true
592+ }
593+ time.Sleep(10 * time.Millisecond)
594+ }
595+ return false
596+}
597+
577 // proxyToRegistry forwards requests to the local registry HTTP server.598 // proxyToRegistry forwards requests to the local registry HTTP server.
578 // SECURITY: This uses http://localhost:{port} which is safe because the registry599 // SECURITY: This uses http://localhost:{port} which is safe because the registry
579 // runs on the same host and traffic never leaves the loopback interface. If Gordon600 // runs on the same host and traffic never leaves the loopback interface. If Gordon
@@ -585,6 +606,9 @@ func (s *Service) proxyToRegistry(w http.ResponseWriter, r *http.Request) {
585 cfg := s.config606 cfg := s.config
586 s.mu.RUnlock()607 s.mu.RUnlock()
587 608
609+ s.registryInFlight.Add(1)
610+ defer s.registryInFlight.Add(-1)
611+
588 log := zerowrap.FromCtx(r.Context())612 log := zerowrap.FromCtx(r.Context())
589 613
590 targetURL, err := url.Parse(fmt.Sprintf("http://localhost:%d", cfg.RegistryPort))614 targetURL, err := url.Parse(fmt.Sprintf("http://localhost:%d", cfg.RegistryPort))
M internal/usecase/proxy/service_test.go +64 -0
@@ -4,6 +4,7 @@ import (
44 "context"
55 "net/http/httptest"
66 "testing"
7+ "time"
78
89 "github.com/bnema/zerowrap"
910 "github.com/stretchr/testify/assert"
@@ -510,3 +511,66 @@ func TestServeHTTP_MaxBodySize(t *testing.T) {
510511 })
511512 }
512513 }
514+
515+func TestRegistryInFlightTracking(t *testing.T) {
516+ svc := &Service{
517+ inFlight: make(map[string]int),
518+ }
519+
520+ if got := svc.registryInFlight.Load(); got != 0 {
521+ t.Fatalf("expected 0 in-flight, got %d", got)
522+ }
523+
524+ svc.registryInFlight.Add(1)
525+ if got := svc.registryInFlight.Load(); got != 1 {
526+ t.Fatalf("expected 1 in-flight after Add, got %d", got)
527+ }
528+
529+ svc.registryInFlight.Add(-1)
530+ if got := svc.registryInFlight.Load(); got != 0 {
531+ t.Fatalf("expected 0 in-flight after release, got %d", got)
532+ }
533+}
534+
535+func TestDrainRegistryInFlight(t *testing.T) {
536+ svc := &Service{
537+ inFlight: make(map[string]int),
538+ }
539+
540+ svc.registryInFlight.Add(2)
541+
542+ done := make(chan struct{})
543+ go func() {
544+ drained := svc.DrainRegistryInFlight(50 * time.Millisecond)
545+ if !drained {
546+ // Signal failure via done channel by leaving it open — test will time out
547+ return
548+ }
549+ close(done)
550+ }()
551+
552+ time.Sleep(5 * time.Millisecond)
553+ svc.registryInFlight.Add(-1)
554+ svc.registryInFlight.Add(-1)
555+
556+ select {
557+ case <-done:
558+ // good — drained cleanly
559+ case <-time.After(500 * time.Millisecond):
560+ t.Fatal("DrainRegistryInFlight did not return true after requests completed")
561+ }
562+}
563+
564+func TestDrainRegistryInFlightTimeout(t *testing.T) {
565+ svc := &Service{
566+ inFlight: make(map[string]int),
567+ }
568+
569+ // Add a request and never release it
570+ svc.registryInFlight.Add(1)
571+
572+ drained := svc.DrainRegistryInFlight(30 * time.Millisecond)
573+ if drained {
574+ t.Fatal("expected DrainRegistryInFlight to return false on timeout, got true")
575+ }
576+}
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 "net/http/httptest"5 "net/http/httptest"
6 "testing"6 "testing"
7+ "time"
7 8
8 "github.com/bnema/zerowrap"9 "github.com/bnema/zerowrap"
9 "github.com/stretchr/testify/assert"10 "github.com/stretchr/testify/assert"
@@ -510,3 +511,66 @@ func TestServeHTTP_MaxBodySize(t *testing.T) {
510 })511 })
511 }512 }
512 }513 }
514+
515+func TestRegistryInFlightTracking(t *testing.T) {
516+ svc := &Service{
517+ inFlight: make(map[string]int),
518+ }
519+
520+ if got := svc.registryInFlight.Load(); got != 0 {
521+ t.Fatalf("expected 0 in-flight, got %d", got)
522+ }
523+
524+ svc.registryInFlight.Add(1)
525+ if got := svc.registryInFlight.Load(); got != 1 {
526+ t.Fatalf("expected 1 in-flight after Add, got %d", got)
527+ }
528+
529+ svc.registryInFlight.Add(-1)
530+ if got := svc.registryInFlight.Load(); got != 0 {
531+ t.Fatalf("expected 0 in-flight after release, got %d", got)
532+ }
533+}
534+
535+func TestDrainRegistryInFlight(t *testing.T) {
536+ svc := &Service{
537+ inFlight: make(map[string]int),
538+ }
539+
540+ svc.registryInFlight.Add(2)
541+
542+ done := make(chan struct{})
543+ go func() {
544+ drained := svc.DrainRegistryInFlight(50 * time.Millisecond)
545+ if !drained {
546+ // Signal failure via done channel by leaving it open — test will time out
547+ return
548+ }
549+ close(done)
550+ }()
551+
552+ time.Sleep(5 * time.Millisecond)
553+ svc.registryInFlight.Add(-1)
554+ svc.registryInFlight.Add(-1)
555+
556+ select {
557+ case <-done:
558+ // good — drained cleanly
559+ case <-time.After(500 * time.Millisecond):
560+ t.Fatal("DrainRegistryInFlight did not return true after requests completed")
561+ }
562+}
563+
564+func TestDrainRegistryInFlightTimeout(t *testing.T) {
565+ svc := &Service{
566+ inFlight: make(map[string]int),
567+ }
568+
569+ // Add a request and never release it
570+ svc.registryInFlight.Add(1)
571+
572+ drained := svc.DrainRegistryInFlight(30 * time.Millisecond)
573+ if drained {
574+ t.Fatal("expected DrainRegistryInFlight to return false on timeout, got true")
575+ }
576+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close