detached 93fc814

fix(runtime): harden reload and cleanup reporting

commit
93fc81411d174d081912a5d12b70f655a0a63303
author
brice · · 2026-05-23 10:10 UTC
parent
78bf949
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +42 -16
M docs/config/server.md +1 -0
@@ -438,6 +438,7 @@ gordon_domain = "gordon.mydomain.com"
438438 ```
439439
440440 With firewall port forwarding:
441+
441442 ```bash
442443 sudo firewall-cmd --permanent --add-forward-port=port=80:proto=tcp:toport=8088
443444 sudo firewall-cmd --reload
@@ -438,6 +438,7 @@ gordon_domain = "gordon.mydomain.com"
438 ```438 ```
439 439
440 With firewall port forwarding:440 With firewall port forwarding:
441+
441 ```bash442 ```bash
442 sudo firewall-cmd --permanent --add-forward-port=port=80:proto=tcp:toport=8088443 sudo firewall-cmd --permanent --add-forward-port=port=80:proto=tcp:toport=8088
443 sudo firewall-cmd --reload444 sudo firewall-cmd --reload
M docs/index.md +0 -1
@@ -78,7 +78,6 @@ Gordon runs on your VPS and provides:
7878 - Linux VPS (Ubuntu/Debian recommended)
7979 - Docker or Podman runtime
8080 - Domain pointing to your server
81-- Domain pointing to your server
8281 - Optional Cloudflare account or other reverse proxy for edge TLS
8382
8483 > **Note:** Gordon can run behind Cloudflare/nginx, or terminate TLS directly via its internal CA (enabled by default on `server.tls_port`). Set `tls_port = 0` to disable.
@@ -78,7 +78,6 @@ Gordon runs on your VPS and provides:
78 - Linux VPS (Ubuntu/Debian recommended)78 - Linux VPS (Ubuntu/Debian recommended)
79 - Docker or Podman runtime79 - Docker or Podman runtime
80 - Domain pointing to your server80 - Domain pointing to your server
81-- Domain pointing to your server
82 - Optional Cloudflare account or other reverse proxy for edge TLS81 - Optional Cloudflare account or other reverse proxy for edge TLS
83 82
84 > **Note:** Gordon can run behind Cloudflare/nginx, or terminate TLS directly via its internal CA (enabled by default on `server.tls_port`). Set `tls_port = 0` to disable.83 > **Note:** Gordon can run behind Cloudflare/nginx, or terminate TLS directly via its internal CA (enabled by default on `server.tls_port`). Set `tls_port = 0` to disable.
M internal/adapters/in/cli/auth.go +1 -1
@@ -238,7 +238,7 @@ Examples:
238238 func resolveRequiredRemote(flagToken string) (*remote.ResolvedRemote, error) {
239239 resolved, isRemote, err := remote.ResolveStrict(remoteFlag, flagToken, insecureTLSFlag)
240240 if err != nil {
241- return nil, err
241+ return nil, fmt.Errorf("failed to resolve remote %q: %w", remoteFlag, err)
242242 }
243243 if !isRemote {
244244 return nil, fmt.Errorf("no remote specified. Use --remote or 'gordon remotes use <name>'")
@@ -238,7 +238,7 @@ Examples:
238 func resolveRequiredRemote(flagToken string) (*remote.ResolvedRemote, error) {238 func resolveRequiredRemote(flagToken string) (*remote.ResolvedRemote, error) {
239 resolved, isRemote, err := remote.ResolveStrict(remoteFlag, flagToken, insecureTLSFlag)239 resolved, isRemote, err := remote.ResolveStrict(remoteFlag, flagToken, insecureTLSFlag)
240 if err != nil {240 if err != nil {
241- return nil, err241+ return nil, fmt.Errorf("failed to resolve remote %q: %w", remoteFlag, err)
242 }242 }
243 if !isRemote {243 if !isRemote {
244 return nil, fmt.Errorf("no remote specified. Use --remote or 'gordon remotes use <name>'")244 return nil, fmt.Errorf("no remote specified. Use --remote or 'gordon remotes use <name>'")
M internal/adapters/in/cli/routes.go +21 -1
@@ -1479,7 +1479,7 @@ func runRoutePurge(ctx context.Context, cp ControlPlane, domainName string, opts
14791479 if diag.Runtime != nil && diag.Runtime.ContainerID != "" {
14801480 report.OrphanedEntities = append(report.OrphanedEntities, domain.CleanupOrphanedEntity{Kind: "route_container", ID: diag.Runtime.ContainerID, Status: diag.Runtime.ContainerStatus, Reason: "route runtime state matched purge target"})
14811481 }
1482- report.OrphanedEntities = append(report.OrphanedEntities, diag.OrphanedEntities...)
1482+ report.OrphanedEntities = appendUniqueCleanupEntities(report.OrphanedEntities, diag.OrphanedEntities...)
14831483 if opts.Volumes {
14841484 for _, volume := range diag.Volumes {
14851485 report.PreservedVolumes = append(report.PreservedVolumes, domain.CleanupVolume{Name: volume.Name, Reason: "volume purge requires explicit --volumes and runtime support"})
@@ -1516,6 +1516,26 @@ func runRoutePurge(ctx context.Context, cp ControlPlane, domainName string, opts
15161516 return report, nil
15171517 }
15181518
1519+func appendUniqueCleanupEntities(existing []domain.CleanupOrphanedEntity, candidates ...domain.CleanupOrphanedEntity) []domain.CleanupOrphanedEntity {
1520+ seen := make(map[string]struct{}, len(existing))
1521+ for _, entity := range existing {
1522+ seen[cleanupEntityKey(entity)] = struct{}{}
1523+ }
1524+ for _, entity := range candidates {
1525+ key := cleanupEntityKey(entity)
1526+ if _, ok := seen[key]; ok {
1527+ continue
1528+ }
1529+ existing = append(existing, entity)
1530+ seen[key] = struct{}{}
1531+ }
1532+ return existing
1533+}
1534+
1535+func cleanupEntityKey(entity domain.CleanupOrphanedEntity) string {
1536+ return entity.Kind + "|" + entity.ID + "|" + entity.Name
1537+}
1538+
15191539 func writeRoutePurgeText(out io.Writer, report *domain.CleanupReport, force bool) error {
15201540 mode := "Purge dry-run"
15211541 if force {
@@ -1479,7 +1479,7 @@ func runRoutePurge(ctx context.Context, cp ControlPlane, domainName string, opts
1479 if diag.Runtime != nil && diag.Runtime.ContainerID != "" {1479 if diag.Runtime != nil && diag.Runtime.ContainerID != "" {
1480 report.OrphanedEntities = append(report.OrphanedEntities, domain.CleanupOrphanedEntity{Kind: "route_container", ID: diag.Runtime.ContainerID, Status: diag.Runtime.ContainerStatus, Reason: "route runtime state matched purge target"})1480 report.OrphanedEntities = append(report.OrphanedEntities, domain.CleanupOrphanedEntity{Kind: "route_container", ID: diag.Runtime.ContainerID, Status: diag.Runtime.ContainerStatus, Reason: "route runtime state matched purge target"})
1481 }1481 }
1482- report.OrphanedEntities = append(report.OrphanedEntities, diag.OrphanedEntities...)1482+ report.OrphanedEntities = appendUniqueCleanupEntities(report.OrphanedEntities, diag.OrphanedEntities...)
1483 if opts.Volumes {1483 if opts.Volumes {
1484 for _, volume := range diag.Volumes {1484 for _, volume := range diag.Volumes {
1485 report.PreservedVolumes = append(report.PreservedVolumes, domain.CleanupVolume{Name: volume.Name, Reason: "volume purge requires explicit --volumes and runtime support"})1485 report.PreservedVolumes = append(report.PreservedVolumes, domain.CleanupVolume{Name: volume.Name, Reason: "volume purge requires explicit --volumes and runtime support"})
@@ -1516,6 +1516,26 @@ func runRoutePurge(ctx context.Context, cp ControlPlane, domainName string, opts
1516 return report, nil1516 return report, nil
1517 }1517 }
1518 1518
1519+func appendUniqueCleanupEntities(existing []domain.CleanupOrphanedEntity, candidates ...domain.CleanupOrphanedEntity) []domain.CleanupOrphanedEntity {
1520+ seen := make(map[string]struct{}, len(existing))
1521+ for _, entity := range existing {
1522+ seen[cleanupEntityKey(entity)] = struct{}{}
1523+ }
1524+ for _, entity := range candidates {
1525+ key := cleanupEntityKey(entity)
1526+ if _, ok := seen[key]; ok {
1527+ continue
1528+ }
1529+ existing = append(existing, entity)
1530+ seen[key] = struct{}{}
1531+ }
1532+ return existing
1533+}
1534+
1535+func cleanupEntityKey(entity domain.CleanupOrphanedEntity) string {
1536+ return entity.Kind + "|" + entity.ID + "|" + entity.Name
1537+}
1538+
1519 func writeRoutePurgeText(out io.Writer, report *domain.CleanupReport, force bool) error {1539 func writeRoutePurgeText(out io.Writer, report *domain.CleanupReport, force bool) error {
1520 mode := "Purge dry-run"1540 mode := "Purge dry-run"
1521 if force {1541 if force {
M internal/app/run.go +9 -9
@@ -701,8 +701,8 @@ func (si *serviceInit) registerReloadCoordinatorHooks() {
701701 return
702702 }
703703
704- si.svc.reloadCoordinator.SetContainerConfigApplier(func(reloadCfg Config) error {
705- containerCfg, err := buildContainerServiceConfig(si.ctx, si.v, reloadCfg, si.svc, si.log)
704+ si.svc.reloadCoordinator.SetContainerConfigApplier(func(reloadCtx context.Context, reloadCfg Config) error {
705+ containerCfg, err := buildContainerServiceConfig(reloadCtx, si.v, reloadCfg, si.svc, si.log)
706706 if err != nil {
707707 return err
708708 }
@@ -1627,7 +1627,7 @@ type reloadCoordinator struct {
16271627 configSvc configReloader
16281628 v *viper.Viper
16291629 proxySvc proxyConfigUpdater
1630- applyContainerConfig func(Config) error
1630+ applyContainerConfig func(context.Context, Config) error
16311631 registryLimits interface {
16321632 UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64)
16331633 }
@@ -1659,7 +1659,7 @@ func (c *reloadCoordinator) SetRegistryLimits(limits interface {
16591659 c.registryLimits = limits
16601660 }
16611661
1662-func (c *reloadCoordinator) SetContainerConfigApplier(apply func(Config) error) {
1662+func (c *reloadCoordinator) SetContainerConfigApplier(apply func(context.Context, Config) error) {
16631663 c.mu.Lock()
16641664 defer c.mu.Unlock()
16651665 c.applyContainerConfig = apply
@@ -1713,16 +1713,16 @@ func (c *reloadCoordinator) applyLoadedConfig(ctx context.Context, now time.Time
17131713 return fmt.Errorf("failed to parse proxy config on reload: %w", err)
17141714 }
17151715
1716- c.proxySvc.UpdateConfig(reloadedProxy.proxyConfig)
1717- if c.registryLimits != nil {
1718- c.registryLimits.UpdateBlobLimits(reloadedProxy.maxBlobChunkSize, reloadedProxy.maxBlobSize)
1719- }
17201716 if c.applyContainerConfig != nil {
1721- if err := c.applyContainerConfig(reloadCfg); err != nil {
1717+ if err := c.applyContainerConfig(ctx, reloadCfg); err != nil {
17221718 c.log.Error().Err(err).Msg("failed to apply container config on reload")
17231719 return fmt.Errorf("failed to apply container config on reload: %w", err)
17241720 }
17251721 }
1722+ c.proxySvc.UpdateConfig(reloadedProxy.proxyConfig)
1723+ if c.registryLimits != nil {
1724+ c.registryLimits.UpdateBlobLimits(reloadedProxy.maxBlobChunkSize, reloadedProxy.maxBlobSize)
1725+ }
17261726
17271727 if c.eventBus != nil {
17281728 if err := c.eventBus.Publish(domain.EventConfigReload, nil); err != nil {
@@ -701,8 +701,8 @@ func (si *serviceInit) registerReloadCoordinatorHooks() {
701 return701 return
702 }702 }
703 703
704- si.svc.reloadCoordinator.SetContainerConfigApplier(func(reloadCfg Config) error {704+ si.svc.reloadCoordinator.SetContainerConfigApplier(func(reloadCtx context.Context, reloadCfg Config) error {
705- containerCfg, err := buildContainerServiceConfig(si.ctx, si.v, reloadCfg, si.svc, si.log)705+ containerCfg, err := buildContainerServiceConfig(reloadCtx, si.v, reloadCfg, si.svc, si.log)
706 if err != nil {706 if err != nil {
707 return err707 return err
708 }708 }
@@ -1627,7 +1627,7 @@ type reloadCoordinator struct {
1627 configSvc configReloader1627 configSvc configReloader
1628 v *viper.Viper1628 v *viper.Viper
1629 proxySvc proxyConfigUpdater1629 proxySvc proxyConfigUpdater
1630- applyContainerConfig func(Config) error1630+ applyContainerConfig func(context.Context, Config) error
1631 registryLimits interface {1631 registryLimits interface {
1632 UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64)1632 UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64)
1633 }1633 }
@@ -1659,7 +1659,7 @@ func (c *reloadCoordinator) SetRegistryLimits(limits interface {
1659 c.registryLimits = limits1659 c.registryLimits = limits
1660 }1660 }
1661 1661
1662-func (c *reloadCoordinator) SetContainerConfigApplier(apply func(Config) error) {1662+func (c *reloadCoordinator) SetContainerConfigApplier(apply func(context.Context, Config) error) {
1663 c.mu.Lock()1663 c.mu.Lock()
1664 defer c.mu.Unlock()1664 defer c.mu.Unlock()
1665 c.applyContainerConfig = apply1665 c.applyContainerConfig = apply
@@ -1713,16 +1713,16 @@ func (c *reloadCoordinator) applyLoadedConfig(ctx context.Context, now time.Time
1713 return fmt.Errorf("failed to parse proxy config on reload: %w", err)1713 return fmt.Errorf("failed to parse proxy config on reload: %w", err)
1714 }1714 }
1715 1715
1716- c.proxySvc.UpdateConfig(reloadedProxy.proxyConfig)
1717- if c.registryLimits != nil {
1718- c.registryLimits.UpdateBlobLimits(reloadedProxy.maxBlobChunkSize, reloadedProxy.maxBlobSize)
1719- }
1720 if c.applyContainerConfig != nil {1716 if c.applyContainerConfig != nil {
1721- if err := c.applyContainerConfig(reloadCfg); err != nil {1717+ if err := c.applyContainerConfig(ctx, reloadCfg); err != nil {
1722 c.log.Error().Err(err).Msg("failed to apply container config on reload")1718 c.log.Error().Err(err).Msg("failed to apply container config on reload")
1723 return fmt.Errorf("failed to apply container config on reload: %w", err)1719 return fmt.Errorf("failed to apply container config on reload: %w", err)
1724 }1720 }
1725 }1721 }
1722+ c.proxySvc.UpdateConfig(reloadedProxy.proxyConfig)
1723+ if c.registryLimits != nil {
1724+ c.registryLimits.UpdateBlobLimits(reloadedProxy.maxBlobChunkSize, reloadedProxy.maxBlobSize)
1725+ }
1726 1726
1727 if c.eventBus != nil {1727 if c.eventBus != nil {
1728 if err := c.eventBus.Publish(domain.EventConfigReload, nil); err != nil {1728 if err := c.eventBus.Publish(domain.EventConfigReload, nil); err != nil {
M internal/app/run_reload_test.go +4 -2
@@ -116,11 +116,13 @@ func (r *registryLimitsRecorder) UpdateBlobLimits(maxBlobChunkSize, maxBlobSize
116116
117117 type containerConfigApplyRecorder struct {
118118 calls int
119+ ctx context.Context
119120 cfg Config
120121 }
121122
122-func (r *containerConfigApplyRecorder) Apply(cfg Config) error {
123+func (r *containerConfigApplyRecorder) Apply(ctx context.Context, cfg Config) error {
123124 r.calls++
125+ r.ctx = ctx
124126 r.cfg = cfg
125127 return nil
126128 }
@@ -256,7 +258,7 @@ func TestServiceInit_RegisterReloadCoordinatorHooks_WiresContainerConfigApplier(
256258 reloadCfg.Server.RegistryPort = 5000
257259 reloadCfg.Images.AllowedRegistries = []string{"docker.io"}
258260
259- require.NoError(t, si.svc.reloadCoordinator.applyContainerConfig(reloadCfg))
261+ require.NoError(t, si.svc.reloadCoordinator.applyContainerConfig(ctx, reloadCfg))
260262 }
261263 func TestReloadCoordinator_DebouncesRepeatedWatchCallbacks(t *testing.T) {
262264 ctx := context.Background()
@@ -116,11 +116,13 @@ func (r *registryLimitsRecorder) UpdateBlobLimits(maxBlobChunkSize, maxBlobSize
116 116
117 type containerConfigApplyRecorder struct {117 type containerConfigApplyRecorder struct {
118 calls int118 calls int
119+ ctx context.Context
119 cfg Config120 cfg Config
120 }121 }
121 122
122-func (r *containerConfigApplyRecorder) Apply(cfg Config) error {123+func (r *containerConfigApplyRecorder) Apply(ctx context.Context, cfg Config) error {
123 r.calls++124 r.calls++
125+ r.ctx = ctx
124 r.cfg = cfg126 r.cfg = cfg
125 return nil127 return nil
126 }128 }
@@ -256,7 +258,7 @@ func TestServiceInit_RegisterReloadCoordinatorHooks_WiresContainerConfigApplier(
256 reloadCfg.Server.RegistryPort = 5000258 reloadCfg.Server.RegistryPort = 5000
257 reloadCfg.Images.AllowedRegistries = []string{"docker.io"}259 reloadCfg.Images.AllowedRegistries = []string{"docker.io"}
258 260
259- require.NoError(t, si.svc.reloadCoordinator.applyContainerConfig(reloadCfg))261+ require.NoError(t, si.svc.reloadCoordinator.applyContainerConfig(ctx, reloadCfg))
260 }262 }
261 func TestReloadCoordinator_DebouncesRepeatedWatchCallbacks(t *testing.T) {263 func TestReloadCoordinator_DebouncesRepeatedWatchCallbacks(t *testing.T) {
262 ctx := context.Background()264 ctx := context.Background()
M internal/usecase/publictls/service_test.go +6 -2
@@ -663,8 +663,12 @@ func TestServiceReconcile_SerializesConcurrentRuns(t *testing.T) {
663663 t.Fatal("first reconcile did not start certificate obtain")
664664 }
665665
666- go func() { errCh <- svc.Reconcile(ctx) }()
667- time.Sleep(50 * time.Millisecond)
666+ secondStarted := make(chan struct{})
667+ go func() {
668+ close(secondStarted)
669+ errCh <- svc.Reconcile(ctx)
670+ }()
671+ <-secondStarted
668672 close(releaseObtain)
669673
670674 require.NoError(t, <-errCh)
@@ -663,8 +663,12 @@ func TestServiceReconcile_SerializesConcurrentRuns(t *testing.T) {
663 t.Fatal("first reconcile did not start certificate obtain")663 t.Fatal("first reconcile did not start certificate obtain")
664 }664 }
665 665
666- go func() { errCh <- svc.Reconcile(ctx) }()666+ secondStarted := make(chan struct{})
667- time.Sleep(50 * time.Millisecond)667+ go func() {
668+ close(secondStarted)
669+ errCh <- svc.Reconcile(ctx)
670+ }()
671+ <-secondStarted
668 close(releaseObtain)672 close(releaseObtain)
669 673
670 require.NoError(t, <-errCh)674 require.NoError(t, <-errCh)

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