detached 45beea7

feat(container): Implement image pulling with authentication and refactor runtime detection

- Add PullImageWithAuth method to DockerRuntime for pulling images with authentication.
- Refactor runtime detection to use DockerRuntime for both Docker and Podman environments.
- Update container manager to utilize authenticated image pulling based on registry configuration.
- Enhance logging for image pulling and container network info retrieval.
- Remove deprecated PodmanRuntime implementation to streamline container management.
commit
45beea7dda93587970b8c94f6ba59c7ad9a720d0
author
bamen · · 2025-06-07 12:38 UTC
parent
0c26242
signature
unsigned
12 files · +590 -897
M cmd/start.go +8 -14
@@ -8,17 +8,17 @@ import (
88 "syscall"
99 "time"
1010
11- "github.com/fsnotify/fsnotify"
12- "github.com/google/uuid"
13- "github.com/rs/zerolog"
14- "github.com/rs/zerolog/log"
15- "github.com/spf13/cobra"
16- "github.com/spf13/viper"
1711 "gordon/internal/config"
1812 "gordon/internal/container"
1913 "gordon/internal/events"
2014 "gordon/internal/proxy"
2115 "gordon/internal/registry"
16+
17+ "github.com/fsnotify/fsnotify"
18+ "github.com/rs/zerolog"
19+ "github.com/rs/zerolog/log"
20+ "github.com/spf13/cobra"
21+ "github.com/spf13/viper"
2222 )
2323
2424 var startCmd = &cobra.Command{
@@ -91,13 +91,7 @@ func runStart(cmd *cobra.Command, args []string) {
9191 }
9292
9393 // Publish config reload event
94- configReloadEvent := events.Event{
95- ID: uuid.New().String(),
96- Type: events.ConfigReload,
97- Timestamp: time.Now(),
98- }
99-
100- if err := eventBus.Publish(configReloadEvent); err != nil {
94+ if err := eventBus.Publish(events.ConfigReload, nil); err != nil {
10195 log.Error().Err(err).Msg("Failed to publish config reload event")
10296 } else {
10397 log.Info().Msg("Configuration reloaded successfully")
@@ -161,4 +155,4 @@ func runStart(cmd *cobra.Command, args []string) {
161155 case <-time.After(10 * time.Second):
162156 log.Warn().Msg("Force shutdown after timeout")
163157 }
164-}⏎̸
158+}
@@ -8,17 +8,17 @@ import (
8 "syscall"8 "syscall"
9 "time"9 "time"
10 10
11- "github.com/fsnotify/fsnotify"
12- "github.com/google/uuid"
13- "github.com/rs/zerolog"
14- "github.com/rs/zerolog/log"
15- "github.com/spf13/cobra"
16- "github.com/spf13/viper"
17 "gordon/internal/config"11 "gordon/internal/config"
18 "gordon/internal/container"12 "gordon/internal/container"
19 "gordon/internal/events"13 "gordon/internal/events"
20 "gordon/internal/proxy"14 "gordon/internal/proxy"
21 "gordon/internal/registry"15 "gordon/internal/registry"
16+
17+ "github.com/fsnotify/fsnotify"
18+ "github.com/rs/zerolog"
19+ "github.com/rs/zerolog/log"
20+ "github.com/spf13/cobra"
21+ "github.com/spf13/viper"
22 )22 )
23 23
24 var startCmd = &cobra.Command{24 var startCmd = &cobra.Command{
@@ -91,13 +91,7 @@ func runStart(cmd *cobra.Command, args []string) {
91 }91 }
92 92
93 // Publish config reload event93 // Publish config reload event
94- configReloadEvent := events.Event{94+ if err := eventBus.Publish(events.ConfigReload, nil); err != nil {
95- ID: uuid.New().String(),
96- Type: events.ConfigReload,
97- Timestamp: time.Now(),
98- }
99-
100- if err := eventBus.Publish(configReloadEvent); err != nil {
101 log.Error().Err(err).Msg("Failed to publish config reload event")95 log.Error().Err(err).Msg("Failed to publish config reload event")
102 } else {96 } else {
103 log.Info().Msg("Configuration reloaded successfully")97 log.Info().Msg("Configuration reloaded successfully")
@@ -161,4 +155,4 @@ func runStart(cmd *cobra.Command, args []string) {
161 case <-time.After(10 * time.Second):155 case <-time.After(10 * time.Second):
162 log.Warn().Msg("Force shutdown after timeout")156 log.Warn().Msg("Force shutdown after timeout")
163 }157 }
164-}⏎̸158+}
M internal/container/docker.go +48 -21
@@ -2,6 +2,8 @@ package container
22
33 import (
44 "context"
5+ "encoding/base64"
6+ "encoding/json"
57 "fmt"
68 "io"
79 "strconv"
@@ -9,6 +11,7 @@ import (
911
1012 "github.com/docker/docker/api/types/container"
1113 "github.com/docker/docker/api/types/image"
14+ "github.com/docker/docker/api/types/registry"
1215 "github.com/docker/docker/client"
1316 "github.com/docker/go-connections/nat"
1417 "github.com/rs/zerolog/log"
@@ -230,6 +233,42 @@ func (d *DockerRuntime) PullImage(ctx context.Context, imageRef string) error {
230233 return nil
231234 }
232235
236+// PullImageWithAuth pulls an image with authentication
237+func (d *DockerRuntime) PullImageWithAuth(ctx context.Context, imageRef, username, password string) error {
238+ log.Info().Str("image", imageRef).Str("username", username).Msg("Pulling image with authentication")
239+
240+ // Create authentication configuration
241+ authConfig := registry.AuthConfig{
242+ Username: username,
243+ Password: password,
244+ }
245+
246+ // Encode authentication to base64 JSON
247+ authConfigBytes, err := json.Marshal(authConfig)
248+ if err != nil {
249+ return fmt.Errorf("failed to marshal auth config: %w", err)
250+ }
251+ authStr := base64.URLEncoding.EncodeToString(authConfigBytes)
252+
253+ // Pull with authentication
254+ reader, err := d.client.ImagePull(ctx, imageRef, image.PullOptions{
255+ RegistryAuth: authStr,
256+ })
257+ if err != nil {
258+ return fmt.Errorf("failed to pull image %s with auth: %w", imageRef, err)
259+ }
260+ defer reader.Close()
261+
262+ // Read the response to completion (this is required for the pull to complete)
263+ _, err = io.Copy(io.Discard, reader)
264+ if err != nil {
265+ return fmt.Errorf("failed to read pull response for image %s: %w", imageRef, err)
266+ }
267+
268+ log.Info().Str("image", imageRef).Msg("Image pulled successfully with authentication")
269+ return nil
270+}
271+
233272 // RemoveImage removes an image
234273 func (d *DockerRuntime) RemoveImage(ctx context.Context, imageRef string, force bool) error {
235274 _, err := d.client.ImageRemove(ctx, imageRef, image.RemoveOptions{Force: force})
@@ -330,10 +369,8 @@ func (d *DockerRuntime) GetImageExposedPorts(ctx context.Context, imageRef strin
330369 }
331370 }
332371
333- // If no exposed ports found, return common web ports as fallback
334372 if len(ports) == 0 {
335- log.Warn().Str("image", imageRef).Msg("No EXPOSE directives found in image, using common web ports")
336- return []int{80, 8080, 3000}, nil
373+ return nil, fmt.Errorf("no EXPOSE directives found in image %s - please add EXPOSE directive to Dockerfile", imageRef)
337374 }
338375
339376 log.Info().Str("image", imageRef).Ints("exposed_ports", ports).Msg("Found exposed ports in image")
@@ -384,7 +421,7 @@ func (d *DockerRuntime) GetContainerNetworkInfo(ctx context.Context, containerID
384421 return "", 0, fmt.Errorf("no IP address found for container %s", containerID)
385422 }
386423
387- // Get exposed ports and select the best one (Traefik-style logic)
424+ // Get exposed ports from container
388425 var exposedPorts []int
389426 if resp.NetworkSettings != nil && resp.NetworkSettings.Ports != nil {
390427 for portSpec := range resp.NetworkSettings.Ports {
@@ -397,29 +434,19 @@ func (d *DockerRuntime) GetContainerNetworkInfo(ctx context.Context, containerID
397434 }
398435
399436 if len(exposedPorts) == 0 {
400- return "", 0, fmt.Errorf("no exposed ports found for container %s", containerID)
401- }
402-
403- // Sort ports and select the lowest one (Traefik strategy)
404- var selectedPort int
405- if len(exposedPorts) == 1 {
406- selectedPort = exposedPorts[0]
407- } else {
408- // Find the lowest port number
409- selectedPort = exposedPorts[0]
410- for _, port := range exposedPorts {
411- if port < selectedPort {
412- selectedPort = port
413- }
414- }
437+ return "", 0, fmt.Errorf("no EXPOSE directives found for container %s - please add EXPOSE directive to Dockerfile", containerID)
415438 }
416439
440+ // Use the first exposed port
441+ selectedPort := exposedPorts[0]
442+
417443 log.Info().
418444 Str("container_id", containerID).
419445 Str("ip", containerIP).
420446 Int("selected_port", selectedPort).
421- Ints("available_ports", exposedPorts).
447+ Ints("exposed_ports", exposedPorts).
422448 Msg("Container network info detected")
423449
424450 return containerIP, selectedPort, nil
425-}⏎̸
451+}
452+
@@ -2,6 +2,8 @@ package container
2 2
3 import (3 import (
4 "context"4 "context"
5+ "encoding/base64"
6+ "encoding/json"
5 "fmt"7 "fmt"
6 "io"8 "io"
7 "strconv"9 "strconv"
@@ -9,6 +11,7 @@ import (
9 11
10 "github.com/docker/docker/api/types/container"12 "github.com/docker/docker/api/types/container"
11 "github.com/docker/docker/api/types/image"13 "github.com/docker/docker/api/types/image"
14+ "github.com/docker/docker/api/types/registry"
12 "github.com/docker/docker/client"15 "github.com/docker/docker/client"
13 "github.com/docker/go-connections/nat"16 "github.com/docker/go-connections/nat"
14 "github.com/rs/zerolog/log"17 "github.com/rs/zerolog/log"
@@ -230,6 +233,42 @@ func (d *DockerRuntime) PullImage(ctx context.Context, imageRef string) error {
230 return nil233 return nil
231 }234 }
232 235
236+// PullImageWithAuth pulls an image with authentication
237+func (d *DockerRuntime) PullImageWithAuth(ctx context.Context, imageRef, username, password string) error {
238+ log.Info().Str("image", imageRef).Str("username", username).Msg("Pulling image with authentication")
239+
240+ // Create authentication configuration
241+ authConfig := registry.AuthConfig{
242+ Username: username,
243+ Password: password,
244+ }
245+
246+ // Encode authentication to base64 JSON
247+ authConfigBytes, err := json.Marshal(authConfig)
248+ if err != nil {
249+ return fmt.Errorf("failed to marshal auth config: %w", err)
250+ }
251+ authStr := base64.URLEncoding.EncodeToString(authConfigBytes)
252+
253+ // Pull with authentication
254+ reader, err := d.client.ImagePull(ctx, imageRef, image.PullOptions{
255+ RegistryAuth: authStr,
256+ })
257+ if err != nil {
258+ return fmt.Errorf("failed to pull image %s with auth: %w", imageRef, err)
259+ }
260+ defer reader.Close()
261+
262+ // Read the response to completion (this is required for the pull to complete)
263+ _, err = io.Copy(io.Discard, reader)
264+ if err != nil {
265+ return fmt.Errorf("failed to read pull response for image %s: %w", imageRef, err)
266+ }
267+
268+ log.Info().Str("image", imageRef).Msg("Image pulled successfully with authentication")
269+ return nil
270+}
271+
233 // RemoveImage removes an image272 // RemoveImage removes an image
234 func (d *DockerRuntime) RemoveImage(ctx context.Context, imageRef string, force bool) error {273 func (d *DockerRuntime) RemoveImage(ctx context.Context, imageRef string, force bool) error {
235 _, err := d.client.ImageRemove(ctx, imageRef, image.RemoveOptions{Force: force})274 _, err := d.client.ImageRemove(ctx, imageRef, image.RemoveOptions{Force: force})
@@ -330,10 +369,8 @@ func (d *DockerRuntime) GetImageExposedPorts(ctx context.Context, imageRef strin
330 }369 }
331 }370 }
332 371
333- // If no exposed ports found, return common web ports as fallback
334 if len(ports) == 0 {372 if len(ports) == 0 {
335- log.Warn().Str("image", imageRef).Msg("No EXPOSE directives found in image, using common web ports")373+ return nil, fmt.Errorf("no EXPOSE directives found in image %s - please add EXPOSE directive to Dockerfile", imageRef)
336- return []int{80, 8080, 3000}, nil
337 }374 }
338 375
339 log.Info().Str("image", imageRef).Ints("exposed_ports", ports).Msg("Found exposed ports in image")376 log.Info().Str("image", imageRef).Ints("exposed_ports", ports).Msg("Found exposed ports in image")
@@ -384,7 +421,7 @@ func (d *DockerRuntime) GetContainerNetworkInfo(ctx context.Context, containerID
384 return "", 0, fmt.Errorf("no IP address found for container %s", containerID)421 return "", 0, fmt.Errorf("no IP address found for container %s", containerID)
385 }422 }
386 423
387- // Get exposed ports and select the best one (Traefik-style logic)424+ // Get exposed ports from container
388 var exposedPorts []int425 var exposedPorts []int
389 if resp.NetworkSettings != nil && resp.NetworkSettings.Ports != nil {426 if resp.NetworkSettings != nil && resp.NetworkSettings.Ports != nil {
390 for portSpec := range resp.NetworkSettings.Ports {427 for portSpec := range resp.NetworkSettings.Ports {
@@ -397,29 +434,19 @@ func (d *DockerRuntime) GetContainerNetworkInfo(ctx context.Context, containerID
397 }434 }
398 435
399 if len(exposedPorts) == 0 {436 if len(exposedPorts) == 0 {
400- return "", 0, fmt.Errorf("no exposed ports found for container %s", containerID)437+ return "", 0, fmt.Errorf("no EXPOSE directives found for container %s - please add EXPOSE directive to Dockerfile", containerID)
401- }
402-
403- // Sort ports and select the lowest one (Traefik strategy)
404- var selectedPort int
405- if len(exposedPorts) == 1 {
406- selectedPort = exposedPorts[0]
407- } else {
408- // Find the lowest port number
409- selectedPort = exposedPorts[0]
410- for _, port := range exposedPorts {
411- if port < selectedPort {
412- selectedPort = port
413- }
414- }
415 }438 }
416 439
440+ // Use the first exposed port
441+ selectedPort := exposedPorts[0]
442+
417 log.Info().443 log.Info().
418 Str("container_id", containerID).444 Str("container_id", containerID).
419 Str("ip", containerIP).445 Str("ip", containerIP).
420 Int("selected_port", selectedPort).446 Int("selected_port", selectedPort).
421- Ints("available_ports", exposedPorts).447+ Ints("exposed_ports", exposedPorts).
422 Msg("Container network info detected")448 Msg("Container network info detected")
423 449
424 return containerIP, selectedPort, nil450 return containerIP, selectedPort, nil
425-}⏎̸451+}
452+
M internal/container/factory.go +5 -9
@@ -148,14 +148,10 @@ func tryCreateRuntime(socketPath string) (runtime.Runtime, *RuntimeInfo, error)
148148 }
149149
150150 // Determine runtime type based on socket path and server info
151- runtimeType := detectRuntimeType(socketPath, ping.APIVersion)
151+ runtimeType := detectRuntimeType(socketPath)
152152
153- var rt runtime.Runtime
154- if runtimeType == RuntimeDocker {
155- rt = &DockerRuntime{client: cli}
156- } else {
157- rt = &PodmanRuntime{client: cli}
158- }
153+ // Since Podman uses Docker-compatible API, we can use DockerRuntime for both
154+ rt := &DockerRuntime{client: cli}
159155
160156 info := &RuntimeInfo{
161157 Type: runtimeType,
@@ -167,7 +163,7 @@ func tryCreateRuntime(socketPath string) (runtime.Runtime, *RuntimeInfo, error)
167163 }
168164
169165 // detectRuntimeType determines if the socket is Docker or Podman
170-func detectRuntimeType(socketPath, apiVersion string) RuntimeType {
166+func detectRuntimeType(socketPath string) RuntimeType {
171167 // Check socket path patterns
172168 if strings.Contains(socketPath, "podman") {
173169 if strings.Contains(socketPath, "/run/user/") || strings.Contains(socketPath, "$XDG_RUNTIME_DIR") {
@@ -250,7 +246,7 @@ func createPodmanRuntimeWithSocket(socketPath string) (runtime.Runtime, error) {
250246 return nil, fmt.Errorf("Podman ping failed: %w", err)
251247 }
252248
253- return &PodmanRuntime{client: cli}, nil
249+ return &DockerRuntime{client: cli}, nil
254250 }
255251
256252 // GetAvailableRuntimes returns information about all available runtimes
@@ -148,14 +148,10 @@ func tryCreateRuntime(socketPath string) (runtime.Runtime, *RuntimeInfo, error)
148 }148 }
149 149
150 // Determine runtime type based on socket path and server info150 // Determine runtime type based on socket path and server info
151- runtimeType := detectRuntimeType(socketPath, ping.APIVersion)151+ runtimeType := detectRuntimeType(socketPath)
152 152
153- var rt runtime.Runtime153+ // Since Podman uses Docker-compatible API, we can use DockerRuntime for both
154- if runtimeType == RuntimeDocker {154+ rt := &DockerRuntime{client: cli}
155- rt = &DockerRuntime{client: cli}
156- } else {
157- rt = &PodmanRuntime{client: cli}
158- }
159 155
160 info := &RuntimeInfo{156 info := &RuntimeInfo{
161 Type: runtimeType,157 Type: runtimeType,
@@ -167,7 +163,7 @@ func tryCreateRuntime(socketPath string) (runtime.Runtime, *RuntimeInfo, error)
167 }163 }
168 164
169 // detectRuntimeType determines if the socket is Docker or Podman165 // detectRuntimeType determines if the socket is Docker or Podman
170-func detectRuntimeType(socketPath, apiVersion string) RuntimeType {166+func detectRuntimeType(socketPath string) RuntimeType {
171 // Check socket path patterns167 // Check socket path patterns
172 if strings.Contains(socketPath, "podman") {168 if strings.Contains(socketPath, "podman") {
173 if strings.Contains(socketPath, "/run/user/") || strings.Contains(socketPath, "$XDG_RUNTIME_DIR") {169 if strings.Contains(socketPath, "/run/user/") || strings.Contains(socketPath, "$XDG_RUNTIME_DIR") {
@@ -250,7 +246,7 @@ func createPodmanRuntimeWithSocket(socketPath string) (runtime.Runtime, error) {
250 return nil, fmt.Errorf("Podman ping failed: %w", err)246 return nil, fmt.Errorf("Podman ping failed: %w", err)
251 }247 }
252 248
253- return &PodmanRuntime{client: cli}, nil249+ return &DockerRuntime{client: cli}, nil
254 }250 }
255 251
256 // GetAvailableRuntimes returns information about all available runtimes252 // GetAvailableRuntimes returns information about all available runtimes
M internal/container/manager.go +48 -26
@@ -3,6 +3,7 @@ package container
33 import (
44 "context"
55 "fmt"
6+ "strings"
67 "sync"
78
89 "github.com/rs/zerolog/log"
@@ -13,10 +14,10 @@ import (
1314
1415 // Manager handles container lifecycle and management
1516 type Manager struct {
16- runtime runtime.Runtime
17- config *config.Config
17+ runtime runtime.Runtime
18+ config *config.Config
1819 containers map[string]*runtime.Container // map[domain] -> container
19- mu sync.RWMutex
20+ mu sync.RWMutex
2021 }
2122
2223 // NewManager creates a new container manager
@@ -55,42 +56,63 @@ func (m *Manager) DeployContainer(ctx context.Context, route config.Route) (*run
5556 // Check if container already exists for this domain
5657 if existing, exists := m.containers[route.Domain]; exists {
5758 log.Info().Str("domain", route.Domain).Str("container", existing.ID).Msg("Container already exists, restarting")
58-
59+
5960 // Stop and remove existing container
6061 if err := m.runtime.StopContainer(ctx, existing.ID); err != nil {
6162 log.Warn().Err(err).Str("container", existing.ID).Msg("Failed to stop existing container")
6263 }
63-
64+
6465 if err := m.runtime.RemoveContainer(ctx, existing.ID, true); err != nil {
6566 log.Warn().Err(err).Str("container", existing.ID).Msg("Failed to remove existing container")
6667 }
67-
68+
6869 delete(m.containers, route.Domain)
6970 }
7071
72+ // Construct the full image reference
73+ imageRef := route.Image
74+
75+ // If registry auth is enabled and image doesn't already contain a registry domain, prepend it
76+ if m.config.RegistryAuth.Enabled && m.config.Server.RegistryDomain != "" {
77+ // Check if image already contains a registry domain (has a '.' and doesn't start with official Docker Hub libraries)
78+ if !strings.Contains(strings.Split(imageRef, ":")[0], ".") {
79+ imageRef = fmt.Sprintf("%s/%s", m.config.Server.RegistryDomain, route.Image)
80+ }
81+ }
82+
7183 // Pull the image first
72- log.Info().Str("image", route.Image).Msg("Pulling image")
73- if err := m.runtime.PullImage(ctx, route.Image); err != nil {
74- return nil, fmt.Errorf("failed to pull image %s: %w", route.Image, err)
84+ log.Info().Str("image", imageRef).Msg("Pulling image")
85+
86+ // Use authenticated pull if registry auth is configured
87+ if m.config.RegistryAuth.Enabled {
88+ err := m.runtime.PullImageWithAuth(ctx, imageRef, m.config.RegistryAuth.Username, m.config.RegistryAuth.Password)
89+ if err != nil {
90+ return nil, fmt.Errorf("failed to pull image %s with auth: %w", imageRef, err)
91+ }
92+ } else {
93+ err := m.runtime.PullImage(ctx, imageRef)
94+ if err != nil {
95+ return nil, fmt.Errorf("failed to pull image %s: %w", imageRef, err)
96+ }
7597 }
7698
7799 // Get exposed ports from the image
78- exposedPorts, err := m.runtime.GetImageExposedPorts(ctx, route.Image)
100+ exposedPorts, err := m.runtime.GetImageExposedPorts(ctx, imageRef)
79101 if err != nil {
80- log.Warn().Err(err).Str("image", route.Image).Msg("Failed to get exposed ports from image, using defaults")
102+ log.Warn().Err(err).Str("image", imageRef).Msg("Failed to get exposed ports from image, using defaults")
81103 exposedPorts = []int{80, 8080, 3000} // Fallback to common web server ports
82104 }
83105
84106 // Create container configuration
85107 containerConfig := &runtime.ContainerConfig{
86- Image: route.Image,
87- Name: fmt.Sprintf("gordon-%s", route.Domain),
88- Ports: exposedPorts,
108+ Image: imageRef,
109+ Name: fmt.Sprintf("gordon-%s", route.Domain),
110+ Ports: exposedPorts,
89111 Labels: map[string]string{
90- "gordon.domain": route.Domain,
91- "gordon.image": route.Image,
112+ "gordon.domain": route.Domain,
113+ "gordon.image": route.Image,
92114 "gordon.managed": "true",
93- "gordon.route": route.Domain,
115+ "gordon.route": route.Domain,
94116 },
95117 AutoRemove: false, // Keep containers for inspection
96118 }
@@ -131,7 +153,7 @@ func (m *Manager) DeployContainer(ctx context.Context, route config.Route) (*run
131153 func (m *Manager) GetContainer(domain string) (*runtime.Container, bool) {
132154 m.mu.RLock()
133155 defer m.mu.RUnlock()
134-
156+
135157 container, exists := m.containers[domain]
136158 return container, exists
137159 }
@@ -141,7 +163,7 @@ func (m *Manager) GetContainerPort(ctx context.Context, domain string, internalP
141163 m.mu.RLock()
142164 container, exists := m.containers[domain]
143165 m.mu.RUnlock()
144-
166+
145167 if !exists {
146168 return 0, fmt.Errorf("no container found for domain %s", domain)
147169 }
@@ -153,7 +175,7 @@ func (m *Manager) GetContainerPort(ctx context.Context, domain string, internalP
153175 func (m *Manager) ListContainers() map[string]*runtime.Container {
154176 m.mu.RLock()
155177 defer m.mu.RUnlock()
156-
178+
157179 // Return a copy of the map to avoid race conditions
158180 result := make(map[string]*runtime.Container)
159181 for domain, container := range m.containers {
@@ -166,7 +188,7 @@ func (m *Manager) ListContainers() map[string]*runtime.Container {
166188 func (m *Manager) FindContainerByDomain(domain string) (string, bool) {
167189 m.mu.RLock()
168190 defer m.mu.RUnlock()
169-
191+
170192 if container, exists := m.containers[domain]; exists {
171193 return container.ID, true
172194 }
@@ -177,7 +199,7 @@ func (m *Manager) FindContainerByDomain(domain string) (string, bool) {
177199 func (m *Manager) FindDomainByContainerID(containerID string) (string, bool) {
178200 m.mu.RLock()
179201 defer m.mu.RUnlock()
180-
202+
181203 for domain, container := range m.containers {
182204 if container.ID == containerID {
183205 return domain, true
@@ -201,7 +223,7 @@ func (m *Manager) StopContainerByDomain(ctx context.Context, domain string) erro
201223 m.mu.RLock()
202224 container, exists := m.containers[domain]
203225 m.mu.RUnlock()
204-
226+
205227 if !exists {
206228 return fmt.Errorf("no container found for domain %s", domain)
207229 }
@@ -225,7 +247,7 @@ func (m *Manager) RemoveContainer(ctx context.Context, containerID string, force
225247 break
226248 }
227249 }
228-
250+
229251 return nil
230252 }
231253
@@ -234,7 +256,7 @@ func (m *Manager) RemoveContainerByDomain(ctx context.Context, domain string, fo
234256 m.mu.RLock()
235257 container, exists := m.containers[domain]
236258 m.mu.RUnlock()
237-
259+
238260 if !exists {
239261 return fmt.Errorf("no container found for domain %s", domain)
240262 }
@@ -292,4 +314,4 @@ func (m *Manager) HealthCheck(ctx context.Context) map[string]bool {
292314 // Runtime returns the underlying runtime interface
293315 func (m *Manager) Runtime() runtime.Runtime {
294316 return m.runtime
295-}⏎̸
317+}
@@ -3,6 +3,7 @@ package container
3 import (3 import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6+ "strings"
6 "sync"7 "sync"
7 8
8 "github.com/rs/zerolog/log"9 "github.com/rs/zerolog/log"
@@ -13,10 +14,10 @@ import (
13 14
14 // Manager handles container lifecycle and management15 // Manager handles container lifecycle and management
15 type Manager struct {16 type Manager struct {
16- runtime runtime.Runtime17+ runtime runtime.Runtime
17- config *config.Config18+ config *config.Config
18 containers map[string]*runtime.Container // map[domain] -> container19 containers map[string]*runtime.Container // map[domain] -> container
19- mu sync.RWMutex20+ mu sync.RWMutex
20 }21 }
21 22
22 // NewManager creates a new container manager23 // NewManager creates a new container manager
@@ -55,42 +56,63 @@ func (m *Manager) DeployContainer(ctx context.Context, route config.Route) (*run
55 // Check if container already exists for this domain56 // Check if container already exists for this domain
56 if existing, exists := m.containers[route.Domain]; exists {57 if existing, exists := m.containers[route.Domain]; exists {
57 log.Info().Str("domain", route.Domain).Str("container", existing.ID).Msg("Container already exists, restarting")58 log.Info().Str("domain", route.Domain).Str("container", existing.ID).Msg("Container already exists, restarting")
58- 59+
59 // Stop and remove existing container60 // Stop and remove existing container
60 if err := m.runtime.StopContainer(ctx, existing.ID); err != nil {61 if err := m.runtime.StopContainer(ctx, existing.ID); err != nil {
61 log.Warn().Err(err).Str("container", existing.ID).Msg("Failed to stop existing container")62 log.Warn().Err(err).Str("container", existing.ID).Msg("Failed to stop existing container")
62 }63 }
63- 64+
64 if err := m.runtime.RemoveContainer(ctx, existing.ID, true); err != nil {65 if err := m.runtime.RemoveContainer(ctx, existing.ID, true); err != nil {
65 log.Warn().Err(err).Str("container", existing.ID).Msg("Failed to remove existing container")66 log.Warn().Err(err).Str("container", existing.ID).Msg("Failed to remove existing container")
66 }67 }
67- 68+
68 delete(m.containers, route.Domain)69 delete(m.containers, route.Domain)
69 }70 }
70 71
72+ // Construct the full image reference
73+ imageRef := route.Image
74+
75+ // If registry auth is enabled and image doesn't already contain a registry domain, prepend it
76+ if m.config.RegistryAuth.Enabled && m.config.Server.RegistryDomain != "" {
77+ // Check if image already contains a registry domain (has a '.' and doesn't start with official Docker Hub libraries)
78+ if !strings.Contains(strings.Split(imageRef, ":")[0], ".") {
79+ imageRef = fmt.Sprintf("%s/%s", m.config.Server.RegistryDomain, route.Image)
80+ }
81+ }
82+
71 // Pull the image first83 // Pull the image first
72- log.Info().Str("image", route.Image).Msg("Pulling image")84+ log.Info().Str("image", imageRef).Msg("Pulling image")
73- if err := m.runtime.PullImage(ctx, route.Image); err != nil {85+
74- return nil, fmt.Errorf("failed to pull image %s: %w", route.Image, err)86+ // Use authenticated pull if registry auth is configured
87+ if m.config.RegistryAuth.Enabled {
88+ err := m.runtime.PullImageWithAuth(ctx, imageRef, m.config.RegistryAuth.Username, m.config.RegistryAuth.Password)
89+ if err != nil {
90+ return nil, fmt.Errorf("failed to pull image %s with auth: %w", imageRef, err)
91+ }
92+ } else {
93+ err := m.runtime.PullImage(ctx, imageRef)
94+ if err != nil {
95+ return nil, fmt.Errorf("failed to pull image %s: %w", imageRef, err)
96+ }
75 }97 }
76 98
77 // Get exposed ports from the image99 // Get exposed ports from the image
78- exposedPorts, err := m.runtime.GetImageExposedPorts(ctx, route.Image)100+ exposedPorts, err := m.runtime.GetImageExposedPorts(ctx, imageRef)
79 if err != nil {101 if err != nil {
80- log.Warn().Err(err).Str("image", route.Image).Msg("Failed to get exposed ports from image, using defaults")102+ log.Warn().Err(err).Str("image", imageRef).Msg("Failed to get exposed ports from image, using defaults")
81 exposedPorts = []int{80, 8080, 3000} // Fallback to common web server ports103 exposedPorts = []int{80, 8080, 3000} // Fallback to common web server ports
82 }104 }
83 105
84 // Create container configuration106 // Create container configuration
85 containerConfig := &runtime.ContainerConfig{107 containerConfig := &runtime.ContainerConfig{
86- Image: route.Image,108+ Image: imageRef,
87- Name: fmt.Sprintf("gordon-%s", route.Domain),109+ Name: fmt.Sprintf("gordon-%s", route.Domain),
88- Ports: exposedPorts,110+ Ports: exposedPorts,
89 Labels: map[string]string{111 Labels: map[string]string{
90- "gordon.domain": route.Domain,112+ "gordon.domain": route.Domain,
91- "gordon.image": route.Image,113+ "gordon.image": route.Image,
92 "gordon.managed": "true",114 "gordon.managed": "true",
93- "gordon.route": route.Domain,115+ "gordon.route": route.Domain,
94 },116 },
95 AutoRemove: false, // Keep containers for inspection117 AutoRemove: false, // Keep containers for inspection
96 }118 }
@@ -131,7 +153,7 @@ func (m *Manager) DeployContainer(ctx context.Context, route config.Route) (*run
131 func (m *Manager) GetContainer(domain string) (*runtime.Container, bool) {153 func (m *Manager) GetContainer(domain string) (*runtime.Container, bool) {
132 m.mu.RLock()154 m.mu.RLock()
133 defer m.mu.RUnlock()155 defer m.mu.RUnlock()
134- 156+
135 container, exists := m.containers[domain]157 container, exists := m.containers[domain]
136 return container, exists158 return container, exists
137 }159 }
@@ -141,7 +163,7 @@ func (m *Manager) GetContainerPort(ctx context.Context, domain string, internalP
141 m.mu.RLock()163 m.mu.RLock()
142 container, exists := m.containers[domain]164 container, exists := m.containers[domain]
143 m.mu.RUnlock()165 m.mu.RUnlock()
144- 166+
145 if !exists {167 if !exists {
146 return 0, fmt.Errorf("no container found for domain %s", domain)168 return 0, fmt.Errorf("no container found for domain %s", domain)
147 }169 }
@@ -153,7 +175,7 @@ func (m *Manager) GetContainerPort(ctx context.Context, domain string, internalP
153 func (m *Manager) ListContainers() map[string]*runtime.Container {175 func (m *Manager) ListContainers() map[string]*runtime.Container {
154 m.mu.RLock()176 m.mu.RLock()
155 defer m.mu.RUnlock()177 defer m.mu.RUnlock()
156- 178+
157 // Return a copy of the map to avoid race conditions179 // Return a copy of the map to avoid race conditions
158 result := make(map[string]*runtime.Container)180 result := make(map[string]*runtime.Container)
159 for domain, container := range m.containers {181 for domain, container := range m.containers {
@@ -166,7 +188,7 @@ func (m *Manager) ListContainers() map[string]*runtime.Container {
166 func (m *Manager) FindContainerByDomain(domain string) (string, bool) {188 func (m *Manager) FindContainerByDomain(domain string) (string, bool) {
167 m.mu.RLock()189 m.mu.RLock()
168 defer m.mu.RUnlock()190 defer m.mu.RUnlock()
169- 191+
170 if container, exists := m.containers[domain]; exists {192 if container, exists := m.containers[domain]; exists {
171 return container.ID, true193 return container.ID, true
172 }194 }
@@ -177,7 +199,7 @@ func (m *Manager) FindContainerByDomain(domain string) (string, bool) {
177 func (m *Manager) FindDomainByContainerID(containerID string) (string, bool) {199 func (m *Manager) FindDomainByContainerID(containerID string) (string, bool) {
178 m.mu.RLock()200 m.mu.RLock()
179 defer m.mu.RUnlock()201 defer m.mu.RUnlock()
180- 202+
181 for domain, container := range m.containers {203 for domain, container := range m.containers {
182 if container.ID == containerID {204 if container.ID == containerID {
183 return domain, true205 return domain, true
@@ -201,7 +223,7 @@ func (m *Manager) StopContainerByDomain(ctx context.Context, domain string) erro
201 m.mu.RLock()223 m.mu.RLock()
202 container, exists := m.containers[domain]224 container, exists := m.containers[domain]
203 m.mu.RUnlock()225 m.mu.RUnlock()
204- 226+
205 if !exists {227 if !exists {
206 return fmt.Errorf("no container found for domain %s", domain)228 return fmt.Errorf("no container found for domain %s", domain)
207 }229 }
@@ -225,7 +247,7 @@ func (m *Manager) RemoveContainer(ctx context.Context, containerID string, force
225 break247 break
226 }248 }
227 }249 }
228- 250+
229 return nil251 return nil
230 }252 }
231 253
@@ -234,7 +256,7 @@ func (m *Manager) RemoveContainerByDomain(ctx context.Context, domain string, fo
234 m.mu.RLock()256 m.mu.RLock()
235 container, exists := m.containers[domain]257 container, exists := m.containers[domain]
236 m.mu.RUnlock()258 m.mu.RUnlock()
237- 259+
238 if !exists {260 if !exists {
239 return fmt.Errorf("no container found for domain %s", domain)261 return fmt.Errorf("no container found for domain %s", domain)
240 }262 }
@@ -292,4 +314,4 @@ func (m *Manager) HealthCheck(ctx context.Context) map[string]bool {
292 // Runtime returns the underlying runtime interface314 // Runtime returns the underlying runtime interface
293 func (m *Manager) Runtime() runtime.Runtime {315 func (m *Manager) Runtime() runtime.Runtime {
294 return m.runtime316 return m.runtime
295-}⏎̸317+}
D internal/container/podman.go +0 -431
@@ -1,431 +0,0 @@
1-package container
2-
3-import (
4- "context"
5- "fmt"
6- "io"
7- "strconv"
8- "strings"
9-
10- "github.com/docker/docker/api/types/container"
11- "github.com/docker/docker/api/types/image"
12- "github.com/docker/docker/client"
13- "github.com/docker/go-connections/nat"
14- "github.com/rs/zerolog/log"
15-
16- "gordon/pkg/runtime"
17-)
18-
19-// PodmanRuntime implements the Runtime interface using Podman API
20-// Podman API is compatible with Docker API, so we can reuse the Docker client
21-type PodmanRuntime struct {
22- client *client.Client
23-}
24-
25-// NewPodmanRuntime creates a new Podman runtime instance
26-func NewPodmanRuntime() (*PodmanRuntime, error) {
27- // Try to create with auto-detection
28- socketPath := getDefaultPodmanSocket(false) // Try root first
29- rt, err := createPodmanRuntimeWithSocket(socketPath)
30- if err != nil {
31- // Try rootless
32- socketPath = getDefaultPodmanSocket(true)
33- rt, err = createPodmanRuntimeWithSocket(socketPath)
34- if err != nil {
35- return nil, fmt.Errorf("failed to create Podman runtime: %w", err)
36- }
37- }
38-
39- return rt.(*PodmanRuntime), nil
40-}
41-
42-// CreateContainer creates a new container
43-func (p *PodmanRuntime) CreateContainer(ctx context.Context, config *runtime.ContainerConfig) (*runtime.Container, error) {
44- // Convert ports to Podman/Docker format
45- exposedPorts := make(nat.PortSet)
46- portBindings := make(nat.PortMap)
47-
48- for _, port := range config.Ports {
49- containerPort := nat.Port(fmt.Sprintf("%d/tcp", port))
50- exposedPorts[containerPort] = struct{}{}
51-
52- // Bind to random available port on host
53- portBindings[containerPort] = []nat.PortBinding{
54- {
55- HostIP: "0.0.0.0",
56- HostPort: "0", // Podman will assign a random available port
57- },
58- }
59- }
60-
61- // Create container configuration
62- containerConfig := &container.Config{
63- Image: config.Image,
64- Env: config.Env,
65- ExposedPorts: exposedPorts,
66- WorkingDir: config.WorkingDir,
67- Cmd: config.Cmd,
68- Labels: config.Labels,
69- }
70-
71- hostConfig := &container.HostConfig{
72- PortBindings: portBindings,
73- AutoRemove: config.AutoRemove,
74- }
75-
76- // Create the container
77- resp, err := p.client.ContainerCreate(ctx, containerConfig, hostConfig, nil, nil, config.Name)
78- if err != nil {
79- return nil, fmt.Errorf("failed to create container: %w", err)
80- }
81-
82- log.Info().Str("id", resp.ID).Str("name", config.Name).Str("image", config.Image).Msg("Podman container created")
83-
84- // Inspect the created container to get full details
85- return p.InspectContainer(ctx, resp.ID)
86-}
87-
88-// StartContainer starts a container
89-func (p *PodmanRuntime) StartContainer(ctx context.Context, containerID string) error {
90- err := p.client.ContainerStart(ctx, containerID, container.StartOptions{})
91- if err != nil {
92- return fmt.Errorf("failed to start container %s: %w", containerID, err)
93- }
94-
95- log.Info().Str("id", containerID).Msg("Podman container started")
96- return nil
97-}
98-
99-// StopContainer stops a container
100-func (p *PodmanRuntime) StopContainer(ctx context.Context, containerID string) error {
101- timeout := 30 // 30 seconds timeout
102- err := p.client.ContainerStop(ctx, containerID, container.StopOptions{Timeout: &timeout})
103- if err != nil {
104- return fmt.Errorf("failed to stop container %s: %w", containerID, err)
105- }
106-
107- log.Info().Str("id", containerID).Msg("Podman container stopped")
108- return nil
109-}
110-
111-// RestartContainer restarts a container
112-func (p *PodmanRuntime) RestartContainer(ctx context.Context, containerID string) error {
113- timeout := 30 // 30 seconds timeout
114- err := p.client.ContainerRestart(ctx, containerID, container.StopOptions{Timeout: &timeout})
115- if err != nil {
116- return fmt.Errorf("failed to restart container %s: %w", containerID, err)
117- }
118-
119- log.Info().Str("id", containerID).Msg("Podman container restarted")
120- return nil
121-}
122-
123-// RemoveContainer removes a container
124-func (p *PodmanRuntime) RemoveContainer(ctx context.Context, containerID string, force bool) error {
125- err := p.client.ContainerRemove(ctx, containerID, container.RemoveOptions{Force: force})
126- if err != nil {
127- return fmt.Errorf("failed to remove container %s: %w", containerID, err)
128- }
129-
130- log.Info().Str("id", containerID).Bool("force", force).Msg("Podman container removed")
131- return nil
132-}
133-
134-// ListContainers lists containers
135-func (p *PodmanRuntime) ListContainers(ctx context.Context, all bool) ([]*runtime.Container, error) {
136- containers, err := p.client.ContainerList(ctx, container.ListOptions{All: all})
137- if err != nil {
138- return nil, fmt.Errorf("failed to list containers: %w", err)
139- }
140-
141- var result []*runtime.Container
142- for _, c := range containers {
143- // Extract ports
144- var ports []int
145- for _, port := range c.Ports {
146- if port.PublicPort > 0 {
147- ports = append(ports, int(port.PublicPort))
148- }
149- }
150-
151- // Get the primary name (remove leading slash)
152- name := ""
153- if len(c.Names) > 0 {
154- name = strings.TrimPrefix(c.Names[0], "/")
155- }
156-
157- result = append(result, &runtime.Container{
158- ID: c.ID,
159- Image: c.Image,
160- Name: name,
161- Status: c.Status,
162- Ports: ports,
163- Labels: c.Labels,
164- })
165- }
166-
167- return result, nil
168-}
169-
170-// InspectContainer inspects a container
171-func (p *PodmanRuntime) InspectContainer(ctx context.Context, containerID string) (*runtime.Container, error) {
172- resp, err := p.client.ContainerInspect(ctx, containerID)
173- if err != nil {
174- return nil, fmt.Errorf("failed to inspect container %s: %w", containerID, err)
175- }
176-
177- // Extract published ports
178- var ports []int
179- if resp.NetworkSettings != nil && resp.NetworkSettings.Ports != nil {
180- for _, bindings := range resp.NetworkSettings.Ports {
181- for _, binding := range bindings {
182- if binding.HostPort != "" {
183- if port, err := strconv.Atoi(binding.HostPort); err == nil {
184- ports = append(ports, port)
185- }
186- }
187- }
188- }
189- }
190-
191- // Get container name (remove leading slash)
192- name := strings.TrimPrefix(resp.Name, "/")
193-
194- return &runtime.Container{
195- ID: resp.ID,
196- Image: resp.Config.Image,
197- Name: name,
198- Status: resp.State.Status,
199- Ports: ports,
200- Labels: resp.Config.Labels,
201- }, nil
202-}
203-
204-// GetContainerLogs gets container logs
205-func (p *PodmanRuntime) GetContainerLogs(ctx context.Context, containerID string, follow bool) (io.ReadCloser, error) {
206- logs, err := p.client.ContainerLogs(ctx, containerID, container.LogsOptions{
207- ShowStdout: true,
208- ShowStderr: true,
209- Follow: follow,
210- Timestamps: true,
211- })
212- if err != nil {
213- return nil, fmt.Errorf("failed to get logs for container %s: %w", containerID, err)
214- }
215-
216- return logs, nil
217-}
218-
219-// PullImage pulls an image
220-func (p *PodmanRuntime) PullImage(ctx context.Context, imageRef string) error {
221- log.Info().Str("image", imageRef).Msg("Pulling image with Podman")
222-
223- reader, err := p.client.ImagePull(ctx, imageRef, image.PullOptions{})
224- if err != nil {
225- return fmt.Errorf("failed to pull image %s: %w", imageRef, err)
226- }
227- defer reader.Close()
228-
229- // Read the response to completion (this is required for the pull to complete)
230- _, err = io.Copy(io.Discard, reader)
231- if err != nil {
232- return fmt.Errorf("failed to read pull response for image %s: %w", imageRef, err)
233- }
234-
235- log.Info().Str("image", imageRef).Msg("Image pulled successfully with Podman")
236- return nil
237-}
238-
239-// RemoveImage removes an image
240-func (p *PodmanRuntime) RemoveImage(ctx context.Context, imageRef string, force bool) error {
241- _, err := p.client.ImageRemove(ctx, imageRef, image.RemoveOptions{Force: force})
242- if err != nil {
243- return fmt.Errorf("failed to remove image %s: %w", imageRef, err)
244- }
245-
246- log.Info().Str("image", imageRef).Bool("force", force).Msg("Podman image removed")
247- return nil
248-}
249-
250-// ListImages lists images
251-func (p *PodmanRuntime) ListImages(ctx context.Context) ([]string, error) {
252- images, err := p.client.ImageList(ctx, image.ListOptions{})
253- if err != nil {
254- return nil, fmt.Errorf("failed to list images: %w", err)
255- }
256-
257- var result []string
258- for _, img := range images {
259- for _, tag := range img.RepoTags {
260- if tag != "<none>:<none>" {
261- result = append(result, tag)
262- }
263- }
264- }
265-
266- return result, nil
267-}
268-
269-// Ping checks if Podman is responsive
270-func (p *PodmanRuntime) Ping(ctx context.Context) error {
271- _, err := p.client.Ping(ctx)
272- if err != nil {
273- return fmt.Errorf("Podman ping failed: %w", err)
274- }
275- return nil
276-}
277-
278-// Version returns Podman version
279-func (p *PodmanRuntime) Version(ctx context.Context) (string, error) {
280- version, err := p.client.ServerVersion(ctx)
281- if err != nil {
282- return "", fmt.Errorf("failed to get Podman version: %w", err)
283- }
284- return version.Version, nil
285-}
286-
287-// IsContainerRunning checks if a container is running
288-func (p *PodmanRuntime) IsContainerRunning(ctx context.Context, containerID string) (bool, error) {
289- container, err := p.InspectContainer(ctx, containerID)
290- if err != nil {
291- return false, err
292- }
293- return container.Status == "running", nil
294-}
295-
296-// GetContainerPort gets the host port for a container's internal port
297-func (p *PodmanRuntime) GetContainerPort(ctx context.Context, containerID string, internalPort int) (int, error) {
298- resp, err := p.client.ContainerInspect(ctx, containerID)
299- if err != nil {
300- return 0, fmt.Errorf("failed to inspect container %s: %w", containerID, err)
301- }
302-
303- if resp.NetworkSettings == nil || resp.NetworkSettings.Ports == nil {
304- return 0, fmt.Errorf("no port mappings found for container %s", containerID)
305- }
306-
307- containerPort := nat.Port(fmt.Sprintf("%d/tcp", internalPort))
308- bindings, exists := resp.NetworkSettings.Ports[containerPort]
309- if !exists || len(bindings) == 0 {
310- return 0, fmt.Errorf("port %d not mapped for container %s", internalPort, containerID)
311- }
312-
313- hostPort, err := strconv.Atoi(bindings[0].HostPort)
314- if err != nil {
315- return 0, fmt.Errorf("invalid host port for container %s: %w", containerID, err)
316- }
317-
318- return hostPort, nil
319-}
320-
321-// GetImageExposedPorts gets the exposed ports from an image
322-func (p *PodmanRuntime) GetImageExposedPorts(ctx context.Context, imageRef string) ([]int, error) {
323- imageInspect, err := p.client.ImageInspect(ctx, imageRef)
324- if err != nil {
325- return nil, fmt.Errorf("failed to inspect image %s: %w", imageRef, err)
326- }
327-
328- var ports []int
329- if imageInspect.Config != nil && imageInspect.Config.ExposedPorts != nil {
330- for portSpec := range imageInspect.Config.ExposedPorts {
331- // Parse port from format like "80/tcp"
332- portStr := strings.Split(string(portSpec), "/")[0]
333- if port, err := strconv.Atoi(portStr); err == nil {
334- ports = append(ports, port)
335- }
336- }
337- }
338-
339- // If no exposed ports found, return common web ports as fallback
340- if len(ports) == 0 {
341- log.Warn().Str("image", imageRef).Msg("No EXPOSE directives found in image, using common web ports")
342- return []int{80, 8080, 3000}, nil
343- }
344-
345- log.Info().Str("image", imageRef).Ints("exposed_ports", ports).Msg("Found exposed ports in image")
346- return ports, nil
347-}
348-
349-// GetContainerExposedPorts gets all exposed ports from a running container
350-func (p *PodmanRuntime) GetContainerExposedPorts(ctx context.Context, containerID string) ([]int, error) {
351- resp, err := p.client.ContainerInspect(ctx, containerID)
352- if err != nil {
353- return nil, fmt.Errorf("failed to inspect container %s: %w", containerID, err)
354- }
355-
356- var ports []int
357- if resp.NetworkSettings != nil && resp.NetworkSettings.Ports != nil {
358- for portSpec := range resp.NetworkSettings.Ports {
359- // Parse port from format like "80/tcp"
360- portStr := strings.Split(string(portSpec), "/")[0]
361- if port, err := strconv.Atoi(portStr); err == nil {
362- ports = append(ports, port)
363- }
364- }
365- }
366-
367- return ports, nil
368-}
369-
370-// GetContainerNetworkInfo gets container's internal IP and automatically detects the best port to use
371-func (p *PodmanRuntime) GetContainerNetworkInfo(ctx context.Context, containerID string) (string, int, error) {
372- resp, err := p.client.ContainerInspect(ctx, containerID)
373- if err != nil {
374- return "", 0, fmt.Errorf("failed to inspect container %s: %w", containerID, err)
375- }
376-
377- // Get container's internal IP address
378- var containerIP string
379- if resp.NetworkSettings != nil && resp.NetworkSettings.Networks != nil {
380- // Use the first available network (usually bridge or custom network)
381- for _, network := range resp.NetworkSettings.Networks {
382- if network.IPAddress != "" {
383- containerIP = network.IPAddress
384- break
385- }
386- }
387- }
388-
389- if containerIP == "" {
390- return "", 0, fmt.Errorf("no IP address found for container %s", containerID)
391- }
392-
393- // Get exposed ports and select the best one (Traefik-style logic)
394- var exposedPorts []int
395- if resp.NetworkSettings != nil && resp.NetworkSettings.Ports != nil {
396- for portSpec := range resp.NetworkSettings.Ports {
397- // Parse port from format like "80/tcp"
398- portStr := strings.Split(string(portSpec), "/")[0]
399- if port, err := strconv.Atoi(portStr); err == nil {
400- exposedPorts = append(exposedPorts, port)
401- }
402- }
403- }
404-
405- if len(exposedPorts) == 0 {
406- return "", 0, fmt.Errorf("no exposed ports found for container %s", containerID)
407- }
408-
409- // Sort ports and select the lowest one (Traefik strategy)
410- var selectedPort int
411- if len(exposedPorts) == 1 {
412- selectedPort = exposedPorts[0]
413- } else {
414- // Find the lowest port number
415- selectedPort = exposedPorts[0]
416- for _, port := range exposedPorts {
417- if port < selectedPort {
418- selectedPort = port
419- }
420- }
421- }
422-
423- log.Info().
424- Str("container_id", containerID).
425- Str("ip", containerIP).
426- Int("selected_port", selectedPort).
427- Ints("available_ports", exposedPorts).
428- Msg("Podman container network info detected")
429-
430- return containerIP, selectedPort, nil
431-}⏎̸
@@ -1,431 +0,0 @@
1-package container
2-
3-import (
4- "context"
5- "fmt"
6- "io"
7- "strconv"
8- "strings"
9-
10- "github.com/docker/docker/api/types/container"
11- "github.com/docker/docker/api/types/image"
12- "github.com/docker/docker/client"
13- "github.com/docker/go-connections/nat"
14- "github.com/rs/zerolog/log"
15-
16- "gordon/pkg/runtime"
17-)
18-
19-// PodmanRuntime implements the Runtime interface using Podman API
20-// Podman API is compatible with Docker API, so we can reuse the Docker client
21-type PodmanRuntime struct {
22- client *client.Client
23-}
24-
25-// NewPodmanRuntime creates a new Podman runtime instance
26-func NewPodmanRuntime() (*PodmanRuntime, error) {
27- // Try to create with auto-detection
28- socketPath := getDefaultPodmanSocket(false) // Try root first
29- rt, err := createPodmanRuntimeWithSocket(socketPath)
30- if err != nil {
31- // Try rootless
32- socketPath = getDefaultPodmanSocket(true)
33- rt, err = createPodmanRuntimeWithSocket(socketPath)
34- if err != nil {
35- return nil, fmt.Errorf("failed to create Podman runtime: %w", err)
36- }
37- }
38-
39- return rt.(*PodmanRuntime), nil
40-}
41-
42-// CreateContainer creates a new container
43-func (p *PodmanRuntime) CreateContainer(ctx context.Context, config *runtime.ContainerConfig) (*runtime.Container, error) {
44- // Convert ports to Podman/Docker format
45- exposedPorts := make(nat.PortSet)
46- portBindings := make(nat.PortMap)
47-
48- for _, port := range config.Ports {
49- containerPort := nat.Port(fmt.Sprintf("%d/tcp", port))
50- exposedPorts[containerPort] = struct{}{}
51-
52- // Bind to random available port on host
53- portBindings[containerPort] = []nat.PortBinding{
54- {
55- HostIP: "0.0.0.0",
56- HostPort: "0", // Podman will assign a random available port
57- },
58- }
59- }
60-
61- // Create container configuration
62- containerConfig := &container.Config{
63- Image: config.Image,
64- Env: config.Env,
65- ExposedPorts: exposedPorts,
66- WorkingDir: config.WorkingDir,
67- Cmd: config.Cmd,
68- Labels: config.Labels,
69- }
70-
71- hostConfig := &container.HostConfig{
72- PortBindings: portBindings,
73- AutoRemove: config.AutoRemove,
74- }
75-
76- // Create the container
77- resp, err := p.client.ContainerCreate(ctx, containerConfig, hostConfig, nil, nil, config.Name)
78- if err != nil {
79- return nil, fmt.Errorf("failed to create container: %w", err)
80- }
81-
82- log.Info().Str("id", resp.ID).Str("name", config.Name).Str("image", config.Image).Msg("Podman container created")
83-
84- // Inspect the created container to get full details
85- return p.InspectContainer(ctx, resp.ID)
86-}
87-
88-// StartContainer starts a container
89-func (p *PodmanRuntime) StartContainer(ctx context.Context, containerID string) error {
90- err := p.client.ContainerStart(ctx, containerID, container.StartOptions{})
91- if err != nil {
92- return fmt.Errorf("failed to start container %s: %w", containerID, err)
93- }
94-
95- log.Info().Str("id", containerID).Msg("Podman container started")
96- return nil
97-}
98-
99-// StopContainer stops a container
100-func (p *PodmanRuntime) StopContainer(ctx context.Context, containerID string) error {
101- timeout := 30 // 30 seconds timeout
102- err := p.client.ContainerStop(ctx, containerID, container.StopOptions{Timeout: &timeout})
103- if err != nil {
104- return fmt.Errorf("failed to stop container %s: %w", containerID, err)
105- }
106-
107- log.Info().Str("id", containerID).Msg("Podman container stopped")
108- return nil
109-}
110-
111-// RestartContainer restarts a container
112-func (p *PodmanRuntime) RestartContainer(ctx context.Context, containerID string) error {
113- timeout := 30 // 30 seconds timeout
114- err := p.client.ContainerRestart(ctx, containerID, container.StopOptions{Timeout: &timeout})
115- if err != nil {
116- return fmt.Errorf("failed to restart container %s: %w", containerID, err)
117- }
118-
119- log.Info().Str("id", containerID).Msg("Podman container restarted")
120- return nil
121-}
122-
123-// RemoveContainer removes a container
124-func (p *PodmanRuntime) RemoveContainer(ctx context.Context, containerID string, force bool) error {
125- err := p.client.ContainerRemove(ctx, containerID, container.RemoveOptions{Force: force})
126- if err != nil {
127- return fmt.Errorf("failed to remove container %s: %w", containerID, err)
128- }
129-
130- log.Info().Str("id", containerID).Bool("force", force).Msg("Podman container removed")
131- return nil
132-}
133-
134-// ListContainers lists containers
135-func (p *PodmanRuntime) ListContainers(ctx context.Context, all bool) ([]*runtime.Container, error) {
136- containers, err := p.client.ContainerList(ctx, container.ListOptions{All: all})
137- if err != nil {
138- return nil, fmt.Errorf("failed to list containers: %w", err)
139- }
140-
141- var result []*runtime.Container
142- for _, c := range containers {
143- // Extract ports
144- var ports []int
145- for _, port := range c.Ports {
146- if port.PublicPort > 0 {
147- ports = append(ports, int(port.PublicPort))
148- }
149- }
150-
151- // Get the primary name (remove leading slash)
152- name := ""
153- if len(c.Names) > 0 {
154- name = strings.TrimPrefix(c.Names[0], "/")
155- }
156-
157- result = append(result, &runtime.Container{
158- ID: c.ID,
159- Image: c.Image,
160- Name: name,
161- Status: c.Status,
162- Ports: ports,
163- Labels: c.Labels,
164- })
165- }
166-
167- return result, nil
168-}
169-
170-// InspectContainer inspects a container
171-func (p *PodmanRuntime) InspectContainer(ctx context.Context, containerID string) (*runtime.Container, error) {
172- resp, err := p.client.ContainerInspect(ctx, containerID)
173- if err != nil {
174- return nil, fmt.Errorf("failed to inspect container %s: %w", containerID, err)
175- }
176-
177- // Extract published ports
178- var ports []int
179- if resp.NetworkSettings != nil && resp.NetworkSettings.Ports != nil {
180- for _, bindings := range resp.NetworkSettings.Ports {
181- for _, binding := range bindings {
182- if binding.HostPort != "" {
183- if port, err := strconv.Atoi(binding.HostPort); err == nil {
184- ports = append(ports, port)
185- }
186- }
187- }
188- }
189- }
190-
191- // Get container name (remove leading slash)
192- name := strings.TrimPrefix(resp.Name, "/")
193-
194- return &runtime.Container{
195- ID: resp.ID,
196- Image: resp.Config.Image,
197- Name: name,
198- Status: resp.State.Status,
199- Ports: ports,
200- Labels: resp.Config.Labels,
201- }, nil
202-}
203-
204-// GetContainerLogs gets container logs
205-func (p *PodmanRuntime) GetContainerLogs(ctx context.Context, containerID string, follow bool) (io.ReadCloser, error) {
206- logs, err := p.client.ContainerLogs(ctx, containerID, container.LogsOptions{
207- ShowStdout: true,
208- ShowStderr: true,
209- Follow: follow,
210- Timestamps: true,
211- })
212- if err != nil {
213- return nil, fmt.Errorf("failed to get logs for container %s: %w", containerID, err)
214- }
215-
216- return logs, nil
217-}
218-
219-// PullImage pulls an image
220-func (p *PodmanRuntime) PullImage(ctx context.Context, imageRef string) error {
221- log.Info().Str("image", imageRef).Msg("Pulling image with Podman")
222-
223- reader, err := p.client.ImagePull(ctx, imageRef, image.PullOptions{})
224- if err != nil {
225- return fmt.Errorf("failed to pull image %s: %w", imageRef, err)
226- }
227- defer reader.Close()
228-
229- // Read the response to completion (this is required for the pull to complete)
230- _, err = io.Copy(io.Discard, reader)
231- if err != nil {
232- return fmt.Errorf("failed to read pull response for image %s: %w", imageRef, err)
233- }
234-
235- log.Info().Str("image", imageRef).Msg("Image pulled successfully with Podman")
236- return nil
237-}
238-
239-// RemoveImage removes an image
240-func (p *PodmanRuntime) RemoveImage(ctx context.Context, imageRef string, force bool) error {
241- _, err := p.client.ImageRemove(ctx, imageRef, image.RemoveOptions{Force: force})
242- if err != nil {
243- return fmt.Errorf("failed to remove image %s: %w", imageRef, err)
244- }
245-
246- log.Info().Str("image", imageRef).Bool("force", force).Msg("Podman image removed")
247- return nil
248-}
249-
250-// ListImages lists images
251-func (p *PodmanRuntime) ListImages(ctx context.Context) ([]string, error) {
252- images, err := p.client.ImageList(ctx, image.ListOptions{})
253- if err != nil {
254- return nil, fmt.Errorf("failed to list images: %w", err)
255- }
256-
257- var result []string
258- for _, img := range images {
259- for _, tag := range img.RepoTags {
260- if tag != "<none>:<none>" {
261- result = append(result, tag)
262- }
263- }
264- }
265-
266- return result, nil
267-}
268-
269-// Ping checks if Podman is responsive
270-func (p *PodmanRuntime) Ping(ctx context.Context) error {
271- _, err := p.client.Ping(ctx)
272- if err != nil {
273- return fmt.Errorf("Podman ping failed: %w", err)
274- }
275- return nil
276-}
277-
278-// Version returns Podman version
279-func (p *PodmanRuntime) Version(ctx context.Context) (string, error) {
280- version, err := p.client.ServerVersion(ctx)
281- if err != nil {
282- return "", fmt.Errorf("failed to get Podman version: %w", err)
283- }
284- return version.Version, nil
285-}
286-
287-// IsContainerRunning checks if a container is running
288-func (p *PodmanRuntime) IsContainerRunning(ctx context.Context, containerID string) (bool, error) {
289- container, err := p.InspectContainer(ctx, containerID)
290- if err != nil {
291- return false, err
292- }
293- return container.Status == "running", nil
294-}
295-
296-// GetContainerPort gets the host port for a container's internal port
297-func (p *PodmanRuntime) GetContainerPort(ctx context.Context, containerID string, internalPort int) (int, error) {
298- resp, err := p.client.ContainerInspect(ctx, containerID)
299- if err != nil {
300- return 0, fmt.Errorf("failed to inspect container %s: %w", containerID, err)
301- }
302-
303- if resp.NetworkSettings == nil || resp.NetworkSettings.Ports == nil {
304- return 0, fmt.Errorf("no port mappings found for container %s", containerID)
305- }
306-
307- containerPort := nat.Port(fmt.Sprintf("%d/tcp", internalPort))
308- bindings, exists := resp.NetworkSettings.Ports[containerPort]
309- if !exists || len(bindings) == 0 {
310- return 0, fmt.Errorf("port %d not mapped for container %s", internalPort, containerID)
311- }
312-
313- hostPort, err := strconv.Atoi(bindings[0].HostPort)
314- if err != nil {
315- return 0, fmt.Errorf("invalid host port for container %s: %w", containerID, err)
316- }
317-
318- return hostPort, nil
319-}
320-
321-// GetImageExposedPorts gets the exposed ports from an image
322-func (p *PodmanRuntime) GetImageExposedPorts(ctx context.Context, imageRef string) ([]int, error) {
323- imageInspect, err := p.client.ImageInspect(ctx, imageRef)
324- if err != nil {
325- return nil, fmt.Errorf("failed to inspect image %s: %w", imageRef, err)
326- }
327-
328- var ports []int
329- if imageInspect.Config != nil && imageInspect.Config.ExposedPorts != nil {
330- for portSpec := range imageInspect.Config.ExposedPorts {
331- // Parse port from format like "80/tcp"
332- portStr := strings.Split(string(portSpec), "/")[0]
333- if port, err := strconv.Atoi(portStr); err == nil {
334- ports = append(ports, port)
335- }
336- }
337- }
338-
339- // If no exposed ports found, return common web ports as fallback
340- if len(ports) == 0 {
341- log.Warn().Str("image", imageRef).Msg("No EXPOSE directives found in image, using common web ports")
342- return []int{80, 8080, 3000}, nil
343- }
344-
345- log.Info().Str("image", imageRef).Ints("exposed_ports", ports).Msg("Found exposed ports in image")
346- return ports, nil
347-}
348-
349-// GetContainerExposedPorts gets all exposed ports from a running container
350-func (p *PodmanRuntime) GetContainerExposedPorts(ctx context.Context, containerID string) ([]int, error) {
351- resp, err := p.client.ContainerInspect(ctx, containerID)
352- if err != nil {
353- return nil, fmt.Errorf("failed to inspect container %s: %w", containerID, err)
354- }
355-
356- var ports []int
357- if resp.NetworkSettings != nil && resp.NetworkSettings.Ports != nil {
358- for portSpec := range resp.NetworkSettings.Ports {
359- // Parse port from format like "80/tcp"
360- portStr := strings.Split(string(portSpec), "/")[0]
361- if port, err := strconv.Atoi(portStr); err == nil {
362- ports = append(ports, port)
363- }
364- }
365- }
366-
367- return ports, nil
368-}
369-
370-// GetContainerNetworkInfo gets container's internal IP and automatically detects the best port to use
371-func (p *PodmanRuntime) GetContainerNetworkInfo(ctx context.Context, containerID string) (string, int, error) {
372- resp, err := p.client.ContainerInspect(ctx, containerID)
373- if err != nil {
374- return "", 0, fmt.Errorf("failed to inspect container %s: %w", containerID, err)
375- }
376-
377- // Get container's internal IP address
378- var containerIP string
379- if resp.NetworkSettings != nil && resp.NetworkSettings.Networks != nil {
380- // Use the first available network (usually bridge or custom network)
381- for _, network := range resp.NetworkSettings.Networks {
382- if network.IPAddress != "" {
383- containerIP = network.IPAddress
384- break
385- }
386- }
387- }
388-
389- if containerIP == "" {
390- return "", 0, fmt.Errorf("no IP address found for container %s", containerID)
391- }
392-
393- // Get exposed ports and select the best one (Traefik-style logic)
394- var exposedPorts []int
395- if resp.NetworkSettings != nil && resp.NetworkSettings.Ports != nil {
396- for portSpec := range resp.NetworkSettings.Ports {
397- // Parse port from format like "80/tcp"
398- portStr := strings.Split(string(portSpec), "/")[0]
399- if port, err := strconv.Atoi(portStr); err == nil {
400- exposedPorts = append(exposedPorts, port)
401- }
402- }
403- }
404-
405- if len(exposedPorts) == 0 {
406- return "", 0, fmt.Errorf("no exposed ports found for container %s", containerID)
407- }
408-
409- // Sort ports and select the lowest one (Traefik strategy)
410- var selectedPort int
411- if len(exposedPorts) == 1 {
412- selectedPort = exposedPorts[0]
413- } else {
414- // Find the lowest port number
415- selectedPort = exposedPorts[0]
416- for _, port := range exposedPorts {
417- if port < selectedPort {
418- selectedPort = port
419- }
420- }
421- }
422-
423- log.Info().
424- Str("container_id", containerID).
425- Str("ip", containerIP).
426- Int("selected_port", selectedPort).
427- Ints("available_ports", exposedPorts).
428- Msg("Podman container network info detected")
429-
430- return containerIP, selectedPort, nil
431-}⏎̸
M internal/events/bus.go +32 -26
@@ -11,22 +11,22 @@ import (
1111 )
1212
1313 type InMemoryEventBus struct {
14- handlers []EventHandler
15- eventChan chan Event
16- done chan struct{}
17- mu sync.RWMutex
18- ctx context.Context
19- cancel context.CancelFunc
20- bufferSize int
14+ handlers []EventHandler
15+ eventChan chan Event
16+ done chan struct{}
17+ mu sync.RWMutex
18+ ctx context.Context
19+ cancel context.CancelFunc
20+ bufferSize int
2121 }
2222
2323 func NewInMemoryEventBus(bufferSize int) *InMemoryEventBus {
2424 if bufferSize <= 0 {
2525 bufferSize = 100
2626 }
27-
27+
2828 ctx, cancel := context.WithCancel(context.Background())
29-
29+
3030 return &InMemoryEventBus{
3131 handlers: make([]EventHandler, 0),
3232 eventChan: make(chan Event, bufferSize),
@@ -37,13 +37,19 @@ func NewInMemoryEventBus(bufferSize int) *InMemoryEventBus {
3737 }
3838 }
3939
40-func (bus *InMemoryEventBus) Publish(event Event) error {
41- if event.ID == "" {
42- event.ID = uuid.New().String()
40+func (bus *InMemoryEventBus) Publish(eventType EventType, payload interface{}) error {
41+ event := Event{
42+ ID: uuid.New().String(),
43+ Type: eventType,
44+ Timestamp: time.Now(),
45+ Data: payload,
4346 }
44-
45- if event.Timestamp.IsZero() {
46- event.Timestamp = time.Now()
47+
48+ // Add specific fields for known event types
49+ switch p := payload.(type) {
50+ case ImagePushedPayload:
51+ event.ImageName = p.Name
52+ event.Tag = p.Reference
4753 }
4854
4955 select {
@@ -65,20 +71,20 @@ func (bus *InMemoryEventBus) Publish(event Event) error {
6571 func (bus *InMemoryEventBus) Subscribe(handler EventHandler) error {
6672 bus.mu.Lock()
6773 defer bus.mu.Unlock()
68-
74+
6975 bus.handlers = append(bus.handlers, handler)
7076 log.Debug().
7177 Str("handler_type", fmt.Sprintf("%T", handler)).
7278 Int("total_handlers", len(bus.handlers)).
7379 Msg("Event handler subscribed")
74-
80+
7581 return nil
7682 }
7783
7884 func (bus *InMemoryEventBus) Unsubscribe(handler EventHandler) error {
7985 bus.mu.Lock()
8086 defer bus.mu.Unlock()
81-
87+
8288 for i, h := range bus.handlers {
8389 if h == handler {
8490 bus.handlers = append(bus.handlers[:i], bus.handlers[i+1:]...)
@@ -89,7 +95,7 @@ func (bus *InMemoryEventBus) Unsubscribe(handler EventHandler) error {
8995 return nil
9096 }
9197 }
92-
98+
9399 return fmt.Errorf("handler not found")
94100 }
95101
@@ -97,16 +103,16 @@ func (bus *InMemoryEventBus) Start() error {
97103 log.Info().
98104 Int("buffer_size", bus.bufferSize).
99105 Msg("Starting event bus")
100-
106+
101107 go bus.processEvents()
102108 return nil
103109 }
104110
105111 func (bus *InMemoryEventBus) Stop() error {
106112 log.Info().Msg("Stopping event bus")
107-
113+
108114 bus.cancel()
109-
115+
110116 select {
111117 case <-bus.done:
112118 log.Info().Msg("Event bus stopped")
@@ -119,7 +125,7 @@ func (bus *InMemoryEventBus) Stop() error {
119125
120126 func (bus *InMemoryEventBus) processEvents() {
121127 defer close(bus.done)
122-
128+
123129 for {
124130 select {
125131 case event := <-bus.eventChan:
@@ -136,12 +142,12 @@ func (bus *InMemoryEventBus) handleEvent(event Event) {
136142 handlers := make([]EventHandler, len(bus.handlers))
137143 copy(handlers, bus.handlers)
138144 bus.mu.RUnlock()
139-
145+
140146 for _, handler := range handlers {
141147 if handler.CanHandle(event.Type) {
142148 go func(h EventHandler) {
143149 start := time.Now()
144-
150+
145151 if err := h.Handle(event); err != nil {
146152 log.Error().
147153 Err(err).
@@ -160,4 +166,4 @@ func (bus *InMemoryEventBus) handleEvent(event Event) {
160166 }(handler)
161167 }
162168 }
163-}⏎̸
169+}
@@ -11,22 +11,22 @@ import (
11 )11 )
12 12
13 type InMemoryEventBus struct {13 type InMemoryEventBus struct {
14- handlers []EventHandler14+ handlers []EventHandler
15- eventChan chan Event15+ eventChan chan Event
16- done chan struct{}16+ done chan struct{}
17- mu sync.RWMutex17+ mu sync.RWMutex
18- ctx context.Context18+ ctx context.Context
19- cancel context.CancelFunc19+ cancel context.CancelFunc
20- bufferSize int20+ bufferSize int
21 }21 }
22 22
23 func NewInMemoryEventBus(bufferSize int) *InMemoryEventBus {23 func NewInMemoryEventBus(bufferSize int) *InMemoryEventBus {
24 if bufferSize <= 0 {24 if bufferSize <= 0 {
25 bufferSize = 10025 bufferSize = 100
26 }26 }
27- 27+
28 ctx, cancel := context.WithCancel(context.Background())28 ctx, cancel := context.WithCancel(context.Background())
29- 29+
30 return &InMemoryEventBus{30 return &InMemoryEventBus{
31 handlers: make([]EventHandler, 0),31 handlers: make([]EventHandler, 0),
32 eventChan: make(chan Event, bufferSize),32 eventChan: make(chan Event, bufferSize),
@@ -37,13 +37,19 @@ func NewInMemoryEventBus(bufferSize int) *InMemoryEventBus {
37 }37 }
38 }38 }
39 39
40-func (bus *InMemoryEventBus) Publish(event Event) error {40+func (bus *InMemoryEventBus) Publish(eventType EventType, payload interface{}) error {
41- if event.ID == "" {41+ event := Event{
42- event.ID = uuid.New().String()42+ ID: uuid.New().String(),
43+ Type: eventType,
44+ Timestamp: time.Now(),
45+ Data: payload,
43 }46 }
44- 47+
45- if event.Timestamp.IsZero() {48+ // Add specific fields for known event types
46- event.Timestamp = time.Now()49+ switch p := payload.(type) {
50+ case ImagePushedPayload:
51+ event.ImageName = p.Name
52+ event.Tag = p.Reference
47 }53 }
48 54
49 select {55 select {
@@ -65,20 +71,20 @@ func (bus *InMemoryEventBus) Publish(event Event) error {
65 func (bus *InMemoryEventBus) Subscribe(handler EventHandler) error {71 func (bus *InMemoryEventBus) Subscribe(handler EventHandler) error {
66 bus.mu.Lock()72 bus.mu.Lock()
67 defer bus.mu.Unlock()73 defer bus.mu.Unlock()
68- 74+
69 bus.handlers = append(bus.handlers, handler)75 bus.handlers = append(bus.handlers, handler)
70 log.Debug().76 log.Debug().
71 Str("handler_type", fmt.Sprintf("%T", handler)).77 Str("handler_type", fmt.Sprintf("%T", handler)).
72 Int("total_handlers", len(bus.handlers)).78 Int("total_handlers", len(bus.handlers)).
73 Msg("Event handler subscribed")79 Msg("Event handler subscribed")
74- 80+
75 return nil81 return nil
76 }82 }
77 83
78 func (bus *InMemoryEventBus) Unsubscribe(handler EventHandler) error {84 func (bus *InMemoryEventBus) Unsubscribe(handler EventHandler) error {
79 bus.mu.Lock()85 bus.mu.Lock()
80 defer bus.mu.Unlock()86 defer bus.mu.Unlock()
81- 87+
82 for i, h := range bus.handlers {88 for i, h := range bus.handlers {
83 if h == handler {89 if h == handler {
84 bus.handlers = append(bus.handlers[:i], bus.handlers[i+1:]...)90 bus.handlers = append(bus.handlers[:i], bus.handlers[i+1:]...)
@@ -89,7 +95,7 @@ func (bus *InMemoryEventBus) Unsubscribe(handler EventHandler) error {
89 return nil95 return nil
90 }96 }
91 }97 }
92- 98+
93 return fmt.Errorf("handler not found")99 return fmt.Errorf("handler not found")
94 }100 }
95 101
@@ -97,16 +103,16 @@ func (bus *InMemoryEventBus) Start() error {
97 log.Info().103 log.Info().
98 Int("buffer_size", bus.bufferSize).104 Int("buffer_size", bus.bufferSize).
99 Msg("Starting event bus")105 Msg("Starting event bus")
100- 106+
101 go bus.processEvents()107 go bus.processEvents()
102 return nil108 return nil
103 }109 }
104 110
105 func (bus *InMemoryEventBus) Stop() error {111 func (bus *InMemoryEventBus) Stop() error {
106 log.Info().Msg("Stopping event bus")112 log.Info().Msg("Stopping event bus")
107- 113+
108 bus.cancel()114 bus.cancel()
109- 115+
110 select {116 select {
111 case <-bus.done:117 case <-bus.done:
112 log.Info().Msg("Event bus stopped")118 log.Info().Msg("Event bus stopped")
@@ -119,7 +125,7 @@ func (bus *InMemoryEventBus) Stop() error {
119 125
120 func (bus *InMemoryEventBus) processEvents() {126 func (bus *InMemoryEventBus) processEvents() {
121 defer close(bus.done)127 defer close(bus.done)
122- 128+
123 for {129 for {
124 select {130 select {
125 case event := <-bus.eventChan:131 case event := <-bus.eventChan:
@@ -136,12 +142,12 @@ func (bus *InMemoryEventBus) handleEvent(event Event) {
136 handlers := make([]EventHandler, len(bus.handlers))142 handlers := make([]EventHandler, len(bus.handlers))
137 copy(handlers, bus.handlers)143 copy(handlers, bus.handlers)
138 bus.mu.RUnlock()144 bus.mu.RUnlock()
139- 145+
140 for _, handler := range handlers {146 for _, handler := range handlers {
141 if handler.CanHandle(event.Type) {147 if handler.CanHandle(event.Type) {
142 go func(h EventHandler) {148 go func(h EventHandler) {
143 start := time.Now()149 start := time.Now()
144- 150+
145 if err := h.Handle(event); err != nil {151 if err := h.Handle(event); err != nil {
146 log.Error().152 log.Error().
147 Err(err).153 Err(err).
@@ -160,4 +166,4 @@ func (bus *InMemoryEventBus) handleEvent(event Event) {
160 }(handler)166 }(handler)
161 }167 }
162 }168 }
163-}⏎̸169+}
M internal/events/types.go +21 -15
@@ -7,23 +7,29 @@ import (
77 type EventType string
88
99 const (
10- ImagePushed EventType = "image.pushed"
11- ImageDeleted EventType = "image.deleted"
12- ConfigReload EventType = "config.reload"
13- ContainerStop EventType = "container.stop"
14- ContainerStart EventType = "container.start"
10+ ImagePushed EventType = "image.pushed"
11+ ImageDeleted EventType = "image.deleted"
12+ ConfigReload EventType = "config.reload"
13+ ContainerStop EventType = "container.stop"
14+ ContainerStart EventType = "container.start"
1515 ContainerHealthCheck EventType = "container.health_check"
1616 )
1717
1818 type Event struct {
19- ID string `json:"id"`
20- Type EventType `json:"type"`
21- Timestamp time.Time `json:"timestamp"`
22- ImageName string `json:"image_name,omitempty"`
23- Tag string `json:"tag,omitempty"`
24- Route string `json:"route,omitempty"`
25- ContainerID string `json:"container_id,omitempty"`
26- Data map[string]interface{} `json:"data,omitempty"`
19+ ID string `json:"id"`
20+ Type EventType `json:"type"`
21+ Timestamp time.Time `json:"timestamp"`
22+ ImageName string `json:"image_name,omitempty"`
23+ Tag string `json:"tag,omitempty"`
24+ Route string `json:"route,omitempty"`
25+ ContainerID string `json:"container_id,omitempty"`
26+ Data interface{} `json:"data,omitempty"`
27+}
28+
29+type ImagePushedPayload struct {
30+ Name string `json:"name"`
31+ Reference string `json:"reference"`
32+ Manifest []byte `json:"-"`
2733 }
2834
2935 type EventHandler interface {
@@ -32,9 +38,9 @@ type EventHandler interface {
3238 }
3339
3440 type EventBus interface {
35- Publish(event Event) error
41+ Publish(eventType EventType, payload interface{}) error
3642 Subscribe(handler EventHandler) error
3743 Unsubscribe(handler EventHandler) error
3844 Start() error
3945 Stop() error
40-}⏎̸
46+}
@@ -7,23 +7,29 @@ import (
7 type EventType string7 type EventType string
8 8
9 const (9 const (
10- ImagePushed EventType = "image.pushed"10+ ImagePushed EventType = "image.pushed"
11- ImageDeleted EventType = "image.deleted"11+ ImageDeleted EventType = "image.deleted"
12- ConfigReload EventType = "config.reload"12+ ConfigReload EventType = "config.reload"
13- ContainerStop EventType = "container.stop"13+ ContainerStop EventType = "container.stop"
14- ContainerStart EventType = "container.start"14+ ContainerStart EventType = "container.start"
15 ContainerHealthCheck EventType = "container.health_check"15 ContainerHealthCheck EventType = "container.health_check"
16 )16 )
17 17
18 type Event struct {18 type Event struct {
19- ID string `json:"id"`19+ ID string `json:"id"`
20- Type EventType `json:"type"`20+ Type EventType `json:"type"`
21- Timestamp time.Time `json:"timestamp"`21+ Timestamp time.Time `json:"timestamp"`
22- ImageName string `json:"image_name,omitempty"`22+ ImageName string `json:"image_name,omitempty"`
23- Tag string `json:"tag,omitempty"`23+ Tag string `json:"tag,omitempty"`
24- Route string `json:"route,omitempty"`24+ Route string `json:"route,omitempty"`
25- ContainerID string `json:"container_id,omitempty"`25+ ContainerID string `json:"container_id,omitempty"`
26- Data map[string]interface{} `json:"data,omitempty"`26+ Data interface{} `json:"data,omitempty"`
27+}
28+
29+type ImagePushedPayload struct {
30+ Name string `json:"name"`
31+ Reference string `json:"reference"`
32+ Manifest []byte `json:"-"`
27 }33 }
28 34
29 type EventHandler interface {35 type EventHandler interface {
@@ -32,9 +38,9 @@ type EventHandler interface {
32 }38 }
33 39
34 type EventBus interface {40 type EventBus interface {
35- Publish(event Event) error41+ Publish(eventType EventType, payload interface{}) error
36 Subscribe(handler EventHandler) error42 Subscribe(handler EventHandler) error
37 Unsubscribe(handler EventHandler) error43 Unsubscribe(handler EventHandler) error
38 Start() error44 Start() error
39 Stop() error45 Stop() error
40-}⏎̸46+}
M internal/middleware/auth.go +22 -12
@@ -11,18 +11,12 @@ import (
1111 func RegistryAuth(username, password string) func(http.Handler) http.Handler {
1212 return func(next http.Handler) http.Handler {
1313 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
14- // Skip auth for registry info endpoint
15- if r.URL.Path == "/v2/" && r.Method == "GET" {
16- next.ServeHTTP(w, r)
17- return
18- }
19-
2014 // Require auth for all other registry operations
2115 if !isAuthenticated(r, username, password) {
2216 w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`)
2317 w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
2418 http.Error(w, "Unauthorized", http.StatusUnauthorized)
25-
19+
2620 log.Warn().
2721 Str("method", r.Method).
2822 Str("path", r.URL.Path).
@@ -30,7 +24,7 @@ func RegistryAuth(username, password string) func(http.Handler) http.Handler {
3024 Msg("Unauthorized registry access attempt")
3125 return
3226 }
33-
27+
3428 next.ServeHTTP(w, r)
3529 })
3630 }
@@ -39,19 +33,28 @@ func RegistryAuth(username, password string) func(http.Handler) http.Handler {
3933 // Helper functions
4034
4135 func isAuthenticated(r *http.Request, expectedUsername, expectedPassword string) bool {
36+ // Debug: Log raw Authorization header
37+ authHeader := r.Header.Get("Authorization")
38+ log.Debug().
39+ Str("method", r.Method).
40+ Str("path", r.URL.Path).
41+ Str("authorization_header", authHeader).
42+ Msg("Processing authentication request")
43+
4244 username, password, ok := r.BasicAuth()
4345 if !ok {
4446 log.Debug().
4547 Str("method", r.Method).
4648 Str("path", r.URL.Path).
49+ Str("authorization_header", authHeader).
4750 Msg("No basic auth provided")
4851 return false
4952 }
50-
53+
5154 // Use constant-time comparison to prevent timing attacks
5255 usernameMatch := subtle.ConstantTimeCompare([]byte(username), []byte(expectedUsername)) == 1
5356 passwordMatch := subtle.ConstantTimeCompare([]byte(password), []byte(expectedPassword)) == 1
54-
57+
5558 authenticated := usernameMatch && passwordMatch
5659 if !authenticated {
5760 log.Debug().
@@ -59,8 +62,15 @@ func isAuthenticated(r *http.Request, expectedUsername, expectedPassword string)
5962 Str("expected_username", expectedUsername).
6063 Str("method", r.Method).
6164 Str("path", r.URL.Path).
65+ Str("authorization_header", authHeader).
6266 Msg("Authentication failed")
67+ } else {
68+ log.Debug().
69+ Str("provided_username", username).
70+ Str("method", r.Method).
71+ Str("path", r.URL.Path).
72+ Msg("Authentication successful")
6373 }
64-
74+
6575 return authenticated
66-}⏎̸
76+}
@@ -11,18 +11,12 @@ import (
11 func RegistryAuth(username, password string) func(http.Handler) http.Handler {11 func RegistryAuth(username, password string) func(http.Handler) http.Handler {
12 return func(next http.Handler) http.Handler {12 return func(next http.Handler) http.Handler {
13 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {13 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
14- // Skip auth for registry info endpoint
15- if r.URL.Path == "/v2/" && r.Method == "GET" {
16- next.ServeHTTP(w, r)
17- return
18- }
19-
20 // Require auth for all other registry operations14 // Require auth for all other registry operations
21 if !isAuthenticated(r, username, password) {15 if !isAuthenticated(r, username, password) {
22 w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`)16 w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`)
23 w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")17 w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
24 http.Error(w, "Unauthorized", http.StatusUnauthorized)18 http.Error(w, "Unauthorized", http.StatusUnauthorized)
25- 19+
26 log.Warn().20 log.Warn().
27 Str("method", r.Method).21 Str("method", r.Method).
28 Str("path", r.URL.Path).22 Str("path", r.URL.Path).
@@ -30,7 +24,7 @@ func RegistryAuth(username, password string) func(http.Handler) http.Handler {
30 Msg("Unauthorized registry access attempt")24 Msg("Unauthorized registry access attempt")
31 return25 return
32 }26 }
33- 27+
34 next.ServeHTTP(w, r)28 next.ServeHTTP(w, r)
35 })29 })
36 }30 }
@@ -39,19 +33,28 @@ func RegistryAuth(username, password string) func(http.Handler) http.Handler {
39 // Helper functions33 // Helper functions
40 34
41 func isAuthenticated(r *http.Request, expectedUsername, expectedPassword string) bool {35 func isAuthenticated(r *http.Request, expectedUsername, expectedPassword string) bool {
36+ // Debug: Log raw Authorization header
37+ authHeader := r.Header.Get("Authorization")
38+ log.Debug().
39+ Str("method", r.Method).
40+ Str("path", r.URL.Path).
41+ Str("authorization_header", authHeader).
42+ Msg("Processing authentication request")
43+
42 username, password, ok := r.BasicAuth()44 username, password, ok := r.BasicAuth()
43 if !ok {45 if !ok {
44 log.Debug().46 log.Debug().
45 Str("method", r.Method).47 Str("method", r.Method).
46 Str("path", r.URL.Path).48 Str("path", r.URL.Path).
49+ Str("authorization_header", authHeader).
47 Msg("No basic auth provided")50 Msg("No basic auth provided")
48 return false51 return false
49 }52 }
50- 53+
51 // Use constant-time comparison to prevent timing attacks54 // Use constant-time comparison to prevent timing attacks
52 usernameMatch := subtle.ConstantTimeCompare([]byte(username), []byte(expectedUsername)) == 155 usernameMatch := subtle.ConstantTimeCompare([]byte(username), []byte(expectedUsername)) == 1
53 passwordMatch := subtle.ConstantTimeCompare([]byte(password), []byte(expectedPassword)) == 156 passwordMatch := subtle.ConstantTimeCompare([]byte(password), []byte(expectedPassword)) == 1
54- 57+
55 authenticated := usernameMatch && passwordMatch58 authenticated := usernameMatch && passwordMatch
56 if !authenticated {59 if !authenticated {
57 log.Debug().60 log.Debug().
@@ -59,8 +62,15 @@ func isAuthenticated(r *http.Request, expectedUsername, expectedPassword string)
59 Str("expected_username", expectedUsername).62 Str("expected_username", expectedUsername).
60 Str("method", r.Method).63 Str("method", r.Method).
61 Str("path", r.URL.Path).64 Str("path", r.URL.Path).
65+ Str("authorization_header", authHeader).
62 Msg("Authentication failed")66 Msg("Authentication failed")
67+ } else {
68+ log.Debug().
69+ Str("provided_username", username).
70+ Str("method", r.Method).
71+ Str("path", r.URL.Path).
72+ Msg("Authentication successful")
63 }73 }
64- 74+
65 return authenticated75 return authenticated
66-}⏎̸76+}
M internal/proxy/server.go +79 -13
@@ -6,7 +6,9 @@ import (
66 "net/http"
77 "net/http/httputil"
88 "net/url"
9+ "os"
910 "strconv"
11+ "strings"
1012
1113 "github.com/rs/zerolog/log"
1214 "gordon/internal/config"
@@ -72,19 +74,46 @@ func (s *Server) proxyToContainer(w http.ResponseWriter, r *http.Request, route
7274 return
7375 }
7476
75- // Get container's internal network info (Traefik-style automatic detection)
76- containerIP, containerPort, err := s.manager.Runtime().GetContainerNetworkInfo(ctx, container.ID)
77- if err != nil {
78- log.Error().Err(err).Str("domain", route.Domain).Str("container_id", container.ID).Msg("Failed to get container network info")
79- http.Error(w, "Service Unavailable", http.StatusServiceUnavailable)
80- return
81- }
82-
83- target, err := url.Parse(fmt.Sprintf("http://%s:%d", containerIP, containerPort))
84- if err != nil {
85- log.Error().Err(err).Msg("Failed to parse target URL")
86- http.Error(w, "Internal Server Error", http.StatusInternalServerError)
87- return
77+ // Detect if Gordon is running in a container or on the host
78+ var target *url.URL
79+ if s.isRunningInContainer() {
80+ // Gordon is in a container - use container network
81+ containerIP, containerPort, err := s.manager.Runtime().GetContainerNetworkInfo(ctx, container.ID)
82+ if err != nil {
83+ log.Error().Err(err).Str("domain", route.Domain).Str("container_id", container.ID).Msg("Failed to get container network info")
84+ http.Error(w, "Service Unavailable", http.StatusServiceUnavailable)
85+ return
86+ }
87+ target, err = url.Parse(fmt.Sprintf("http://%s:%d", containerIP, containerPort))
88+ if err != nil {
89+ log.Error().Err(err).Msg("Failed to parse container target URL")
90+ http.Error(w, "Internal Server Error", http.StatusInternalServerError)
91+ return
92+ }
93+ log.Debug().Str("mode", "container").Str("target", target.String()).Msg("Using container-to-container networking")
94+ } else {
95+ // Gordon is on the host - use host port mapping
96+ exposedPorts, err := s.manager.Runtime().GetImageExposedPorts(ctx, route.Image)
97+ if err != nil {
98+ log.Error().Err(err).Str("domain", route.Domain).Str("image", route.Image).Msg("Failed to get exposed ports from image")
99+ http.Error(w, "Service Unavailable", http.StatusServiceUnavailable)
100+ return
101+ }
102+
103+ hostPort, err := s.manager.Runtime().GetContainerPort(ctx, container.ID, exposedPorts[0])
104+ if err != nil {
105+ log.Error().Err(err).Str("domain", route.Domain).Str("container_id", container.ID).Int("internal_port", exposedPorts[0]).Msg("Failed to get host port mapping")
106+ http.Error(w, "Service Unavailable", http.StatusServiceUnavailable)
107+ return
108+ }
109+
110+ target, err = url.Parse(fmt.Sprintf("http://localhost:%d", hostPort))
111+ if err != nil {
112+ log.Error().Err(err).Msg("Failed to parse host target URL")
113+ http.Error(w, "Internal Server Error", http.StatusInternalServerError)
114+ return
115+ }
116+ log.Debug().Str("mode", "host").Str("target", target.String()).Msg("Using host port mapping")
88117 }
89118
90119 proxy := httputil.NewSingleHostReverseProxy(target)
@@ -146,6 +175,43 @@ func (s *Server) proxyToRegistry(w http.ResponseWriter, r *http.Request) {
146175 proxy.ServeHTTP(w, r)
147176 }
148177
178+// isRunningInContainer detects if Gordon is running inside a container
179+func (s *Server) isRunningInContainer() bool {
180+ // Check for common container indicators
181+
182+ // 1. Check for /.dockerenv file (Docker creates this)
183+ if _, err := os.Stat("/.dockerenv"); err == nil {
184+ return true
185+ }
186+
187+ // 2. Check cgroup for container indicators
188+ if data, err := os.ReadFile("/proc/1/cgroup"); err == nil {
189+ content := string(data)
190+ if strings.Contains(content, "docker") ||
191+ strings.Contains(content, "containerd") ||
192+ strings.Contains(content, "podman") {
193+ return true
194+ }
195+ }
196+
197+ // 3. Check if hostname matches container ID pattern (64 hex chars)
198+ if hostname, err := os.Hostname(); err == nil {
199+ if len(hostname) == 12 || len(hostname) == 64 {
200+ // Container hostnames are typically 12 or 64 character hex strings
201+ return true
202+ }
203+ }
204+
205+ // 4. Check environment variables set by container runtimes
206+ if os.Getenv("KUBERNETES_SERVICE_HOST") != "" ||
207+ os.Getenv("DOCKER_CONTAINER") != "" ||
208+ os.Getenv("container") != "" {
209+ return true
210+ }
211+
212+ return false
213+}
214+
149215 func (s *Server) Start(ctx context.Context) error {
150216 addr := ":" + strconv.Itoa(s.config.Server.Port)
151217
@@ -6,7 +6,9 @@ import (
6 "net/http"6 "net/http"
7 "net/http/httputil"7 "net/http/httputil"
8 "net/url"8 "net/url"
9+ "os"
9 "strconv"10 "strconv"
11+ "strings"
10 12
11 "github.com/rs/zerolog/log"13 "github.com/rs/zerolog/log"
12 "gordon/internal/config"14 "gordon/internal/config"
@@ -72,19 +74,46 @@ func (s *Server) proxyToContainer(w http.ResponseWriter, r *http.Request, route
72 return74 return
73 }75 }
74 76
75- // Get container's internal network info (Traefik-style automatic detection)77+ // Detect if Gordon is running in a container or on the host
76- containerIP, containerPort, err := s.manager.Runtime().GetContainerNetworkInfo(ctx, container.ID)78+ var target *url.URL
77- if err != nil {79+ if s.isRunningInContainer() {
78- log.Error().Err(err).Str("domain", route.Domain).Str("container_id", container.ID).Msg("Failed to get container network info")80+ // Gordon is in a container - use container network
79- http.Error(w, "Service Unavailable", http.StatusServiceUnavailable)81+ containerIP, containerPort, err := s.manager.Runtime().GetContainerNetworkInfo(ctx, container.ID)
80- return82+ if err != nil {
81- }83+ log.Error().Err(err).Str("domain", route.Domain).Str("container_id", container.ID).Msg("Failed to get container network info")
82- 84+ http.Error(w, "Service Unavailable", http.StatusServiceUnavailable)
83- target, err := url.Parse(fmt.Sprintf("http://%s:%d", containerIP, containerPort))85+ return
84- if err != nil {86+ }
85- log.Error().Err(err).Msg("Failed to parse target URL")87+ target, err = url.Parse(fmt.Sprintf("http://%s:%d", containerIP, containerPort))
86- http.Error(w, "Internal Server Error", http.StatusInternalServerError)88+ if err != nil {
87- return89+ log.Error().Err(err).Msg("Failed to parse container target URL")
90+ http.Error(w, "Internal Server Error", http.StatusInternalServerError)
91+ return
92+ }
93+ log.Debug().Str("mode", "container").Str("target", target.String()).Msg("Using container-to-container networking")
94+ } else {
95+ // Gordon is on the host - use host port mapping
96+ exposedPorts, err := s.manager.Runtime().GetImageExposedPorts(ctx, route.Image)
97+ if err != nil {
98+ log.Error().Err(err).Str("domain", route.Domain).Str("image", route.Image).Msg("Failed to get exposed ports from image")
99+ http.Error(w, "Service Unavailable", http.StatusServiceUnavailable)
100+ return
101+ }
102+
103+ hostPort, err := s.manager.Runtime().GetContainerPort(ctx, container.ID, exposedPorts[0])
104+ if err != nil {
105+ log.Error().Err(err).Str("domain", route.Domain).Str("container_id", container.ID).Int("internal_port", exposedPorts[0]).Msg("Failed to get host port mapping")
106+ http.Error(w, "Service Unavailable", http.StatusServiceUnavailable)
107+ return
108+ }
109+
110+ target, err = url.Parse(fmt.Sprintf("http://localhost:%d", hostPort))
111+ if err != nil {
112+ log.Error().Err(err).Msg("Failed to parse host target URL")
113+ http.Error(w, "Internal Server Error", http.StatusInternalServerError)
114+ return
115+ }
116+ log.Debug().Str("mode", "host").Str("target", target.String()).Msg("Using host port mapping")
88 }117 }
89 118
90 proxy := httputil.NewSingleHostReverseProxy(target)119 proxy := httputil.NewSingleHostReverseProxy(target)
@@ -146,6 +175,43 @@ func (s *Server) proxyToRegistry(w http.ResponseWriter, r *http.Request) {
146 proxy.ServeHTTP(w, r)175 proxy.ServeHTTP(w, r)
147 }176 }
148 177
178+// isRunningInContainer detects if Gordon is running inside a container
179+func (s *Server) isRunningInContainer() bool {
180+ // Check for common container indicators
181+
182+ // 1. Check for /.dockerenv file (Docker creates this)
183+ if _, err := os.Stat("/.dockerenv"); err == nil {
184+ return true
185+ }
186+
187+ // 2. Check cgroup for container indicators
188+ if data, err := os.ReadFile("/proc/1/cgroup"); err == nil {
189+ content := string(data)
190+ if strings.Contains(content, "docker") ||
191+ strings.Contains(content, "containerd") ||
192+ strings.Contains(content, "podman") {
193+ return true
194+ }
195+ }
196+
197+ // 3. Check if hostname matches container ID pattern (64 hex chars)
198+ if hostname, err := os.Hostname(); err == nil {
199+ if len(hostname) == 12 || len(hostname) == 64 {
200+ // Container hostnames are typically 12 or 64 character hex strings
201+ return true
202+ }
203+ }
204+
205+ // 4. Check environment variables set by container runtimes
206+ if os.Getenv("KUBERNETES_SERVICE_HOST") != "" ||
207+ os.Getenv("DOCKER_CONTAINER") != "" ||
208+ os.Getenv("container") != "" {
209+ return true
210+ }
211+
212+ return false
213+}
214+
149 func (s *Server) Start(ctx context.Context) error {215 func (s *Server) Start(ctx context.Context) error {
150 addr := ":" + strconv.Itoa(s.config.Server.Port)216 addr := ":" + strconv.Itoa(s.config.Server.Port)
151 217
M internal/registry/server.go +171 -265
@@ -7,18 +7,18 @@ import (
77 "io"
88 "net/http"
99 "strconv"
10- "strings"
1110
12- "github.com/rs/zerolog/log"
1311 "gordon/internal/config"
1412 "gordon/internal/events"
1513 "gordon/internal/middleware"
14+
15+ "github.com/rs/zerolog/log"
1616 )
1717
1818 type Server struct {
19- config *config.Config
20- mux *http.ServeMux
21- storage Storage
19+ config *config.Config
20+ mux *http.ServeMux
21+ storage Storage
2222 eventBus events.EventBus
2323 }
2424
@@ -26,7 +26,7 @@ func NewServer(cfg *config.Config, eventBus events.EventBus) (*Server, error) {
2626 // Initialize storage
2727 registryPath := cfg.Server.DataDir + "/registry"
2828 log.Debug().Str("data_dir", cfg.Server.DataDir).Str("registry_path", registryPath).Msg("Initializing registry storage")
29-
29+
3030 storage, err := NewFilesystemStorage(registryPath)
3131 if err != nil {
3232 return nil, fmt.Errorf("failed to initialize storage: %w", err)
@@ -44,188 +44,71 @@ func NewServer(cfg *config.Config, eventBus events.EventBus) (*Server, error) {
4444
4545 func (s *Server) setupRoutes() {
4646 // Docker Registry API v2 endpoints using Go 1.22+ ServeMux patterns
47-
48- // Registry info endpoint
49- s.mux.HandleFunc("GET /v2/", s.handleRegistryInfo)
50-
51- // Since Go's ServeMux requires wildcards to be at the end, we need a different approach
52- // Use a catch-all handler for /v2/ paths and route internally
53- s.mux.HandleFunc("/v2/", s.handleV2Routes)
47+ s.mux.HandleFunc("GET /v2/", s.handleBase)
48+ s.mux.HandleFunc("HEAD /v2/{name}/manifests/{reference}", s.handleGetManifest)
49+ s.mux.HandleFunc("GET /v2/{name}/manifests/{reference}", s.handleGetManifest)
50+ s.mux.HandleFunc("PUT /v2/{name}/manifests/{reference}", s.handlePutManifest)
51+ s.mux.HandleFunc("HEAD /v2/{name}/blobs/{digest}", s.handleGetBlob)
52+ s.mux.HandleFunc("GET /v2/{name}/blobs/{digest}", s.handleGetBlob)
53+ s.mux.HandleFunc("POST /v2/{name}/blobs/uploads/", s.handleStartBlobUpload)
54+ s.mux.HandleFunc("PATCH /v2/{name}/blobs/uploads/{uuid}", s.handleBlobUpload)
55+ s.mux.HandleFunc("PUT /v2/{name}/blobs/uploads/{uuid}", s.handleBlobUpload)
56+ s.mux.HandleFunc("GET /v2/{name}/tags/list", s.handleListTags)
5457 }
5558
56-func (s *Server) handleV2Routes(w http.ResponseWriter, r *http.Request) {
57- path := r.URL.Path
58- method := r.Method
59-
60- // Route based on path pattern and method
61- switch {
62- case path == "/v2/" && method == "GET":
63- s.handleRegistryInfo(w, r)
64- case method == "GET" && matches(path, "/v2/.*/manifests/.*"):
65- s.handleGetManifest(w, r)
66- case method == "PUT" && matches(path, "/v2/.*/manifests/.*"):
67- s.handlePutManifest(w, r)
68- case (method == "GET" || method == "HEAD") && matches(path, "/v2/.*/blobs/.*"):
69- s.handleGetBlob(w, r)
70- case method == "POST" && matches(path, "/v2/.*/blobs/uploads/"):
71- s.handleStartBlobUpload(w, r)
72- case (method == "PUT" || method == "PATCH") && matches(path, "/v2/.*/blobs/uploads/.*"):
73- s.handleBlobUpload(w, r)
74- case method == "GET" && matches(path, "/v2/.*/tags/list"):
75- s.handleListTags(w, r)
76- default:
77- http.NotFound(w, r)
78- }
59+func (s *Server) handleBase(w http.ResponseWriter, r *http.Request) {
60+ // For Docker clients, we need to respond with a 200 OK to this endpoint
61+ // even if authentication is required. The client will then check the
62+ // WWW-Authenticate header.
63+ // see: https://docs.docker.com/registry/spec/api/#base
64+ //
65+ // Podman, as of v4.4.1, will not send credentials in the initial /v2/
66+ // request. It expects a 401 Unauthorized to then send credentials.
67+ //
68+ // To support both, we'll check for the "Docker-Client" user agent.
69+ // For now, we will just return a 200 OK to all clients.
70+ w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
71+ w.WriteHeader(http.StatusOK)
7972 }
8073
81-// Simple pattern matching helper
82-func matches(path, pattern string) bool {
83- // Convert pattern to regex-like matching
84- // For now, just check if the pattern structure matches
85- if pattern == "/v2/.*/manifests/.*" {
86- return len(path) > 4 && path[:4] == "/v2/" &&
87- len(path) > 14 && path[len(path)-10:] != "/manifests/" &&
88- contains(path, "/manifests/")
89- }
90- if pattern == "/v2/.*/blobs/.*" {
91- return len(path) > 4 && path[:4] == "/v2/" && contains(path, "/blobs/")
92- }
93- if pattern == "/v2/.*/blobs/uploads/" {
94- return len(path) > 4 && path[:4] == "/v2/" &&
95- contains(path, "/blobs/uploads/") && path[len(path)-1:] == "/"
96- }
97- if pattern == "/v2/.*/blobs/uploads/.*" {
98- return len(path) > 4 && path[:4] == "/v2/" && contains(path, "/blobs/uploads/")
99- }
100- if pattern == "/v2/.*/tags/list" {
101- return len(path) > 4 && path[:4] == "/v2/" &&
102- len(path) > 10 && path[len(path)-10:] == "/tags/list"
103- }
104- return false
105-}
74+func (s *Server) handleGetManifest(w http.ResponseWriter, r *http.Request) {
75+ name := r.PathValue("name")
76+ reference := r.PathValue("reference")
10677
107-func contains(s, substr string) bool {
108- for i := 0; i <= len(s)-len(substr); i++ {
109- if s[i:i+len(substr)] == substr {
110- return true
111- }
112- }
113- return false
114-}
78+ log.Debug().Str("name", name).Str("reference", reference).Msg("GET manifest")
11579
116-// Path parsing helpers
117-func (s *Server) parseManifestPath(path string) (name, reference string) {
118- // Expected format: /v2/{name}/manifests/{reference}
119- parts := strings.Split(path, "/")
120- if len(parts) >= 5 && parts[3] == "manifests" {
121- name = parts[2]
122- reference = parts[4]
80+ manifest, contentType, err := s.storage.GetManifest(name, reference)
81+ if err != nil {
82+ log.Warn().Err(err).Str("name", name).Str("reference", reference).Msg("Manifest not found")
83+ http.NotFound(w, r)
84+ return
12385 }
124- return
125-}
12686
127-func (s *Server) parseBlobPath(path string) (name, digest string) {
128- // Expected format: /v2/{name}/blobs/{digest}
129- parts := strings.Split(path, "/")
130- if len(parts) >= 5 && parts[3] == "blobs" {
131- name = parts[2]
132- digest = parts[4]
133- }
134- return
135-}
87+ log.Debug().Str("name", name).Str("reference", reference).Str("content_type", contentType).Msg("GET manifest - serving")
13688
137-func (s *Server) parseUploadPath(path string) (name, uuid string) {
138- // Expected format: /v2/{name}/blobs/uploads/{uuid}
139- parts := strings.Split(path, "/")
140- if len(parts) >= 6 && parts[3] == "blobs" && parts[4] == "uploads" {
141- name = parts[2]
142- if len(parts) > 5 {
143- uuid = parts[5]
144- }
145- }
146- return
147-}
89+ // Return the appropriate content type based on what was stored
90+ w.Header().Set("Content-Type", contentType)
91+ w.Header().Set("Content-Length", strconv.Itoa(len(manifest)))
92+ w.WriteHeader(http.StatusOK)
14893
149-func (s *Server) parseTagsPath(path string) string {
150- // Expected format: /v2/{name}/tags/list
151- parts := strings.Split(path, "/")
152- if len(parts) >= 4 && parts[3] == "tags" {
153- return parts[2]
94+ if r.Method == "GET" {
95+ _, _ = w.Write(manifest)
15496 }
155- return ""
15697 }
15798
158-func (s *Server) Start(ctx context.Context) error {
159- addr := ":" + strconv.Itoa(s.config.Server.RegistryPort)
160-
161- // Build middleware chain
162- middlewares := []func(http.Handler) http.Handler{
163- middleware.PanicRecovery,
164- middleware.RequestLogger,
165- }
166-
167- // Add registry authentication if enabled
168- if s.config.RegistryAuth.Enabled {
169- middlewares = append(middlewares, middleware.RegistryAuth(s.config.RegistryAuth.Username, s.config.RegistryAuth.Password))
170- }
171-
172- // Wrap the mux with middleware
173- handler := middleware.Chain(middlewares...)(s.mux)
174-
175- server := &http.Server{
176- Addr: addr,
177- Handler: handler,
178- }
179-
180- log.Info().Str("address", addr).Msg("Registry server starting")
181-
182- // Start server in a goroutine
183- errChan := make(chan error, 1)
184- go func() {
185- if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
186- errChan <- err
187- }
188- }()
189-
190- // Wait for context cancellation or server error
191- select {
192- case err := <-errChan:
193- return err
194- case <-ctx.Done():
195- log.Info().Msg("Registry server shutting down...")
196- return server.Shutdown(context.Background())
197- }
198-}
99+func (s *Server) handlePutManifest(w http.ResponseWriter, r *http.Request) {
100+ name := r.PathValue("name")
101+ reference := r.PathValue("reference")
199102
200-func (s *Server) handleRegistryInfo(w http.ResponseWriter, r *http.Request) {
201- w.Header().Set("Content-Type", "application/json")
202- w.WriteHeader(http.StatusOK)
203- fmt.Fprint(w, `{"name":"gordon-registry"}`)
204-}
103+ contentType := r.Header.Get("Content-Type")
104+ log.Debug().Str("name", name).Str("reference", reference).Str("content_type", contentType).Msg("PUT manifest")
205105
206-func (s *Server) handleGetManifest(w http.ResponseWriter, r *http.Request) {
207- name, reference := s.parseManifestPath(r.URL.Path)
208-
209- log.Debug().Str("name", name).Str("reference", reference).Msg("GET manifest")
210-
211- manifest, err := s.storage.GetManifest(name, reference)
212- if err != nil {
213- log.Warn().Err(err).Str("name", name).Str("reference", reference).Msg("Manifest not found")
214- w.WriteHeader(http.StatusNotFound)
106+ if contentType == "" {
107+ log.Warn().Str("name", name).Str("reference", reference).Msg("Content-Type header missing for PUT manifest")
108+ http.Error(w, "Content-Type header required", http.StatusBadRequest)
215109 return
216110 }
217-
218- w.Header().Set("Content-Type", "application/vnd.docker.distribution.manifest.v2+json")
219- w.Header().Set("Content-Length", strconv.Itoa(len(manifest)))
220- w.WriteHeader(http.StatusOK)
221- w.Write(manifest)
222-}
223111
224-func (s *Server) handlePutManifest(w http.ResponseWriter, r *http.Request) {
225- name, reference := s.parseManifestPath(r.URL.Path)
226-
227- log.Debug().Str("name", name).Str("reference", reference).Msg("PUT manifest")
228-
229112 // Read manifest data
230113 data, err := io.ReadAll(r.Body)
231114 if err != nil {
@@ -233,151 +116,174 @@ func (s *Server) handlePutManifest(w http.ResponseWriter, r *http.Request) {
233116 http.Error(w, "Bad Request", http.StatusBadRequest)
234117 return
235118 }
236-
119+
237120 // Store manifest
238- if err := s.storage.PutManifest(name, reference, data); err != nil {
121+ if err := s.storage.PutManifest(name, reference, contentType, data); err != nil {
239122 log.Error().Err(err).Str("name", name).Str("reference", reference).Msg("Failed to store manifest")
240123 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
241124 return
242125 }
243-
126+
244127 // Calculate digest for Docker-Content-Digest header
245128 digest := fmt.Sprintf("sha256:%x", sha256.Sum256(data))
246129 w.Header().Set("Docker-Content-Digest", digest)
247130 w.Header().Set("Location", fmt.Sprintf("/v2/%s/manifests/%s", name, reference))
248131 w.WriteHeader(http.StatusCreated)
249-
132+
250133 // Emit image pushed event
251- event := events.Event{
252- Type: events.ImagePushed,
253- ImageName: name,
254- Tag: reference,
255- Data: map[string]interface{}{
256- "digest": digest,
257- "size": len(data),
258- },
259- }
260-
261- if err := s.eventBus.Publish(event); err != nil {
262- log.Error().Err(err).Str("name", name).Str("reference", reference).Msg("Failed to publish image pushed event")
263- } else {
264- log.Info().Str("name", name).Str("reference", reference).Msg("Image pushed event published")
265- }
134+ s.eventBus.Publish(events.ImagePushed, events.ImagePushedPayload{
135+ Name: name,
136+ Reference: reference,
137+ Manifest: data,
138+ })
266139 }
267140
268141 func (s *Server) handleGetBlob(w http.ResponseWriter, r *http.Request) {
269- name, digest := s.parseBlobPath(r.URL.Path)
270-
271- log.Debug().Str("name", name).Str("digest", digest).Str("method", r.Method).Msg("Handle blob request")
272-
273- reader, err := s.storage.GetBlob(digest)
142+ name := r.PathValue("name")
143+ digest := r.PathValue("digest")
144+
145+ log.Debug().Str("name", name).Str("digest", digest).Msg("GET blob")
146+
147+ path, err := s.storage.GetBlobPath(digest)
274148 if err != nil {
275- log.Warn().Err(err).Str("digest", digest).Msg("Blob not found")
276- w.WriteHeader(http.StatusNotFound)
277- return
278- }
279- defer reader.Close()
280-
281- w.Header().Set("Content-Type", "application/octet-stream")
282- w.WriteHeader(http.StatusOK)
283-
284- // For HEAD requests, don't send the body
285- if r.Method == "HEAD" {
149+ log.Warn().Err(err).Str("name", name).Str("digest", digest).Msg("Blob not found")
150+ http.NotFound(w, r)
286151 return
287152 }
288-
289- _, err = io.Copy(w, reader)
290- if err != nil {
291- log.Error().Err(err).Str("digest", digest).Msg("Failed to serve blob")
292- }
153+
154+ // Serve the file directly
155+ http.ServeFile(w, r, path)
293156 }
294157
295158 func (s *Server) handleStartBlobUpload(w http.ResponseWriter, r *http.Request) {
296- name, _ := s.parseUploadPath(r.URL.Path)
297-
298- log.Debug().Str("name", name).Msg("Start blob upload")
299-
159+ name := r.PathValue("name")
160+
161+ log.Debug().Str("name", name).Msg("Starting blob upload")
162+
163+ // Generate a unique upload ID
300164 uuid, err := s.storage.StartBlobUpload(name)
301165 if err != nil {
302- log.Error().Err(err).Str("name", name).Msg("Failed to start blob upload")
166+ log.Error().Err(err).Msg("Failed to start blob upload")
303167 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
304168 return
305169 }
306-
170+
171+ // Set headers for the response
307172 w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/uploads/%s", name, uuid))
308- w.Header().Set("Range", "0-0")
173+ w.Header().Set("Range", "0-0") // Indicate that no bytes have been received yet
309174 w.WriteHeader(http.StatusAccepted)
310175 }
311176
312177 func (s *Server) handleBlobUpload(w http.ResponseWriter, r *http.Request) {
313- name, uuid := s.parseUploadPath(r.URL.Path)
314-
315- log.Debug().Str("name", name).Str("uuid", uuid).Msg("Blob upload")
316-
317- // Check if this is a completion request (has digest parameter)
178+ name := r.PathValue("name")
179+ uuid := r.PathValue("uuid")
318180 digest := r.URL.Query().Get("digest")
319-
181+
182+ log.Debug().
183+ Str("name", name).
184+ Str("uuid", uuid).
185+ Str("digest", digest).
186+ Str("method", r.Method).
187+ Msg("Handling blob upload chunk")
188+
189+ // Read the chunk from the request body
190+ chunk, err := io.ReadAll(r.Body)
191+ if err != nil {
192+ log.Error().Err(err).Msg("Failed to read blob chunk")
193+ http.Error(w, "Bad Request", http.StatusBadRequest)
194+ return
195+ }
196+
197+ // Append the chunk to the upload
198+ length, err := s.storage.AppendBlobChunk(name, uuid, chunk)
199+ if err != nil {
200+ log.Error().Err(err).Msg("Failed to append blob chunk")
201+ http.Error(w, "Internal Server Error", http.StatusInternalServerError)
202+ return
203+ }
204+
205+ // If this is the final chunk (PUT request with digest), finalize the upload
320206 if r.Method == "PUT" && digest != "" {
321- // This is a completion request
322207 if err := s.storage.FinishBlobUpload(uuid, digest); err != nil {
323- log.Error().Err(err).Str("uuid", uuid).Str("digest", digest).Msg("Failed to finish blob upload")
324- http.Error(w, "Internal Server Error", http.StatusInternalServerError)
208+ log.Error().Err(err).Str("digest", digest).Msg("Failed to finalize blob upload")
209+ // The upload is invalid, delete it
210+ _ = s.storage.CancelBlobUpload(uuid)
211+ http.Error(w, "Bad Request: digest mismatch", http.StatusBadRequest)
325212 return
326213 }
327-
214+
215+ // Respond with 201 Created
328216 w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/%s", name, digest))
329217 w.Header().Set("Docker-Content-Digest", digest)
330218 w.WriteHeader(http.StatusCreated)
331-
332- log.Info().Str("name", name).Str("uuid", uuid).Str("digest", digest).Msg("Blob upload completed")
333219 return
334220 }
335-
336- // This is a chunk upload (PATCH)
337- writer, err := s.storage.GetBlobUpload(uuid)
338- if err != nil {
339- log.Error().Err(err).Str("uuid", uuid).Msg("Failed to get blob upload")
340- http.Error(w, "Not Found", http.StatusNotFound)
341- return
342- }
343- defer writer.Close()
344-
345- written, err := io.Copy(writer, r.Body)
346- if err != nil {
347- log.Error().Err(err).Str("uuid", uuid).Msg("Failed to write blob chunk")
348- http.Error(w, "Internal Server Error", http.StatusInternalServerError)
349- return
350- }
351-
221+
222+ // For PATCH requests, respond with 202 Accepted
352223 w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/uploads/%s", name, uuid))
353- w.Header().Set("Range", fmt.Sprintf("0-%d", written-1))
224+ w.Header().Set("Range", fmt.Sprintf("0-%d", length-1))
354225 w.WriteHeader(http.StatusAccepted)
355-
356- log.Debug().Str("uuid", uuid).Int64("bytes", written).Msg("Blob chunk uploaded")
357226 }
358227
359228 func (s *Server) handleListTags(w http.ResponseWriter, r *http.Request) {
360- name := s.parseTagsPath(r.URL.Path)
361-
362- log.Debug().Str("name", name).Msg("List tags")
363-
229+ name := r.PathValue("name")
230+
231+ log.Debug().Str("name", name).Msg("Listing tags")
232+
364233 tags, err := s.storage.ListTags(name)
365234 if err != nil {
366- log.Error().Err(err).Str("name", name).Msg("Failed to list tags")
367- http.Error(w, "Internal Server Error", http.StatusInternalServerError)
235+ log.Warn().Err(err).Str("name", name).Msg("Tag not found")
236+ w.WriteHeader(http.StatusNotFound)
368237 return
369238 }
370-
239+
240+ // Construct the response JSON
241+ response := fmt.Sprintf(`{"name":"%s","tags":%s}`, name, tags)
242+
371243 w.Header().Set("Content-Type", "application/json")
244+ w.Header().Set("Content-Length", strconv.Itoa(len(response)))
372245 w.WriteHeader(http.StatusOK)
373-
374- // Build JSON response
375- fmt.Fprintf(w, `{"name":"%s","tags":[`, name)
376- for i, tag := range tags {
377- if i > 0 {
378- fmt.Fprint(w, ",")
246+ w.Write([]byte(response))
247+}
248+
249+func (s *Server) Start(ctx context.Context) error {
250+ addr := ":" + strconv.Itoa(s.config.Server.RegistryPort)
251+
252+ // Build middleware chain
253+ middlewares := []func(http.Handler) http.Handler{
254+ middleware.PanicRecovery,
255+ middleware.RequestLogger,
256+ }
257+
258+ // Add registry authentication if enabled
259+ if s.config.RegistryAuth.Enabled {
260+ middlewares = append(middlewares, middleware.RegistryAuth(s.config.RegistryAuth.Username, s.config.RegistryAuth.Password))
261+ }
262+
263+ // Wrap the mux with middleware
264+ handler := middleware.Chain(middlewares...)(s.mux)
265+
266+ server := &http.Server{
267+ Addr: addr,
268+ Handler: handler,
269+ }
270+
271+ log.Info().Str("address", addr).Msg("Registry server starting")
272+
273+ // Start server in a goroutine
274+ errChan := make(chan error, 1)
275+ go func() {
276+ if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
277+ errChan <- err
379278 }
380- fmt.Fprintf(w, `"%s"`, tag)
279+ }()
280+
281+ // Wait for context cancellation or server error
282+ select {
283+ case err := <-errChan:
284+ return err
285+ case <-ctx.Done():
286+ log.Info().Msg("Registry server shutting down...")
287+ return server.Shutdown(context.Background())
381288 }
382- fmt.Fprint(w, "]}")
383-}⏎̸
289+}
@@ -7,18 +7,18 @@ import (
7 "io"7 "io"
8 "net/http"8 "net/http"
9 "strconv"9 "strconv"
10- "strings"
11 10
12- "github.com/rs/zerolog/log"
13 "gordon/internal/config"11 "gordon/internal/config"
14 "gordon/internal/events"12 "gordon/internal/events"
15 "gordon/internal/middleware"13 "gordon/internal/middleware"
14+
15+ "github.com/rs/zerolog/log"
16 )16 )
17 17
18 type Server struct {18 type Server struct {
19- config *config.Config19+ config *config.Config
20- mux *http.ServeMux20+ mux *http.ServeMux
21- storage Storage21+ storage Storage
22 eventBus events.EventBus22 eventBus events.EventBus
23 }23 }
24 24
@@ -26,7 +26,7 @@ func NewServer(cfg *config.Config, eventBus events.EventBus) (*Server, error) {
26 // Initialize storage26 // Initialize storage
27 registryPath := cfg.Server.DataDir + "/registry"27 registryPath := cfg.Server.DataDir + "/registry"
28 log.Debug().Str("data_dir", cfg.Server.DataDir).Str("registry_path", registryPath).Msg("Initializing registry storage")28 log.Debug().Str("data_dir", cfg.Server.DataDir).Str("registry_path", registryPath).Msg("Initializing registry storage")
29- 29+
30 storage, err := NewFilesystemStorage(registryPath)30 storage, err := NewFilesystemStorage(registryPath)
31 if err != nil {31 if err != nil {
32 return nil, fmt.Errorf("failed to initialize storage: %w", err)32 return nil, fmt.Errorf("failed to initialize storage: %w", err)
@@ -44,188 +44,71 @@ func NewServer(cfg *config.Config, eventBus events.EventBus) (*Server, error) {
44 44
45 func (s *Server) setupRoutes() {45 func (s *Server) setupRoutes() {
46 // Docker Registry API v2 endpoints using Go 1.22+ ServeMux patterns46 // Docker Registry API v2 endpoints using Go 1.22+ ServeMux patterns
47- 47+ s.mux.HandleFunc("GET /v2/", s.handleBase)
48- // Registry info endpoint48+ s.mux.HandleFunc("HEAD /v2/{name}/manifests/{reference}", s.handleGetManifest)
49- s.mux.HandleFunc("GET /v2/", s.handleRegistryInfo)49+ s.mux.HandleFunc("GET /v2/{name}/manifests/{reference}", s.handleGetManifest)
50- 50+ s.mux.HandleFunc("PUT /v2/{name}/manifests/{reference}", s.handlePutManifest)
51- // Since Go's ServeMux requires wildcards to be at the end, we need a different approach51+ s.mux.HandleFunc("HEAD /v2/{name}/blobs/{digest}", s.handleGetBlob)
52- // Use a catch-all handler for /v2/ paths and route internally52+ s.mux.HandleFunc("GET /v2/{name}/blobs/{digest}", s.handleGetBlob)
53- s.mux.HandleFunc("/v2/", s.handleV2Routes)53+ s.mux.HandleFunc("POST /v2/{name}/blobs/uploads/", s.handleStartBlobUpload)
54+ s.mux.HandleFunc("PATCH /v2/{name}/blobs/uploads/{uuid}", s.handleBlobUpload)
55+ s.mux.HandleFunc("PUT /v2/{name}/blobs/uploads/{uuid}", s.handleBlobUpload)
56+ s.mux.HandleFunc("GET /v2/{name}/tags/list", s.handleListTags)
54 }57 }
55 58
56-func (s *Server) handleV2Routes(w http.ResponseWriter, r *http.Request) {59+func (s *Server) handleBase(w http.ResponseWriter, r *http.Request) {
57- path := r.URL.Path60+ // For Docker clients, we need to respond with a 200 OK to this endpoint
58- method := r.Method61+ // even if authentication is required. The client will then check the
59- 62+ // WWW-Authenticate header.
60- // Route based on path pattern and method63+ // see: https://docs.docker.com/registry/spec/api/#base
61- switch {64+ //
62- case path == "/v2/" && method == "GET":65+ // Podman, as of v4.4.1, will not send credentials in the initial /v2/
63- s.handleRegistryInfo(w, r)66+ // request. It expects a 401 Unauthorized to then send credentials.
64- case method == "GET" && matches(path, "/v2/.*/manifests/.*"):67+ //
65- s.handleGetManifest(w, r)68+ // To support both, we'll check for the "Docker-Client" user agent.
66- case method == "PUT" && matches(path, "/v2/.*/manifests/.*"):69+ // For now, we will just return a 200 OK to all clients.
67- s.handlePutManifest(w, r)70+ w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
68- case (method == "GET" || method == "HEAD") && matches(path, "/v2/.*/blobs/.*"):71+ w.WriteHeader(http.StatusOK)
69- s.handleGetBlob(w, r)
70- case method == "POST" && matches(path, "/v2/.*/blobs/uploads/"):
71- s.handleStartBlobUpload(w, r)
72- case (method == "PUT" || method == "PATCH") && matches(path, "/v2/.*/blobs/uploads/.*"):
73- s.handleBlobUpload(w, r)
74- case method == "GET" && matches(path, "/v2/.*/tags/list"):
75- s.handleListTags(w, r)
76- default:
77- http.NotFound(w, r)
78- }
79 }72 }
80 73
81-// Simple pattern matching helper74+func (s *Server) handleGetManifest(w http.ResponseWriter, r *http.Request) {
82-func matches(path, pattern string) bool {75+ name := r.PathValue("name")
83- // Convert pattern to regex-like matching76+ reference := r.PathValue("reference")
84- // For now, just check if the pattern structure matches
85- if pattern == "/v2/.*/manifests/.*" {
86- return len(path) > 4 && path[:4] == "/v2/" &&
87- len(path) > 14 && path[len(path)-10:] != "/manifests/" &&
88- contains(path, "/manifests/")
89- }
90- if pattern == "/v2/.*/blobs/.*" {
91- return len(path) > 4 && path[:4] == "/v2/" && contains(path, "/blobs/")
92- }
93- if pattern == "/v2/.*/blobs/uploads/" {
94- return len(path) > 4 && path[:4] == "/v2/" &&
95- contains(path, "/blobs/uploads/") && path[len(path)-1:] == "/"
96- }
97- if pattern == "/v2/.*/blobs/uploads/.*" {
98- return len(path) > 4 && path[:4] == "/v2/" && contains(path, "/blobs/uploads/")
99- }
100- if pattern == "/v2/.*/tags/list" {
101- return len(path) > 4 && path[:4] == "/v2/" &&
102- len(path) > 10 && path[len(path)-10:] == "/tags/list"
103- }
104- return false
105-}
106 77
107-func contains(s, substr string) bool {78+ log.Debug().Str("name", name).Str("reference", reference).Msg("GET manifest")
108- for i := 0; i <= len(s)-len(substr); i++ {
109- if s[i:i+len(substr)] == substr {
110- return true
111- }
112- }
113- return false
114-}
115 79
116-// Path parsing helpers80+ manifest, contentType, err := s.storage.GetManifest(name, reference)
117-func (s *Server) parseManifestPath(path string) (name, reference string) {81+ if err != nil {
118- // Expected format: /v2/{name}/manifests/{reference}82+ log.Warn().Err(err).Str("name", name).Str("reference", reference).Msg("Manifest not found")
119- parts := strings.Split(path, "/")83+ http.NotFound(w, r)
120- if len(parts) >= 5 && parts[3] == "manifests" {84+ return
121- name = parts[2]
122- reference = parts[4]
123 }85 }
124- return
125-}
126 86
127-func (s *Server) parseBlobPath(path string) (name, digest string) {87+ log.Debug().Str("name", name).Str("reference", reference).Str("content_type", contentType).Msg("GET manifest - serving")
128- // Expected format: /v2/{name}/blobs/{digest}
129- parts := strings.Split(path, "/")
130- if len(parts) >= 5 && parts[3] == "blobs" {
131- name = parts[2]
132- digest = parts[4]
133- }
134- return
135-}
136 88
137-func (s *Server) parseUploadPath(path string) (name, uuid string) {89+ // Return the appropriate content type based on what was stored
138- // Expected format: /v2/{name}/blobs/uploads/{uuid}90+ w.Header().Set("Content-Type", contentType)
139- parts := strings.Split(path, "/")91+ w.Header().Set("Content-Length", strconv.Itoa(len(manifest)))
140- if len(parts) >= 6 && parts[3] == "blobs" && parts[4] == "uploads" {92+ w.WriteHeader(http.StatusOK)
141- name = parts[2]
142- if len(parts) > 5 {
143- uuid = parts[5]
144- }
145- }
146- return
147-}
148 93
149-func (s *Server) parseTagsPath(path string) string {94+ if r.Method == "GET" {
150- // Expected format: /v2/{name}/tags/list95+ _, _ = w.Write(manifest)
151- parts := strings.Split(path, "/")
152- if len(parts) >= 4 && parts[3] == "tags" {
153- return parts[2]
154 }96 }
155- return ""
156 }97 }
157 98
158-func (s *Server) Start(ctx context.Context) error {99+func (s *Server) handlePutManifest(w http.ResponseWriter, r *http.Request) {
159- addr := ":" + strconv.Itoa(s.config.Server.RegistryPort)100+ name := r.PathValue("name")
160- 101+ reference := r.PathValue("reference")
161- // Build middleware chain
162- middlewares := []func(http.Handler) http.Handler{
163- middleware.PanicRecovery,
164- middleware.RequestLogger,
165- }
166-
167- // Add registry authentication if enabled
168- if s.config.RegistryAuth.Enabled {
169- middlewares = append(middlewares, middleware.RegistryAuth(s.config.RegistryAuth.Username, s.config.RegistryAuth.Password))
170- }
171-
172- // Wrap the mux with middleware
173- handler := middleware.Chain(middlewares...)(s.mux)
174-
175- server := &http.Server{
176- Addr: addr,
177- Handler: handler,
178- }
179-
180- log.Info().Str("address", addr).Msg("Registry server starting")
181-
182- // Start server in a goroutine
183- errChan := make(chan error, 1)
184- go func() {
185- if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
186- errChan <- err
187- }
188- }()
189-
190- // Wait for context cancellation or server error
191- select {
192- case err := <-errChan:
193- return err
194- case <-ctx.Done():
195- log.Info().Msg("Registry server shutting down...")
196- return server.Shutdown(context.Background())
197- }
198-}
199 102
200-func (s *Server) handleRegistryInfo(w http.ResponseWriter, r *http.Request) {103+ contentType := r.Header.Get("Content-Type")
201- w.Header().Set("Content-Type", "application/json")104+ log.Debug().Str("name", name).Str("reference", reference).Str("content_type", contentType).Msg("PUT manifest")
202- w.WriteHeader(http.StatusOK)
203- fmt.Fprint(w, `{"name":"gordon-registry"}`)
204-}
205 105
206-func (s *Server) handleGetManifest(w http.ResponseWriter, r *http.Request) {106+ if contentType == "" {
207- name, reference := s.parseManifestPath(r.URL.Path)107+ log.Warn().Str("name", name).Str("reference", reference).Msg("Content-Type header missing for PUT manifest")
208- 108+ http.Error(w, "Content-Type header required", http.StatusBadRequest)
209- log.Debug().Str("name", name).Str("reference", reference).Msg("GET manifest")
210-
211- manifest, err := s.storage.GetManifest(name, reference)
212- if err != nil {
213- log.Warn().Err(err).Str("name", name).Str("reference", reference).Msg("Manifest not found")
214- w.WriteHeader(http.StatusNotFound)
215 return109 return
216 }110 }
217-
218- w.Header().Set("Content-Type", "application/vnd.docker.distribution.manifest.v2+json")
219- w.Header().Set("Content-Length", strconv.Itoa(len(manifest)))
220- w.WriteHeader(http.StatusOK)
221- w.Write(manifest)
222-}
223 111
224-func (s *Server) handlePutManifest(w http.ResponseWriter, r *http.Request) {
225- name, reference := s.parseManifestPath(r.URL.Path)
226-
227- log.Debug().Str("name", name).Str("reference", reference).Msg("PUT manifest")
228-
229 // Read manifest data112 // Read manifest data
230 data, err := io.ReadAll(r.Body)113 data, err := io.ReadAll(r.Body)
231 if err != nil {114 if err != nil {
@@ -233,151 +116,174 @@ func (s *Server) handlePutManifest(w http.ResponseWriter, r *http.Request) {
233 http.Error(w, "Bad Request", http.StatusBadRequest)116 http.Error(w, "Bad Request", http.StatusBadRequest)
234 return117 return
235 }118 }
236- 119+
237 // Store manifest120 // Store manifest
238- if err := s.storage.PutManifest(name, reference, data); err != nil {121+ if err := s.storage.PutManifest(name, reference, contentType, data); err != nil {
239 log.Error().Err(err).Str("name", name).Str("reference", reference).Msg("Failed to store manifest")122 log.Error().Err(err).Str("name", name).Str("reference", reference).Msg("Failed to store manifest")
240 http.Error(w, "Internal Server Error", http.StatusInternalServerError)123 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
241 return124 return
242 }125 }
243- 126+
244 // Calculate digest for Docker-Content-Digest header127 // Calculate digest for Docker-Content-Digest header
245 digest := fmt.Sprintf("sha256:%x", sha256.Sum256(data))128 digest := fmt.Sprintf("sha256:%x", sha256.Sum256(data))
246 w.Header().Set("Docker-Content-Digest", digest)129 w.Header().Set("Docker-Content-Digest", digest)
247 w.Header().Set("Location", fmt.Sprintf("/v2/%s/manifests/%s", name, reference))130 w.Header().Set("Location", fmt.Sprintf("/v2/%s/manifests/%s", name, reference))
248 w.WriteHeader(http.StatusCreated)131 w.WriteHeader(http.StatusCreated)
249- 132+
250 // Emit image pushed event133 // Emit image pushed event
251- event := events.Event{134+ s.eventBus.Publish(events.ImagePushed, events.ImagePushedPayload{
252- Type: events.ImagePushed,135+ Name: name,
253- ImageName: name,136+ Reference: reference,
254- Tag: reference,137+ Manifest: data,
255- Data: map[string]interface{}{138+ })
256- "digest": digest,
257- "size": len(data),
258- },
259- }
260-
261- if err := s.eventBus.Publish(event); err != nil {
262- log.Error().Err(err).Str("name", name).Str("reference", reference).Msg("Failed to publish image pushed event")
263- } else {
264- log.Info().Str("name", name).Str("reference", reference).Msg("Image pushed event published")
265- }
266 }139 }
267 140
268 func (s *Server) handleGetBlob(w http.ResponseWriter, r *http.Request) {141 func (s *Server) handleGetBlob(w http.ResponseWriter, r *http.Request) {
269- name, digest := s.parseBlobPath(r.URL.Path)142+ name := r.PathValue("name")
270- 143+ digest := r.PathValue("digest")
271- log.Debug().Str("name", name).Str("digest", digest).Str("method", r.Method).Msg("Handle blob request")144+
272- 145+ log.Debug().Str("name", name).Str("digest", digest).Msg("GET blob")
273- reader, err := s.storage.GetBlob(digest)146+
147+ path, err := s.storage.GetBlobPath(digest)
274 if err != nil {148 if err != nil {
275- log.Warn().Err(err).Str("digest", digest).Msg("Blob not found")149+ log.Warn().Err(err).Str("name", name).Str("digest", digest).Msg("Blob not found")
276- w.WriteHeader(http.StatusNotFound)150+ http.NotFound(w, r)
277- return
278- }
279- defer reader.Close()
280-
281- w.Header().Set("Content-Type", "application/octet-stream")
282- w.WriteHeader(http.StatusOK)
283-
284- // For HEAD requests, don't send the body
285- if r.Method == "HEAD" {
286 return151 return
287 }152 }
288- 153+
289- _, err = io.Copy(w, reader)154+ // Serve the file directly
290- if err != nil {155+ http.ServeFile(w, r, path)
291- log.Error().Err(err).Str("digest", digest).Msg("Failed to serve blob")
292- }
293 }156 }
294 157
295 func (s *Server) handleStartBlobUpload(w http.ResponseWriter, r *http.Request) {158 func (s *Server) handleStartBlobUpload(w http.ResponseWriter, r *http.Request) {
296- name, _ := s.parseUploadPath(r.URL.Path)159+ name := r.PathValue("name")
297- 160+
298- log.Debug().Str("name", name).Msg("Start blob upload")161+ log.Debug().Str("name", name).Msg("Starting blob upload")
299- 162+
163+ // Generate a unique upload ID
300 uuid, err := s.storage.StartBlobUpload(name)164 uuid, err := s.storage.StartBlobUpload(name)
301 if err != nil {165 if err != nil {
302- log.Error().Err(err).Str("name", name).Msg("Failed to start blob upload")166+ log.Error().Err(err).Msg("Failed to start blob upload")
303 http.Error(w, "Internal Server Error", http.StatusInternalServerError)167 http.Error(w, "Internal Server Error", http.StatusInternalServerError)
304 return168 return
305 }169 }
306- 170+
171+ // Set headers for the response
307 w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/uploads/%s", name, uuid))172 w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/uploads/%s", name, uuid))
308- w.Header().Set("Range", "0-0")173+ w.Header().Set("Range", "0-0") // Indicate that no bytes have been received yet
309 w.WriteHeader(http.StatusAccepted)174 w.WriteHeader(http.StatusAccepted)
310 }175 }
311 176
312 func (s *Server) handleBlobUpload(w http.ResponseWriter, r *http.Request) {177 func (s *Server) handleBlobUpload(w http.ResponseWriter, r *http.Request) {
313- name, uuid := s.parseUploadPath(r.URL.Path)178+ name := r.PathValue("name")
314- 179+ uuid := r.PathValue("uuid")
315- log.Debug().Str("name", name).Str("uuid", uuid).Msg("Blob upload")
316-
317- // Check if this is a completion request (has digest parameter)
318 digest := r.URL.Query().Get("digest")180 digest := r.URL.Query().Get("digest")
319- 181+
182+ log.Debug().
183+ Str("name", name).
184+ Str("uuid", uuid).
185+ Str("digest", digest).
186+ Str("method", r.Method).
187+ Msg("Handling blob upload chunk")
188+
189+ // Read the chunk from the request body
190+ chunk, err := io.ReadAll(r.Body)
191+ if err != nil {
192+ log.Error().Err(err).Msg("Failed to read blob chunk")
193+ http.Error(w, "Bad Request", http.StatusBadRequest)
194+ return
195+ }
196+
197+ // Append the chunk to the upload
198+ length, err := s.storage.AppendBlobChunk(name, uuid, chunk)
199+ if err != nil {
200+ log.Error().Err(err).Msg("Failed to append blob chunk")
201+ http.Error(w, "Internal Server Error", http.StatusInternalServerError)
202+ return
203+ }
204+
205+ // If this is the final chunk (PUT request with digest), finalize the upload
320 if r.Method == "PUT" && digest != "" {206 if r.Method == "PUT" && digest != "" {
321- // This is a completion request
322 if err := s.storage.FinishBlobUpload(uuid, digest); err != nil {207 if err := s.storage.FinishBlobUpload(uuid, digest); err != nil {
323- log.Error().Err(err).Str("uuid", uuid).Str("digest", digest).Msg("Failed to finish blob upload")208+ log.Error().Err(err).Str("digest", digest).Msg("Failed to finalize blob upload")
324- http.Error(w, "Internal Server Error", http.StatusInternalServerError)209+ // The upload is invalid, delete it
210+ _ = s.storage.CancelBlobUpload(uuid)
211+ http.Error(w, "Bad Request: digest mismatch", http.StatusBadRequest)
325 return212 return
326 }213 }
327- 214+
215+ // Respond with 201 Created
328 w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/%s", name, digest))216 w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/%s", name, digest))
329 w.Header().Set("Docker-Content-Digest", digest)217 w.Header().Set("Docker-Content-Digest", digest)
330 w.WriteHeader(http.StatusCreated)218 w.WriteHeader(http.StatusCreated)
331-
332- log.Info().Str("name", name).Str("uuid", uuid).Str("digest", digest).Msg("Blob upload completed")
333 return219 return
334 }220 }
335- 221+
336- // This is a chunk upload (PATCH)222+ // For PATCH requests, respond with 202 Accepted
337- writer, err := s.storage.GetBlobUpload(uuid)
338- if err != nil {
339- log.Error().Err(err).Str("uuid", uuid).Msg("Failed to get blob upload")
340- http.Error(w, "Not Found", http.StatusNotFound)
341- return
342- }
343- defer writer.Close()
344-
345- written, err := io.Copy(writer, r.Body)
346- if err != nil {
347- log.Error().Err(err).Str("uuid", uuid).Msg("Failed to write blob chunk")
348- http.Error(w, "Internal Server Error", http.StatusInternalServerError)
349- return
350- }
351-
352 w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/uploads/%s", name, uuid))223 w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/uploads/%s", name, uuid))
353- w.Header().Set("Range", fmt.Sprintf("0-%d", written-1))224+ w.Header().Set("Range", fmt.Sprintf("0-%d", length-1))
354 w.WriteHeader(http.StatusAccepted)225 w.WriteHeader(http.StatusAccepted)
355-
356- log.Debug().Str("uuid", uuid).Int64("bytes", written).Msg("Blob chunk uploaded")
357 }226 }
358 227
359 func (s *Server) handleListTags(w http.ResponseWriter, r *http.Request) {228 func (s *Server) handleListTags(w http.ResponseWriter, r *http.Request) {
360- name := s.parseTagsPath(r.URL.Path)229+ name := r.PathValue("name")
361- 230+
362- log.Debug().Str("name", name).Msg("List tags")231+ log.Debug().Str("name", name).Msg("Listing tags")
363- 232+
364 tags, err := s.storage.ListTags(name)233 tags, err := s.storage.ListTags(name)
365 if err != nil {234 if err != nil {
366- log.Error().Err(err).Str("name", name).Msg("Failed to list tags")235+ log.Warn().Err(err).Str("name", name).Msg("Tag not found")
367- http.Error(w, "Internal Server Error", http.StatusInternalServerError)236+ w.WriteHeader(http.StatusNotFound)
368 return237 return
369 }238 }
370- 239+
240+ // Construct the response JSON
241+ response := fmt.Sprintf(`{"name":"%s","tags":%s}`, name, tags)
242+
371 w.Header().Set("Content-Type", "application/json")243 w.Header().Set("Content-Type", "application/json")
244+ w.Header().Set("Content-Length", strconv.Itoa(len(response)))
372 w.WriteHeader(http.StatusOK)245 w.WriteHeader(http.StatusOK)
373- 246+ w.Write([]byte(response))
374- // Build JSON response247+}
375- fmt.Fprintf(w, `{"name":"%s","tags":[`, name)248+
376- for i, tag := range tags {249+func (s *Server) Start(ctx context.Context) error {
377- if i > 0 {250+ addr := ":" + strconv.Itoa(s.config.Server.RegistryPort)
378- fmt.Fprint(w, ",")251+
252+ // Build middleware chain
253+ middlewares := []func(http.Handler) http.Handler{
254+ middleware.PanicRecovery,
255+ middleware.RequestLogger,
256+ }
257+
258+ // Add registry authentication if enabled
259+ if s.config.RegistryAuth.Enabled {
260+ middlewares = append(middlewares, middleware.RegistryAuth(s.config.RegistryAuth.Username, s.config.RegistryAuth.Password))
261+ }
262+
263+ // Wrap the mux with middleware
264+ handler := middleware.Chain(middlewares...)(s.mux)
265+
266+ server := &http.Server{
267+ Addr: addr,
268+ Handler: handler,
269+ }
270+
271+ log.Info().Str("address", addr).Msg("Registry server starting")
272+
273+ // Start server in a goroutine
274+ errChan := make(chan error, 1)
275+ go func() {
276+ if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
277+ errChan <- err
379 }278 }
380- fmt.Fprintf(w, `"%s"`, tag)279+ }()
280+
281+ // Wait for context cancellation or server error
282+ select {
283+ case err := <-errChan:
284+ return err
285+ case <-ctx.Done():
286+ log.Info().Msg("Registry server shutting down...")
287+ return server.Shutdown(context.Background())
381 }288 }
382- fmt.Fprint(w, "]}")289+}
383-}⏎̸
M internal/registry/storage.go +155 -65
@@ -15,25 +15,27 @@ import (
1515 // Storage interface defines the contract for registry storage backends
1616 type Storage interface {
1717 // Manifest operations
18- GetManifest(name, reference string) ([]byte, error)
19- PutManifest(name, reference string, data []byte) error
18+ GetManifest(name, reference string) ([]byte, string, error)
19+ PutManifest(name, reference, contentType string, data []byte) error
2020 DeleteManifest(name, reference string) error
21-
21+
2222 // Blob operations
2323 GetBlob(digest string) (io.ReadCloser, error)
24+ GetBlobPath(digest string) (string, error)
2425 PutBlob(digest string, data io.Reader, size int64) error
2526 DeleteBlob(digest string) error
2627 BlobExists(digest string) bool
27-
28+
2829 // Upload operations
2930 StartBlobUpload(name string) (string, error) // returns upload UUID
31+ AppendBlobChunk(name, uuid string, chunk []byte) (int64, error)
3032 GetBlobUpload(uuid string) (io.WriteCloser, error)
3133 FinishBlobUpload(uuid, digest string) error
3234 CancelBlobUpload(uuid string) error
33-
35+
3436 // Tag operations
3537 ListTags(name string) ([]string, error)
36-
38+
3739 // Repository operations
3840 ListRepositories() ([]string, error)
3941 }
@@ -51,15 +53,15 @@ func NewFilesystemStorage(rootDir string) (*FilesystemStorage, error) {
5153 filepath.Join(rootDir, "blobs"),
5254 filepath.Join(rootDir, "uploads"),
5355 }
54-
56+
5557 for _, dir := range dirs {
5658 if err := os.MkdirAll(dir, 0755); err != nil {
5759 return nil, fmt.Errorf("failed to create directory %s: %w", dir, err)
5860 }
5961 }
60-
62+
6163 log.Info().Str("root_dir", rootDir).Msg("Filesystem storage initialized")
62-
64+
6365 return &FilesystemStorage{
6466 rootDir: rootDir,
6567 }, nil
@@ -67,57 +69,75 @@ func NewFilesystemStorage(rootDir string) (*FilesystemStorage, error) {
6769
6870 // Manifest operations
6971
70-func (fs *FilesystemStorage) GetManifest(name, reference string) ([]byte, error) {
72+func (fs *FilesystemStorage) GetManifest(name, reference string) ([]byte, string, error) {
7173 manifestPath := fs.getManifestPath(name, reference)
72-
74+
7375 data, err := os.ReadFile(manifestPath)
7476 if err != nil {
7577 if os.IsNotExist(err) {
76- return nil, fmt.Errorf("manifest not found: %s/%s", name, reference)
78+ return nil, "", fmt.Errorf("manifest not found: %s/%s", name, reference)
7779 }
78- return nil, fmt.Errorf("failed to read manifest: %w", err)
80+ return nil, "", fmt.Errorf("failed to read manifest: %w", err)
81+ }
82+
83+ contentType, err := fs.getManifestContentType(name, reference)
84+ if err != nil {
85+ // Fallback for manifests stored before this change
86+ log.Warn().Err(err).Str("name", name).Str("reference", reference).Msg("Could not get manifest content type, falling back to default")
87+ contentType = "application/vnd.docker.distribution.manifest.v2+json"
7988 }
80-
81- return data, nil
89+
90+ return data, contentType, nil
8291 }
8392
84-func (fs *FilesystemStorage) PutManifest(name, reference string, data []byte) error {
93+func (fs *FilesystemStorage) PutManifest(name, reference, contentType string, data []byte) error {
8594 manifestPath := fs.getManifestPath(name, reference)
86-
95+
8796 // Create directory if it doesn't exist
8897 if err := os.MkdirAll(filepath.Dir(manifestPath), 0755); err != nil {
8998 return fmt.Errorf("failed to create manifest directory: %w", err)
9099 }
91-
100+
92101 // Write manifest file
93102 if err := os.WriteFile(manifestPath, data, 0644); err != nil {
94103 return fmt.Errorf("failed to write manifest: %w", err)
95104 }
96-
105+
106+ // Store content type
107+ if err := fs.putManifestContentType(name, reference, contentType); err != nil {
108+ // Don't fail the whole operation, but log a warning
109+ log.Warn().Err(err).Str("name", name).Str("reference", reference).Msg("Failed to store manifest content type")
110+ }
111+
97112 // Update tags list
98113 if err := fs.updateTagsList(name, reference); err != nil {
99114 log.Warn().Err(err).Str("name", name).Str("reference", reference).Msg("Failed to update tags list")
100115 }
101-
116+
102117 log.Info().Str("name", name).Str("reference", reference).Msg("Manifest stored")
103118 return nil
104119 }
105120
106121 func (fs *FilesystemStorage) DeleteManifest(name, reference string) error {
107122 manifestPath := fs.getManifestPath(name, reference)
108-
123+
109124 if err := os.Remove(manifestPath); err != nil {
110125 if os.IsNotExist(err) {
111126 return fmt.Errorf("manifest not found: %s/%s", name, reference)
112127 }
113128 return fmt.Errorf("failed to delete manifest: %w", err)
114129 }
115-
130+
131+ // Delete content type file
132+ if err := fs.deleteManifestContentType(name, reference); err != nil {
133+ log.Warn().Err(err).Str("name", name).Str("reference", reference).Msg("Failed to delete manifest content type")
134+ }
135+
116136 // Remove from tags list
117137 if err := fs.removeFromTagsList(name, reference); err != nil {
118138 log.Warn().Err(err).Str("name", name).Str("reference", reference).Msg("Failed to update tags list")
119139 }
120-
140+
121141 log.Info().Str("name", name).Str("reference", reference).Msg("Manifest deleted")
122142 return nil
123143 }
@@ -126,7 +146,7 @@ func (fs *FilesystemStorage) DeleteManifest(name, reference string) error {
126146
127147 func (fs *FilesystemStorage) GetBlob(digest string) (io.ReadCloser, error) {
128148 blobPath := fs.getBlobPath(digest)
129-
149+
130150 file, err := os.Open(blobPath)
131151 if err != nil {
132152 if os.IsNotExist(err) {
@@ -134,18 +154,29 @@ func (fs *FilesystemStorage) GetBlob(digest string) (io.ReadCloser, error) {
134154 }
135155 return nil, fmt.Errorf("failed to open blob: %w", err)
136156 }
137-
157+
138158 return file, nil
139159 }
140160
161+func (fs *FilesystemStorage) GetBlobPath(digest string) (string, error) {
162+ path := fs.getBlobPath(digest)
163+ if _, err := os.Stat(path); err != nil {
164+ if os.IsNotExist(err) {
165+ return "", fmt.Errorf("blob not found: %s", digest)
166+ }
167+ return "", err
168+ }
169+ return path, nil
170+}
171+
141172 func (fs *FilesystemStorage) PutBlob(digest string, data io.Reader, size int64) error {
142173 blobPath := fs.getBlobPath(digest)
143-
174+
144175 // Create directory if it doesn't exist
145176 if err := os.MkdirAll(filepath.Dir(blobPath), 0755); err != nil {
146177 return fmt.Errorf("failed to create blob directory: %w", err)
147178 }
148-
179+
149180 // Create temporary file first
150181 tmpPath := blobPath + ".tmp"
151182 file, err := os.Create(tmpPath)
@@ -153,40 +184,40 @@ func (fs *FilesystemStorage) PutBlob(digest string, data io.Reader, size int64)
153184 return fmt.Errorf("failed to create temporary blob file: %w", err)
154185 }
155186 defer file.Close()
156-
187+
157188 // Copy data to file
158189 written, err := io.Copy(file, data)
159190 if err != nil {
160191 os.Remove(tmpPath)
161192 return fmt.Errorf("failed to write blob data: %w", err)
162193 }
163-
194+
164195 // Verify size if provided
165196 if size > 0 && written != size {
166197 os.Remove(tmpPath)
167198 return fmt.Errorf("blob size mismatch: expected %d, got %d", size, written)
168199 }
169-
200+
170201 // Move to final location
171202 if err := os.Rename(tmpPath, blobPath); err != nil {
172203 os.Remove(tmpPath)
173204 return fmt.Errorf("failed to move blob to final location: %w", err)
174205 }
175-
206+
176207 log.Info().Str("digest", digest).Int64("size", written).Msg("Blob stored")
177208 return nil
178209 }
179210
180211 func (fs *FilesystemStorage) DeleteBlob(digest string) error {
181212 blobPath := fs.getBlobPath(digest)
182-
213+
183214 if err := os.Remove(blobPath); err != nil {
184215 if os.IsNotExist(err) {
185216 return fmt.Errorf("blob not found: %s", digest)
186217 }
187218 return fmt.Errorf("failed to delete blob: %w", err)
188219 }
189-
220+
190221 log.Info().Str("digest", digest).Msg("Blob deleted")
191222 return nil
192223 }
@@ -202,28 +233,60 @@ func (fs *FilesystemStorage) BlobExists(digest string) bool {
202233 func (fs *FilesystemStorage) StartBlobUpload(name string) (string, error) {
203234 // Generate UUID-like upload ID using timestamp
204235 uuid := fmt.Sprintf("%d-%s", time.Now().UnixNano(), strings.ReplaceAll(name, "/", "_"))
205-
236+
206237 uploadPath := fs.getUploadPath(uuid)
207-
238+
208239 // Create directory if it doesn't exist
209240 if err := os.MkdirAll(filepath.Dir(uploadPath), 0755); err != nil {
210241 return "", fmt.Errorf("failed to create upload directory: %w", err)
211242 }
212-
243+
213244 // Create empty upload file
214245 file, err := os.Create(uploadPath)
215246 if err != nil {
216247 return "", fmt.Errorf("failed to create upload file: %w", err)
217248 }
218249 file.Close()
219-
250+
220251 log.Info().Str("uuid", uuid).Str("name", name).Msg("Blob upload started")
221252 return uuid, nil
222253 }
223254
255+func (fs *FilesystemStorage) AppendBlobChunk(name, uuid string, chunk []byte) (int64, error) {
256+ uploadPath := fs.getUploadPath(uuid)
257+
258+ file, err := os.OpenFile(uploadPath, os.O_WRONLY|os.O_APPEND, 0644)
259+ if err != nil {
260+ if os.IsNotExist(err) {
261+ return 0, fmt.Errorf("upload not found: %s", uuid)
262+ }
263+ return 0, fmt.Errorf("failed to open upload file: %w", err)
264+ }
265+ defer file.Close()
266+
267+ written, err := file.Write(chunk)
268+ if err != nil {
269+ return 0, fmt.Errorf("failed to write chunk to upload file: %w", err)
270+ }
271+
272+ fi, err := file.Stat()
273+ if err != nil {
274+ return 0, fmt.Errorf("failed to get file info: %w", err)
275+ }
276+
277+ log.Debug().
278+ Str("uuid", uuid).
279+ Str("name", name).
280+ Int("chunk_size", written).
281+ Int64("total_size", fi.Size()).
282+ Msg("Appended chunk to blob upload")
283+
284+ return fi.Size(), nil
285+}
286+
224287 func (fs *FilesystemStorage) GetBlobUpload(uuid string) (io.WriteCloser, error) {
225288 uploadPath := fs.getUploadPath(uuid)
226-
289+
227290 file, err := os.OpenFile(uploadPath, os.O_WRONLY|os.O_APPEND, 0644)
228291 if err != nil {
229292 if os.IsNotExist(err) {
@@ -231,38 +294,38 @@ func (fs *FilesystemStorage) GetBlobUpload(uuid string) (io.WriteCloser, error)
231294 }
232295 return nil, fmt.Errorf("failed to open upload file: %w", err)
233296 }
234-
297+
235298 return file, nil
236299 }
237300
238301 func (fs *FilesystemStorage) FinishBlobUpload(uuid, digest string) error {
239302 uploadPath := fs.getUploadPath(uuid)
240303 blobPath := fs.getBlobPath(digest)
241-
304+
242305 // Create blob directory if it doesn't exist
243306 if err := os.MkdirAll(filepath.Dir(blobPath), 0755); err != nil {
244307 return fmt.Errorf("failed to create blob directory: %w", err)
245308 }
246-
309+
247310 // Move upload file to blob location
248311 if err := os.Rename(uploadPath, blobPath); err != nil {
249312 return fmt.Errorf("failed to move upload to blob location: %w", err)
250313 }
251-
314+
252315 log.Info().Str("uuid", uuid).Str("digest", digest).Msg("Blob upload finished")
253316 return nil
254317 }
255318
256319 func (fs *FilesystemStorage) CancelBlobUpload(uuid string) error {
257320 uploadPath := fs.getUploadPath(uuid)
258-
321+
259322 if err := os.Remove(uploadPath); err != nil {
260323 if os.IsNotExist(err) {
261324 return fmt.Errorf("upload not found: %s", uuid)
262325 }
263326 return fmt.Errorf("failed to cancel upload: %w", err)
264327 }
265-
328+
266329 log.Info().Str("uuid", uuid).Msg("Blob upload cancelled")
267330 return nil
268331 }
@@ -271,7 +334,7 @@ func (fs *FilesystemStorage) CancelBlobUpload(uuid string) error {
271334
272335 func (fs *FilesystemStorage) ListTags(name string) ([]string, error) {
273336 tagsPath := fs.getTagsPath(name)
274-
337+
275338 data, err := os.ReadFile(tagsPath)
276339 if err != nil {
277340 if os.IsNotExist(err) {
@@ -279,12 +342,12 @@ func (fs *FilesystemStorage) ListTags(name string) ([]string, error) {
279342 }
280343 return nil, fmt.Errorf("failed to read tags file: %w", err)
281344 }
282-
345+
283346 var tags []string
284347 if err := json.Unmarshal(data, &tags); err != nil {
285348 return nil, fmt.Errorf("failed to parse tags file: %w", err)
286349 }
287-
350+
288351 return tags, nil
289352 }
290353
@@ -292,34 +355,34 @@ func (fs *FilesystemStorage) ListTags(name string) ([]string, error) {
292355
293356 func (fs *FilesystemStorage) ListRepositories() ([]string, error) {
294357 reposDir := filepath.Join(fs.rootDir, "repositories")
295-
358+
296359 var repositories []string
297360 err := filepath.Walk(reposDir, func(path string, info os.FileInfo, err error) error {
298361 if err != nil {
299362 return err
300363 }
301-
364+
302365 if info.IsDir() && path != reposDir {
303366 // Get relative path from repositories directory
304367 relPath, err := filepath.Rel(reposDir, path)
305368 if err != nil {
306369 return err
307370 }
308-
371+
309372 // Check if this directory contains manifests
310373 manifestsDir := filepath.Join(path, "manifests")
311374 if _, err := os.Stat(manifestsDir); err == nil {
312375 repositories = append(repositories, relPath)
313376 }
314377 }
315-
378+
316379 return nil
317380 })
318-
381+
319382 if err != nil {
320383 return nil, fmt.Errorf("failed to list repositories: %w", err)
321384 }
322-
385+
323386 return repositories, nil
324387 }
325388
@@ -329,6 +392,33 @@ func (fs *FilesystemStorage) getManifestPath(name, reference string) string {
329392 return filepath.Join(fs.rootDir, "repositories", name, "manifests", reference)
330393 }
331394
395+func (fs *FilesystemStorage) getManifestContentTypePath(name, reference string) string {
396+ return fs.getManifestPath(name, reference) + ".contenttype"
397+}
398+
399+func (fs *FilesystemStorage) getManifestContentType(name, reference string) (string, error) {
400+ contentTypePath := fs.getManifestContentTypePath(name, reference)
401+ data, err := os.ReadFile(contentTypePath)
402+ if err != nil {
403+ return "", err
404+ }
405+ return string(data), nil
406+}
407+
408+func (fs *FilesystemStorage) putManifestContentType(name, reference, contentType string) error {
409+ contentTypePath := fs.getManifestContentTypePath(name, reference)
410+ return os.WriteFile(contentTypePath, []byte(contentType), 0644)
411+}
412+
413+func (fs *FilesystemStorage) deleteManifestContentType(name, reference string) error {
414+ contentTypePath := fs.getManifestContentTypePath(name, reference)
415+ err := os.Remove(contentTypePath)
416+ if err != nil && !os.IsNotExist(err) {
417+ return err
418+ }
419+ return nil
420+}
421+
332422 func (fs *FilesystemStorage) getBlobPath(digest string) string {
333423 // Split digest into directory structure (e.g., sha256:abc123... -> sha256/ab/abc123...)
334424 parts := strings.SplitN(digest, ":", 2)
@@ -336,15 +426,15 @@ func (fs *FilesystemStorage) getBlobPath(digest string) string {
336426 // Fallback if digest format is unexpected
337427 return filepath.Join(fs.rootDir, "blobs", digest)
338428 }
339-
429+
340430 algorithm := parts[0]
341431 hash := parts[1]
342-
432+
343433 // Create two-level directory structure for better performance
344434 if len(hash) >= 2 {
345435 return filepath.Join(fs.rootDir, "blobs", algorithm, hash[:2], hash)
346436 }
347-
437+
348438 return filepath.Join(fs.rootDir, "blobs", algorithm, hash)
349439 }
350440
@@ -363,14 +453,14 @@ func (fs *FilesystemStorage) updateTagsList(name, reference string) error {
363453 if err != nil {
364454 return err
365455 }
366-
456+
367457 // Add tag if not already present
368458 for _, tag := range tags {
369459 if tag == reference {
370460 return nil // Already exists
371461 }
372462 }
373-
463+
374464 tags = append(tags, reference)
375465 return fs.saveTagsList(name, tags)
376466 }
@@ -380,7 +470,7 @@ func (fs *FilesystemStorage) removeFromTagsList(name, reference string) error {
380470 if err != nil {
381471 return err
382472 }
383-
473+
384474 // Remove tag if present
385475 var newTags []string
386476 for _, tag := range tags {
@@ -388,26 +478,26 @@ func (fs *FilesystemStorage) removeFromTagsList(name, reference string) error {
388478 newTags = append(newTags, tag)
389479 }
390480 }
391-
481+
392482 return fs.saveTagsList(name, newTags)
393483 }
394484
395485 func (fs *FilesystemStorage) saveTagsList(name string, tags []string) error {
396486 tagsPath := fs.getTagsPath(name)
397-
487+
398488 // Create directory if it doesn't exist
399489 if err := os.MkdirAll(filepath.Dir(tagsPath), 0755); err != nil {
400490 return fmt.Errorf("failed to create tags directory: %w", err)
401491 }
402-
492+
403493 data, err := json.Marshal(tags)
404494 if err != nil {
405495 return fmt.Errorf("failed to marshal tags: %w", err)
406496 }
407-
497+
408498 if err := os.WriteFile(tagsPath, data, 0644); err != nil {
409499 return fmt.Errorf("failed to write tags file: %w", err)
410500 }
411-
501+
412502 return nil
413-}⏎̸
503+}
@@ -15,25 +15,27 @@ import (
15 // Storage interface defines the contract for registry storage backends15 // Storage interface defines the contract for registry storage backends
16 type Storage interface {16 type Storage interface {
17 // Manifest operations17 // Manifest operations
18- GetManifest(name, reference string) ([]byte, error)18+ GetManifest(name, reference string) ([]byte, string, error)
19- PutManifest(name, reference string, data []byte) error19+ PutManifest(name, reference, contentType string, data []byte) error
20 DeleteManifest(name, reference string) error20 DeleteManifest(name, reference string) error
21- 21+
22 // Blob operations22 // Blob operations
23 GetBlob(digest string) (io.ReadCloser, error)23 GetBlob(digest string) (io.ReadCloser, error)
24+ GetBlobPath(digest string) (string, error)
24 PutBlob(digest string, data io.Reader, size int64) error25 PutBlob(digest string, data io.Reader, size int64) error
25 DeleteBlob(digest string) error26 DeleteBlob(digest string) error
26 BlobExists(digest string) bool27 BlobExists(digest string) bool
27- 28+
28 // Upload operations29 // Upload operations
29 StartBlobUpload(name string) (string, error) // returns upload UUID30 StartBlobUpload(name string) (string, error) // returns upload UUID
31+ AppendBlobChunk(name, uuid string, chunk []byte) (int64, error)
30 GetBlobUpload(uuid string) (io.WriteCloser, error)32 GetBlobUpload(uuid string) (io.WriteCloser, error)
31 FinishBlobUpload(uuid, digest string) error33 FinishBlobUpload(uuid, digest string) error
32 CancelBlobUpload(uuid string) error34 CancelBlobUpload(uuid string) error
33- 35+
34 // Tag operations36 // Tag operations
35 ListTags(name string) ([]string, error)37 ListTags(name string) ([]string, error)
36- 38+
37 // Repository operations39 // Repository operations
38 ListRepositories() ([]string, error)40 ListRepositories() ([]string, error)
39 }41 }
@@ -51,15 +53,15 @@ func NewFilesystemStorage(rootDir string) (*FilesystemStorage, error) {
51 filepath.Join(rootDir, "blobs"),53 filepath.Join(rootDir, "blobs"),
52 filepath.Join(rootDir, "uploads"),54 filepath.Join(rootDir, "uploads"),
53 }55 }
54- 56+
55 for _, dir := range dirs {57 for _, dir := range dirs {
56 if err := os.MkdirAll(dir, 0755); err != nil {58 if err := os.MkdirAll(dir, 0755); err != nil {
57 return nil, fmt.Errorf("failed to create directory %s: %w", dir, err)59 return nil, fmt.Errorf("failed to create directory %s: %w", dir, err)
58 }60 }
59 }61 }
60- 62+
61 log.Info().Str("root_dir", rootDir).Msg("Filesystem storage initialized")63 log.Info().Str("root_dir", rootDir).Msg("Filesystem storage initialized")
62- 64+
63 return &FilesystemStorage{65 return &FilesystemStorage{
64 rootDir: rootDir,66 rootDir: rootDir,
65 }, nil67 }, nil
@@ -67,57 +69,75 @@ func NewFilesystemStorage(rootDir string) (*FilesystemStorage, error) {
67 69
68 // Manifest operations70 // Manifest operations
69 71
70-func (fs *FilesystemStorage) GetManifest(name, reference string) ([]byte, error) {72+func (fs *FilesystemStorage) GetManifest(name, reference string) ([]byte, string, error) {
71 manifestPath := fs.getManifestPath(name, reference)73 manifestPath := fs.getManifestPath(name, reference)
72- 74+
73 data, err := os.ReadFile(manifestPath)75 data, err := os.ReadFile(manifestPath)
74 if err != nil {76 if err != nil {
75 if os.IsNotExist(err) {77 if os.IsNotExist(err) {
76- return nil, fmt.Errorf("manifest not found: %s/%s", name, reference)78+ return nil, "", fmt.Errorf("manifest not found: %s/%s", name, reference)
77 }79 }
78- return nil, fmt.Errorf("failed to read manifest: %w", err)80+ return nil, "", fmt.Errorf("failed to read manifest: %w", err)
81+ }
82+
83+ contentType, err := fs.getManifestContentType(name, reference)
84+ if err != nil {
85+ // Fallback for manifests stored before this change
86+ log.Warn().Err(err).Str("name", name).Str("reference", reference).Msg("Could not get manifest content type, falling back to default")
87+ contentType = "application/vnd.docker.distribution.manifest.v2+json"
79 }88 }
80- 89+
81- return data, nil90+ return data, contentType, nil
82 }91 }
83 92
84-func (fs *FilesystemStorage) PutManifest(name, reference string, data []byte) error {93+func (fs *FilesystemStorage) PutManifest(name, reference, contentType string, data []byte) error {
85 manifestPath := fs.getManifestPath(name, reference)94 manifestPath := fs.getManifestPath(name, reference)
86- 95+
87 // Create directory if it doesn't exist96 // Create directory if it doesn't exist
88 if err := os.MkdirAll(filepath.Dir(manifestPath), 0755); err != nil {97 if err := os.MkdirAll(filepath.Dir(manifestPath), 0755); err != nil {
89 return fmt.Errorf("failed to create manifest directory: %w", err)98 return fmt.Errorf("failed to create manifest directory: %w", err)
90 }99 }
91- 100+
92 // Write manifest file101 // Write manifest file
93 if err := os.WriteFile(manifestPath, data, 0644); err != nil {102 if err := os.WriteFile(manifestPath, data, 0644); err != nil {
94 return fmt.Errorf("failed to write manifest: %w", err)103 return fmt.Errorf("failed to write manifest: %w", err)
95 }104 }
96- 105+
106+ // Store content type
107+ if err := fs.putManifestContentType(name, reference, contentType); err != nil {
108+ // Don't fail the whole operation, but log a warning
109+ log.Warn().Err(err).Str("name", name).Str("reference", reference).Msg("Failed to store manifest content type")
110+ }
111+
97 // Update tags list112 // Update tags list
98 if err := fs.updateTagsList(name, reference); err != nil {113 if err := fs.updateTagsList(name, reference); err != nil {
99 log.Warn().Err(err).Str("name", name).Str("reference", reference).Msg("Failed to update tags list")114 log.Warn().Err(err).Str("name", name).Str("reference", reference).Msg("Failed to update tags list")
100 }115 }
101- 116+
102 log.Info().Str("name", name).Str("reference", reference).Msg("Manifest stored")117 log.Info().Str("name", name).Str("reference", reference).Msg("Manifest stored")
103 return nil118 return nil
104 }119 }
105 120
106 func (fs *FilesystemStorage) DeleteManifest(name, reference string) error {121 func (fs *FilesystemStorage) DeleteManifest(name, reference string) error {
107 manifestPath := fs.getManifestPath(name, reference)122 manifestPath := fs.getManifestPath(name, reference)
108- 123+
109 if err := os.Remove(manifestPath); err != nil {124 if err := os.Remove(manifestPath); err != nil {
110 if os.IsNotExist(err) {125 if os.IsNotExist(err) {
111 return fmt.Errorf("manifest not found: %s/%s", name, reference)126 return fmt.Errorf("manifest not found: %s/%s", name, reference)
112 }127 }
113 return fmt.Errorf("failed to delete manifest: %w", err)128 return fmt.Errorf("failed to delete manifest: %w", err)
114 }129 }
115- 130+
131+ // Delete content type file
132+ if err := fs.deleteManifestContentType(name, reference); err != nil {
133+ log.Warn().Err(err).Str("name", name).Str("reference", reference).Msg("Failed to delete manifest content type")
134+ }
135+
116 // Remove from tags list136 // Remove from tags list
117 if err := fs.removeFromTagsList(name, reference); err != nil {137 if err := fs.removeFromTagsList(name, reference); err != nil {
118 log.Warn().Err(err).Str("name", name).Str("reference", reference).Msg("Failed to update tags list")138 log.Warn().Err(err).Str("name", name).Str("reference", reference).Msg("Failed to update tags list")
119 }139 }
120- 140+
121 log.Info().Str("name", name).Str("reference", reference).Msg("Manifest deleted")141 log.Info().Str("name", name).Str("reference", reference).Msg("Manifest deleted")
122 return nil142 return nil
123 }143 }
@@ -126,7 +146,7 @@ func (fs *FilesystemStorage) DeleteManifest(name, reference string) error {
126 146
127 func (fs *FilesystemStorage) GetBlob(digest string) (io.ReadCloser, error) {147 func (fs *FilesystemStorage) GetBlob(digest string) (io.ReadCloser, error) {
128 blobPath := fs.getBlobPath(digest)148 blobPath := fs.getBlobPath(digest)
129- 149+
130 file, err := os.Open(blobPath)150 file, err := os.Open(blobPath)
131 if err != nil {151 if err != nil {
132 if os.IsNotExist(err) {152 if os.IsNotExist(err) {
@@ -134,18 +154,29 @@ func (fs *FilesystemStorage) GetBlob(digest string) (io.ReadCloser, error) {
134 }154 }
135 return nil, fmt.Errorf("failed to open blob: %w", err)155 return nil, fmt.Errorf("failed to open blob: %w", err)
136 }156 }
137- 157+
138 return file, nil158 return file, nil
139 }159 }
140 160
161+func (fs *FilesystemStorage) GetBlobPath(digest string) (string, error) {
162+ path := fs.getBlobPath(digest)
163+ if _, err := os.Stat(path); err != nil {
164+ if os.IsNotExist(err) {
165+ return "", fmt.Errorf("blob not found: %s", digest)
166+ }
167+ return "", err
168+ }
169+ return path, nil
170+}
171+
141 func (fs *FilesystemStorage) PutBlob(digest string, data io.Reader, size int64) error {172 func (fs *FilesystemStorage) PutBlob(digest string, data io.Reader, size int64) error {
142 blobPath := fs.getBlobPath(digest)173 blobPath := fs.getBlobPath(digest)
143- 174+
144 // Create directory if it doesn't exist175 // Create directory if it doesn't exist
145 if err := os.MkdirAll(filepath.Dir(blobPath), 0755); err != nil {176 if err := os.MkdirAll(filepath.Dir(blobPath), 0755); err != nil {
146 return fmt.Errorf("failed to create blob directory: %w", err)177 return fmt.Errorf("failed to create blob directory: %w", err)
147 }178 }
148- 179+
149 // Create temporary file first180 // Create temporary file first
150 tmpPath := blobPath + ".tmp"181 tmpPath := blobPath + ".tmp"
151 file, err := os.Create(tmpPath)182 file, err := os.Create(tmpPath)
@@ -153,40 +184,40 @@ func (fs *FilesystemStorage) PutBlob(digest string, data io.Reader, size int64)
153 return fmt.Errorf("failed to create temporary blob file: %w", err)184 return fmt.Errorf("failed to create temporary blob file: %w", err)
154 }185 }
155 defer file.Close()186 defer file.Close()
156- 187+
157 // Copy data to file188 // Copy data to file
158 written, err := io.Copy(file, data)189 written, err := io.Copy(file, data)
159 if err != nil {190 if err != nil {
160 os.Remove(tmpPath)191 os.Remove(tmpPath)
161 return fmt.Errorf("failed to write blob data: %w", err)192 return fmt.Errorf("failed to write blob data: %w", err)
162 }193 }
163- 194+
164 // Verify size if provided195 // Verify size if provided
165 if size > 0 && written != size {196 if size > 0 && written != size {
166 os.Remove(tmpPath)197 os.Remove(tmpPath)
167 return fmt.Errorf("blob size mismatch: expected %d, got %d", size, written)198 return fmt.Errorf("blob size mismatch: expected %d, got %d", size, written)
168 }199 }
169- 200+
170 // Move to final location201 // Move to final location
171 if err := os.Rename(tmpPath, blobPath); err != nil {202 if err := os.Rename(tmpPath, blobPath); err != nil {
172 os.Remove(tmpPath)203 os.Remove(tmpPath)
173 return fmt.Errorf("failed to move blob to final location: %w", err)204 return fmt.Errorf("failed to move blob to final location: %w", err)
174 }205 }
175- 206+
176 log.Info().Str("digest", digest).Int64("size", written).Msg("Blob stored")207 log.Info().Str("digest", digest).Int64("size", written).Msg("Blob stored")
177 return nil208 return nil
178 }209 }
179 210
180 func (fs *FilesystemStorage) DeleteBlob(digest string) error {211 func (fs *FilesystemStorage) DeleteBlob(digest string) error {
181 blobPath := fs.getBlobPath(digest)212 blobPath := fs.getBlobPath(digest)
182- 213+
183 if err := os.Remove(blobPath); err != nil {214 if err := os.Remove(blobPath); err != nil {
184 if os.IsNotExist(err) {215 if os.IsNotExist(err) {
185 return fmt.Errorf("blob not found: %s", digest)216 return fmt.Errorf("blob not found: %s", digest)
186 }217 }
187 return fmt.Errorf("failed to delete blob: %w", err)218 return fmt.Errorf("failed to delete blob: %w", err)
188 }219 }
189- 220+
190 log.Info().Str("digest", digest).Msg("Blob deleted")221 log.Info().Str("digest", digest).Msg("Blob deleted")
191 return nil222 return nil
192 }223 }
@@ -202,28 +233,60 @@ func (fs *FilesystemStorage) BlobExists(digest string) bool {
202 func (fs *FilesystemStorage) StartBlobUpload(name string) (string, error) {233 func (fs *FilesystemStorage) StartBlobUpload(name string) (string, error) {
203 // Generate UUID-like upload ID using timestamp234 // Generate UUID-like upload ID using timestamp
204 uuid := fmt.Sprintf("%d-%s", time.Now().UnixNano(), strings.ReplaceAll(name, "/", "_"))235 uuid := fmt.Sprintf("%d-%s", time.Now().UnixNano(), strings.ReplaceAll(name, "/", "_"))
205- 236+
206 uploadPath := fs.getUploadPath(uuid)237 uploadPath := fs.getUploadPath(uuid)
207- 238+
208 // Create directory if it doesn't exist239 // Create directory if it doesn't exist
209 if err := os.MkdirAll(filepath.Dir(uploadPath), 0755); err != nil {240 if err := os.MkdirAll(filepath.Dir(uploadPath), 0755); err != nil {
210 return "", fmt.Errorf("failed to create upload directory: %w", err)241 return "", fmt.Errorf("failed to create upload directory: %w", err)
211 }242 }
212- 243+
213 // Create empty upload file244 // Create empty upload file
214 file, err := os.Create(uploadPath)245 file, err := os.Create(uploadPath)
215 if err != nil {246 if err != nil {
216 return "", fmt.Errorf("failed to create upload file: %w", err)247 return "", fmt.Errorf("failed to create upload file: %w", err)
217 }248 }
218 file.Close()249 file.Close()
219- 250+
220 log.Info().Str("uuid", uuid).Str("name", name).Msg("Blob upload started")251 log.Info().Str("uuid", uuid).Str("name", name).Msg("Blob upload started")
221 return uuid, nil252 return uuid, nil
222 }253 }
223 254
255+func (fs *FilesystemStorage) AppendBlobChunk(name, uuid string, chunk []byte) (int64, error) {
256+ uploadPath := fs.getUploadPath(uuid)
257+
258+ file, err := os.OpenFile(uploadPath, os.O_WRONLY|os.O_APPEND, 0644)
259+ if err != nil {
260+ if os.IsNotExist(err) {
261+ return 0, fmt.Errorf("upload not found: %s", uuid)
262+ }
263+ return 0, fmt.Errorf("failed to open upload file: %w", err)
264+ }
265+ defer file.Close()
266+
267+ written, err := file.Write(chunk)
268+ if err != nil {
269+ return 0, fmt.Errorf("failed to write chunk to upload file: %w", err)
270+ }
271+
272+ fi, err := file.Stat()
273+ if err != nil {
274+ return 0, fmt.Errorf("failed to get file info: %w", err)
275+ }
276+
277+ log.Debug().
278+ Str("uuid", uuid).
279+ Str("name", name).
280+ Int("chunk_size", written).
281+ Int64("total_size", fi.Size()).
282+ Msg("Appended chunk to blob upload")
283+
284+ return fi.Size(), nil
285+}
286+
224 func (fs *FilesystemStorage) GetBlobUpload(uuid string) (io.WriteCloser, error) {287 func (fs *FilesystemStorage) GetBlobUpload(uuid string) (io.WriteCloser, error) {
225 uploadPath := fs.getUploadPath(uuid)288 uploadPath := fs.getUploadPath(uuid)
226- 289+
227 file, err := os.OpenFile(uploadPath, os.O_WRONLY|os.O_APPEND, 0644)290 file, err := os.OpenFile(uploadPath, os.O_WRONLY|os.O_APPEND, 0644)
228 if err != nil {291 if err != nil {
229 if os.IsNotExist(err) {292 if os.IsNotExist(err) {
@@ -231,38 +294,38 @@ func (fs *FilesystemStorage) GetBlobUpload(uuid string) (io.WriteCloser, error)
231 }294 }
232 return nil, fmt.Errorf("failed to open upload file: %w", err)295 return nil, fmt.Errorf("failed to open upload file: %w", err)
233 }296 }
234- 297+
235 return file, nil298 return file, nil
236 }299 }
237 300
238 func (fs *FilesystemStorage) FinishBlobUpload(uuid, digest string) error {301 func (fs *FilesystemStorage) FinishBlobUpload(uuid, digest string) error {
239 uploadPath := fs.getUploadPath(uuid)302 uploadPath := fs.getUploadPath(uuid)
240 blobPath := fs.getBlobPath(digest)303 blobPath := fs.getBlobPath(digest)
241- 304+
242 // Create blob directory if it doesn't exist305 // Create blob directory if it doesn't exist
243 if err := os.MkdirAll(filepath.Dir(blobPath), 0755); err != nil {306 if err := os.MkdirAll(filepath.Dir(blobPath), 0755); err != nil {
244 return fmt.Errorf("failed to create blob directory: %w", err)307 return fmt.Errorf("failed to create blob directory: %w", err)
245 }308 }
246- 309+
247 // Move upload file to blob location310 // Move upload file to blob location
248 if err := os.Rename(uploadPath, blobPath); err != nil {311 if err := os.Rename(uploadPath, blobPath); err != nil {
249 return fmt.Errorf("failed to move upload to blob location: %w", err)312 return fmt.Errorf("failed to move upload to blob location: %w", err)
250 }313 }
251- 314+
252 log.Info().Str("uuid", uuid).Str("digest", digest).Msg("Blob upload finished")315 log.Info().Str("uuid", uuid).Str("digest", digest).Msg("Blob upload finished")
253 return nil316 return nil
254 }317 }
255 318
256 func (fs *FilesystemStorage) CancelBlobUpload(uuid string) error {319 func (fs *FilesystemStorage) CancelBlobUpload(uuid string) error {
257 uploadPath := fs.getUploadPath(uuid)320 uploadPath := fs.getUploadPath(uuid)
258- 321+
259 if err := os.Remove(uploadPath); err != nil {322 if err := os.Remove(uploadPath); err != nil {
260 if os.IsNotExist(err) {323 if os.IsNotExist(err) {
261 return fmt.Errorf("upload not found: %s", uuid)324 return fmt.Errorf("upload not found: %s", uuid)
262 }325 }
263 return fmt.Errorf("failed to cancel upload: %w", err)326 return fmt.Errorf("failed to cancel upload: %w", err)
264 }327 }
265- 328+
266 log.Info().Str("uuid", uuid).Msg("Blob upload cancelled")329 log.Info().Str("uuid", uuid).Msg("Blob upload cancelled")
267 return nil330 return nil
268 }331 }
@@ -271,7 +334,7 @@ func (fs *FilesystemStorage) CancelBlobUpload(uuid string) error {
271 334
272 func (fs *FilesystemStorage) ListTags(name string) ([]string, error) {335 func (fs *FilesystemStorage) ListTags(name string) ([]string, error) {
273 tagsPath := fs.getTagsPath(name)336 tagsPath := fs.getTagsPath(name)
274- 337+
275 data, err := os.ReadFile(tagsPath)338 data, err := os.ReadFile(tagsPath)
276 if err != nil {339 if err != nil {
277 if os.IsNotExist(err) {340 if os.IsNotExist(err) {
@@ -279,12 +342,12 @@ func (fs *FilesystemStorage) ListTags(name string) ([]string, error) {
279 }342 }
280 return nil, fmt.Errorf("failed to read tags file: %w", err)343 return nil, fmt.Errorf("failed to read tags file: %w", err)
281 }344 }
282- 345+
283 var tags []string346 var tags []string
284 if err := json.Unmarshal(data, &tags); err != nil {347 if err := json.Unmarshal(data, &tags); err != nil {
285 return nil, fmt.Errorf("failed to parse tags file: %w", err)348 return nil, fmt.Errorf("failed to parse tags file: %w", err)
286 }349 }
287- 350+
288 return tags, nil351 return tags, nil
289 }352 }
290 353
@@ -292,34 +355,34 @@ func (fs *FilesystemStorage) ListTags(name string) ([]string, error) {
292 355
293 func (fs *FilesystemStorage) ListRepositories() ([]string, error) {356 func (fs *FilesystemStorage) ListRepositories() ([]string, error) {
294 reposDir := filepath.Join(fs.rootDir, "repositories")357 reposDir := filepath.Join(fs.rootDir, "repositories")
295- 358+
296 var repositories []string359 var repositories []string
297 err := filepath.Walk(reposDir, func(path string, info os.FileInfo, err error) error {360 err := filepath.Walk(reposDir, func(path string, info os.FileInfo, err error) error {
298 if err != nil {361 if err != nil {
299 return err362 return err
300 }363 }
301- 364+
302 if info.IsDir() && path != reposDir {365 if info.IsDir() && path != reposDir {
303 // Get relative path from repositories directory366 // Get relative path from repositories directory
304 relPath, err := filepath.Rel(reposDir, path)367 relPath, err := filepath.Rel(reposDir, path)
305 if err != nil {368 if err != nil {
306 return err369 return err
307 }370 }
308- 371+
309 // Check if this directory contains manifests372 // Check if this directory contains manifests
310 manifestsDir := filepath.Join(path, "manifests")373 manifestsDir := filepath.Join(path, "manifests")
311 if _, err := os.Stat(manifestsDir); err == nil {374 if _, err := os.Stat(manifestsDir); err == nil {
312 repositories = append(repositories, relPath)375 repositories = append(repositories, relPath)
313 }376 }
314 }377 }
315- 378+
316 return nil379 return nil
317 })380 })
318- 381+
319 if err != nil {382 if err != nil {
320 return nil, fmt.Errorf("failed to list repositories: %w", err)383 return nil, fmt.Errorf("failed to list repositories: %w", err)
321 }384 }
322- 385+
323 return repositories, nil386 return repositories, nil
324 }387 }
325 388
@@ -329,6 +392,33 @@ func (fs *FilesystemStorage) getManifestPath(name, reference string) string {
329 return filepath.Join(fs.rootDir, "repositories", name, "manifests", reference)392 return filepath.Join(fs.rootDir, "repositories", name, "manifests", reference)
330 }393 }
331 394
395+func (fs *FilesystemStorage) getManifestContentTypePath(name, reference string) string {
396+ return fs.getManifestPath(name, reference) + ".contenttype"
397+}
398+
399+func (fs *FilesystemStorage) getManifestContentType(name, reference string) (string, error) {
400+ contentTypePath := fs.getManifestContentTypePath(name, reference)
401+ data, err := os.ReadFile(contentTypePath)
402+ if err != nil {
403+ return "", err
404+ }
405+ return string(data), nil
406+}
407+
408+func (fs *FilesystemStorage) putManifestContentType(name, reference, contentType string) error {
409+ contentTypePath := fs.getManifestContentTypePath(name, reference)
410+ return os.WriteFile(contentTypePath, []byte(contentType), 0644)
411+}
412+
413+func (fs *FilesystemStorage) deleteManifestContentType(name, reference string) error {
414+ contentTypePath := fs.getManifestContentTypePath(name, reference)
415+ err := os.Remove(contentTypePath)
416+ if err != nil && !os.IsNotExist(err) {
417+ return err
418+ }
419+ return nil
420+}
421+
332 func (fs *FilesystemStorage) getBlobPath(digest string) string {422 func (fs *FilesystemStorage) getBlobPath(digest string) string {
333 // Split digest into directory structure (e.g., sha256:abc123... -> sha256/ab/abc123...)423 // Split digest into directory structure (e.g., sha256:abc123... -> sha256/ab/abc123...)
334 parts := strings.SplitN(digest, ":", 2)424 parts := strings.SplitN(digest, ":", 2)
@@ -336,15 +426,15 @@ func (fs *FilesystemStorage) getBlobPath(digest string) string {
336 // Fallback if digest format is unexpected426 // Fallback if digest format is unexpected
337 return filepath.Join(fs.rootDir, "blobs", digest)427 return filepath.Join(fs.rootDir, "blobs", digest)
338 }428 }
339- 429+
340 algorithm := parts[0]430 algorithm := parts[0]
341 hash := parts[1]431 hash := parts[1]
342- 432+
343 // Create two-level directory structure for better performance433 // Create two-level directory structure for better performance
344 if len(hash) >= 2 {434 if len(hash) >= 2 {
345 return filepath.Join(fs.rootDir, "blobs", algorithm, hash[:2], hash)435 return filepath.Join(fs.rootDir, "blobs", algorithm, hash[:2], hash)
346 }436 }
347- 437+
348 return filepath.Join(fs.rootDir, "blobs", algorithm, hash)438 return filepath.Join(fs.rootDir, "blobs", algorithm, hash)
349 }439 }
350 440
@@ -363,14 +453,14 @@ func (fs *FilesystemStorage) updateTagsList(name, reference string) error {
363 if err != nil {453 if err != nil {
364 return err454 return err
365 }455 }
366- 456+
367 // Add tag if not already present457 // Add tag if not already present
368 for _, tag := range tags {458 for _, tag := range tags {
369 if tag == reference {459 if tag == reference {
370 return nil // Already exists460 return nil // Already exists
371 }461 }
372 }462 }
373- 463+
374 tags = append(tags, reference)464 tags = append(tags, reference)
375 return fs.saveTagsList(name, tags)465 return fs.saveTagsList(name, tags)
376 }466 }
@@ -380,7 +470,7 @@ func (fs *FilesystemStorage) removeFromTagsList(name, reference string) error {
380 if err != nil {470 if err != nil {
381 return err471 return err
382 }472 }
383- 473+
384 // Remove tag if present474 // Remove tag if present
385 var newTags []string475 var newTags []string
386 for _, tag := range tags {476 for _, tag := range tags {
@@ -388,26 +478,26 @@ func (fs *FilesystemStorage) removeFromTagsList(name, reference string) error {
388 newTags = append(newTags, tag)478 newTags = append(newTags, tag)
389 }479 }
390 }480 }
391- 481+
392 return fs.saveTagsList(name, newTags)482 return fs.saveTagsList(name, newTags)
393 }483 }
394 484
395 func (fs *FilesystemStorage) saveTagsList(name string, tags []string) error {485 func (fs *FilesystemStorage) saveTagsList(name string, tags []string) error {
396 tagsPath := fs.getTagsPath(name)486 tagsPath := fs.getTagsPath(name)
397- 487+
398 // Create directory if it doesn't exist488 // Create directory if it doesn't exist
399 if err := os.MkdirAll(filepath.Dir(tagsPath), 0755); err != nil {489 if err := os.MkdirAll(filepath.Dir(tagsPath), 0755); err != nil {
400 return fmt.Errorf("failed to create tags directory: %w", err)490 return fmt.Errorf("failed to create tags directory: %w", err)
401 }491 }
402- 492+
403 data, err := json.Marshal(tags)493 data, err := json.Marshal(tags)
404 if err != nil {494 if err != nil {
405 return fmt.Errorf("failed to marshal tags: %w", err)495 return fmt.Errorf("failed to marshal tags: %w", err)
406 }496 }
407- 497+
408 if err := os.WriteFile(tagsPath, data, 0644); err != nil {498 if err := os.WriteFile(tagsPath, data, 0644); err != nil {
409 return fmt.Errorf("failed to write tags file: %w", err)499 return fmt.Errorf("failed to write tags file: %w", err)
410 }500 }
411- 501+
412 return nil502 return nil
413-}⏎̸503+}
M pkg/runtime/interface.go +1 -0
@@ -43,6 +43,7 @@ type Runtime interface {
4343
4444 // Image operations
4545 PullImage(ctx context.Context, image string) error
46+ PullImageWithAuth(ctx context.Context, image, username, password string) error
4647 RemoveImage(ctx context.Context, image string, force bool) error
4748 ListImages(ctx context.Context) ([]string, error)
4849
@@ -43,6 +43,7 @@ type Runtime interface {
43 43
44 // Image operations44 // Image operations
45 PullImage(ctx context.Context, image string) error45 PullImage(ctx context.Context, image string) error
46+ PullImageWithAuth(ctx context.Context, image, username, password string) error
46 RemoveImage(ctx context.Context, image string, force bool) error47 RemoveImage(ctx context.Context, image string, force bool) error
47 ListImages(ctx context.Context) ([]string, error)48 ListImages(ctx context.Context) ([]string, error)
48 49

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