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
- e46e6fca99446e8653fcf2c38d6d1dfe2ca05fee
- author
- bamen · · 2025-06-07 12:38 UTC
- parent
- 7053479
- signature
- unsigned
12 files · +590 -897
M cmd/start.go +8 -14
| @@ -8,17 +8,17 @@ import ( | |||
| 8 | 8 | "syscall" | |
| 9 | 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 | 11 | "gordon/internal/config" | |
| 18 | 12 | "gordon/internal/container" | |
| 19 | 13 | "gordon/internal/events" | |
| 20 | 14 | "gordon/internal/proxy" | |
| 21 | 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 | 24 | var startCmd = &cobra.Command{ | |
| @@ -91,13 +91,7 @@ func runStart(cmd *cobra.Command, args []string) { | |||
| 91 | 91 | } | |
| 92 | 92 | ||
| 93 | 93 | // 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 { | |
| 101 | 95 | log.Error().Err(err).Msg("Failed to publish config reload event") | |
| 102 | 96 | } else { | |
| 103 | 97 | log.Info().Msg("Configuration reloaded successfully") | |
| @@ -161,4 +155,4 @@ func runStart(cmd *cobra.Command, args []string) { | |||
| 161 | 155 | case <-time.After(10 * time.Second): | |
| 162 | 156 | log.Warn().Msg("Force shutdown after timeout") | |
| 163 | 157 | } | |
| 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 event | 93 | // 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "encoding/base64" | |
| 6 | + | "encoding/json" | |
| 5 | 7 | "fmt" | |
| 6 | 8 | "io" | |
| 7 | 9 | "strconv" | |
| @@ -9,6 +11,7 @@ import ( | |||
| 9 | 11 | ||
| 10 | 12 | "github.com/docker/docker/api/types/container" | |
| 11 | 13 | "github.com/docker/docker/api/types/image" | |
| 14 | + | "github.com/docker/docker/api/types/registry" | |
| 12 | 15 | "github.com/docker/docker/client" | |
| 13 | 16 | "github.com/docker/go-connections/nat" | |
| 14 | 17 | "github.com/rs/zerolog/log" | |
| @@ -230,6 +233,42 @@ func (d *DockerRuntime) PullImage(ctx context.Context, imageRef string) error { | |||
| 230 | 233 | 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 | 272 | // RemoveImage removes an image | |
| 234 | 273 | func (d *DockerRuntime) RemoveImage(ctx context.Context, imageRef string, force bool) error { | |
| 235 | 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 | 372 | 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) | |
| 337 | 374 | } | |
| 338 | 375 | ||
| 339 | 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 | 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 | 425 | var exposedPorts []int | |
| 389 | 426 | if resp.NetworkSettings != nil && resp.NetworkSettings.Ports != nil { | |
| 390 | 427 | for portSpec := range resp.NetworkSettings.Ports { | |
| @@ -397,29 +434,19 @@ func (d *DockerRuntime) GetContainerNetworkInfo(ctx context.Context, containerID | |||
| 397 | 434 | } | |
| 398 | 435 | ||
| 399 | 436 | 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) | |
| 415 | 438 | } | |
| 416 | 439 | ||
| 440 | + | // Use the first exposed port | |
| 441 | + | selectedPort := exposedPorts[0] | |
| 442 | + | ||
| 417 | 443 | log.Info(). | |
| 418 | 444 | Str("container_id", containerID). | |
| 419 | 445 | Str("ip", containerIP). | |
| 420 | 446 | Int("selected_port", selectedPort). | |
| 421 | - | Ints("available_ports", exposedPorts). | |
| 447 | + | Ints("exposed_ports", exposedPorts). | |
| 422 | 448 | Msg("Container network info detected") | |
| 423 | 449 | ||
| 424 | 450 | 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 nil | 233 | 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 image | 272 | // 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 []int | 425 | 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, nil | 450 | 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) | |||
| 148 | 148 | } | |
| 149 | 149 | ||
| 150 | 150 | // 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.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} | |
| 159 | 155 | ||
| 160 | 156 | info := &RuntimeInfo{ | |
| 161 | 157 | Type: runtimeType, | |
| @@ -167,7 +163,7 @@ func tryCreateRuntime(socketPath string) (runtime.Runtime, *RuntimeInfo, error) | |||
| 167 | 163 | } | |
| 168 | 164 | ||
| 169 | 165 | // detectRuntimeType determines if the socket is Docker or Podman | |
| 170 | - | func detectRuntimeType(socketPath, apiVersion string) RuntimeType { | |
| 166 | + | func detectRuntimeType(socketPath string) RuntimeType { | |
| 171 | 167 | // Check socket path patterns | |
| 172 | 168 | if strings.Contains(socketPath, "podman") { | |
| 173 | 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 | 246 | return nil, fmt.Errorf("Podman ping failed: %w", err) | |
| 251 | 247 | } | |
| 252 | 248 | ||
| 253 | - | return &PodmanRuntime{client: cli}, nil | |
| 249 | + | return &DockerRuntime{client: cli}, nil | |
| 254 | 250 | } | |
| 255 | 251 | ||
| 256 | 252 | // 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 info | 150 | // 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.Runtime | 153 | + | // 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 Podman | 165 | // 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 patterns | 167 | // 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}, nil | 249 | + | return &DockerRuntime{client: cli}, nil |
| 254 | } | 250 | } | ||
| 255 | 251 | ||||
| 256 | // GetAvailableRuntimes returns information about all available runtimes | 252 | // GetAvailableRuntimes returns information about all available runtimes | ||
M internal/container/manager.go +48 -26
| @@ -3,6 +3,7 @@ package container | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | + | "strings" | |
| 6 | 7 | "sync" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/rs/zerolog/log" | |
| @@ -13,10 +14,10 @@ import ( | |||
| 13 | 14 | ||
| 14 | 15 | // Manager handles container lifecycle and management | |
| 15 | 16 | type Manager struct { | |
| 16 | - | runtime runtime.Runtime | |
| 17 | - | config *config.Config | |
| 17 | + | runtime runtime.Runtime | |
| 18 | + | config *config.Config | |
| 18 | 19 | containers map[string]*runtime.Container // map[domain] -> container | |
| 19 | - | mu sync.RWMutex | |
| 20 | + | mu sync.RWMutex | |
| 20 | 21 | } | |
| 21 | 22 | ||
| 22 | 23 | // NewManager creates a new container manager | |
| @@ -55,42 +56,63 @@ func (m *Manager) DeployContainer(ctx context.Context, route config.Route) (*run | |||
| 55 | 56 | // Check if container already exists for this domain | |
| 56 | 57 | if existing, exists := m.containers[route.Domain]; exists { | |
| 57 | 58 | log.Info().Str("domain", route.Domain).Str("container", existing.ID).Msg("Container already exists, restarting") | |
| 58 | - | ||
| 59 | + | ||
| 59 | 60 | // Stop and remove existing container | |
| 60 | 61 | if err := m.runtime.StopContainer(ctx, existing.ID); err != nil { | |
| 61 | 62 | log.Warn().Err(err).Str("container", existing.ID).Msg("Failed to stop existing container") | |
| 62 | 63 | } | |
| 63 | - | ||
| 64 | + | ||
| 64 | 65 | if err := m.runtime.RemoveContainer(ctx, existing.ID, true); err != nil { | |
| 65 | 66 | log.Warn().Err(err).Str("container", existing.ID).Msg("Failed to remove existing container") | |
| 66 | 67 | } | |
| 67 | - | ||
| 68 | + | ||
| 68 | 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 | 83 | // 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 | + | } | |
| 75 | 97 | } | |
| 76 | 98 | ||
| 77 | 99 | // Get exposed ports from the image | |
| 78 | - | exposedPorts, err := m.runtime.GetImageExposedPorts(ctx, route.Image) | |
| 100 | + | exposedPorts, err := m.runtime.GetImageExposedPorts(ctx, imageRef) | |
| 79 | 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 | 103 | exposedPorts = []int{80, 8080, 3000} // Fallback to common web server ports | |
| 82 | 104 | } | |
| 83 | 105 | ||
| 84 | 106 | // Create container configuration | |
| 85 | 107 | 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, | |
| 89 | 111 | Labels: map[string]string{ | |
| 90 | - | "gordon.domain": route.Domain, | |
| 91 | - | "gordon.image": route.Image, | |
| 112 | + | "gordon.domain": route.Domain, | |
| 113 | + | "gordon.image": route.Image, | |
| 92 | 114 | "gordon.managed": "true", | |
| 93 | - | "gordon.route": route.Domain, | |
| 115 | + | "gordon.route": route.Domain, | |
| 94 | 116 | }, | |
| 95 | 117 | AutoRemove: false, // Keep containers for inspection | |
| 96 | 118 | } | |
| @@ -131,7 +153,7 @@ func (m *Manager) DeployContainer(ctx context.Context, route config.Route) (*run | |||
| 131 | 153 | func (m *Manager) GetContainer(domain string) (*runtime.Container, bool) { | |
| 132 | 154 | m.mu.RLock() | |
| 133 | 155 | defer m.mu.RUnlock() | |
| 134 | - | ||
| 156 | + | ||
| 135 | 157 | container, exists := m.containers[domain] | |
| 136 | 158 | return container, exists | |
| 137 | 159 | } | |
| @@ -141,7 +163,7 @@ func (m *Manager) GetContainerPort(ctx context.Context, domain string, internalP | |||
| 141 | 163 | m.mu.RLock() | |
| 142 | 164 | container, exists := m.containers[domain] | |
| 143 | 165 | m.mu.RUnlock() | |
| 144 | - | ||
| 166 | + | ||
| 145 | 167 | if !exists { | |
| 146 | 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 | 175 | func (m *Manager) ListContainers() map[string]*runtime.Container { | |
| 154 | 176 | m.mu.RLock() | |
| 155 | 177 | defer m.mu.RUnlock() | |
| 156 | - | ||
| 178 | + | ||
| 157 | 179 | // Return a copy of the map to avoid race conditions | |
| 158 | 180 | result := make(map[string]*runtime.Container) | |
| 159 | 181 | for domain, container := range m.containers { | |
| @@ -166,7 +188,7 @@ func (m *Manager) ListContainers() map[string]*runtime.Container { | |||
| 166 | 188 | func (m *Manager) FindContainerByDomain(domain string) (string, bool) { | |
| 167 | 189 | m.mu.RLock() | |
| 168 | 190 | defer m.mu.RUnlock() | |
| 169 | - | ||
| 191 | + | ||
| 170 | 192 | if container, exists := m.containers[domain]; exists { | |
| 171 | 193 | return container.ID, true | |
| 172 | 194 | } | |
| @@ -177,7 +199,7 @@ func (m *Manager) FindContainerByDomain(domain string) (string, bool) { | |||
| 177 | 199 | func (m *Manager) FindDomainByContainerID(containerID string) (string, bool) { | |
| 178 | 200 | m.mu.RLock() | |
| 179 | 201 | defer m.mu.RUnlock() | |
| 180 | - | ||
| 202 | + | ||
| 181 | 203 | for domain, container := range m.containers { | |
| 182 | 204 | if container.ID == containerID { | |
| 183 | 205 | return domain, true | |
| @@ -201,7 +223,7 @@ func (m *Manager) StopContainerByDomain(ctx context.Context, domain string) erro | |||
| 201 | 223 | m.mu.RLock() | |
| 202 | 224 | container, exists := m.containers[domain] | |
| 203 | 225 | m.mu.RUnlock() | |
| 204 | - | ||
| 226 | + | ||
| 205 | 227 | if !exists { | |
| 206 | 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 | 247 | break | |
| 226 | 248 | } | |
| 227 | 249 | } | |
| 228 | - | ||
| 250 | + | ||
| 229 | 251 | return nil | |
| 230 | 252 | } | |
| 231 | 253 | ||
| @@ -234,7 +256,7 @@ func (m *Manager) RemoveContainerByDomain(ctx context.Context, domain string, fo | |||
| 234 | 256 | m.mu.RLock() | |
| 235 | 257 | container, exists := m.containers[domain] | |
| 236 | 258 | m.mu.RUnlock() | |
| 237 | - | ||
| 259 | + | ||
| 238 | 260 | if !exists { | |
| 239 | 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 | 314 | // Runtime returns the underlying runtime interface | |
| 293 | 315 | func (m *Manager) Runtime() runtime.Runtime { | |
| 294 | 316 | 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 management | 15 | // Manager handles container lifecycle and management | ||
| 15 | type Manager struct { | 16 | type Manager struct { | ||
| 16 | - | runtime runtime.Runtime | 17 | + | runtime runtime.Runtime |
| 17 | - | config *config.Config | 18 | + | config *config.Config |
| 18 | containers map[string]*runtime.Container // map[domain] -> container | 19 | containers map[string]*runtime.Container // map[domain] -> container | ||
| 19 | - | mu sync.RWMutex | 20 | + | mu sync.RWMutex |
| 20 | } | 21 | } | ||
| 21 | 22 | ||||
| 22 | // NewManager creates a new container manager | 23 | // 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 domain | 56 | // 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 container | 60 | // 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 first | 83 | // 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 image | 99 | // 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 ports | 103 | exposedPorts = []int{80, 8080, 3000} // Fallback to common web server ports | ||
| 82 | } | 104 | } | ||
| 83 | 105 | ||||
| 84 | // Create container configuration | 106 | // 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 inspection | 117 | 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, exists | 158 | 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 conditions | 179 | // 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, true | 193 | 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, true | 205 | 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 | break | 247 | break | ||
| 226 | } | 248 | } | ||
| 227 | } | 249 | } | ||
| 228 | - | 250 | + | ||
| 229 | return nil | 251 | 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 interface | 314 | // Runtime returns the underlying runtime interface | ||
| 293 | func (m *Manager) Runtime() runtime.Runtime { | 315 | func (m *Manager) Runtime() runtime.Runtime { | ||
| 294 | return m.runtime | 316 | 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 ( | |||
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | 13 | 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 | |
| 21 | 21 | } | |
| 22 | 22 | ||
| 23 | 23 | func NewInMemoryEventBus(bufferSize int) *InMemoryEventBus { | |
| 24 | 24 | if bufferSize <= 0 { | |
| 25 | 25 | bufferSize = 100 | |
| 26 | 26 | } | |
| 27 | - | ||
| 27 | + | ||
| 28 | 28 | ctx, cancel := context.WithCancel(context.Background()) | |
| 29 | - | ||
| 29 | + | ||
| 30 | 30 | return &InMemoryEventBus{ | |
| 31 | 31 | handlers: make([]EventHandler, 0), | |
| 32 | 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 { | |
| 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, | |
| 43 | 46 | } | |
| 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 | |
| 47 | 53 | } | |
| 48 | 54 | ||
| 49 | 55 | select { | |
| @@ -65,20 +71,20 @@ func (bus *InMemoryEventBus) Publish(event Event) error { | |||
| 65 | 71 | func (bus *InMemoryEventBus) Subscribe(handler EventHandler) error { | |
| 66 | 72 | bus.mu.Lock() | |
| 67 | 73 | defer bus.mu.Unlock() | |
| 68 | - | ||
| 74 | + | ||
| 69 | 75 | bus.handlers = append(bus.handlers, handler) | |
| 70 | 76 | log.Debug(). | |
| 71 | 77 | Str("handler_type", fmt.Sprintf("%T", handler)). | |
| 72 | 78 | Int("total_handlers", len(bus.handlers)). | |
| 73 | 79 | Msg("Event handler subscribed") | |
| 74 | - | ||
| 80 | + | ||
| 75 | 81 | return nil | |
| 76 | 82 | } | |
| 77 | 83 | ||
| 78 | 84 | func (bus *InMemoryEventBus) Unsubscribe(handler EventHandler) error { | |
| 79 | 85 | bus.mu.Lock() | |
| 80 | 86 | defer bus.mu.Unlock() | |
| 81 | - | ||
| 87 | + | ||
| 82 | 88 | for i, h := range bus.handlers { | |
| 83 | 89 | if h == handler { | |
| 84 | 90 | bus.handlers = append(bus.handlers[:i], bus.handlers[i+1:]...) | |
| @@ -89,7 +95,7 @@ func (bus *InMemoryEventBus) Unsubscribe(handler EventHandler) error { | |||
| 89 | 95 | return nil | |
| 90 | 96 | } | |
| 91 | 97 | } | |
| 92 | - | ||
| 98 | + | ||
| 93 | 99 | return fmt.Errorf("handler not found") | |
| 94 | 100 | } | |
| 95 | 101 | ||
| @@ -97,16 +103,16 @@ func (bus *InMemoryEventBus) Start() error { | |||
| 97 | 103 | log.Info(). | |
| 98 | 104 | Int("buffer_size", bus.bufferSize). | |
| 99 | 105 | Msg("Starting event bus") | |
| 100 | - | ||
| 106 | + | ||
| 101 | 107 | go bus.processEvents() | |
| 102 | 108 | return nil | |
| 103 | 109 | } | |
| 104 | 110 | ||
| 105 | 111 | func (bus *InMemoryEventBus) Stop() error { | |
| 106 | 112 | log.Info().Msg("Stopping event bus") | |
| 107 | - | ||
| 113 | + | ||
| 108 | 114 | bus.cancel() | |
| 109 | - | ||
| 115 | + | ||
| 110 | 116 | select { | |
| 111 | 117 | case <-bus.done: | |
| 112 | 118 | log.Info().Msg("Event bus stopped") | |
| @@ -119,7 +125,7 @@ func (bus *InMemoryEventBus) Stop() error { | |||
| 119 | 125 | ||
| 120 | 126 | func (bus *InMemoryEventBus) processEvents() { | |
| 121 | 127 | defer close(bus.done) | |
| 122 | - | ||
| 128 | + | ||
| 123 | 129 | for { | |
| 124 | 130 | select { | |
| 125 | 131 | case event := <-bus.eventChan: | |
| @@ -136,12 +142,12 @@ func (bus *InMemoryEventBus) handleEvent(event Event) { | |||
| 136 | 142 | handlers := make([]EventHandler, len(bus.handlers)) | |
| 137 | 143 | copy(handlers, bus.handlers) | |
| 138 | 144 | bus.mu.RUnlock() | |
| 139 | - | ||
| 145 | + | ||
| 140 | 146 | for _, handler := range handlers { | |
| 141 | 147 | if handler.CanHandle(event.Type) { | |
| 142 | 148 | go func(h EventHandler) { | |
| 143 | 149 | start := time.Now() | |
| 144 | - | ||
| 150 | + | ||
| 145 | 151 | if err := h.Handle(event); err != nil { | |
| 146 | 152 | log.Error(). | |
| 147 | 153 | Err(err). | |
| @@ -160,4 +166,4 @@ func (bus *InMemoryEventBus) handleEvent(event Event) { | |||
| 160 | 166 | }(handler) | |
| 161 | 167 | } | |
| 162 | 168 | } | |
| 163 | - | }⏎̸ | |
| 169 | + | } | |
| @@ -11,22 +11,22 @@ import ( | |||||
| 11 | ) | 11 | ) | ||
| 12 | 12 | ||||
| 13 | type InMemoryEventBus struct { | 13 | type InMemoryEventBus struct { | ||
| 14 | - | handlers []EventHandler | 14 | + | handlers []EventHandler |
| 15 | - | eventChan chan Event | 15 | + | eventChan chan Event |
| 16 | - | done chan struct{} | 16 | + | done chan struct{} |
| 17 | - | mu sync.RWMutex | 17 | + | mu sync.RWMutex |
| 18 | - | ctx context.Context | 18 | + | ctx context.Context |
| 19 | - | cancel context.CancelFunc | 19 | + | cancel context.CancelFunc |
| 20 | - | bufferSize int | 20 | + | 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 = 100 | 25 | 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 nil | 81 | 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 nil | 95 | 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 nil | 108 | 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 ( | |||
| 7 | 7 | type EventType string | |
| 8 | 8 | ||
| 9 | 9 | 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" | |
| 15 | 15 | ContainerHealthCheck EventType = "container.health_check" | |
| 16 | 16 | ) | |
| 17 | 17 | ||
| 18 | 18 | 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:"-"` | |
| 27 | 33 | } | |
| 28 | 34 | ||
| 29 | 35 | type EventHandler interface { | |
| @@ -32,9 +38,9 @@ type EventHandler interface { | |||
| 32 | 38 | } | |
| 33 | 39 | ||
| 34 | 40 | type EventBus interface { | |
| 35 | - | Publish(event Event) error | |
| 41 | + | Publish(eventType EventType, payload interface{}) error | |
| 36 | 42 | Subscribe(handler EventHandler) error | |
| 37 | 43 | Unsubscribe(handler EventHandler) error | |
| 38 | 44 | Start() error | |
| 39 | 45 | Stop() error | |
| 40 | - | }⏎̸ | |
| 46 | + | } | |
| @@ -7,23 +7,29 @@ import ( | |||||
| 7 | type EventType string | 7 | 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) error | 41 | + | Publish(eventType EventType, payload interface{}) error |
| 36 | Subscribe(handler EventHandler) error | 42 | Subscribe(handler EventHandler) error | ||
| 37 | Unsubscribe(handler EventHandler) error | 43 | Unsubscribe(handler EventHandler) error | ||
| 38 | Start() error | 44 | Start() error | ||
| 39 | Stop() error | 45 | Stop() error | ||
| 40 | - | }⏎̸ | 46 | + | } |
M internal/middleware/auth.go +22 -12
| @@ -11,18 +11,12 @@ import ( | |||
| 11 | 11 | func RegistryAuth(username, password string) func(http.Handler) http.Handler { | |
| 12 | 12 | return func(next http.Handler) http.Handler { | |
| 13 | 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 | 14 | // Require auth for all other registry operations | |
| 21 | 15 | if !isAuthenticated(r, username, password) { | |
| 22 | 16 | w.Header().Set("WWW-Authenticate", `Basic realm="Gordon Registry"`) | |
| 23 | 17 | w.Header().Set("Docker-Distribution-API-Version", "registry/2.0") | |
| 24 | 18 | http.Error(w, "Unauthorized", http.StatusUnauthorized) | |
| 25 | - | ||
| 19 | + | ||
| 26 | 20 | log.Warn(). | |
| 27 | 21 | Str("method", r.Method). | |
| 28 | 22 | Str("path", r.URL.Path). | |
| @@ -30,7 +24,7 @@ func RegistryAuth(username, password string) func(http.Handler) http.Handler { | |||
| 30 | 24 | Msg("Unauthorized registry access attempt") | |
| 31 | 25 | return | |
| 32 | 26 | } | |
| 33 | - | ||
| 27 | + | ||
| 34 | 28 | next.ServeHTTP(w, r) | |
| 35 | 29 | }) | |
| 36 | 30 | } | |
| @@ -39,19 +33,28 @@ func RegistryAuth(username, password string) func(http.Handler) http.Handler { | |||
| 39 | 33 | // Helper functions | |
| 40 | 34 | ||
| 41 | 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 | 44 | username, password, ok := r.BasicAuth() | |
| 43 | 45 | if !ok { | |
| 44 | 46 | log.Debug(). | |
| 45 | 47 | Str("method", r.Method). | |
| 46 | 48 | Str("path", r.URL.Path). | |
| 49 | + | Str("authorization_header", authHeader). | |
| 47 | 50 | Msg("No basic auth provided") | |
| 48 | 51 | return false | |
| 49 | 52 | } | |
| 50 | - | ||
| 53 | + | ||
| 51 | 54 | // Use constant-time comparison to prevent timing attacks | |
| 52 | 55 | usernameMatch := subtle.ConstantTimeCompare([]byte(username), []byte(expectedUsername)) == 1 | |
| 53 | 56 | passwordMatch := subtle.ConstantTimeCompare([]byte(password), []byte(expectedPassword)) == 1 | |
| 54 | - | ||
| 57 | + | ||
| 55 | 58 | authenticated := usernameMatch && passwordMatch | |
| 56 | 59 | if !authenticated { | |
| 57 | 60 | log.Debug(). | |
| @@ -59,8 +62,15 @@ func isAuthenticated(r *http.Request, expectedUsername, expectedPassword string) | |||
| 59 | 62 | Str("expected_username", expectedUsername). | |
| 60 | 63 | Str("method", r.Method). | |
| 61 | 64 | Str("path", r.URL.Path). | |
| 65 | + | Str("authorization_header", authHeader). | |
| 62 | 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 | 75 | 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 operations | 14 | // 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 | return | 25 | 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 functions | 33 | // 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 false | 51 | return false | ||
| 49 | } | 52 | } | ||
| 50 | - | 53 | + | ||
| 51 | // Use constant-time comparison to prevent timing attacks | 54 | // Use constant-time comparison to prevent timing attacks | ||
| 52 | usernameMatch := subtle.ConstantTimeCompare([]byte(username), []byte(expectedUsername)) == 1 | 55 | usernameMatch := subtle.ConstantTimeCompare([]byte(username), []byte(expectedUsername)) == 1 | ||
| 53 | passwordMatch := subtle.ConstantTimeCompare([]byte(password), []byte(expectedPassword)) == 1 | 56 | passwordMatch := subtle.ConstantTimeCompare([]byte(password), []byte(expectedPassword)) == 1 | ||
| 54 | - | 57 | + | ||
| 55 | authenticated := usernameMatch && passwordMatch | 58 | 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 authenticated | 75 | return authenticated | ||
| 66 | - | }⏎̸ | 76 | + | } |
M internal/proxy/server.go +79 -13
| @@ -6,7 +6,9 @@ import ( | |||
| 6 | 6 | "net/http" | |
| 7 | 7 | "net/http/httputil" | |
| 8 | 8 | "net/url" | |
| 9 | + | "os" | |
| 9 | 10 | "strconv" | |
| 11 | + | "strings" | |
| 10 | 12 | ||
| 11 | 13 | "github.com/rs/zerolog/log" | |
| 12 | 14 | "gordon/internal/config" | |
| @@ -72,19 +74,46 @@ func (s *Server) proxyToContainer(w http.ResponseWriter, r *http.Request, route | |||
| 72 | 74 | return | |
| 73 | 75 | } | |
| 74 | 76 | ||
| 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") | |
| 88 | 117 | } | |
| 89 | 118 | ||
| 90 | 119 | proxy := httputil.NewSingleHostReverseProxy(target) | |
| @@ -146,6 +175,43 @@ func (s *Server) proxyToRegistry(w http.ResponseWriter, r *http.Request) { | |||
| 146 | 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 | 215 | func (s *Server) Start(ctx context.Context) error { | |
| 150 | 216 | addr := ":" + strconv.Itoa(s.config.Server.Port) | |
| 151 | 217 | ||
| @@ -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 | return | 74 | 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 | - | return | 82 | + | 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 | - | return | 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") | |||
| 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 ( | |||
| 7 | 7 | "io" | |
| 8 | 8 | "net/http" | |
| 9 | 9 | "strconv" | |
| 10 | - | "strings" | |
| 11 | 10 | ||
| 12 | - | "github.com/rs/zerolog/log" | |
| 13 | 11 | "gordon/internal/config" | |
| 14 | 12 | "gordon/internal/events" | |
| 15 | 13 | "gordon/internal/middleware" | |
| 14 | + | ||
| 15 | + | "github.com/rs/zerolog/log" | |
| 16 | 16 | ) | |
| 17 | 17 | ||
| 18 | 18 | 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 | |
| 22 | 22 | eventBus events.EventBus | |
| 23 | 23 | } | |
| 24 | 24 | ||
| @@ -26,7 +26,7 @@ func NewServer(cfg *config.Config, eventBus events.EventBus) (*Server, error) { | |||
| 26 | 26 | // Initialize storage | |
| 27 | 27 | registryPath := cfg.Server.DataDir + "/registry" | |
| 28 | 28 | log.Debug().Str("data_dir", cfg.Server.DataDir).Str("registry_path", registryPath).Msg("Initializing registry storage") | |
| 29 | - | ||
| 29 | + | ||
| 30 | 30 | storage, err := NewFilesystemStorage(registryPath) | |
| 31 | 31 | if err != nil { | |
| 32 | 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 | 45 | func (s *Server) setupRoutes() { | |
| 46 | 46 | // 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) | |
| 54 | 57 | } | |
| 55 | 58 | ||
| 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) | |
| 79 | 72 | } | |
| 80 | 73 | ||
| 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") | |
| 106 | 77 | ||
| 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") | |
| 115 | 79 | ||
| 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 | |
| 123 | 85 | } | |
| 124 | - | return | |
| 125 | - | } | |
| 126 | 86 | ||
| 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") | |
| 136 | 88 | ||
| 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) | |
| 148 | 93 | ||
| 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) | |
| 154 | 96 | } | |
| 155 | - | return "" | |
| 156 | 97 | } | |
| 157 | 98 | ||
| 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") | |
| 199 | 102 | ||
| 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") | |
| 205 | 105 | ||
| 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) | |
| 215 | 109 | 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 | 112 | // Read manifest data | |
| 230 | 113 | data, err := io.ReadAll(r.Body) | |
| 231 | 114 | if err != nil { | |
| @@ -233,151 +116,174 @@ func (s *Server) handlePutManifest(w http.ResponseWriter, r *http.Request) { | |||
| 233 | 116 | http.Error(w, "Bad Request", http.StatusBadRequest) | |
| 234 | 117 | return | |
| 235 | 118 | } | |
| 236 | - | ||
| 119 | + | ||
| 237 | 120 | // 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 | 122 | log.Error().Err(err).Str("name", name).Str("reference", reference).Msg("Failed to store manifest") | |
| 240 | 123 | http.Error(w, "Internal Server Error", http.StatusInternalServerError) | |
| 241 | 124 | return | |
| 242 | 125 | } | |
| 243 | - | ||
| 126 | + | ||
| 244 | 127 | // Calculate digest for Docker-Content-Digest header | |
| 245 | 128 | digest := fmt.Sprintf("sha256:%x", sha256.Sum256(data)) | |
| 246 | 129 | w.Header().Set("Docker-Content-Digest", digest) | |
| 247 | 130 | w.Header().Set("Location", fmt.Sprintf("/v2/%s/manifests/%s", name, reference)) | |
| 248 | 131 | w.WriteHeader(http.StatusCreated) | |
| 249 | - | ||
| 132 | + | ||
| 250 | 133 | // 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 | + | }) | |
| 266 | 139 | } | |
| 267 | 140 | ||
| 268 | 141 | 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) | |
| 274 | 148 | 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) | |
| 286 | 151 | return | |
| 287 | 152 | } | |
| 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) | |
| 293 | 156 | } | |
| 294 | 157 | ||
| 295 | 158 | 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 | |
| 300 | 164 | uuid, err := s.storage.StartBlobUpload(name) | |
| 301 | 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 | 167 | http.Error(w, "Internal Server Error", http.StatusInternalServerError) | |
| 304 | 168 | return | |
| 305 | 169 | } | |
| 306 | - | ||
| 170 | + | ||
| 171 | + | // Set headers for the response | |
| 307 | 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 | 174 | w.WriteHeader(http.StatusAccepted) | |
| 310 | 175 | } | |
| 311 | 176 | ||
| 312 | 177 | 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") | |
| 318 | 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 | 206 | if r.Method == "PUT" && digest != "" { | |
| 321 | - | // This is a completion request | |
| 322 | 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") | |
| 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) | |
| 325 | 212 | return | |
| 326 | 213 | } | |
| 327 | - | ||
| 214 | + | ||
| 215 | + | // Respond with 201 Created | |
| 328 | 216 | w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/%s", name, digest)) | |
| 329 | 217 | w.Header().Set("Docker-Content-Digest", digest) | |
| 330 | 218 | w.WriteHeader(http.StatusCreated) | |
| 331 | - | ||
| 332 | - | log.Info().Str("name", name).Str("uuid", uuid).Str("digest", digest).Msg("Blob upload completed") | |
| 333 | 219 | return | |
| 334 | 220 | } | |
| 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 | |
| 352 | 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 | 225 | w.WriteHeader(http.StatusAccepted) | |
| 355 | - | ||
| 356 | - | log.Debug().Str("uuid", uuid).Int64("bytes", written).Msg("Blob chunk uploaded") | |
| 357 | 226 | } | |
| 358 | 227 | ||
| 359 | 228 | 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 | + | ||
| 364 | 233 | tags, err := s.storage.ListTags(name) | |
| 365 | 234 | 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) | |
| 368 | 237 | return | |
| 369 | 238 | } | |
| 370 | - | ||
| 239 | + | ||
| 240 | + | // Construct the response JSON | |
| 241 | + | response := fmt.Sprintf(`{"name":"%s","tags":%s}`, name, tags) | |
| 242 | + | ||
| 371 | 243 | w.Header().Set("Content-Type", "application/json") | |
| 244 | + | w.Header().Set("Content-Length", strconv.Itoa(len(response))) | |
| 372 | 245 | 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 | |
| 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, "]}") | |
| 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.Config | 19 | + | config *config.Config |
| 20 | - | mux *http.ServeMux | 20 | + | mux *http.ServeMux |
| 21 | - | storage Storage | 21 | + | storage Storage |
| 22 | eventBus events.EventBus | 22 | eventBus events.EventBus | ||
| 23 | } | 23 | } | ||
| 24 | 24 | ||||
| @@ -26,7 +26,7 @@ func NewServer(cfg *config.Config, eventBus events.EventBus) (*Server, error) { | |||||
| 26 | // Initialize storage | 26 | // 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 patterns | 46 | // Docker Registry API v2 endpoints using Go 1.22+ ServeMux patterns | ||
| 47 | - | 47 | + | s.mux.HandleFunc("GET /v2/", s.handleBase) | |
| 48 | - | // Registry info endpoint | 48 | + | 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 approach | 51 | + | s.mux.HandleFunc("HEAD /v2/{name}/blobs/{digest}", s.handleGetBlob) |
| 52 | - | // Use a catch-all handler for /v2/ paths and route internally | 52 | + | 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.Path | 60 | + | // For Docker clients, we need to respond with a 200 OK to this endpoint |
| 58 | - | method := r.Method | 61 | + | // even if authentication is required. The client will then check the |
| 59 | - | 62 | + | // WWW-Authenticate header. | |
| 60 | - | // Route based on path pattern and method | 63 | + | // 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 helper | 74 | + | 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 matching | 76 | + | 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 helpers | 80 | + | 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/list | 95 | + | _, _ = 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 | return | 109 | 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 data | 112 | // 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 | return | 117 | return | ||
| 235 | } | 118 | } | ||
| 236 | - | 119 | + | ||
| 237 | // Store manifest | 120 | // 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 | return | 124 | return | ||
| 242 | } | 125 | } | ||
| 243 | - | 126 | + | ||
| 244 | // Calculate digest for Docker-Content-Digest header | 127 | // 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 event | 133 | // 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 | return | 151 | 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 | return | 168 | 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 | return | 212 | 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 | return | 219 | 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 | return | 237 | 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 response | 247 | + | } |
| 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 ( | |||
| 15 | 15 | // Storage interface defines the contract for registry storage backends | |
| 16 | 16 | type Storage interface { | |
| 17 | 17 | // 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 | |
| 20 | 20 | DeleteManifest(name, reference string) error | |
| 21 | - | ||
| 21 | + | ||
| 22 | 22 | // Blob operations | |
| 23 | 23 | GetBlob(digest string) (io.ReadCloser, error) | |
| 24 | + | GetBlobPath(digest string) (string, error) | |
| 24 | 25 | PutBlob(digest string, data io.Reader, size int64) error | |
| 25 | 26 | DeleteBlob(digest string) error | |
| 26 | 27 | BlobExists(digest string) bool | |
| 27 | - | ||
| 28 | + | ||
| 28 | 29 | // Upload operations | |
| 29 | 30 | StartBlobUpload(name string) (string, error) // returns upload UUID | |
| 31 | + | AppendBlobChunk(name, uuid string, chunk []byte) (int64, error) | |
| 30 | 32 | GetBlobUpload(uuid string) (io.WriteCloser, error) | |
| 31 | 33 | FinishBlobUpload(uuid, digest string) error | |
| 32 | 34 | CancelBlobUpload(uuid string) error | |
| 33 | - | ||
| 35 | + | ||
| 34 | 36 | // Tag operations | |
| 35 | 37 | ListTags(name string) ([]string, error) | |
| 36 | - | ||
| 38 | + | ||
| 37 | 39 | // Repository operations | |
| 38 | 40 | ListRepositories() ([]string, error) | |
| 39 | 41 | } | |
| @@ -51,15 +53,15 @@ func NewFilesystemStorage(rootDir string) (*FilesystemStorage, error) { | |||
| 51 | 53 | filepath.Join(rootDir, "blobs"), | |
| 52 | 54 | filepath.Join(rootDir, "uploads"), | |
| 53 | 55 | } | |
| 54 | - | ||
| 56 | + | ||
| 55 | 57 | for _, dir := range dirs { | |
| 56 | 58 | if err := os.MkdirAll(dir, 0755); err != nil { | |
| 57 | 59 | return nil, fmt.Errorf("failed to create directory %s: %w", dir, err) | |
| 58 | 60 | } | |
| 59 | 61 | } | |
| 60 | - | ||
| 62 | + | ||
| 61 | 63 | log.Info().Str("root_dir", rootDir).Msg("Filesystem storage initialized") | |
| 62 | - | ||
| 64 | + | ||
| 63 | 65 | return &FilesystemStorage{ | |
| 64 | 66 | rootDir: rootDir, | |
| 65 | 67 | }, nil | |
| @@ -67,57 +69,75 @@ func NewFilesystemStorage(rootDir string) (*FilesystemStorage, error) { | |||
| 67 | 69 | ||
| 68 | 70 | // 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 | 73 | manifestPath := fs.getManifestPath(name, reference) | |
| 72 | - | ||
| 74 | + | ||
| 73 | 75 | data, err := os.ReadFile(manifestPath) | |
| 74 | 76 | if err != nil { | |
| 75 | 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 | - | ||
| 81 | - | return data, nil | |
| 89 | + | ||
| 90 | + | 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 | 94 | manifestPath := fs.getManifestPath(name, reference) | |
| 86 | - | ||
| 95 | + | ||
| 87 | 96 | // Create directory if it doesn't exist | |
| 88 | 97 | if err := os.MkdirAll(filepath.Dir(manifestPath), 0755); err != nil { | |
| 89 | 98 | return fmt.Errorf("failed to create manifest directory: %w", err) | |
| 90 | 99 | } | |
| 91 | - | ||
| 100 | + | ||
| 92 | 101 | // Write manifest file | |
| 93 | 102 | if err := os.WriteFile(manifestPath, data, 0644); err != nil { | |
| 94 | 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 | 112 | // Update tags list | |
| 98 | 113 | if err := fs.updateTagsList(name, reference); err != nil { | |
| 99 | 114 | log.Warn().Err(err).Str("name", name).Str("reference", reference).Msg("Failed to update tags list") | |
| 100 | 115 | } | |
| 101 | - | ||
| 116 | + | ||
| 102 | 117 | log.Info().Str("name", name).Str("reference", reference).Msg("Manifest stored") | |
| 103 | 118 | return nil | |
| 104 | 119 | } | |
| 105 | 120 | ||
| 106 | 121 | func (fs *FilesystemStorage) DeleteManifest(name, reference string) error { | |
| 107 | 122 | manifestPath := fs.getManifestPath(name, reference) | |
| 108 | - | ||
| 123 | + | ||
| 109 | 124 | if err := os.Remove(manifestPath); err != nil { | |
| 110 | 125 | if os.IsNotExist(err) { | |
| 111 | 126 | return fmt.Errorf("manifest not found: %s/%s", name, reference) | |
| 112 | 127 | } | |
| 113 | 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 | 136 | // Remove from tags list | |
| 117 | 137 | if err := fs.removeFromTagsList(name, reference); err != nil { | |
| 118 | 138 | log.Warn().Err(err).Str("name", name).Str("reference", reference).Msg("Failed to update tags list") | |
| 119 | 139 | } | |
| 120 | - | ||
| 140 | + | ||
| 121 | 141 | log.Info().Str("name", name).Str("reference", reference).Msg("Manifest deleted") | |
| 122 | 142 | return nil | |
| 123 | 143 | } | |
| @@ -126,7 +146,7 @@ func (fs *FilesystemStorage) DeleteManifest(name, reference string) error { | |||
| 126 | 146 | ||
| 127 | 147 | func (fs *FilesystemStorage) GetBlob(digest string) (io.ReadCloser, error) { | |
| 128 | 148 | blobPath := fs.getBlobPath(digest) | |
| 129 | - | ||
| 149 | + | ||
| 130 | 150 | file, err := os.Open(blobPath) | |
| 131 | 151 | if err != nil { | |
| 132 | 152 | if os.IsNotExist(err) { | |
| @@ -134,18 +154,29 @@ func (fs *FilesystemStorage) GetBlob(digest string) (io.ReadCloser, error) { | |||
| 134 | 154 | } | |
| 135 | 155 | return nil, fmt.Errorf("failed to open blob: %w", err) | |
| 136 | 156 | } | |
| 137 | - | ||
| 157 | + | ||
| 138 | 158 | 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 | 172 | func (fs *FilesystemStorage) PutBlob(digest string, data io.Reader, size int64) error { | |
| 142 | 173 | blobPath := fs.getBlobPath(digest) | |
| 143 | - | ||
| 174 | + | ||
| 144 | 175 | // Create directory if it doesn't exist | |
| 145 | 176 | if err := os.MkdirAll(filepath.Dir(blobPath), 0755); err != nil { | |
| 146 | 177 | return fmt.Errorf("failed to create blob directory: %w", err) | |
| 147 | 178 | } | |
| 148 | - | ||
| 179 | + | ||
| 149 | 180 | // Create temporary file first | |
| 150 | 181 | tmpPath := blobPath + ".tmp" | |
| 151 | 182 | file, err := os.Create(tmpPath) | |
| @@ -153,40 +184,40 @@ func (fs *FilesystemStorage) PutBlob(digest string, data io.Reader, size int64) | |||
| 153 | 184 | return fmt.Errorf("failed to create temporary blob file: %w", err) | |
| 154 | 185 | } | |
| 155 | 186 | defer file.Close() | |
| 156 | - | ||
| 187 | + | ||
| 157 | 188 | // Copy data to file | |
| 158 | 189 | written, err := io.Copy(file, data) | |
| 159 | 190 | if err != nil { | |
| 160 | 191 | os.Remove(tmpPath) | |
| 161 | 192 | return fmt.Errorf("failed to write blob data: %w", err) | |
| 162 | 193 | } | |
| 163 | - | ||
| 194 | + | ||
| 164 | 195 | // Verify size if provided | |
| 165 | 196 | if size > 0 && written != size { | |
| 166 | 197 | os.Remove(tmpPath) | |
| 167 | 198 | return fmt.Errorf("blob size mismatch: expected %d, got %d", size, written) | |
| 168 | 199 | } | |
| 169 | - | ||
| 200 | + | ||
| 170 | 201 | // Move to final location | |
| 171 | 202 | if err := os.Rename(tmpPath, blobPath); err != nil { | |
| 172 | 203 | os.Remove(tmpPath) | |
| 173 | 204 | return fmt.Errorf("failed to move blob to final location: %w", err) | |
| 174 | 205 | } | |
| 175 | - | ||
| 206 | + | ||
| 176 | 207 | log.Info().Str("digest", digest).Int64("size", written).Msg("Blob stored") | |
| 177 | 208 | return nil | |
| 178 | 209 | } | |
| 179 | 210 | ||
| 180 | 211 | func (fs *FilesystemStorage) DeleteBlob(digest string) error { | |
| 181 | 212 | blobPath := fs.getBlobPath(digest) | |
| 182 | - | ||
| 213 | + | ||
| 183 | 214 | if err := os.Remove(blobPath); err != nil { | |
| 184 | 215 | if os.IsNotExist(err) { | |
| 185 | 216 | return fmt.Errorf("blob not found: %s", digest) | |
| 186 | 217 | } | |
| 187 | 218 | return fmt.Errorf("failed to delete blob: %w", err) | |
| 188 | 219 | } | |
| 189 | - | ||
| 220 | + | ||
| 190 | 221 | log.Info().Str("digest", digest).Msg("Blob deleted") | |
| 191 | 222 | return nil | |
| 192 | 223 | } | |
| @@ -202,28 +233,60 @@ func (fs *FilesystemStorage) BlobExists(digest string) bool { | |||
| 202 | 233 | func (fs *FilesystemStorage) StartBlobUpload(name string) (string, error) { | |
| 203 | 234 | // Generate UUID-like upload ID using timestamp | |
| 204 | 235 | uuid := fmt.Sprintf("%d-%s", time.Now().UnixNano(), strings.ReplaceAll(name, "/", "_")) | |
| 205 | - | ||
| 236 | + | ||
| 206 | 237 | uploadPath := fs.getUploadPath(uuid) | |
| 207 | - | ||
| 238 | + | ||
| 208 | 239 | // Create directory if it doesn't exist | |
| 209 | 240 | if err := os.MkdirAll(filepath.Dir(uploadPath), 0755); err != nil { | |
| 210 | 241 | return "", fmt.Errorf("failed to create upload directory: %w", err) | |
| 211 | 242 | } | |
| 212 | - | ||
| 243 | + | ||
| 213 | 244 | // Create empty upload file | |
| 214 | 245 | file, err := os.Create(uploadPath) | |
| 215 | 246 | if err != nil { | |
| 216 | 247 | return "", fmt.Errorf("failed to create upload file: %w", err) | |
| 217 | 248 | } | |
| 218 | 249 | file.Close() | |
| 219 | - | ||
| 250 | + | ||
| 220 | 251 | log.Info().Str("uuid", uuid).Str("name", name).Msg("Blob upload started") | |
| 221 | 252 | 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 | 287 | func (fs *FilesystemStorage) GetBlobUpload(uuid string) (io.WriteCloser, error) { | |
| 225 | 288 | uploadPath := fs.getUploadPath(uuid) | |
| 226 | - | ||
| 289 | + | ||
| 227 | 290 | file, err := os.OpenFile(uploadPath, os.O_WRONLY|os.O_APPEND, 0644) | |
| 228 | 291 | if err != nil { | |
| 229 | 292 | if os.IsNotExist(err) { | |
| @@ -231,38 +294,38 @@ func (fs *FilesystemStorage) GetBlobUpload(uuid string) (io.WriteCloser, error) | |||
| 231 | 294 | } | |
| 232 | 295 | return nil, fmt.Errorf("failed to open upload file: %w", err) | |
| 233 | 296 | } | |
| 234 | - | ||
| 297 | + | ||
| 235 | 298 | return file, nil | |
| 236 | 299 | } | |
| 237 | 300 | ||
| 238 | 301 | func (fs *FilesystemStorage) FinishBlobUpload(uuid, digest string) error { | |
| 239 | 302 | uploadPath := fs.getUploadPath(uuid) | |
| 240 | 303 | blobPath := fs.getBlobPath(digest) | |
| 241 | - | ||
| 304 | + | ||
| 242 | 305 | // Create blob directory if it doesn't exist | |
| 243 | 306 | if err := os.MkdirAll(filepath.Dir(blobPath), 0755); err != nil { | |
| 244 | 307 | return fmt.Errorf("failed to create blob directory: %w", err) | |
| 245 | 308 | } | |
| 246 | - | ||
| 309 | + | ||
| 247 | 310 | // Move upload file to blob location | |
| 248 | 311 | if err := os.Rename(uploadPath, blobPath); err != nil { | |
| 249 | 312 | return fmt.Errorf("failed to move upload to blob location: %w", err) | |
| 250 | 313 | } | |
| 251 | - | ||
| 314 | + | ||
| 252 | 315 | log.Info().Str("uuid", uuid).Str("digest", digest).Msg("Blob upload finished") | |
| 253 | 316 | return nil | |
| 254 | 317 | } | |
| 255 | 318 | ||
| 256 | 319 | func (fs *FilesystemStorage) CancelBlobUpload(uuid string) error { | |
| 257 | 320 | uploadPath := fs.getUploadPath(uuid) | |
| 258 | - | ||
| 321 | + | ||
| 259 | 322 | if err := os.Remove(uploadPath); err != nil { | |
| 260 | 323 | if os.IsNotExist(err) { | |
| 261 | 324 | return fmt.Errorf("upload not found: %s", uuid) | |
| 262 | 325 | } | |
| 263 | 326 | return fmt.Errorf("failed to cancel upload: %w", err) | |
| 264 | 327 | } | |
| 265 | - | ||
| 328 | + | ||
| 266 | 329 | log.Info().Str("uuid", uuid).Msg("Blob upload cancelled") | |
| 267 | 330 | return nil | |
| 268 | 331 | } | |
| @@ -271,7 +334,7 @@ func (fs *FilesystemStorage) CancelBlobUpload(uuid string) error { | |||
| 271 | 334 | ||
| 272 | 335 | func (fs *FilesystemStorage) ListTags(name string) ([]string, error) { | |
| 273 | 336 | tagsPath := fs.getTagsPath(name) | |
| 274 | - | ||
| 337 | + | ||
| 275 | 338 | data, err := os.ReadFile(tagsPath) | |
| 276 | 339 | if err != nil { | |
| 277 | 340 | if os.IsNotExist(err) { | |
| @@ -279,12 +342,12 @@ func (fs *FilesystemStorage) ListTags(name string) ([]string, error) { | |||
| 279 | 342 | } | |
| 280 | 343 | return nil, fmt.Errorf("failed to read tags file: %w", err) | |
| 281 | 344 | } | |
| 282 | - | ||
| 345 | + | ||
| 283 | 346 | var tags []string | |
| 284 | 347 | if err := json.Unmarshal(data, &tags); err != nil { | |
| 285 | 348 | return nil, fmt.Errorf("failed to parse tags file: %w", err) | |
| 286 | 349 | } | |
| 287 | - | ||
| 350 | + | ||
| 288 | 351 | return tags, nil | |
| 289 | 352 | } | |
| 290 | 353 | ||
| @@ -292,34 +355,34 @@ func (fs *FilesystemStorage) ListTags(name string) ([]string, error) { | |||
| 292 | 355 | ||
| 293 | 356 | func (fs *FilesystemStorage) ListRepositories() ([]string, error) { | |
| 294 | 357 | reposDir := filepath.Join(fs.rootDir, "repositories") | |
| 295 | - | ||
| 358 | + | ||
| 296 | 359 | var repositories []string | |
| 297 | 360 | err := filepath.Walk(reposDir, func(path string, info os.FileInfo, err error) error { | |
| 298 | 361 | if err != nil { | |
| 299 | 362 | return err | |
| 300 | 363 | } | |
| 301 | - | ||
| 364 | + | ||
| 302 | 365 | if info.IsDir() && path != reposDir { | |
| 303 | 366 | // Get relative path from repositories directory | |
| 304 | 367 | relPath, err := filepath.Rel(reposDir, path) | |
| 305 | 368 | if err != nil { | |
| 306 | 369 | return err | |
| 307 | 370 | } | |
| 308 | - | ||
| 371 | + | ||
| 309 | 372 | // Check if this directory contains manifests | |
| 310 | 373 | manifestsDir := filepath.Join(path, "manifests") | |
| 311 | 374 | if _, err := os.Stat(manifestsDir); err == nil { | |
| 312 | 375 | repositories = append(repositories, relPath) | |
| 313 | 376 | } | |
| 314 | 377 | } | |
| 315 | - | ||
| 378 | + | ||
| 316 | 379 | return nil | |
| 317 | 380 | }) | |
| 318 | - | ||
| 381 | + | ||
| 319 | 382 | if err != nil { | |
| 320 | 383 | return nil, fmt.Errorf("failed to list repositories: %w", err) | |
| 321 | 384 | } | |
| 322 | - | ||
| 385 | + | ||
| 323 | 386 | return repositories, nil | |
| 324 | 387 | } | |
| 325 | 388 | ||
| @@ -329,6 +392,33 @@ func (fs *FilesystemStorage) getManifestPath(name, reference string) string { | |||
| 329 | 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 | 422 | func (fs *FilesystemStorage) getBlobPath(digest string) string { | |
| 333 | 423 | // Split digest into directory structure (e.g., sha256:abc123... -> sha256/ab/abc123...) | |
| 334 | 424 | parts := strings.SplitN(digest, ":", 2) | |
| @@ -336,15 +426,15 @@ func (fs *FilesystemStorage) getBlobPath(digest string) string { | |||
| 336 | 426 | // Fallback if digest format is unexpected | |
| 337 | 427 | return filepath.Join(fs.rootDir, "blobs", digest) | |
| 338 | 428 | } | |
| 339 | - | ||
| 429 | + | ||
| 340 | 430 | algorithm := parts[0] | |
| 341 | 431 | hash := parts[1] | |
| 342 | - | ||
| 432 | + | ||
| 343 | 433 | // Create two-level directory structure for better performance | |
| 344 | 434 | if len(hash) >= 2 { | |
| 345 | 435 | return filepath.Join(fs.rootDir, "blobs", algorithm, hash[:2], hash) | |
| 346 | 436 | } | |
| 347 | - | ||
| 437 | + | ||
| 348 | 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 | 453 | if err != nil { | |
| 364 | 454 | return err | |
| 365 | 455 | } | |
| 366 | - | ||
| 456 | + | ||
| 367 | 457 | // Add tag if not already present | |
| 368 | 458 | for _, tag := range tags { | |
| 369 | 459 | if tag == reference { | |
| 370 | 460 | return nil // Already exists | |
| 371 | 461 | } | |
| 372 | 462 | } | |
| 373 | - | ||
| 463 | + | ||
| 374 | 464 | tags = append(tags, reference) | |
| 375 | 465 | return fs.saveTagsList(name, tags) | |
| 376 | 466 | } | |
| @@ -380,7 +470,7 @@ func (fs *FilesystemStorage) removeFromTagsList(name, reference string) error { | |||
| 380 | 470 | if err != nil { | |
| 381 | 471 | return err | |
| 382 | 472 | } | |
| 383 | - | ||
| 473 | + | ||
| 384 | 474 | // Remove tag if present | |
| 385 | 475 | var newTags []string | |
| 386 | 476 | for _, tag := range tags { | |
| @@ -388,26 +478,26 @@ func (fs *FilesystemStorage) removeFromTagsList(name, reference string) error { | |||
| 388 | 478 | newTags = append(newTags, tag) | |
| 389 | 479 | } | |
| 390 | 480 | } | |
| 391 | - | ||
| 481 | + | ||
| 392 | 482 | return fs.saveTagsList(name, newTags) | |
| 393 | 483 | } | |
| 394 | 484 | ||
| 395 | 485 | func (fs *FilesystemStorage) saveTagsList(name string, tags []string) error { | |
| 396 | 486 | tagsPath := fs.getTagsPath(name) | |
| 397 | - | ||
| 487 | + | ||
| 398 | 488 | // Create directory if it doesn't exist | |
| 399 | 489 | if err := os.MkdirAll(filepath.Dir(tagsPath), 0755); err != nil { | |
| 400 | 490 | return fmt.Errorf("failed to create tags directory: %w", err) | |
| 401 | 491 | } | |
| 402 | - | ||
| 492 | + | ||
| 403 | 493 | data, err := json.Marshal(tags) | |
| 404 | 494 | if err != nil { | |
| 405 | 495 | return fmt.Errorf("failed to marshal tags: %w", err) | |
| 406 | 496 | } | |
| 407 | - | ||
| 497 | + | ||
| 408 | 498 | if err := os.WriteFile(tagsPath, data, 0644); err != nil { | |
| 409 | 499 | return fmt.Errorf("failed to write tags file: %w", err) | |
| 410 | 500 | } | |
| 411 | - | ||
| 501 | + | ||
| 412 | 502 | return nil | |
| 413 | - | }⏎̸ | |
| 503 | + | } | |
| @@ -15,25 +15,27 @@ import ( | |||||
| 15 | // Storage interface defines the contract for registry storage backends | 15 | // Storage interface defines the contract for registry storage backends | ||
| 16 | type Storage interface { | 16 | type Storage interface { | ||
| 17 | // Manifest operations | 17 | // Manifest operations | ||
| 18 | - | GetManifest(name, reference string) ([]byte, error) | 18 | + | GetManifest(name, reference string) ([]byte, string, error) |
| 19 | - | PutManifest(name, reference string, data []byte) error | 19 | + | PutManifest(name, reference, contentType string, data []byte) error |
| 20 | DeleteManifest(name, reference string) error | 20 | DeleteManifest(name, reference string) error | ||
| 21 | - | 21 | + | ||
| 22 | // Blob operations | 22 | // 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) error | 25 | PutBlob(digest string, data io.Reader, size int64) error | ||
| 25 | DeleteBlob(digest string) error | 26 | DeleteBlob(digest string) error | ||
| 26 | BlobExists(digest string) bool | 27 | BlobExists(digest string) bool | ||
| 27 | - | 28 | + | ||
| 28 | // Upload operations | 29 | // Upload operations | ||
| 29 | StartBlobUpload(name string) (string, error) // returns upload UUID | 30 | 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) error | 33 | FinishBlobUpload(uuid, digest string) error | ||
| 32 | CancelBlobUpload(uuid string) error | 34 | CancelBlobUpload(uuid string) error | ||
| 33 | - | 35 | + | ||
| 34 | // Tag operations | 36 | // Tag operations | ||
| 35 | ListTags(name string) ([]string, error) | 37 | ListTags(name string) ([]string, error) | ||
| 36 | - | 38 | + | ||
| 37 | // Repository operations | 39 | // 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 | }, nil | 67 | }, nil | ||
| @@ -67,57 +69,75 @@ func NewFilesystemStorage(rootDir string) (*FilesystemStorage, error) { | |||||
| 67 | 69 | ||||
| 68 | // Manifest operations | 70 | // 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, nil | 90 | + | 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 exist | 96 | // 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 file | 101 | // 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 list | 112 | // 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 nil | 118 | 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 list | 136 | // 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 nil | 142 | 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, nil | 158 | 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 exist | 175 | // 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 first | 180 | // 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 file | 188 | // 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 provided | 195 | // 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 location | 201 | // 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 nil | 208 | 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 nil | 222 | 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 timestamp | 234 | // 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 exist | 239 | // 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 file | 244 | // 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, nil | 252 | 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, nil | 298 | 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 exist | 305 | // 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 location | 310 | // 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 nil | 316 | 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 nil | 330 | 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 []string | 346 | 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, nil | 351 | 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 []string | 359 | 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 err | 362 | return err | ||
| 300 | } | 363 | } | ||
| 301 | - | 364 | + | ||
| 302 | if info.IsDir() && path != reposDir { | 365 | if info.IsDir() && path != reposDir { | ||
| 303 | // Get relative path from repositories directory | 366 | // 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 err | 369 | return err | ||
| 307 | } | 370 | } | ||
| 308 | - | 371 | + | ||
| 309 | // Check if this directory contains manifests | 372 | // 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 nil | 379 | 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, nil | 386 | 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 unexpected | 426 | // 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 performance | 433 | // 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 err | 454 | return err | ||
| 365 | } | 455 | } | ||
| 366 | - | 456 | + | ||
| 367 | // Add tag if not already present | 457 | // 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 exists | 460 | 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 err | 471 | return err | ||
| 382 | } | 472 | } | ||
| 383 | - | 473 | + | ||
| 384 | // Remove tag if present | 474 | // Remove tag if present | ||
| 385 | var newTags []string | 475 | 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 exist | 488 | // 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 nil | 502 | return nil | ||
| 413 | - | }⏎̸ | 503 | + | } |
M pkg/runtime/interface.go +1 -0
| @@ -43,6 +43,7 @@ type Runtime interface { | |||
| 43 | 43 | ||
| 44 | 44 | // Image operations | |
| 45 | 45 | PullImage(ctx context.Context, image string) error | |
| 46 | + | PullImageWithAuth(ctx context.Context, image, username, password string) error | |
| 46 | 47 | RemoveImage(ctx context.Context, image string, force bool) error | |
| 47 | 48 | ListImages(ctx context.Context) ([]string, error) | |
| 48 | 49 | ||
| @@ -43,6 +43,7 @@ type Runtime interface { | |||||
| 43 | 43 | ||||
| 44 | // Image operations | 44 | // Image operations | ||
| 45 | PullImage(ctx context.Context, image string) error | 45 | 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) error | 47 | 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 | ||||