detached 22272ae

feat(app): wire admin API and auto-route handler

commit
22272ae9e2a0783acdd03b3f1ea842cbec1ad71c
author
bnema · · 2026-01-16 20:52 UTC
parent
1013c8d
signature
unsigned
2 files · +52 -11
M internal/adapters/in/http/middleware/logging.go +5 -0
@@ -47,6 +47,7 @@ func (rw *ResponseWriter) BytesWritten() int {
4747 }
4848
4949 // RequestLogger is a middleware that logs HTTP requests using zerowrap.
50+// It also attaches the logger to the request context for downstream handlers.
5051 func RequestLogger(log zerowrap.Logger) func(http.Handler) http.Handler {
5152 return func(next http.Handler) http.Handler {
5253 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -58,6 +59,10 @@ func RequestLogger(log zerowrap.Logger) func(http.Handler) http.Handler {
5859 // Get client IP (handle X-Forwarded-For and X-Real-IP headers)
5960 clientIP := getClientIP(r)
6061
62+ // Attach the logger to the request context for downstream handlers
63+ ctx := zerowrap.WithCtx(r.Context(), log)
64+ r = r.WithContext(ctx)
65+
6166 // Call the next handler
6267 next.ServeHTTP(rw, r)
6368
@@ -47,6 +47,7 @@ func (rw *ResponseWriter) BytesWritten() int {
47 }47 }
48 48
49 // RequestLogger is a middleware that logs HTTP requests using zerowrap.49 // RequestLogger is a middleware that logs HTTP requests using zerowrap.
50+// It also attaches the logger to the request context for downstream handlers.
50 func RequestLogger(log zerowrap.Logger) func(http.Handler) http.Handler {51 func RequestLogger(log zerowrap.Logger) func(http.Handler) http.Handler {
51 return func(next http.Handler) http.Handler {52 return func(next http.Handler) http.Handler {
52 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {53 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -58,6 +59,10 @@ func RequestLogger(log zerowrap.Logger) func(http.Handler) http.Handler {
58 // Get client IP (handle X-Forwarded-For and X-Real-IP headers)59 // Get client IP (handle X-Forwarded-For and X-Real-IP headers)
59 clientIP := getClientIP(r)60 clientIP := getClientIP(r)
60 61
62+ // Attach the logger to the request context for downstream handlers
63+ ctx := zerowrap.WithCtx(r.Context(), log)
64+ r = r.WithContext(ctx)
65+
61 // Call the next handler66 // Call the next handler
62 next.ServeHTTP(rw, r)67 next.ServeHTTP(rw, r)
63 68
M internal/app/run.go +47 -11
@@ -29,6 +29,7 @@ import (
2929 "gordon/internal/adapters/out/tokenstore"
3030
3131 // Adapters - Input
32+ "gordon/internal/adapters/in/http/admin"
3233 "gordon/internal/adapters/in/http/middleware"
3334 "gordon/internal/adapters/in/http/registry"
3435
@@ -108,8 +109,10 @@ type services struct {
108109 proxySvc *proxy.Service
109110 authSvc *auth.Service
110111 tokenHandler *registry.TokenHandler
112+ adminHandler *admin.Handler
111113 internalRegUser string
112114 internalRegPass string
115+ envDir string
113116 }
114117
115118 // Run initializes and starts the Gordon application.
@@ -145,7 +148,7 @@ func Run(ctx context.Context, configPath string) error {
145148 }
146149
147150 // Register event handlers
148- if err := registerEventHandlers(ctx, svc); err != nil {
151+ if err := registerEventHandlers(ctx, svc, cfg); err != nil {
149152 return err
150153 }
151154
@@ -275,6 +278,19 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
275278 svc.tokenHandler = registry.NewTokenHandler(svc.authSvc, internalAuth, log)
276279 }
277280
281+ // Determine env directory for admin API
282+ dataDir := cfg.Server.DataDir
283+ if dataDir == "" {
284+ dataDir = DefaultDataDir()
285+ }
286+ svc.envDir = cfg.Env.Dir
287+ if svc.envDir == "" {
288+ svc.envDir = filepath.Join(dataDir, "env")
289+ }
290+
291+ // Create admin handler for admin API
292+ svc.adminHandler = admin.NewHandler(svc.configSvc, svc.authSvc, svc.containerSvc, svc.eventBus, svc.envDir, log)
293+
278294 return svc, nil
279295 }
280296
@@ -636,6 +652,7 @@ func createContainerService(v *viper.Viper, cfg Config, svc *services) *containe
636652 RegistryPassword: cfg.RegistryAuth.Password,
637653 InternalRegistryUsername: svc.internalRegUser,
638654 InternalRegistryPassword: svc.internalRegPass,
655+ PullPolicy: v.GetString("deploy.pull_policy"),
639656 VolumeAutoCreate: v.GetBool("volumes.auto_create"),
640657 VolumePrefix: v.GetString("volumes.prefix"),
641658 VolumePreserve: v.GetBool("volumes.preserve"),
@@ -649,12 +666,19 @@ func createContainerService(v *viper.Viper, cfg Config, svc *services) *containe
649666 }
650667
651668 // registerEventHandlers registers all event handlers.
652-func registerEventHandlers(ctx context.Context, svc *services) error {
669+func registerEventHandlers(ctx context.Context, svc *services, cfg Config) error {
653670 imagePushedHandler := container.NewImagePushedHandler(ctx, svc.containerSvc, svc.configSvc)
654671 if err := svc.eventBus.Subscribe(imagePushedHandler); err != nil {
655672 return fmt.Errorf("failed to subscribe image pushed handler: %w", err)
656673 }
657674
675+ // Auto-route handler for creating routes from image labels
676+ autoRouteHandler := container.NewAutoRouteHandler(ctx, svc.configSvc, svc.containerSvc, svc.blobStorage, cfg.Server.RegistryDomain).
677+ WithEnvExtractor(svc.runtime, svc.envDir)
678+ if err := svc.eventBus.Subscribe(autoRouteHandler); err != nil {
679+ return fmt.Errorf("failed to subscribe auto-route handler: %w", err)
680+ }
681+
658682 configReloadHandler := container.NewConfigReloadHandler(ctx, svc.containerSvc, svc.configSvc)
659683 if err := svc.eventBus.Subscribe(configReloadHandler); err != nil {
660684 return fmt.Errorf("failed to subscribe config reload handler: %w", err)
@@ -680,6 +704,10 @@ func registerEventHandlers(ctx context.Context, svc *services) error {
680704 }
681705
682706 // setupConfigHotReload sets up Viper config hot reload.
707+// NOTE: This does NOT reload routes into memory. Routes are managed via API
708+// (AddRoute/UpdateRoute/RemoveRoute) and memory is the source of truth.
709+// The file watcher only updates proxy config and refreshes targets.
710+// Manual config file edits to routes require a server restart.
683711 func setupConfigHotReload(ctx context.Context, v *viper.Viper, svc *services, log zerowrap.Logger) {
684712 v.OnConfigChange(func(e fsnotify.Event) {
685713 log.Info().Str("file", e.Name).Msg("config file changed")
@@ -689,11 +717,7 @@ func setupConfigHotReload(ctx context.Context, v *viper.Viper, svc *services, lo
689717 return
690718 }
691719
692- if err := svc.configSvc.Load(ctx); err != nil {
693- log.Error().Err(err).Msg("failed to reload configuration")
694- return
695- }
696-
720+ // Update proxy config from viper (reads directly from viper, not memory)
697721 svc.proxySvc.UpdateConfig(proxy.Config{
698722 RegistryDomain: v.GetString("server.registry_domain"),
699723 RegistryPort: v.GetInt("server.registry_port"),
@@ -704,10 +728,7 @@ func setupConfigHotReload(ctx context.Context, v *viper.Viper, svc *services, lo
704728 log.Warn().Err(err).Msg("failed to refresh proxy targets")
705729 }
706730
707- // Publish config reload event to trigger container updates
708- if err := svc.eventBus.Publish(domain.EventConfigReload, nil); err != nil {
709- log.Error().Err(err).Msg("failed to publish config reload event")
710- }
731+ log.Debug().Msg("config hot reload complete (routes unchanged, memory is source of truth)")
711732 })
712733 v.WatchConfig()
713734 }
@@ -759,6 +780,20 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
759780 }
760781 registryMux.Handle("/v2/", registryWithMiddleware)
761782
783+ // Add admin API handler with auth middleware
784+ if svc.adminHandler != nil {
785+ adminMiddlewares := []func(http.Handler) http.Handler{
786+ middleware.PanicRecovery(log),
787+ middleware.RequestLogger(log),
788+ }
789+ // Add admin auth middleware if auth is enabled
790+ if svc.authSvc != nil {
791+ adminMiddlewares = append(adminMiddlewares, admin.AuthMiddleware(svc.authSvc, log))
792+ }
793+ adminWithMiddleware := middleware.Chain(adminMiddlewares...)(svc.adminHandler)
794+ registryMux.Handle("/admin/", adminWithMiddleware)
795+ }
796+
762797 proxyMiddlewares := []func(http.Handler) http.Handler{
763798 middleware.PanicRecovery(log),
764799 middleware.RequestLogger(log),
@@ -1063,6 +1098,7 @@ func loadConfig(v *viper.Viper, configPath string) error {
10631098 v.SetDefault("registry_auth.enabled", false)
10641099 v.SetDefault("registry_auth.type", "password")
10651100 v.SetDefault("registry_auth.token_expiry", "720h")
1101+ v.SetDefault("deploy.pull_policy", container.PullPolicyIfTagChanged)
10661102
10671103 ConfigureViper(v, configPath)
10681104
@@ -29,6 +29,7 @@ import (
29 "gordon/internal/adapters/out/tokenstore"29 "gordon/internal/adapters/out/tokenstore"
30 30
31 // Adapters - Input31 // Adapters - Input
32+ "gordon/internal/adapters/in/http/admin"
32 "gordon/internal/adapters/in/http/middleware"33 "gordon/internal/adapters/in/http/middleware"
33 "gordon/internal/adapters/in/http/registry"34 "gordon/internal/adapters/in/http/registry"
34 35
@@ -108,8 +109,10 @@ type services struct {
108 proxySvc *proxy.Service109 proxySvc *proxy.Service
109 authSvc *auth.Service110 authSvc *auth.Service
110 tokenHandler *registry.TokenHandler111 tokenHandler *registry.TokenHandler
112+ adminHandler *admin.Handler
111 internalRegUser string113 internalRegUser string
112 internalRegPass string114 internalRegPass string
115+ envDir string
113 }116 }
114 117
115 // Run initializes and starts the Gordon application.118 // Run initializes and starts the Gordon application.
@@ -145,7 +148,7 @@ func Run(ctx context.Context, configPath string) error {
145 }148 }
146 149
147 // Register event handlers150 // Register event handlers
148- if err := registerEventHandlers(ctx, svc); err != nil {151+ if err := registerEventHandlers(ctx, svc, cfg); err != nil {
149 return err152 return err
150 }153 }
151 154
@@ -275,6 +278,19 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
275 svc.tokenHandler = registry.NewTokenHandler(svc.authSvc, internalAuth, log)278 svc.tokenHandler = registry.NewTokenHandler(svc.authSvc, internalAuth, log)
276 }279 }
277 280
281+ // Determine env directory for admin API
282+ dataDir := cfg.Server.DataDir
283+ if dataDir == "" {
284+ dataDir = DefaultDataDir()
285+ }
286+ svc.envDir = cfg.Env.Dir
287+ if svc.envDir == "" {
288+ svc.envDir = filepath.Join(dataDir, "env")
289+ }
290+
291+ // Create admin handler for admin API
292+ svc.adminHandler = admin.NewHandler(svc.configSvc, svc.authSvc, svc.containerSvc, svc.eventBus, svc.envDir, log)
293+
278 return svc, nil294 return svc, nil
279 }295 }
280 296
@@ -636,6 +652,7 @@ func createContainerService(v *viper.Viper, cfg Config, svc *services) *containe
636 RegistryPassword: cfg.RegistryAuth.Password,652 RegistryPassword: cfg.RegistryAuth.Password,
637 InternalRegistryUsername: svc.internalRegUser,653 InternalRegistryUsername: svc.internalRegUser,
638 InternalRegistryPassword: svc.internalRegPass,654 InternalRegistryPassword: svc.internalRegPass,
655+ PullPolicy: v.GetString("deploy.pull_policy"),
639 VolumeAutoCreate: v.GetBool("volumes.auto_create"),656 VolumeAutoCreate: v.GetBool("volumes.auto_create"),
640 VolumePrefix: v.GetString("volumes.prefix"),657 VolumePrefix: v.GetString("volumes.prefix"),
641 VolumePreserve: v.GetBool("volumes.preserve"),658 VolumePreserve: v.GetBool("volumes.preserve"),
@@ -649,12 +666,19 @@ func createContainerService(v *viper.Viper, cfg Config, svc *services) *containe
649 }666 }
650 667
651 // registerEventHandlers registers all event handlers.668 // registerEventHandlers registers all event handlers.
652-func registerEventHandlers(ctx context.Context, svc *services) error {669+func registerEventHandlers(ctx context.Context, svc *services, cfg Config) error {
653 imagePushedHandler := container.NewImagePushedHandler(ctx, svc.containerSvc, svc.configSvc)670 imagePushedHandler := container.NewImagePushedHandler(ctx, svc.containerSvc, svc.configSvc)
654 if err := svc.eventBus.Subscribe(imagePushedHandler); err != nil {671 if err := svc.eventBus.Subscribe(imagePushedHandler); err != nil {
655 return fmt.Errorf("failed to subscribe image pushed handler: %w", err)672 return fmt.Errorf("failed to subscribe image pushed handler: %w", err)
656 }673 }
657 674
675+ // Auto-route handler for creating routes from image labels
676+ autoRouteHandler := container.NewAutoRouteHandler(ctx, svc.configSvc, svc.containerSvc, svc.blobStorage, cfg.Server.RegistryDomain).
677+ WithEnvExtractor(svc.runtime, svc.envDir)
678+ if err := svc.eventBus.Subscribe(autoRouteHandler); err != nil {
679+ return fmt.Errorf("failed to subscribe auto-route handler: %w", err)
680+ }
681+
658 configReloadHandler := container.NewConfigReloadHandler(ctx, svc.containerSvc, svc.configSvc)682 configReloadHandler := container.NewConfigReloadHandler(ctx, svc.containerSvc, svc.configSvc)
659 if err := svc.eventBus.Subscribe(configReloadHandler); err != nil {683 if err := svc.eventBus.Subscribe(configReloadHandler); err != nil {
660 return fmt.Errorf("failed to subscribe config reload handler: %w", err)684 return fmt.Errorf("failed to subscribe config reload handler: %w", err)
@@ -680,6 +704,10 @@ func registerEventHandlers(ctx context.Context, svc *services) error {
680 }704 }
681 705
682 // setupConfigHotReload sets up Viper config hot reload.706 // setupConfigHotReload sets up Viper config hot reload.
707+// NOTE: This does NOT reload routes into memory. Routes are managed via API
708+// (AddRoute/UpdateRoute/RemoveRoute) and memory is the source of truth.
709+// The file watcher only updates proxy config and refreshes targets.
710+// Manual config file edits to routes require a server restart.
683 func setupConfigHotReload(ctx context.Context, v *viper.Viper, svc *services, log zerowrap.Logger) {711 func setupConfigHotReload(ctx context.Context, v *viper.Viper, svc *services, log zerowrap.Logger) {
684 v.OnConfigChange(func(e fsnotify.Event) {712 v.OnConfigChange(func(e fsnotify.Event) {
685 log.Info().Str("file", e.Name).Msg("config file changed")713 log.Info().Str("file", e.Name).Msg("config file changed")
@@ -689,11 +717,7 @@ func setupConfigHotReload(ctx context.Context, v *viper.Viper, svc *services, lo
689 return717 return
690 }718 }
691 719
692- if err := svc.configSvc.Load(ctx); err != nil {720+ // Update proxy config from viper (reads directly from viper, not memory)
693- log.Error().Err(err).Msg("failed to reload configuration")
694- return
695- }
696-
697 svc.proxySvc.UpdateConfig(proxy.Config{721 svc.proxySvc.UpdateConfig(proxy.Config{
698 RegistryDomain: v.GetString("server.registry_domain"),722 RegistryDomain: v.GetString("server.registry_domain"),
699 RegistryPort: v.GetInt("server.registry_port"),723 RegistryPort: v.GetInt("server.registry_port"),
@@ -704,10 +728,7 @@ func setupConfigHotReload(ctx context.Context, v *viper.Viper, svc *services, lo
704 log.Warn().Err(err).Msg("failed to refresh proxy targets")728 log.Warn().Err(err).Msg("failed to refresh proxy targets")
705 }729 }
706 730
707- // Publish config reload event to trigger container updates731+ log.Debug().Msg("config hot reload complete (routes unchanged, memory is source of truth)")
708- if err := svc.eventBus.Publish(domain.EventConfigReload, nil); err != nil {
709- log.Error().Err(err).Msg("failed to publish config reload event")
710- }
711 })732 })
712 v.WatchConfig()733 v.WatchConfig()
713 }734 }
@@ -759,6 +780,20 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger) (http.Ha
759 }780 }
760 registryMux.Handle("/v2/", registryWithMiddleware)781 registryMux.Handle("/v2/", registryWithMiddleware)
761 782
783+ // Add admin API handler with auth middleware
784+ if svc.adminHandler != nil {
785+ adminMiddlewares := []func(http.Handler) http.Handler{
786+ middleware.PanicRecovery(log),
787+ middleware.RequestLogger(log),
788+ }
789+ // Add admin auth middleware if auth is enabled
790+ if svc.authSvc != nil {
791+ adminMiddlewares = append(adminMiddlewares, admin.AuthMiddleware(svc.authSvc, log))
792+ }
793+ adminWithMiddleware := middleware.Chain(adminMiddlewares...)(svc.adminHandler)
794+ registryMux.Handle("/admin/", adminWithMiddleware)
795+ }
796+
762 proxyMiddlewares := []func(http.Handler) http.Handler{797 proxyMiddlewares := []func(http.Handler) http.Handler{
763 middleware.PanicRecovery(log),798 middleware.PanicRecovery(log),
764 middleware.RequestLogger(log),799 middleware.RequestLogger(log),
@@ -1063,6 +1098,7 @@ func loadConfig(v *viper.Viper, configPath string) error {
1063 v.SetDefault("registry_auth.enabled", false)1098 v.SetDefault("registry_auth.enabled", false)
1064 v.SetDefault("registry_auth.type", "password")1099 v.SetDefault("registry_auth.type", "password")
1065 v.SetDefault("registry_auth.token_expiry", "720h")1100 v.SetDefault("registry_auth.token_expiry", "720h")
1101+ v.SetDefault("deploy.pull_policy", container.PullPolicyIfTagChanged)
1066 1102
1067 ConfigureViper(v, configPath)1103 ConfigureViper(v, configPath)
1068 1104

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 8

Tags 106

↑↓ moveenter openesc close