Merge pull request #70 from bnema/refactor/push-command-remove-domain-arg
refactor(cli): remove domain arg from push command
14 files · +906 -73
M internal/adapters/dto/admin_route.go +6 -0
| @@ -42,6 +42,12 @@ type AttachmentsResponse struct { | |||
| 42 | 42 | Attachments []Attachment `json:"attachments"` | |
| 43 | 43 | } | |
| 44 | 44 | ||
| 45 | + | // RoutesByImageResponse represents routes found for a given image. | |
| 46 | + | type RoutesByImageResponse struct { | |
| 47 | + | Image string `json:"image"` | |
| 48 | + | Routes []Route `json:"routes"` | |
| 49 | + | } | |
| 50 | + | ||
| 45 | 51 | // RouteDeleteResponse represents a route removal response. | |
| 46 | 52 | type RouteDeleteResponse struct { | |
| 47 | 53 | Status string `json:"status"` | |
| @@ -42,6 +42,12 @@ type AttachmentsResponse struct { | |||||
| 42 | Attachments []Attachment `json:"attachments"` | 42 | Attachments []Attachment `json:"attachments"` | ||
| 43 | } | 43 | } | ||
| 44 | 44 | ||||
| 45 | + | // RoutesByImageResponse represents routes found for a given image. | |||
| 46 | + | type RoutesByImageResponse struct { | |||
| 47 | + | Image string `json:"image"` | |||
| 48 | + | Routes []Route `json:"routes"` | |||
| 49 | + | } | |||
| 50 | + | ||||
| 45 | // RouteDeleteResponse represents a route removal response. | 51 | // RouteDeleteResponse represents a route removal response. | ||
| 46 | type RouteDeleteResponse struct { | 52 | type RouteDeleteResponse struct { | ||
| 47 | Status string `json:"status"` | 53 | Status string `json:"status"` | ||
M internal/adapters/in/cli/controlplane.go +1 -0
| @@ -16,6 +16,7 @@ type ControlPlane interface { | |||
| 16 | 16 | ListRoutesWithDetails(ctx context.Context) ([]remote.RouteInfo, error) | |
| 17 | 17 | GetHealth(ctx context.Context) (map[string]*remote.RouteHealth, error) | |
| 18 | 18 | GetRoute(ctx context.Context, routeDomain string) (*domain.Route, error) | |
| 19 | + | FindRoutesByImage(ctx context.Context, imageName string) ([]domain.Route, error) | |
| 19 | 20 | AddRoute(ctx context.Context, route domain.Route) error | |
| 20 | 21 | UpdateRoute(ctx context.Context, route domain.Route) error | |
| 21 | 22 | RemoveRoute(ctx context.Context, routeDomain string) error | |
| @@ -16,6 +16,7 @@ type ControlPlane interface { | |||||
| 16 | ListRoutesWithDetails(ctx context.Context) ([]remote.RouteInfo, error) | 16 | ListRoutesWithDetails(ctx context.Context) ([]remote.RouteInfo, error) | ||
| 17 | GetHealth(ctx context.Context) (map[string]*remote.RouteHealth, error) | 17 | GetHealth(ctx context.Context) (map[string]*remote.RouteHealth, error) | ||
| 18 | GetRoute(ctx context.Context, routeDomain string) (*domain.Route, error) | 18 | GetRoute(ctx context.Context, routeDomain string) (*domain.Route, error) | ||
| 19 | + | FindRoutesByImage(ctx context.Context, imageName string) ([]domain.Route, error) | |||
| 19 | AddRoute(ctx context.Context, route domain.Route) error | 20 | AddRoute(ctx context.Context, route domain.Route) error | ||
| 20 | UpdateRoute(ctx context.Context, route domain.Route) error | 21 | UpdateRoute(ctx context.Context, route domain.Route) error | ||
| 21 | RemoveRoute(ctx context.Context, routeDomain string) error | 22 | RemoveRoute(ctx context.Context, routeDomain string) error | ||
M internal/adapters/in/cli/controlplane_local.go +7 -0
| @@ -84,6 +84,13 @@ func (l *localControlPlane) GetRoute(ctx context.Context, routeDomain string) (* | |||
| 84 | 84 | return l.configSvc.GetRoute(ctx, routeDomain) | |
| 85 | 85 | } | |
| 86 | 86 | ||
| 87 | + | func (l *localControlPlane) FindRoutesByImage(ctx context.Context, imageName string) ([]domain.Route, error) { | |
| 88 | + | if l.configSvc == nil { | |
| 89 | + | return nil, fmt.Errorf("local config service unavailable") | |
| 90 | + | } | |
| 91 | + | return l.configSvc.FindRoutesByImage(ctx, imageName), nil | |
| 92 | + | } | |
| 93 | + | ||
| 87 | 94 | func (l *localControlPlane) AddRoute(ctx context.Context, route domain.Route) error { | |
| 88 | 95 | if l.configSvc == nil { | |
| 89 | 96 | return fmt.Errorf("local config service unavailable") | |
| @@ -84,6 +84,13 @@ func (l *localControlPlane) GetRoute(ctx context.Context, routeDomain string) (* | |||||
| 84 | return l.configSvc.GetRoute(ctx, routeDomain) | 84 | return l.configSvc.GetRoute(ctx, routeDomain) | ||
| 85 | } | 85 | } | ||
| 86 | 86 | ||||
| 87 | + | func (l *localControlPlane) FindRoutesByImage(ctx context.Context, imageName string) ([]domain.Route, error) { | |||
| 88 | + | if l.configSvc == nil { | |||
| 89 | + | return nil, fmt.Errorf("local config service unavailable") | |||
| 90 | + | } | |||
| 91 | + | return l.configSvc.FindRoutesByImage(ctx, imageName), nil | |||
| 92 | + | } | |||
| 93 | + | ||||
| 87 | func (l *localControlPlane) AddRoute(ctx context.Context, route domain.Route) error { | 94 | func (l *localControlPlane) AddRoute(ctx context.Context, route domain.Route) error { | ||
| 88 | if l.configSvc == nil { | 95 | if l.configSvc == nil { | ||
| 89 | return fmt.Errorf("local config service unavailable") | 96 | return fmt.Errorf("local config service unavailable") | ||
M internal/adapters/in/cli/controlplane_remote.go +4 -0
| @@ -28,6 +28,10 @@ func (r *remoteControlPlane) GetRoute(ctx context.Context, routeDomain string) ( | |||
| 28 | 28 | return r.client.GetRoute(ctx, routeDomain) | |
| 29 | 29 | } | |
| 30 | 30 | ||
| 31 | + | func (r *remoteControlPlane) FindRoutesByImage(ctx context.Context, imageName string) ([]domain.Route, error) { | |
| 32 | + | return r.client.FindRoutesByImage(ctx, imageName) | |
| 33 | + | } | |
| 34 | + | ||
| 31 | 35 | func (r *remoteControlPlane) AddRoute(ctx context.Context, route domain.Route) error { | |
| 32 | 36 | return r.client.AddRoute(ctx, route) | |
| 33 | 37 | } | |
| @@ -28,6 +28,10 @@ func (r *remoteControlPlane) GetRoute(ctx context.Context, routeDomain string) ( | |||||
| 28 | return r.client.GetRoute(ctx, routeDomain) | 28 | return r.client.GetRoute(ctx, routeDomain) | ||
| 29 | } | 29 | } | ||
| 30 | 30 | ||||
| 31 | + | func (r *remoteControlPlane) FindRoutesByImage(ctx context.Context, imageName string) ([]domain.Route, error) { | |||
| 32 | + | return r.client.FindRoutesByImage(ctx, imageName) | |||
| 33 | + | } | |||
| 34 | + | ||||
| 31 | func (r *remoteControlPlane) AddRoute(ctx context.Context, route domain.Route) error { | 35 | func (r *remoteControlPlane) AddRoute(ctx context.Context, route domain.Route) error { | ||
| 32 | return r.client.AddRoute(ctx, route) | 36 | return r.client.AddRoute(ctx, route) | ||
| 33 | } | 37 | } | ||
M internal/adapters/in/cli/push.go +288 -22
| @@ -1,10 +1,12 @@ | |||
| 1 | 1 | package cli | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "bufio" | |
| 4 | 5 | "context" | |
| 5 | 6 | "fmt" | |
| 6 | 7 | "os" | |
| 7 | 8 | "os/exec" | |
| 9 | + | "path/filepath" | |
| 8 | 10 | "regexp" | |
| 9 | 11 | "strings" | |
| 10 | 12 | ||
| @@ -14,6 +16,7 @@ import ( | |||
| 14 | 16 | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |
| 15 | 17 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/components" | |
| 16 | 18 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles" | |
| 19 | + | "github.com/bnema/gordon/internal/domain" | |
| 17 | 20 | "github.com/bnema/gordon/pkg/validation" | |
| 18 | 21 | ) | |
| 19 | 22 | ||
| @@ -26,24 +29,35 @@ func newPushCmd() *cobra.Command { | |||
| 26 | 29 | tag string | |
| 27 | 30 | dockerfile string | |
| 28 | 31 | buildArgs []string | |
| 32 | + | domainFlag string | |
| 29 | 33 | ) | |
| 30 | 34 | ||
| 31 | 35 | cmd := &cobra.Command{ | |
| 32 | - | Use: "push <domain>", | |
| 36 | + | Use: "push [image]", | |
| 33 | 37 | Short: "Tag, push, and optionally deploy an image", | |
| 34 | 38 | Long: `Tags a local image for the Gordon registry and pushes it. | |
| 35 | 39 | Uses git tags for versioning. Optionally triggers deployment after push. | |
| 36 | 40 | ||
| 41 | + | The image argument is optional. Resolution order: | |
| 42 | + | 1. If --domain is provided, uses the legacy domain-based lookup | |
| 43 | + | 2. If an image name is provided as argument, resolves domain(s) from the backend | |
| 44 | + | 3. If no argument, auto-detects from Dockerfile labels or current directory name | |
| 45 | + | ||
| 37 | 46 | With --build, builds the image first using docker buildx. | |
| 38 | 47 | ||
| 39 | 48 | Examples: | |
| 40 | - | gordon push myapp.example.com --remote ... | |
| 41 | - | gordon push myapp.example.com --build --remote ... | |
| 42 | - | gordon push myapp.example.com --tag v1.2.0 --no-deploy --remote ... | |
| 43 | - | gordon push myapp.example.com --build --build-arg CGO_ENABLED=0 --remote ...`, | |
| 44 | - | Args: cobra.ExactArgs(1), | |
| 49 | + | gordon push --build --remote ... | |
| 50 | + | gordon push myapp --build --remote ... | |
| 51 | + | gordon push --domain myapp.example.com --build --remote ... | |
| 52 | + | gordon push myapp --tag v1.2.0 --no-deploy --remote ... | |
| 53 | + | gordon push --build --build-arg CGO_ENABLED=0 --remote ...`, | |
| 54 | + | Args: cobra.MaximumNArgs(1), | |
| 45 | 55 | RunE: func(cmd *cobra.Command, args []string) error { | |
| 46 | - | return runPush(cmd.Context(), args[0], tag, build, platform, dockerfile, buildArgs, noDeploy, noConfirm) | |
| 56 | + | var imageArg string | |
| 57 | + | if len(args) > 0 { | |
| 58 | + | imageArg = args[0] | |
| 59 | + | } | |
| 60 | + | return runPush(cmd.Context(), imageArg, domainFlag, tag, build, platform, dockerfile, buildArgs, noDeploy, noConfirm) | |
| 47 | 61 | }, | |
| 48 | 62 | } | |
| 49 | 63 | ||
| @@ -54,6 +68,7 @@ Examples: | |||
| 54 | 68 | cmd.Flags().StringVarP(&dockerfile, "file", "f", "", "Path to Dockerfile (default: ./Dockerfile, used with --build)") | |
| 55 | 69 | cmd.Flags().StringVar(&tag, "tag", "", "Override version tag (default: git describe)") | |
| 56 | 70 | cmd.Flags().StringArrayVar(&buildArgs, "build-arg", nil, "Additional build args (used with --build)") | |
| 71 | + | cmd.Flags().StringVar(&domainFlag, "domain", "", "Explicit domain override (legacy mode)") | |
| 57 | 72 | ||
| 58 | 73 | return cmd | |
| 59 | 74 | } | |
| @@ -74,7 +89,35 @@ func resolveImageRefs(registry, imageName, version string) (versionRef, latestRe | |||
| 74 | 89 | return versionRef, latestRef | |
| 75 | 90 | } | |
| 76 | 91 | ||
| 77 | - | func runPush(ctx context.Context, pushDomain, tag string, build bool, platform string, dockerfile string, buildArgs []string, noDeploy bool, noConfirm bool) error { | |
| 92 | + | // resolveRoute determines the registry, image name, and domain from the input mode. | |
| 93 | + | func resolveRoute(ctx context.Context, cp ControlPlane, imageArg, domainFlag, dockerfile string) (registry, imageName, pushDomain string, err error) { | |
| 94 | + | if domainFlag != "" { | |
| 95 | + | return resolveFromDomain(ctx, cp, domainFlag) | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | if imageArg == "" { | |
| 99 | + | imageArg, err = detectImageName(dockerfile) | |
| 100 | + | if err != nil { | |
| 101 | + | return "", "", "", err | |
| 102 | + | } | |
| 103 | + | fmt.Printf("Detected image: %s\n", styles.Theme.Bold.Render(imageArg)) | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | return resolveFromImage(ctx, cp, imageArg, dockerfile) | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | // resolveVersion determines and validates the version tag. | |
| 110 | + | func resolveVersion(ctx context.Context, tag string) (string, error) { | |
| 111 | + | version := determineVersion(ctx, tag) | |
| 112 | + | if version != "latest" { | |
| 113 | + | if err := validation.ValidateReference(version); err != nil { | |
| 114 | + | return "", fmt.Errorf("invalid version tag %q: %w", version, err) | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | return version, nil | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func runPush(ctx context.Context, imageArg, domainFlag, tag string, build bool, platform string, dockerfile string, buildArgs []string, noDeploy bool, noConfirm bool) error { | |
| 78 | 121 | handle, err := resolveControlPlane(configPath) | |
| 79 | 122 | if err != nil { | |
| 80 | 123 | return err | |
| @@ -86,21 +129,14 @@ func runPush(ctx context.Context, pushDomain, tag string, build bool, platform s | |||
| 86 | 129 | return err | |
| 87 | 130 | } | |
| 88 | 131 | ||
| 89 | - | route, err := handle.plane.GetRoute(ctx, pushDomain) | |
| 132 | + | registry, imageName, pushDomain, err := resolveRoute(ctx, handle.plane, imageArg, domainFlag, dockerfile) | |
| 90 | 133 | if err != nil { | |
| 91 | - | return fmt.Errorf("failed to get route: %w", err) | |
| 92 | - | } | |
| 93 | - | ||
| 94 | - | registry, imageName, _ := parseImageRef(route.Image) | |
| 95 | - | if registry == "" || imageName == "" { | |
| 96 | - | return fmt.Errorf("cannot parse registry/image from route image: %s", route.Image) | |
| 134 | + | return err | |
| 97 | 135 | } | |
| 98 | 136 | ||
| 99 | - | version := determineVersion(ctx, tag) | |
| 100 | - | if version != "latest" { | |
| 101 | - | if err := validation.ValidateReference(version); err != nil { | |
| 102 | - | return fmt.Errorf("invalid version tag %q: %w", version, err) | |
| 103 | - | } | |
| 137 | + | version, err := resolveVersion(ctx, tag) | |
| 138 | + | if err != nil { | |
| 139 | + | return err | |
| 104 | 140 | } | |
| 105 | 141 | ||
| 106 | 142 | for _, ba := range buildArgs { | |
| @@ -111,10 +147,11 @@ func runPush(ctx context.Context, pushDomain, tag string, build bool, platform s | |||
| 111 | 147 | ||
| 112 | 148 | versionRef, latestRef := resolveImageRefs(registry, imageName, version) | |
| 113 | 149 | ||
| 114 | - | fmt.Printf("Image: %s\n", styles.Theme.Bold.Render(versionRef)) | |
| 150 | + | fmt.Printf("Image: %s\n", styles.Theme.Bold.Render(versionRef)) | |
| 115 | 151 | if version != "latest" { | |
| 116 | - | fmt.Printf("Also: %s\n", styles.Theme.Bold.Render(latestRef)) | |
| 152 | + | fmt.Printf("Also: %s\n", styles.Theme.Bold.Render(latestRef)) | |
| 117 | 153 | } | |
| 154 | + | fmt.Printf("Domain: %s\n", styles.Theme.Bold.Render(pushDomain)) | |
| 118 | 155 | ||
| 119 | 156 | if build { | |
| 120 | 157 | if err := buildAndPush(ctx, version, platform, dockerfile, buildArgs, versionRef, latestRef); err != nil { | |
| @@ -135,6 +172,235 @@ func runPush(ctx context.Context, pushDomain, tag string, build bool, platform s | |||
| 135 | 172 | return nil | |
| 136 | 173 | } | |
| 137 | 174 | ||
| 175 | + | // resolveFromDomain resolves image info from a domain name (legacy mode). | |
| 176 | + | func resolveFromDomain(ctx context.Context, cp ControlPlane, pushDomain string) (registry, imageName, resolvedDomain string, err error) { | |
| 177 | + | route, err := cp.GetRoute(ctx, pushDomain) | |
| 178 | + | if err != nil { | |
| 179 | + | return "", "", "", fmt.Errorf("failed to get route for domain %q: %w", pushDomain, err) | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | registry, imageName, _ = parseImageRef(route.Image) | |
| 183 | + | if registry == "" || imageName == "" { | |
| 184 | + | return "", "", "", fmt.Errorf("cannot parse registry/image from route image: %s", route.Image) | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | return registry, imageName, pushDomain, nil | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | // resolveFromImage resolves domain(s) from an image name using the backend. | |
| 191 | + | func resolveFromImage(ctx context.Context, cp ControlPlane, imageArg, dockerfile string) (registry, imageName, resolvedDomain string, err error) { | |
| 192 | + | // First, query the backend to find routes for this image | |
| 193 | + | routes, err := cp.FindRoutesByImage(ctx, imageArg) | |
| 194 | + | if err != nil { | |
| 195 | + | return "", "", "", fmt.Errorf("failed to find routes for image %q: %w", imageArg, err) | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | if len(routes) == 0 { | |
| 199 | + | return "", "", "", fmt.Errorf( | |
| 200 | + | "no route configured for image %q\n\nUse 'gordon routes add' to create a route first", | |
| 201 | + | imageArg, | |
| 202 | + | ) | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | // Pick the target domain | |
| 206 | + | var targetRoute domain.Route | |
| 207 | + | if len(routes) == 1 { | |
| 208 | + | targetRoute = routes[0] | |
| 209 | + | } else { | |
| 210 | + | // Multiple domains: check Dockerfile labels first, then prompt | |
| 211 | + | targetRoute, err = selectDomain(routes, dockerfile) | |
| 212 | + | if err != nil { | |
| 213 | + | return "", "", "", err | |
| 214 | + | } | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | registry, imageName, _ = parseImageRef(targetRoute.Image) | |
| 218 | + | if registry == "" || imageName == "" { | |
| 219 | + | return "", "", "", fmt.Errorf("cannot parse registry/image from route image: %s", targetRoute.Image) | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | return registry, imageName, targetRoute.Domain, nil | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | // selectDomain picks the target domain from multiple routes. | |
| 226 | + | // Checks Dockerfile labels first, then falls back to interactive selection. | |
| 227 | + | func selectDomain(routes []domain.Route, dockerfile string) (domain.Route, error) { | |
| 228 | + | // Try to resolve from Dockerfile labels | |
| 229 | + | labels := parseDockerfileLabels(dockerfile) | |
| 230 | + | labelDomain := labels[domain.LabelDomain] | |
| 231 | + | labelDomains := labels[domain.LabelDomains] | |
| 232 | + | ||
| 233 | + | // Collect domains from labels | |
| 234 | + | var labelDomainList []string | |
| 235 | + | if labelDomain != "" { | |
| 236 | + | labelDomainList = append(labelDomainList, labelDomain) | |
| 237 | + | } | |
| 238 | + | if labelDomains != "" { | |
| 239 | + | for _, d := range strings.Split(labelDomains, ",") { | |
| 240 | + | d = strings.TrimSpace(d) | |
| 241 | + | if d != "" { | |
| 242 | + | labelDomainList = append(labelDomainList, d) | |
| 243 | + | } | |
| 244 | + | } | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | // Try to find a matching route from labels | |
| 248 | + | if len(labelDomainList) > 0 { | |
| 249 | + | routeMap := make(map[string]domain.Route, len(routes)) | |
| 250 | + | for _, r := range routes { | |
| 251 | + | routeMap[r.Domain] = r | |
| 252 | + | } | |
| 253 | + | for _, ld := range labelDomainList { | |
| 254 | + | if r, ok := routeMap[ld]; ok { | |
| 255 | + | return r, nil | |
| 256 | + | } | |
| 257 | + | } | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | // Fall back to interactive selection | |
| 261 | + | items := make([]string, 0, len(routes)) | |
| 262 | + | for _, r := range routes { | |
| 263 | + | items = append(items, fmt.Sprintf("%s %s", r.Domain, styles.Theme.Muted.Render(r.Image))) | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | selected, err := components.RunSelector( | |
| 267 | + | "Multiple domains found for this image. Select target:", | |
| 268 | + | items, | |
| 269 | + | "", | |
| 270 | + | ) | |
| 271 | + | if err != nil { | |
| 272 | + | return domain.Route{}, fmt.Errorf("selection error: %w", err) | |
| 273 | + | } | |
| 274 | + | if selected == "" { | |
| 275 | + | return domain.Route{}, fmt.Errorf("no domain selected") | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | // Extract domain from the selected display string | |
| 279 | + | for i, item := range items { | |
| 280 | + | if item == selected { | |
| 281 | + | return routes[i], nil | |
| 282 | + | } | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | return domain.Route{}, fmt.Errorf("selected domain not found") | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | // detectImageName auto-detects the image name from context. | |
| 289 | + | // Resolution order: | |
| 290 | + | // 1. Dockerfile label gordon.domain (if Dockerfile exists) | |
| 291 | + | // 2. Current directory name | |
| 292 | + | func detectImageName(dockerfile string) (string, error) { | |
| 293 | + | // Try Dockerfile labels | |
| 294 | + | labels := parseDockerfileLabels(dockerfile) | |
| 295 | + | if d := labels[domain.LabelDomain]; d != "" { | |
| 296 | + | // Strip the registry prefix if it looks like a full image ref | |
| 297 | + | _, name, _ := parseImageRef(d) | |
| 298 | + | if name != "" { | |
| 299 | + | return name, nil | |
| 300 | + | } | |
| 301 | + | return d, nil | |
| 302 | + | } | |
| 303 | + | ||
| 304 | + | // Fall back to current directory name | |
| 305 | + | cwd, err := os.Getwd() | |
| 306 | + | if err != nil { | |
| 307 | + | return "", fmt.Errorf("cannot detect image name: %w", err) | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | dirName := filepath.Base(cwd) | |
| 311 | + | if dirName == "." || dirName == "/" { | |
| 312 | + | return "", fmt.Errorf("cannot detect image name from current directory; provide an image name or use --domain") | |
| 313 | + | } | |
| 314 | + | ||
| 315 | + | return dirName, nil | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | // parseDockerfileLabels extracts LABEL instructions from a Dockerfile. | |
| 319 | + | // Returns a map of label key -> value. Only parses gordon.* labels. | |
| 320 | + | func parseDockerfileLabels(dockerfile string) map[string]string { | |
| 321 | + | labels := make(map[string]string) | |
| 322 | + | ||
| 323 | + | f, err := os.Open(dockerfile) | |
| 324 | + | if err != nil { | |
| 325 | + | return labels | |
| 326 | + | } | |
| 327 | + | defer f.Close() | |
| 328 | + | ||
| 329 | + | scanner := bufio.NewScanner(f) | |
| 330 | + | for scanner.Scan() { | |
| 331 | + | line := strings.TrimSpace(scanner.Text()) | |
| 332 | + | ||
| 333 | + | // Skip comments and empty lines | |
| 334 | + | if line == "" || strings.HasPrefix(line, "#") { | |
| 335 | + | continue | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | // Match LABEL instructions | |
| 339 | + | if !strings.HasPrefix(strings.ToUpper(line), "LABEL ") { | |
| 340 | + | continue | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | // Parse "LABEL key=value" or "LABEL key=\"value\"" | |
| 344 | + | labelContent := strings.TrimSpace(line[6:]) | |
| 345 | + | for _, pair := range splitLabelPairs(labelContent) { | |
| 346 | + | key, value, ok := parseLabelPair(pair) | |
| 347 | + | if ok && strings.HasPrefix(key, "gordon.") { | |
| 348 | + | labels[key] = value | |
| 349 | + | } | |
| 350 | + | } | |
| 351 | + | } | |
| 352 | + | ||
| 353 | + | return labels | |
| 354 | + | } | |
| 355 | + | ||
| 356 | + | // splitLabelPairs splits LABEL content into key=value pairs, | |
| 357 | + | // handling quoted values that may contain spaces. | |
| 358 | + | func splitLabelPairs(content string) []string { | |
| 359 | + | var pairs []string | |
| 360 | + | var current strings.Builder | |
| 361 | + | inQuote := false | |
| 362 | + | quoteChar := byte(0) | |
| 363 | + | ||
| 364 | + | for i := 0; i < len(content); i++ { | |
| 365 | + | ch := content[i] | |
| 366 | + | if inQuote { | |
| 367 | + | current.WriteByte(ch) | |
| 368 | + | if ch == quoteChar { | |
| 369 | + | inQuote = false | |
| 370 | + | } | |
| 371 | + | } else if ch == '"' || ch == '\'' { | |
| 372 | + | inQuote = true | |
| 373 | + | quoteChar = ch | |
| 374 | + | current.WriteByte(ch) | |
| 375 | + | } else if ch == ' ' || ch == '\t' { | |
| 376 | + | if current.Len() > 0 { | |
| 377 | + | pairs = append(pairs, current.String()) | |
| 378 | + | current.Reset() | |
| 379 | + | } | |
| 380 | + | } else { | |
| 381 | + | current.WriteByte(ch) | |
| 382 | + | } | |
| 383 | + | } | |
| 384 | + | if current.Len() > 0 { | |
| 385 | + | pairs = append(pairs, current.String()) | |
| 386 | + | } | |
| 387 | + | ||
| 388 | + | return pairs | |
| 389 | + | } | |
| 390 | + | ||
| 391 | + | // parseLabelPair parses a single "key=value" or "key=\"value\"" pair. | |
| 392 | + | func parseLabelPair(pair string) (key, value string, ok bool) { | |
| 393 | + | idx := strings.Index(pair, "=") | |
| 394 | + | if idx == -1 { | |
| 395 | + | return "", "", false | |
| 396 | + | } | |
| 397 | + | key = pair[:idx] | |
| 398 | + | value = pair[idx+1:] | |
| 399 | + | // Strip surrounding quotes | |
| 400 | + | value = strings.Trim(value, "\"'") | |
| 401 | + | return key, value, true | |
| 402 | + | } | |
| 403 | + | ||
| 138 | 404 | func determineVersion(ctx context.Context, tag string) string { | |
| 139 | 405 | version := tag | |
| 140 | 406 | if version == "" { | |
| @@ -1,10 +1,12 @@ | |||||
| 1 | package cli | 1 | package cli | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "bufio" | |||
| 4 | "context" | 5 | "context" | ||
| 5 | "fmt" | 6 | "fmt" | ||
| 6 | "os" | 7 | "os" | ||
| 7 | "os/exec" | 8 | "os/exec" | ||
| 9 | + | "path/filepath" | |||
| 8 | "regexp" | 10 | "regexp" | ||
| 9 | "strings" | 11 | "strings" | ||
| 10 | 12 | ||||
| @@ -14,6 +16,7 @@ import ( | |||||
| 14 | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | 16 | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | ||
| 15 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/components" | 17 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/components" | ||
| 16 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles" | 18 | "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles" | ||
| 19 | + | "github.com/bnema/gordon/internal/domain" | |||
| 17 | "github.com/bnema/gordon/pkg/validation" | 20 | "github.com/bnema/gordon/pkg/validation" | ||
| 18 | ) | 21 | ) | ||
| 19 | 22 | ||||
| @@ -26,24 +29,35 @@ func newPushCmd() *cobra.Command { | |||||
| 26 | tag string | 29 | tag string | ||
| 27 | dockerfile string | 30 | dockerfile string | ||
| 28 | buildArgs []string | 31 | buildArgs []string | ||
| 32 | + | domainFlag string | |||
| 29 | ) | 33 | ) | ||
| 30 | 34 | ||||
| 31 | cmd := &cobra.Command{ | 35 | cmd := &cobra.Command{ | ||
| 32 | - | Use: "push <domain>", | 36 | + | Use: "push [image]", |
| 33 | Short: "Tag, push, and optionally deploy an image", | 37 | Short: "Tag, push, and optionally deploy an image", | ||
| 34 | Long: `Tags a local image for the Gordon registry and pushes it. | 38 | Long: `Tags a local image for the Gordon registry and pushes it. | ||
| 35 | Uses git tags for versioning. Optionally triggers deployment after push. | 39 | Uses git tags for versioning. Optionally triggers deployment after push. | ||
| 36 | 40 | ||||
| 41 | + | The image argument is optional. Resolution order: | |||
| 42 | + | 1. If --domain is provided, uses the legacy domain-based lookup | |||
| 43 | + | 2. If an image name is provided as argument, resolves domain(s) from the backend | |||
| 44 | + | 3. If no argument, auto-detects from Dockerfile labels or current directory name | |||
| 45 | + | ||||
| 37 | With --build, builds the image first using docker buildx. | 46 | With --build, builds the image first using docker buildx. | ||
| 38 | 47 | ||||
| 39 | Examples: | 48 | Examples: | ||
| 40 | - | gordon push myapp.example.com --remote ... | 49 | + | gordon push --build --remote ... |
| 41 | - | gordon push myapp.example.com --build --remote ... | 50 | + | gordon push myapp --build --remote ... |
| 42 | - | gordon push myapp.example.com --tag v1.2.0 --no-deploy --remote ... | 51 | + | gordon push --domain myapp.example.com --build --remote ... |
| 43 | - | gordon push myapp.example.com --build --build-arg CGO_ENABLED=0 --remote ...`, | 52 | + | gordon push myapp --tag v1.2.0 --no-deploy --remote ... |
| 44 | - | Args: cobra.ExactArgs(1), | 53 | + | gordon push --build --build-arg CGO_ENABLED=0 --remote ...`, |
| 54 | + | Args: cobra.MaximumNArgs(1), | |||
| 45 | RunE: func(cmd *cobra.Command, args []string) error { | 55 | RunE: func(cmd *cobra.Command, args []string) error { | ||
| 46 | - | return runPush(cmd.Context(), args[0], tag, build, platform, dockerfile, buildArgs, noDeploy, noConfirm) | 56 | + | var imageArg string |
| 57 | + | if len(args) > 0 { | |||
| 58 | + | imageArg = args[0] | |||
| 59 | + | } | |||
| 60 | + | return runPush(cmd.Context(), imageArg, domainFlag, tag, build, platform, dockerfile, buildArgs, noDeploy, noConfirm) | |||
| 47 | }, | 61 | }, | ||
| 48 | } | 62 | } | ||
| 49 | 63 | ||||
| @@ -54,6 +68,7 @@ Examples: | |||||
| 54 | cmd.Flags().StringVarP(&dockerfile, "file", "f", "", "Path to Dockerfile (default: ./Dockerfile, used with --build)") | 68 | cmd.Flags().StringVarP(&dockerfile, "file", "f", "", "Path to Dockerfile (default: ./Dockerfile, used with --build)") | ||
| 55 | cmd.Flags().StringVar(&tag, "tag", "", "Override version tag (default: git describe)") | 69 | cmd.Flags().StringVar(&tag, "tag", "", "Override version tag (default: git describe)") | ||
| 56 | cmd.Flags().StringArrayVar(&buildArgs, "build-arg", nil, "Additional build args (used with --build)") | 70 | cmd.Flags().StringArrayVar(&buildArgs, "build-arg", nil, "Additional build args (used with --build)") | ||
| 71 | + | cmd.Flags().StringVar(&domainFlag, "domain", "", "Explicit domain override (legacy mode)") | |||
| 57 | 72 | ||||
| 58 | return cmd | 73 | return cmd | ||
| 59 | } | 74 | } | ||
| @@ -74,7 +89,35 @@ func resolveImageRefs(registry, imageName, version string) (versionRef, latestRe | |||||
| 74 | return versionRef, latestRef | 89 | return versionRef, latestRef | ||
| 75 | } | 90 | } | ||
| 76 | 91 | ||||
| 77 | - | func runPush(ctx context.Context, pushDomain, tag string, build bool, platform string, dockerfile string, buildArgs []string, noDeploy bool, noConfirm bool) error { | 92 | + | // resolveRoute determines the registry, image name, and domain from the input mode. |
| 93 | + | func resolveRoute(ctx context.Context, cp ControlPlane, imageArg, domainFlag, dockerfile string) (registry, imageName, pushDomain string, err error) { | |||
| 94 | + | if domainFlag != "" { | |||
| 95 | + | return resolveFromDomain(ctx, cp, domainFlag) | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | if imageArg == "" { | |||
| 99 | + | imageArg, err = detectImageName(dockerfile) | |||
| 100 | + | if err != nil { | |||
| 101 | + | return "", "", "", err | |||
| 102 | + | } | |||
| 103 | + | fmt.Printf("Detected image: %s\n", styles.Theme.Bold.Render(imageArg)) | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | return resolveFromImage(ctx, cp, imageArg, dockerfile) | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | // resolveVersion determines and validates the version tag. | |||
| 110 | + | func resolveVersion(ctx context.Context, tag string) (string, error) { | |||
| 111 | + | version := determineVersion(ctx, tag) | |||
| 112 | + | if version != "latest" { | |||
| 113 | + | if err := validation.ValidateReference(version); err != nil { | |||
| 114 | + | return "", fmt.Errorf("invalid version tag %q: %w", version, err) | |||
| 115 | + | } | |||
| 116 | + | } | |||
| 117 | + | return version, nil | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | func runPush(ctx context.Context, imageArg, domainFlag, tag string, build bool, platform string, dockerfile string, buildArgs []string, noDeploy bool, noConfirm bool) error { | |||
| 78 | handle, err := resolveControlPlane(configPath) | 121 | handle, err := resolveControlPlane(configPath) | ||
| 79 | if err != nil { | 122 | if err != nil { | ||
| 80 | return err | 123 | return err | ||
| @@ -86,21 +129,14 @@ func runPush(ctx context.Context, pushDomain, tag string, build bool, platform s | |||||
| 86 | return err | 129 | return err | ||
| 87 | } | 130 | } | ||
| 88 | 131 | ||||
| 89 | - | route, err := handle.plane.GetRoute(ctx, pushDomain) | 132 | + | registry, imageName, pushDomain, err := resolveRoute(ctx, handle.plane, imageArg, domainFlag, dockerfile) |
| 90 | if err != nil { | 133 | if err != nil { | ||
| 91 | - | return fmt.Errorf("failed to get route: %w", err) | 134 | + | return err |
| 92 | - | } | |||
| 93 | - | ||||
| 94 | - | registry, imageName, _ := parseImageRef(route.Image) | |||
| 95 | - | if registry == "" || imageName == "" { | |||
| 96 | - | return fmt.Errorf("cannot parse registry/image from route image: %s", route.Image) | |||
| 97 | } | 135 | } | ||
| 98 | 136 | ||||
| 99 | - | version := determineVersion(ctx, tag) | 137 | + | version, err := resolveVersion(ctx, tag) |
| 100 | - | if version != "latest" { | 138 | + | if err != nil { |
| 101 | - | if err := validation.ValidateReference(version); err != nil { | 139 | + | return err |
| 102 | - | return fmt.Errorf("invalid version tag %q: %w", version, err) | |||
| 103 | - | } | |||
| 104 | } | 140 | } | ||
| 105 | 141 | ||||
| 106 | for _, ba := range buildArgs { | 142 | for _, ba := range buildArgs { | ||
| @@ -111,10 +147,11 @@ func runPush(ctx context.Context, pushDomain, tag string, build bool, platform s | |||||
| 111 | 147 | ||||
| 112 | versionRef, latestRef := resolveImageRefs(registry, imageName, version) | 148 | versionRef, latestRef := resolveImageRefs(registry, imageName, version) | ||
| 113 | 149 | ||||
| 114 | - | fmt.Printf("Image: %s\n", styles.Theme.Bold.Render(versionRef)) | 150 | + | fmt.Printf("Image: %s\n", styles.Theme.Bold.Render(versionRef)) |
| 115 | if version != "latest" { | 151 | if version != "latest" { | ||
| 116 | - | fmt.Printf("Also: %s\n", styles.Theme.Bold.Render(latestRef)) | 152 | + | fmt.Printf("Also: %s\n", styles.Theme.Bold.Render(latestRef)) |
| 117 | } | 153 | } | ||
| 154 | + | fmt.Printf("Domain: %s\n", styles.Theme.Bold.Render(pushDomain)) | |||
| 118 | 155 | ||||
| 119 | if build { | 156 | if build { | ||
| 120 | if err := buildAndPush(ctx, version, platform, dockerfile, buildArgs, versionRef, latestRef); err != nil { | 157 | if err := buildAndPush(ctx, version, platform, dockerfile, buildArgs, versionRef, latestRef); err != nil { | ||
| @@ -135,6 +172,235 @@ func runPush(ctx context.Context, pushDomain, tag string, build bool, platform s | |||||
| 135 | return nil | 172 | return nil | ||
| 136 | } | 173 | } | ||
| 137 | 174 | ||||
| 175 | + | // resolveFromDomain resolves image info from a domain name (legacy mode). | |||
| 176 | + | func resolveFromDomain(ctx context.Context, cp ControlPlane, pushDomain string) (registry, imageName, resolvedDomain string, err error) { | |||
| 177 | + | route, err := cp.GetRoute(ctx, pushDomain) | |||
| 178 | + | if err != nil { | |||
| 179 | + | return "", "", "", fmt.Errorf("failed to get route for domain %q: %w", pushDomain, err) | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | registry, imageName, _ = parseImageRef(route.Image) | |||
| 183 | + | if registry == "" || imageName == "" { | |||
| 184 | + | return "", "", "", fmt.Errorf("cannot parse registry/image from route image: %s", route.Image) | |||
| 185 | + | } | |||
| 186 | + | ||||
| 187 | + | return registry, imageName, pushDomain, nil | |||
| 188 | + | } | |||
| 189 | + | ||||
| 190 | + | // resolveFromImage resolves domain(s) from an image name using the backend. | |||
| 191 | + | func resolveFromImage(ctx context.Context, cp ControlPlane, imageArg, dockerfile string) (registry, imageName, resolvedDomain string, err error) { | |||
| 192 | + | // First, query the backend to find routes for this image | |||
| 193 | + | routes, err := cp.FindRoutesByImage(ctx, imageArg) | |||
| 194 | + | if err != nil { | |||
| 195 | + | return "", "", "", fmt.Errorf("failed to find routes for image %q: %w", imageArg, err) | |||
| 196 | + | } | |||
| 197 | + | ||||
| 198 | + | if len(routes) == 0 { | |||
| 199 | + | return "", "", "", fmt.Errorf( | |||
| 200 | + | "no route configured for image %q\n\nUse 'gordon routes add' to create a route first", | |||
| 201 | + | imageArg, | |||
| 202 | + | ) | |||
| 203 | + | } | |||
| 204 | + | ||||
| 205 | + | // Pick the target domain | |||
| 206 | + | var targetRoute domain.Route | |||
| 207 | + | if len(routes) == 1 { | |||
| 208 | + | targetRoute = routes[0] | |||
| 209 | + | } else { | |||
| 210 | + | // Multiple domains: check Dockerfile labels first, then prompt | |||
| 211 | + | targetRoute, err = selectDomain(routes, dockerfile) | |||
| 212 | + | if err != nil { | |||
| 213 | + | return "", "", "", err | |||
| 214 | + | } | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | registry, imageName, _ = parseImageRef(targetRoute.Image) | |||
| 218 | + | if registry == "" || imageName == "" { | |||
| 219 | + | return "", "", "", fmt.Errorf("cannot parse registry/image from route image: %s", targetRoute.Image) | |||
| 220 | + | } | |||
| 221 | + | ||||
| 222 | + | return registry, imageName, targetRoute.Domain, nil | |||
| 223 | + | } | |||
| 224 | + | ||||
| 225 | + | // selectDomain picks the target domain from multiple routes. | |||
| 226 | + | // Checks Dockerfile labels first, then falls back to interactive selection. | |||
| 227 | + | func selectDomain(routes []domain.Route, dockerfile string) (domain.Route, error) { | |||
| 228 | + | // Try to resolve from Dockerfile labels | |||
| 229 | + | labels := parseDockerfileLabels(dockerfile) | |||
| 230 | + | labelDomain := labels[domain.LabelDomain] | |||
| 231 | + | labelDomains := labels[domain.LabelDomains] | |||
| 232 | + | ||||
| 233 | + | // Collect domains from labels | |||
| 234 | + | var labelDomainList []string | |||
| 235 | + | if labelDomain != "" { | |||
| 236 | + | labelDomainList = append(labelDomainList, labelDomain) | |||
| 237 | + | } | |||
| 238 | + | if labelDomains != "" { | |||
| 239 | + | for _, d := range strings.Split(labelDomains, ",") { | |||
| 240 | + | d = strings.TrimSpace(d) | |||
| 241 | + | if d != "" { | |||
| 242 | + | labelDomainList = append(labelDomainList, d) | |||
| 243 | + | } | |||
| 244 | + | } | |||
| 245 | + | } | |||
| 246 | + | ||||
| 247 | + | // Try to find a matching route from labels | |||
| 248 | + | if len(labelDomainList) > 0 { | |||
| 249 | + | routeMap := make(map[string]domain.Route, len(routes)) | |||
| 250 | + | for _, r := range routes { | |||
| 251 | + | routeMap[r.Domain] = r | |||
| 252 | + | } | |||
| 253 | + | for _, ld := range labelDomainList { | |||
| 254 | + | if r, ok := routeMap[ld]; ok { | |||
| 255 | + | return r, nil | |||
| 256 | + | } | |||
| 257 | + | } | |||
| 258 | + | } | |||
| 259 | + | ||||
| 260 | + | // Fall back to interactive selection | |||
| 261 | + | items := make([]string, 0, len(routes)) | |||
| 262 | + | for _, r := range routes { | |||
| 263 | + | items = append(items, fmt.Sprintf("%s %s", r.Domain, styles.Theme.Muted.Render(r.Image))) | |||
| 264 | + | } | |||
| 265 | + | ||||
| 266 | + | selected, err := components.RunSelector( | |||
| 267 | + | "Multiple domains found for this image. Select target:", | |||
| 268 | + | items, | |||
| 269 | + | "", | |||
| 270 | + | ) | |||
| 271 | + | if err != nil { | |||
| 272 | + | return domain.Route{}, fmt.Errorf("selection error: %w", err) | |||
| 273 | + | } | |||
| 274 | + | if selected == "" { | |||
| 275 | + | return domain.Route{}, fmt.Errorf("no domain selected") | |||
| 276 | + | } | |||
| 277 | + | ||||
| 278 | + | // Extract domain from the selected display string | |||
| 279 | + | for i, item := range items { | |||
| 280 | + | if item == selected { | |||
| 281 | + | return routes[i], nil | |||
| 282 | + | } | |||
| 283 | + | } | |||
| 284 | + | ||||
| 285 | + | return domain.Route{}, fmt.Errorf("selected domain not found") | |||
| 286 | + | } | |||
| 287 | + | ||||
| 288 | + | // detectImageName auto-detects the image name from context. | |||
| 289 | + | // Resolution order: | |||
| 290 | + | // 1. Dockerfile label gordon.domain (if Dockerfile exists) | |||
| 291 | + | // 2. Current directory name | |||
| 292 | + | func detectImageName(dockerfile string) (string, error) { | |||
| 293 | + | // Try Dockerfile labels | |||
| 294 | + | labels := parseDockerfileLabels(dockerfile) | |||
| 295 | + | if d := labels[domain.LabelDomain]; d != "" { | |||
| 296 | + | // Strip the registry prefix if it looks like a full image ref | |||
| 297 | + | _, name, _ := parseImageRef(d) | |||
| 298 | + | if name != "" { | |||
| 299 | + | return name, nil | |||
| 300 | + | } | |||
| 301 | + | return d, nil | |||
| 302 | + | } | |||
| 303 | + | ||||
| 304 | + | // Fall back to current directory name | |||
| 305 | + | cwd, err := os.Getwd() | |||
| 306 | + | if err != nil { | |||
| 307 | + | return "", fmt.Errorf("cannot detect image name: %w", err) | |||
| 308 | + | } | |||
| 309 | + | ||||
| 310 | + | dirName := filepath.Base(cwd) | |||
| 311 | + | if dirName == "." || dirName == "/" { | |||
| 312 | + | return "", fmt.Errorf("cannot detect image name from current directory; provide an image name or use --domain") | |||
| 313 | + | } | |||
| 314 | + | ||||
| 315 | + | return dirName, nil | |||
| 316 | + | } | |||
| 317 | + | ||||
| 318 | + | // parseDockerfileLabels extracts LABEL instructions from a Dockerfile. | |||
| 319 | + | // Returns a map of label key -> value. Only parses gordon.* labels. | |||
| 320 | + | func parseDockerfileLabels(dockerfile string) map[string]string { | |||
| 321 | + | labels := make(map[string]string) | |||
| 322 | + | ||||
| 323 | + | f, err := os.Open(dockerfile) | |||
| 324 | + | if err != nil { | |||
| 325 | + | return labels | |||
| 326 | + | } | |||
| 327 | + | defer f.Close() | |||
| 328 | + | ||||
| 329 | + | scanner := bufio.NewScanner(f) | |||
| 330 | + | for scanner.Scan() { | |||
| 331 | + | line := strings.TrimSpace(scanner.Text()) | |||
| 332 | + | ||||
| 333 | + | // Skip comments and empty lines | |||
| 334 | + | if line == "" || strings.HasPrefix(line, "#") { | |||
| 335 | + | continue | |||
| 336 | + | } | |||
| 337 | + | ||||
| 338 | + | // Match LABEL instructions | |||
| 339 | + | if !strings.HasPrefix(strings.ToUpper(line), "LABEL ") { | |||
| 340 | + | continue | |||
| 341 | + | } | |||
| 342 | + | ||||
| 343 | + | // Parse "LABEL key=value" or "LABEL key=\"value\"" | |||
| 344 | + | labelContent := strings.TrimSpace(line[6:]) | |||
| 345 | + | for _, pair := range splitLabelPairs(labelContent) { | |||
| 346 | + | key, value, ok := parseLabelPair(pair) | |||
| 347 | + | if ok && strings.HasPrefix(key, "gordon.") { | |||
| 348 | + | labels[key] = value | |||
| 349 | + | } | |||
| 350 | + | } | |||
| 351 | + | } | |||
| 352 | + | ||||
| 353 | + | return labels | |||
| 354 | + | } | |||
| 355 | + | ||||
| 356 | + | // splitLabelPairs splits LABEL content into key=value pairs, | |||
| 357 | + | // handling quoted values that may contain spaces. | |||
| 358 | + | func splitLabelPairs(content string) []string { | |||
| 359 | + | var pairs []string | |||
| 360 | + | var current strings.Builder | |||
| 361 | + | inQuote := false | |||
| 362 | + | quoteChar := byte(0) | |||
| 363 | + | ||||
| 364 | + | for i := 0; i < len(content); i++ { | |||
| 365 | + | ch := content[i] | |||
| 366 | + | if inQuote { | |||
| 367 | + | current.WriteByte(ch) | |||
| 368 | + | if ch == quoteChar { | |||
| 369 | + | inQuote = false | |||
| 370 | + | } | |||
| 371 | + | } else if ch == '"' || ch == '\'' { | |||
| 372 | + | inQuote = true | |||
| 373 | + | quoteChar = ch | |||
| 374 | + | current.WriteByte(ch) | |||
| 375 | + | } else if ch == ' ' || ch == '\t' { | |||
| 376 | + | if current.Len() > 0 { | |||
| 377 | + | pairs = append(pairs, current.String()) | |||
| 378 | + | current.Reset() | |||
| 379 | + | } | |||
| 380 | + | } else { | |||
| 381 | + | current.WriteByte(ch) | |||
| 382 | + | } | |||
| 383 | + | } | |||
| 384 | + | if current.Len() > 0 { | |||
| 385 | + | pairs = append(pairs, current.String()) | |||
| 386 | + | } | |||
| 387 | + | ||||
| 388 | + | return pairs | |||
| 389 | + | } | |||
| 390 | + | ||||
| 391 | + | // parseLabelPair parses a single "key=value" or "key=\"value\"" pair. | |||
| 392 | + | func parseLabelPair(pair string) (key, value string, ok bool) { | |||
| 393 | + | idx := strings.Index(pair, "=") | |||
| 394 | + | if idx == -1 { | |||
| 395 | + | return "", "", false | |||
| 396 | + | } | |||
| 397 | + | key = pair[:idx] | |||
| 398 | + | value = pair[idx+1:] | |||
| 399 | + | // Strip surrounding quotes | |||
| 400 | + | value = strings.Trim(value, "\"'") | |||
| 401 | + | return key, value, true | |||
| 402 | + | } | |||
| 403 | + | ||||
| 138 | func determineVersion(ctx context.Context, tag string) string { | 404 | func determineVersion(ctx context.Context, tag string) string { | ||
| 139 | version := tag | 405 | version := tag | ||
| 140 | if version == "" { | 406 | if version == "" { | ||
M internal/adapters/in/cli/push_test.go +182 -0
| @@ -1,6 +1,8 @@ | |||
| 1 | 1 | package cli | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "os" | |
| 5 | + | "path/filepath" | |
| 4 | 6 | "testing" | |
| 5 | 7 | ||
| 6 | 8 | "github.com/stretchr/testify/assert" | |
| @@ -78,3 +80,183 @@ func TestBuildImageArgs_CustomDockerfile(t *testing.T) { | |||
| 78 | 80 | assert.Contains(t, args, "-f") | |
| 79 | 81 | assert.Contains(t, args, "docker/app/Dockerfile") | |
| 80 | 82 | } | |
| 83 | + | ||
| 84 | + | func TestParseDockerfileLabels(t *testing.T) { | |
| 85 | + | tests := []struct { | |
| 86 | + | name string | |
| 87 | + | content string | |
| 88 | + | expected map[string]string | |
| 89 | + | }{ | |
| 90 | + | { | |
| 91 | + | name: "simple label", | |
| 92 | + | content: `LABEL gordon.domain="myapp.example.com"`, | |
| 93 | + | expected: map[string]string{ | |
| 94 | + | "gordon.domain": "myapp.example.com", | |
| 95 | + | }, | |
| 96 | + | }, | |
| 97 | + | { | |
| 98 | + | name: "multiple labels", | |
| 99 | + | content: `LABEL gordon.domain="myapp.example.com" | |
| 100 | + | LABEL gordon.port="8080" | |
| 101 | + | LABEL gordon.health="/health"`, | |
| 102 | + | expected: map[string]string{ | |
| 103 | + | "gordon.domain": "myapp.example.com", | |
| 104 | + | "gordon.port": "8080", | |
| 105 | + | "gordon.health": "/health", | |
| 106 | + | }, | |
| 107 | + | }, | |
| 108 | + | { | |
| 109 | + | name: "multi-label on one line", | |
| 110 | + | content: `LABEL gordon.domain="myapp.example.com" gordon.port="8080"`, | |
| 111 | + | expected: map[string]string{ | |
| 112 | + | "gordon.domain": "myapp.example.com", | |
| 113 | + | "gordon.port": "8080", | |
| 114 | + | }, | |
| 115 | + | }, | |
| 116 | + | { | |
| 117 | + | name: "unquoted value", | |
| 118 | + | content: `LABEL gordon.domain=myapp.example.com`, | |
| 119 | + | expected: map[string]string{ | |
| 120 | + | "gordon.domain": "myapp.example.com", | |
| 121 | + | }, | |
| 122 | + | }, | |
| 123 | + | { | |
| 124 | + | name: "single-quoted value", | |
| 125 | + | content: `LABEL gordon.domain='myapp.example.com'`, | |
| 126 | + | expected: map[string]string{ | |
| 127 | + | "gordon.domain": "myapp.example.com", | |
| 128 | + | }, | |
| 129 | + | }, | |
| 130 | + | { | |
| 131 | + | name: "non-gordon labels ignored", | |
| 132 | + | content: `LABEL maintainer="John" gordon.domain="myapp.example.com"`, | |
| 133 | + | expected: map[string]string{ | |
| 134 | + | "gordon.domain": "myapp.example.com", | |
| 135 | + | }, | |
| 136 | + | }, | |
| 137 | + | { | |
| 138 | + | name: "comma-separated domains", | |
| 139 | + | content: `LABEL gordon.domains="app1.example.com,app2.example.com"`, | |
| 140 | + | expected: map[string]string{ | |
| 141 | + | "gordon.domains": "app1.example.com,app2.example.com", | |
| 142 | + | }, | |
| 143 | + | }, | |
| 144 | + | { | |
| 145 | + | name: "no labels", | |
| 146 | + | content: `FROM alpine:latest`, | |
| 147 | + | expected: map[string]string{}, | |
| 148 | + | }, | |
| 149 | + | { | |
| 150 | + | name: "comments and empty lines", | |
| 151 | + | content: "# This is a comment\n\nLABEL gordon.domain=\"test.com\"", | |
| 152 | + | expected: map[string]string{"gordon.domain": "test.com"}, | |
| 153 | + | }, | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | for _, tt := range tests { | |
| 157 | + | t.Run(tt.name, func(t *testing.T) { | |
| 158 | + | // Write a temp Dockerfile | |
| 159 | + | dir := t.TempDir() | |
| 160 | + | dockerfile := filepath.Join(dir, "Dockerfile") | |
| 161 | + | err := os.WriteFile(dockerfile, []byte(tt.content), 0644) | |
| 162 | + | assert.NoError(t, err) | |
| 163 | + | ||
| 164 | + | labels := parseDockerfileLabels(dockerfile) | |
| 165 | + | assert.Equal(t, tt.expected, labels) | |
| 166 | + | }) | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | func TestParseDockerfileLabels_NonExistent(t *testing.T) { | |
| 171 | + | labels := parseDockerfileLabels("/nonexistent/Dockerfile") | |
| 172 | + | assert.Empty(t, labels) | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | func TestDetectImageName_FromDockerfileLabels(t *testing.T) { | |
| 176 | + | dir := t.TempDir() | |
| 177 | + | dockerfile := filepath.Join(dir, "Dockerfile") | |
| 178 | + | err := os.WriteFile(dockerfile, []byte(`FROM alpine | |
| 179 | + | LABEL gordon.domain="myapp.example.com" | |
| 180 | + | `), 0644) | |
| 181 | + | assert.NoError(t, err) | |
| 182 | + | ||
| 183 | + | name, err := detectImageName(dockerfile) | |
| 184 | + | assert.NoError(t, err) | |
| 185 | + | assert.Equal(t, "myapp.example.com", name) | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | func TestDetectImageName_FallbackToDir(t *testing.T) { | |
| 189 | + | dir := t.TempDir() | |
| 190 | + | dockerfile := filepath.Join(dir, "Dockerfile") | |
| 191 | + | err := os.WriteFile(dockerfile, []byte(`FROM alpine | |
| 192 | + | `), 0644) | |
| 193 | + | assert.NoError(t, err) | |
| 194 | + | ||
| 195 | + | // When no gordon.domain label, falls back to cwd basename | |
| 196 | + | name, err := detectImageName(dockerfile) | |
| 197 | + | assert.NoError(t, err) | |
| 198 | + | assert.NotEmpty(t, name) | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | func TestSplitLabelPairs(t *testing.T) { | |
| 202 | + | tests := []struct { | |
| 203 | + | name string | |
| 204 | + | content string | |
| 205 | + | expected []string | |
| 206 | + | }{ | |
| 207 | + | { | |
| 208 | + | name: "single pair", | |
| 209 | + | content: `gordon.domain="test.com"`, | |
| 210 | + | expected: []string{`gordon.domain="test.com"`}, | |
| 211 | + | }, | |
| 212 | + | { | |
| 213 | + | name: "two pairs", | |
| 214 | + | content: `gordon.domain="test.com" gordon.port="8080"`, | |
| 215 | + | expected: []string{`gordon.domain="test.com"`, `gordon.port="8080"`}, | |
| 216 | + | }, | |
| 217 | + | { | |
| 218 | + | name: "unquoted", | |
| 219 | + | content: `gordon.domain=test.com gordon.port=8080`, | |
| 220 | + | expected: []string{`gordon.domain=test.com`, `gordon.port=8080`}, | |
| 221 | + | }, | |
| 222 | + | { | |
| 223 | + | name: "quoted with spaces in value", | |
| 224 | + | content: `gordon.domain="my app.com"`, | |
| 225 | + | expected: []string{`gordon.domain="my app.com"`}, | |
| 226 | + | }, | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | for _, tt := range tests { | |
| 230 | + | t.Run(tt.name, func(t *testing.T) { | |
| 231 | + | result := splitLabelPairs(tt.content) | |
| 232 | + | assert.Equal(t, tt.expected, result) | |
| 233 | + | }) | |
| 234 | + | } | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | func TestParseLabelPair(t *testing.T) { | |
| 238 | + | tests := []struct { | |
| 239 | + | name string | |
| 240 | + | pair string | |
| 241 | + | wantKey string | |
| 242 | + | wantValue string | |
| 243 | + | wantOk bool | |
| 244 | + | }{ | |
| 245 | + | {"quoted", `gordon.domain="test.com"`, "gordon.domain", "test.com", true}, | |
| 246 | + | {"unquoted", `gordon.domain=test.com`, "gordon.domain", "test.com", true}, | |
| 247 | + | {"single quoted", `gordon.domain='test.com'`, "gordon.domain", "test.com", true}, | |
| 248 | + | {"empty value", `gordon.domain=`, "gordon.domain", "", true}, | |
| 249 | + | {"no equals", `gordon.domain`, "", "", false}, | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | for _, tt := range tests { | |
| 253 | + | t.Run(tt.name, func(t *testing.T) { | |
| 254 | + | key, value, ok := parseLabelPair(tt.pair) | |
| 255 | + | assert.Equal(t, tt.wantOk, ok) | |
| 256 | + | if ok { | |
| 257 | + | assert.Equal(t, tt.wantKey, key) | |
| 258 | + | assert.Equal(t, tt.wantValue, value) | |
| 259 | + | } | |
| 260 | + | }) | |
| 261 | + | } | |
| 262 | + | } | |
| @@ -1,6 +1,8 @@ | |||||
| 1 | package cli | 1 | package cli | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "os" | |||
| 5 | + | "path/filepath" | |||
| 4 | "testing" | 6 | "testing" | ||
| 5 | 7 | ||||
| 6 | "github.com/stretchr/testify/assert" | 8 | "github.com/stretchr/testify/assert" | ||
| @@ -78,3 +80,183 @@ func TestBuildImageArgs_CustomDockerfile(t *testing.T) { | |||||
| 78 | assert.Contains(t, args, "-f") | 80 | assert.Contains(t, args, "-f") | ||
| 79 | assert.Contains(t, args, "docker/app/Dockerfile") | 81 | assert.Contains(t, args, "docker/app/Dockerfile") | ||
| 80 | } | 82 | } | ||
| 83 | + | ||||
| 84 | + | func TestParseDockerfileLabels(t *testing.T) { | |||
| 85 | + | tests := []struct { | |||
| 86 | + | name string | |||
| 87 | + | content string | |||
| 88 | + | expected map[string]string | |||
| 89 | + | }{ | |||
| 90 | + | { | |||
| 91 | + | name: "simple label", | |||
| 92 | + | content: `LABEL gordon.domain="myapp.example.com"`, | |||
| 93 | + | expected: map[string]string{ | |||
| 94 | + | "gordon.domain": "myapp.example.com", | |||
| 95 | + | }, | |||
| 96 | + | }, | |||
| 97 | + | { | |||
| 98 | + | name: "multiple labels", | |||
| 99 | + | content: `LABEL gordon.domain="myapp.example.com" | |||
| 100 | + | LABEL gordon.port="8080" | |||
| 101 | + | LABEL gordon.health="/health"`, | |||
| 102 | + | expected: map[string]string{ | |||
| 103 | + | "gordon.domain": "myapp.example.com", | |||
| 104 | + | "gordon.port": "8080", | |||
| 105 | + | "gordon.health": "/health", | |||
| 106 | + | }, | |||
| 107 | + | }, | |||
| 108 | + | { | |||
| 109 | + | name: "multi-label on one line", | |||
| 110 | + | content: `LABEL gordon.domain="myapp.example.com" gordon.port="8080"`, | |||
| 111 | + | expected: map[string]string{ | |||
| 112 | + | "gordon.domain": "myapp.example.com", | |||
| 113 | + | "gordon.port": "8080", | |||
| 114 | + | }, | |||
| 115 | + | }, | |||
| 116 | + | { | |||
| 117 | + | name: "unquoted value", | |||
| 118 | + | content: `LABEL gordon.domain=myapp.example.com`, | |||
| 119 | + | expected: map[string]string{ | |||
| 120 | + | "gordon.domain": "myapp.example.com", | |||
| 121 | + | }, | |||
| 122 | + | }, | |||
| 123 | + | { | |||
| 124 | + | name: "single-quoted value", | |||
| 125 | + | content: `LABEL gordon.domain='myapp.example.com'`, | |||
| 126 | + | expected: map[string]string{ | |||
| 127 | + | "gordon.domain": "myapp.example.com", | |||
| 128 | + | }, | |||
| 129 | + | }, | |||
| 130 | + | { | |||
| 131 | + | name: "non-gordon labels ignored", | |||
| 132 | + | content: `LABEL maintainer="John" gordon.domain="myapp.example.com"`, | |||
| 133 | + | expected: map[string]string{ | |||
| 134 | + | "gordon.domain": "myapp.example.com", | |||
| 135 | + | }, | |||
| 136 | + | }, | |||
| 137 | + | { | |||
| 138 | + | name: "comma-separated domains", | |||
| 139 | + | content: `LABEL gordon.domains="app1.example.com,app2.example.com"`, | |||
| 140 | + | expected: map[string]string{ | |||
| 141 | + | "gordon.domains": "app1.example.com,app2.example.com", | |||
| 142 | + | }, | |||
| 143 | + | }, | |||
| 144 | + | { | |||
| 145 | + | name: "no labels", | |||
| 146 | + | content: `FROM alpine:latest`, | |||
| 147 | + | expected: map[string]string{}, | |||
| 148 | + | }, | |||
| 149 | + | { | |||
| 150 | + | name: "comments and empty lines", | |||
| 151 | + | content: "# This is a comment\n\nLABEL gordon.domain=\"test.com\"", | |||
| 152 | + | expected: map[string]string{"gordon.domain": "test.com"}, | |||
| 153 | + | }, | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | for _, tt := range tests { | |||
| 157 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 158 | + | // Write a temp Dockerfile | |||
| 159 | + | dir := t.TempDir() | |||
| 160 | + | dockerfile := filepath.Join(dir, "Dockerfile") | |||
| 161 | + | err := os.WriteFile(dockerfile, []byte(tt.content), 0644) | |||
| 162 | + | assert.NoError(t, err) | |||
| 163 | + | ||||
| 164 | + | labels := parseDockerfileLabels(dockerfile) | |||
| 165 | + | assert.Equal(t, tt.expected, labels) | |||
| 166 | + | }) | |||
| 167 | + | } | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | func TestParseDockerfileLabels_NonExistent(t *testing.T) { | |||
| 171 | + | labels := parseDockerfileLabels("/nonexistent/Dockerfile") | |||
| 172 | + | assert.Empty(t, labels) | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | func TestDetectImageName_FromDockerfileLabels(t *testing.T) { | |||
| 176 | + | dir := t.TempDir() | |||
| 177 | + | dockerfile := filepath.Join(dir, "Dockerfile") | |||
| 178 | + | err := os.WriteFile(dockerfile, []byte(`FROM alpine | |||
| 179 | + | LABEL gordon.domain="myapp.example.com" | |||
| 180 | + | `), 0644) | |||
| 181 | + | assert.NoError(t, err) | |||
| 182 | + | ||||
| 183 | + | name, err := detectImageName(dockerfile) | |||
| 184 | + | assert.NoError(t, err) | |||
| 185 | + | assert.Equal(t, "myapp.example.com", name) | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | func TestDetectImageName_FallbackToDir(t *testing.T) { | |||
| 189 | + | dir := t.TempDir() | |||
| 190 | + | dockerfile := filepath.Join(dir, "Dockerfile") | |||
| 191 | + | err := os.WriteFile(dockerfile, []byte(`FROM alpine | |||
| 192 | + | `), 0644) | |||
| 193 | + | assert.NoError(t, err) | |||
| 194 | + | ||||
| 195 | + | // When no gordon.domain label, falls back to cwd basename | |||
| 196 | + | name, err := detectImageName(dockerfile) | |||
| 197 | + | assert.NoError(t, err) | |||
| 198 | + | assert.NotEmpty(t, name) | |||
| 199 | + | } | |||
| 200 | + | ||||
| 201 | + | func TestSplitLabelPairs(t *testing.T) { | |||
| 202 | + | tests := []struct { | |||
| 203 | + | name string | |||
| 204 | + | content string | |||
| 205 | + | expected []string | |||
| 206 | + | }{ | |||
| 207 | + | { | |||
| 208 | + | name: "single pair", | |||
| 209 | + | content: `gordon.domain="test.com"`, | |||
| 210 | + | expected: []string{`gordon.domain="test.com"`}, | |||
| 211 | + | }, | |||
| 212 | + | { | |||
| 213 | + | name: "two pairs", | |||
| 214 | + | content: `gordon.domain="test.com" gordon.port="8080"`, | |||
| 215 | + | expected: []string{`gordon.domain="test.com"`, `gordon.port="8080"`}, | |||
| 216 | + | }, | |||
| 217 | + | { | |||
| 218 | + | name: "unquoted", | |||
| 219 | + | content: `gordon.domain=test.com gordon.port=8080`, | |||
| 220 | + | expected: []string{`gordon.domain=test.com`, `gordon.port=8080`}, | |||
| 221 | + | }, | |||
| 222 | + | { | |||
| 223 | + | name: "quoted with spaces in value", | |||
| 224 | + | content: `gordon.domain="my app.com"`, | |||
| 225 | + | expected: []string{`gordon.domain="my app.com"`}, | |||
| 226 | + | }, | |||
| 227 | + | } | |||
| 228 | + | ||||
| 229 | + | for _, tt := range tests { | |||
| 230 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 231 | + | result := splitLabelPairs(tt.content) | |||
| 232 | + | assert.Equal(t, tt.expected, result) | |||
| 233 | + | }) | |||
| 234 | + | } | |||
| 235 | + | } | |||
| 236 | + | ||||
| 237 | + | func TestParseLabelPair(t *testing.T) { | |||
| 238 | + | tests := []struct { | |||
| 239 | + | name string | |||
| 240 | + | pair string | |||
| 241 | + | wantKey string | |||
| 242 | + | wantValue string | |||
| 243 | + | wantOk bool | |||
| 244 | + | }{ | |||
| 245 | + | {"quoted", `gordon.domain="test.com"`, "gordon.domain", "test.com", true}, | |||
| 246 | + | {"unquoted", `gordon.domain=test.com`, "gordon.domain", "test.com", true}, | |||
| 247 | + | {"single quoted", `gordon.domain='test.com'`, "gordon.domain", "test.com", true}, | |||
| 248 | + | {"empty value", `gordon.domain=`, "gordon.domain", "", true}, | |||
| 249 | + | {"no equals", `gordon.domain`, "", "", false}, | |||
| 250 | + | } | |||
| 251 | + | ||||
| 252 | + | for _, tt := range tests { | |||
| 253 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 254 | + | key, value, ok := parseLabelPair(tt.pair) | |||
| 255 | + | assert.Equal(t, tt.wantOk, ok) | |||
| 256 | + | if ok { | |||
| 257 | + | assert.Equal(t, tt.wantKey, key) | |||
| 258 | + | assert.Equal(t, tt.wantValue, value) | |||
| 259 | + | } | |||
| 260 | + | }) | |||
| 261 | + | } | |||
| 262 | + | } | |||
M internal/adapters/in/cli/remote/client.go +18 -0
| @@ -322,6 +322,24 @@ func (c *Client) GetRoute(ctx context.Context, routeDomain string) (*domain.Rout | |||
| 322 | 322 | return &route, nil | |
| 323 | 323 | } | |
| 324 | 324 | ||
| 325 | + | // FindRoutesByImage returns all routes associated with the given image name. | |
| 326 | + | func (c *Client) FindRoutesByImage(ctx context.Context, imageName string) ([]domain.Route, error) { | |
| 327 | + | resp, err := c.request(ctx, http.MethodGet, "/routes/by-image/"+url.PathEscape(imageName), nil) | |
| 328 | + | if err != nil { | |
| 329 | + | return nil, err | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | var result struct { | |
| 333 | + | Image string `json:"image"` | |
| 334 | + | Routes []domain.Route `json:"routes"` | |
| 335 | + | } | |
| 336 | + | if err := parseResponse(resp, &result); err != nil { | |
| 337 | + | return nil, err | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | return result.Routes, nil | |
| 341 | + | } | |
| 342 | + | ||
| 325 | 343 | // AddRoute adds a new route. | |
| 326 | 344 | func (c *Client) AddRoute(ctx context.Context, route domain.Route) error { | |
| 327 | 345 | resp, err := c.request(ctx, http.MethodPost, "/routes", route) | |
| @@ -322,6 +322,24 @@ func (c *Client) GetRoute(ctx context.Context, routeDomain string) (*domain.Rout | |||||
| 322 | return &route, nil | 322 | return &route, nil | ||
| 323 | } | 323 | } | ||
| 324 | 324 | ||||
| 325 | + | // FindRoutesByImage returns all routes associated with the given image name. | |||
| 326 | + | func (c *Client) FindRoutesByImage(ctx context.Context, imageName string) ([]domain.Route, error) { | |||
| 327 | + | resp, err := c.request(ctx, http.MethodGet, "/routes/by-image/"+url.PathEscape(imageName), nil) | |||
| 328 | + | if err != nil { | |||
| 329 | + | return nil, err | |||
| 330 | + | } | |||
| 331 | + | ||||
| 332 | + | var result struct { | |||
| 333 | + | Image string `json:"image"` | |||
| 334 | + | Routes []domain.Route `json:"routes"` | |||
| 335 | + | } | |||
| 336 | + | if err := parseResponse(resp, &result); err != nil { | |||
| 337 | + | return nil, err | |||
| 338 | + | } | |||
| 339 | + | ||||
| 340 | + | return result.Routes, nil | |||
| 341 | + | } | |||
| 342 | + | ||||
| 325 | // AddRoute adds a new route. | 343 | // AddRoute adds a new route. | ||
| 326 | func (c *Client) AddRoute(ctx context.Context, route domain.Route) error { | 344 | func (c *Client) AddRoute(ctx context.Context, route domain.Route) error { | ||
| 327 | resp, err := c.request(ctx, http.MethodPost, "/routes", route) | 345 | resp, err := c.request(ctx, http.MethodPost, "/routes", route) | ||
M internal/adapters/in/http/admin/handler.go +35 -0
| @@ -210,6 +210,7 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) { | |||
| 210 | 210 | }{ | |
| 211 | 211 | {"/backups", h.handleBackups}, | |
| 212 | 212 | {"/attachments", h.handleAttachmentsConfig}, | |
| 213 | + | {"/routes/by-image", h.handleRoutesByImage}, | |
| 213 | 214 | {"/routes", h.handleRoutes}, | |
| 214 | 215 | {"/secrets", h.handleSecrets}, | |
| 215 | 216 | {"/deploy", h.handleDeploy}, | |
| @@ -453,6 +454,40 @@ func (h *Handler) handleRoutesDelete(w http.ResponseWriter, r *http.Request, rou | |||
| 453 | 454 | h.sendJSON(w, http.StatusOK, dto.RouteDeleteResponse{Status: "removed"}) | |
| 454 | 455 | } | |
| 455 | 456 | ||
| 457 | + | // handleRoutesByImage handles GET /admin/routes/by-image/{image} endpoint. | |
| 458 | + | // Returns all routes associated with the given image name. | |
| 459 | + | func (h *Handler) handleRoutesByImage(w http.ResponseWriter, r *http.Request, path string) { | |
| 460 | + | if r.Method != http.MethodGet { | |
| 461 | + | h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") | |
| 462 | + | return | |
| 463 | + | } | |
| 464 | + | ||
| 465 | + | ctx := r.Context() | |
| 466 | + | ||
| 467 | + | if !HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionRead) { | |
| 468 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:read") | |
| 469 | + | return | |
| 470 | + | } | |
| 471 | + | ||
| 472 | + | imageName := strings.TrimPrefix(path, "/routes/by-image/") | |
| 473 | + | if imageName == "" || imageName == "/routes/by-image" { | |
| 474 | + | h.sendError(w, http.StatusBadRequest, "image name required in path") | |
| 475 | + | return | |
| 476 | + | } | |
| 477 | + | ||
| 478 | + | routes := h.configSvc.FindRoutesByImage(ctx, imageName) | |
| 479 | + | ||
| 480 | + | response := make([]routeResponse, 0, len(routes)) | |
| 481 | + | for _, route := range routes { | |
| 482 | + | response = append(response, toRouteResponse(route)) | |
| 483 | + | } | |
| 484 | + | ||
| 485 | + | h.sendJSON(w, http.StatusOK, dto.RoutesByImageResponse{ | |
| 486 | + | Image: imageName, | |
| 487 | + | Routes: response, | |
| 488 | + | }) | |
| 489 | + | } | |
| 490 | + | ||
| 456 | 491 | func (h *Handler) handleNetworks(w http.ResponseWriter, r *http.Request) { | |
| 457 | 492 | if r.Method != http.MethodGet { | |
| 458 | 493 | h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") | |
| @@ -210,6 +210,7 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) { | |||||
| 210 | }{ | 210 | }{ | ||
| 211 | {"/backups", h.handleBackups}, | 211 | {"/backups", h.handleBackups}, | ||
| 212 | {"/attachments", h.handleAttachmentsConfig}, | 212 | {"/attachments", h.handleAttachmentsConfig}, | ||
| 213 | + | {"/routes/by-image", h.handleRoutesByImage}, | |||
| 213 | {"/routes", h.handleRoutes}, | 214 | {"/routes", h.handleRoutes}, | ||
| 214 | {"/secrets", h.handleSecrets}, | 215 | {"/secrets", h.handleSecrets}, | ||
| 215 | {"/deploy", h.handleDeploy}, | 216 | {"/deploy", h.handleDeploy}, | ||
| @@ -453,6 +454,40 @@ func (h *Handler) handleRoutesDelete(w http.ResponseWriter, r *http.Request, rou | |||||
| 453 | h.sendJSON(w, http.StatusOK, dto.RouteDeleteResponse{Status: "removed"}) | 454 | h.sendJSON(w, http.StatusOK, dto.RouteDeleteResponse{Status: "removed"}) | ||
| 454 | } | 455 | } | ||
| 455 | 456 | ||||
| 457 | + | // handleRoutesByImage handles GET /admin/routes/by-image/{image} endpoint. | |||
| 458 | + | // Returns all routes associated with the given image name. | |||
| 459 | + | func (h *Handler) handleRoutesByImage(w http.ResponseWriter, r *http.Request, path string) { | |||
| 460 | + | if r.Method != http.MethodGet { | |||
| 461 | + | h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") | |||
| 462 | + | return | |||
| 463 | + | } | |||
| 464 | + | ||||
| 465 | + | ctx := r.Context() | |||
| 466 | + | ||||
| 467 | + | if !HasAccess(ctx, domain.AdminResourceRoutes, domain.AdminActionRead) { | |||
| 468 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for routes:read") | |||
| 469 | + | return | |||
| 470 | + | } | |||
| 471 | + | ||||
| 472 | + | imageName := strings.TrimPrefix(path, "/routes/by-image/") | |||
| 473 | + | if imageName == "" || imageName == "/routes/by-image" { | |||
| 474 | + | h.sendError(w, http.StatusBadRequest, "image name required in path") | |||
| 475 | + | return | |||
| 476 | + | } | |||
| 477 | + | ||||
| 478 | + | routes := h.configSvc.FindRoutesByImage(ctx, imageName) | |||
| 479 | + | ||||
| 480 | + | response := make([]routeResponse, 0, len(routes)) | |||
| 481 | + | for _, route := range routes { | |||
| 482 | + | response = append(response, toRouteResponse(route)) | |||
| 483 | + | } | |||
| 484 | + | ||||
| 485 | + | h.sendJSON(w, http.StatusOK, dto.RoutesByImageResponse{ | |||
| 486 | + | Image: imageName, | |||
| 487 | + | Routes: response, | |||
| 488 | + | }) | |||
| 489 | + | } | |||
| 490 | + | ||||
| 456 | func (h *Handler) handleNetworks(w http.ResponseWriter, r *http.Request) { | 491 | func (h *Handler) handleNetworks(w http.ResponseWriter, r *http.Request) { | ||
| 457 | if r.Method != http.MethodGet { | 492 | if r.Method != http.MethodGet { | ||
| 458 | h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") | 493 | h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") | ||
M internal/boundaries/in/config.go +4 -0
| @@ -21,6 +21,10 @@ type ConfigService interface { | |||
| 21 | 21 | // GetRoute returns a single route by domain. | |
| 22 | 22 | GetRoute(ctx context.Context, domain string) (*domain.Route, error) | |
| 23 | 23 | ||
| 24 | + | // FindRoutesByImage returns all routes that match the given image name. | |
| 25 | + | // Image names are normalized by stripping the registry domain prefix before comparison. | |
| 26 | + | FindRoutesByImage(ctx context.Context, imageName string) []domain.Route | |
| 27 | + | ||
| 24 | 28 | // AddRoute adds a new route to the configuration. | |
| 25 | 29 | AddRoute(ctx context.Context, route domain.Route) error | |
| 26 | 30 | ||
| @@ -21,6 +21,10 @@ type ConfigService interface { | |||||
| 21 | // GetRoute returns a single route by domain. | 21 | // GetRoute returns a single route by domain. | ||
| 22 | GetRoute(ctx context.Context, domain string) (*domain.Route, error) | 22 | GetRoute(ctx context.Context, domain string) (*domain.Route, error) | ||
| 23 | 23 | ||||
| 24 | + | // FindRoutesByImage returns all routes that match the given image name. | |||
| 25 | + | // Image names are normalized by stripping the registry domain prefix before comparison. | |||
| 26 | + | FindRoutesByImage(ctx context.Context, imageName string) []domain.Route | |||
| 27 | + | ||||
| 24 | // AddRoute adds a new route to the configuration. | 28 | // AddRoute adds a new route to the configuration. | ||
| 25 | AddRoute(ctx context.Context, route domain.Route) error | 29 | AddRoute(ctx context.Context, route domain.Route) error | ||
| 26 | 30 | ||||
M internal/boundaries/in/mocks/mock_config_service.go +59 -0
| @@ -158,6 +158,65 @@ func (_c *MockConfigService_AddRoute_Call) RunAndReturn(run func(ctx context.Con | |||
| 158 | 158 | return _c | |
| 159 | 159 | } | |
| 160 | 160 | ||
| 161 | + | // FindRoutesByImage provides a mock function for the type MockConfigService | |
| 162 | + | func (_mock *MockConfigService) FindRoutesByImage(ctx context.Context, imageName string) []domain.Route { | |
| 163 | + | ret := _mock.Called(ctx, imageName) | |
| 164 | + | ||
| 165 | + | if len(ret) == 0 { | |
| 166 | + | panic("no return value specified for FindRoutesByImage") | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | var r0 []domain.Route | |
| 170 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) []domain.Route); ok { | |
| 171 | + | r0 = returnFunc(ctx, imageName) | |
| 172 | + | } else { | |
| 173 | + | if ret.Get(0) != nil { | |
| 174 | + | r0 = ret.Get(0).([]domain.Route) | |
| 175 | + | } | |
| 176 | + | } | |
| 177 | + | return r0 | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | // MockConfigService_FindRoutesByImage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindRoutesByImage' | |
| 181 | + | type MockConfigService_FindRoutesByImage_Call struct { | |
| 182 | + | *mock.Call | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | // FindRoutesByImage is a helper method to define mock.On call | |
| 186 | + | // - ctx context.Context | |
| 187 | + | // - imageName string | |
| 188 | + | func (_e *MockConfigService_Expecter) FindRoutesByImage(ctx interface{}, imageName interface{}) *MockConfigService_FindRoutesByImage_Call { | |
| 189 | + | return &MockConfigService_FindRoutesByImage_Call{Call: _e.mock.On("FindRoutesByImage", ctx, imageName)} | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | func (_c *MockConfigService_FindRoutesByImage_Call) Run(run func(ctx context.Context, imageName string)) *MockConfigService_FindRoutesByImage_Call { | |
| 193 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 194 | + | var arg0 context.Context | |
| 195 | + | if args[0] != nil { | |
| 196 | + | arg0 = args[0].(context.Context) | |
| 197 | + | } | |
| 198 | + | var arg1 string | |
| 199 | + | if args[1] != nil { | |
| 200 | + | arg1 = args[1].(string) | |
| 201 | + | } | |
| 202 | + | run( | |
| 203 | + | arg0, | |
| 204 | + | arg1, | |
| 205 | + | ) | |
| 206 | + | }) | |
| 207 | + | return _c | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | func (_c *MockConfigService_FindRoutesByImage_Call) Return(routes []domain.Route) *MockConfigService_FindRoutesByImage_Call { | |
| 211 | + | _c.Call.Return(routes) | |
| 212 | + | return _c | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | func (_c *MockConfigService_FindRoutesByImage_Call) RunAndReturn(run func(ctx context.Context, imageName string) []domain.Route) *MockConfigService_FindRoutesByImage_Call { | |
| 216 | + | _c.Call.Return(run) | |
| 217 | + | return _c | |
| 218 | + | } | |
| 219 | + | ||
| 161 | 220 | // GetAllAttachments provides a mock function for the type MockConfigService | |
| 162 | 221 | func (_mock *MockConfigService) GetAllAttachments(ctx context.Context) map[string][]string { | |
| 163 | 222 | ret := _mock.Called(ctx) | |
| @@ -158,6 +158,65 @@ func (_c *MockConfigService_AddRoute_Call) RunAndReturn(run func(ctx context.Con | |||||
| 158 | return _c | 158 | return _c | ||
| 159 | } | 159 | } | ||
| 160 | 160 | ||||
| 161 | + | // FindRoutesByImage provides a mock function for the type MockConfigService | |||
| 162 | + | func (_mock *MockConfigService) FindRoutesByImage(ctx context.Context, imageName string) []domain.Route { | |||
| 163 | + | ret := _mock.Called(ctx, imageName) | |||
| 164 | + | ||||
| 165 | + | if len(ret) == 0 { | |||
| 166 | + | panic("no return value specified for FindRoutesByImage") | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | var r0 []domain.Route | |||
| 170 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) []domain.Route); ok { | |||
| 171 | + | r0 = returnFunc(ctx, imageName) | |||
| 172 | + | } else { | |||
| 173 | + | if ret.Get(0) != nil { | |||
| 174 | + | r0 = ret.Get(0).([]domain.Route) | |||
| 175 | + | } | |||
| 176 | + | } | |||
| 177 | + | return r0 | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | // MockConfigService_FindRoutesByImage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'FindRoutesByImage' | |||
| 181 | + | type MockConfigService_FindRoutesByImage_Call struct { | |||
| 182 | + | *mock.Call | |||
| 183 | + | } | |||
| 184 | + | ||||
| 185 | + | // FindRoutesByImage is a helper method to define mock.On call | |||
| 186 | + | // - ctx context.Context | |||
| 187 | + | // - imageName string | |||
| 188 | + | func (_e *MockConfigService_Expecter) FindRoutesByImage(ctx interface{}, imageName interface{}) *MockConfigService_FindRoutesByImage_Call { | |||
| 189 | + | return &MockConfigService_FindRoutesByImage_Call{Call: _e.mock.On("FindRoutesByImage", ctx, imageName)} | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | func (_c *MockConfigService_FindRoutesByImage_Call) Run(run func(ctx context.Context, imageName string)) *MockConfigService_FindRoutesByImage_Call { | |||
| 193 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 194 | + | var arg0 context.Context | |||
| 195 | + | if args[0] != nil { | |||
| 196 | + | arg0 = args[0].(context.Context) | |||
| 197 | + | } | |||
| 198 | + | var arg1 string | |||
| 199 | + | if args[1] != nil { | |||
| 200 | + | arg1 = args[1].(string) | |||
| 201 | + | } | |||
| 202 | + | run( | |||
| 203 | + | arg0, | |||
| 204 | + | arg1, | |||
| 205 | + | ) | |||
| 206 | + | }) | |||
| 207 | + | return _c | |||
| 208 | + | } | |||
| 209 | + | ||||
| 210 | + | func (_c *MockConfigService_FindRoutesByImage_Call) Return(routes []domain.Route) *MockConfigService_FindRoutesByImage_Call { | |||
| 211 | + | _c.Call.Return(routes) | |||
| 212 | + | return _c | |||
| 213 | + | } | |||
| 214 | + | ||||
| 215 | + | func (_c *MockConfigService_FindRoutesByImage_Call) RunAndReturn(run func(ctx context.Context, imageName string) []domain.Route) *MockConfigService_FindRoutesByImage_Call { | |||
| 216 | + | _c.Call.Return(run) | |||
| 217 | + | return _c | |||
| 218 | + | } | |||
| 219 | + | ||||
| 161 | // GetAllAttachments provides a mock function for the type MockConfigService | 220 | // GetAllAttachments provides a mock function for the type MockConfigService | ||
| 162 | func (_mock *MockConfigService) GetAllAttachments(ctx context.Context) map[string][]string { | 221 | func (_mock *MockConfigService) GetAllAttachments(ctx context.Context) map[string][]string { | ||
| 163 | ret := _mock.Called(ctx) | 222 | ret := _mock.Called(ctx) | ||
M internal/usecase/config/service.go +66 -0
| @@ -226,6 +226,72 @@ func (s *Service) GetRoute(_ context.Context, domainName string) (*domain.Route, | |||
| 226 | 226 | return route, nil | |
| 227 | 227 | } | |
| 228 | 228 | ||
| 229 | + | // FindRoutesByImage returns all routes whose image matches the given image name. | |
| 230 | + | // Image names are normalized by stripping the registry domain prefix before comparison. | |
| 231 | + | // When the input has no tag, only the name portion is compared (i.e. "myapp" matches "myapp:latest"). | |
| 232 | + | func (s *Service) FindRoutesByImage(_ context.Context, imageName string) []domain.Route { | |
| 233 | + | s.mu.RLock() | |
| 234 | + | defer s.mu.RUnlock() | |
| 235 | + | ||
| 236 | + | registryDomain := s.config.RegistryDomain | |
| 237 | + | normalizedImage := NormalizeRegistryImage(imageName, registryDomain) | |
| 238 | + | inputName, inputHasTag := splitImageNameTag(normalizedImage) | |
| 239 | + | ||
| 240 | + | var routes []domain.Route | |
| 241 | + | for domainName, image := range s.config.Routes { | |
| 242 | + | normalizedRouteImage := NormalizeRegistryImage(image, registryDomain) | |
| 243 | + | ||
| 244 | + | match := false | |
| 245 | + | if inputHasTag { | |
| 246 | + | // Exact match when the caller provided a tag (e.g. event handler). | |
| 247 | + | match = strings.EqualFold(normalizedRouteImage, normalizedImage) | |
| 248 | + | } else { | |
| 249 | + | // Name-only match when no tag was provided (e.g. CLI push). | |
| 250 | + | routeName, _ := splitImageNameTag(normalizedRouteImage) | |
| 251 | + | match = strings.EqualFold(routeName, inputName) | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | if match { | |
| 255 | + | route := domain.Route{ | |
| 256 | + | Image: image, | |
| 257 | + | HTTPS: true, | |
| 258 | + | } | |
| 259 | + | if strings.HasPrefix(domainName, "http://") { | |
| 260 | + | route.Domain = strings.TrimPrefix(domainName, "http://") | |
| 261 | + | route.HTTPS = false | |
| 262 | + | } else { | |
| 263 | + | route.Domain = domainName | |
| 264 | + | } | |
| 265 | + | routes = append(routes, route) | |
| 266 | + | } | |
| 267 | + | } | |
| 268 | + | ||
| 269 | + | return routes | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | // splitImageNameTag splits "name:tag" into ("name", true) or ("name", false) when no tag is present. | |
| 273 | + | func splitImageNameTag(image string) (name string, hasTag bool) { | |
| 274 | + | if idx := strings.LastIndex(image, ":"); idx != -1 { | |
| 275 | + | return image[:idx], true | |
| 276 | + | } | |
| 277 | + | return image, false | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | // NormalizeRegistryImage strips the registry domain prefix from an image name for comparison. | |
| 281 | + | func NormalizeRegistryImage(imageName, registryDomain string) string { | |
| 282 | + | registryDomain = strings.TrimSuffix(registryDomain, "/") | |
| 283 | + | if registryDomain == "" { | |
| 284 | + | return imageName | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | prefix := registryDomain + "/" | |
| 288 | + | if strings.HasPrefix(imageName, prefix) { | |
| 289 | + | return strings.TrimPrefix(imageName, prefix) | |
| 290 | + | } | |
| 291 | + | ||
| 292 | + | return imageName | |
| 293 | + | } | |
| 294 | + | ||
| 229 | 295 | // AddRoute adds a new route to the configuration and persists it. | |
| 230 | 296 | func (s *Service) AddRoute(ctx context.Context, route domain.Route) error { | |
| 231 | 297 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | |
| @@ -226,6 +226,72 @@ func (s *Service) GetRoute(_ context.Context, domainName string) (*domain.Route, | |||||
| 226 | return route, nil | 226 | return route, nil | ||
| 227 | } | 227 | } | ||
| 228 | 228 | ||||
| 229 | + | // FindRoutesByImage returns all routes whose image matches the given image name. | |||
| 230 | + | // Image names are normalized by stripping the registry domain prefix before comparison. | |||
| 231 | + | // When the input has no tag, only the name portion is compared (i.e. "myapp" matches "myapp:latest"). | |||
| 232 | + | func (s *Service) FindRoutesByImage(_ context.Context, imageName string) []domain.Route { | |||
| 233 | + | s.mu.RLock() | |||
| 234 | + | defer s.mu.RUnlock() | |||
| 235 | + | ||||
| 236 | + | registryDomain := s.config.RegistryDomain | |||
| 237 | + | normalizedImage := NormalizeRegistryImage(imageName, registryDomain) | |||
| 238 | + | inputName, inputHasTag := splitImageNameTag(normalizedImage) | |||
| 239 | + | ||||
| 240 | + | var routes []domain.Route | |||
| 241 | + | for domainName, image := range s.config.Routes { | |||
| 242 | + | normalizedRouteImage := NormalizeRegistryImage(image, registryDomain) | |||
| 243 | + | ||||
| 244 | + | match := false | |||
| 245 | + | if inputHasTag { | |||
| 246 | + | // Exact match when the caller provided a tag (e.g. event handler). | |||
| 247 | + | match = strings.EqualFold(normalizedRouteImage, normalizedImage) | |||
| 248 | + | } else { | |||
| 249 | + | // Name-only match when no tag was provided (e.g. CLI push). | |||
| 250 | + | routeName, _ := splitImageNameTag(normalizedRouteImage) | |||
| 251 | + | match = strings.EqualFold(routeName, inputName) | |||
| 252 | + | } | |||
| 253 | + | ||||
| 254 | + | if match { | |||
| 255 | + | route := domain.Route{ | |||
| 256 | + | Image: image, | |||
| 257 | + | HTTPS: true, | |||
| 258 | + | } | |||
| 259 | + | if strings.HasPrefix(domainName, "http://") { | |||
| 260 | + | route.Domain = strings.TrimPrefix(domainName, "http://") | |||
| 261 | + | route.HTTPS = false | |||
| 262 | + | } else { | |||
| 263 | + | route.Domain = domainName | |||
| 264 | + | } | |||
| 265 | + | routes = append(routes, route) | |||
| 266 | + | } | |||
| 267 | + | } | |||
| 268 | + | ||||
| 269 | + | return routes | |||
| 270 | + | } | |||
| 271 | + | ||||
| 272 | + | // splitImageNameTag splits "name:tag" into ("name", true) or ("name", false) when no tag is present. | |||
| 273 | + | func splitImageNameTag(image string) (name string, hasTag bool) { | |||
| 274 | + | if idx := strings.LastIndex(image, ":"); idx != -1 { | |||
| 275 | + | return image[:idx], true | |||
| 276 | + | } | |||
| 277 | + | return image, false | |||
| 278 | + | } | |||
| 279 | + | ||||
| 280 | + | // NormalizeRegistryImage strips the registry domain prefix from an image name for comparison. | |||
| 281 | + | func NormalizeRegistryImage(imageName, registryDomain string) string { | |||
| 282 | + | registryDomain = strings.TrimSuffix(registryDomain, "/") | |||
| 283 | + | if registryDomain == "" { | |||
| 284 | + | return imageName | |||
| 285 | + | } | |||
| 286 | + | ||||
| 287 | + | prefix := registryDomain + "/" | |||
| 288 | + | if strings.HasPrefix(imageName, prefix) { | |||
| 289 | + | return strings.TrimPrefix(imageName, prefix) | |||
| 290 | + | } | |||
| 291 | + | ||||
| 292 | + | return imageName | |||
| 293 | + | } | |||
| 294 | + | ||||
| 229 | // AddRoute adds a new route to the configuration and persists it. | 295 | // AddRoute adds a new route to the configuration and persists it. | ||
| 230 | func (s *Service) AddRoute(ctx context.Context, route domain.Route) error { | 296 | func (s *Service) AddRoute(ctx context.Context, route domain.Route) error { | ||
| 231 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | 297 | ctx = zerowrap.CtxWithFields(ctx, map[string]any{ | ||
M internal/usecase/config/service_test.go +227 -0
| @@ -864,6 +864,233 @@ func TestService_GetExternalRoutes_Empty(t *testing.T) { | |||
| 864 | 864 | assert.Empty(t, routes) | |
| 865 | 865 | } | |
| 866 | 866 | ||
| 867 | + | func TestSplitImageNameTag(t *testing.T) { | |
| 868 | + | tests := []struct { | |
| 869 | + | name string | |
| 870 | + | image string | |
| 871 | + | wantName string | |
| 872 | + | wantHasTag bool | |
| 873 | + | }{ | |
| 874 | + | {"with tag", "myapp:latest", "myapp", true}, | |
| 875 | + | {"with version tag", "myapp:v1.2.0", "myapp", true}, | |
| 876 | + | {"no tag", "myapp", "myapp", false}, | |
| 877 | + | {"empty string", "", "", false}, | |
| 878 | + | {"tag only colon", "myapp:", "myapp", true}, | |
| 879 | + | {"registry prefixed with tag", "reg.example.com/myapp:latest", "reg.example.com/myapp", true}, | |
| 880 | + | {"registry prefixed no tag", "reg.example.com/myapp", "reg.example.com/myapp", false}, | |
| 881 | + | } | |
| 882 | + | ||
| 883 | + | for _, tt := range tests { | |
| 884 | + | t.Run(tt.name, func(t *testing.T) { | |
| 885 | + | name, hasTag := splitImageNameTag(tt.image) | |
| 886 | + | assert.Equal(t, tt.wantName, name) | |
| 887 | + | assert.Equal(t, tt.wantHasTag, hasTag) | |
| 888 | + | }) | |
| 889 | + | } | |
| 890 | + | } | |
| 891 | + | ||
| 892 | + | func TestNormalizeRegistryImage(t *testing.T) { | |
| 893 | + | tests := []struct { | |
| 894 | + | name string | |
| 895 | + | imageName string | |
| 896 | + | registryDomain string | |
| 897 | + | expected string | |
| 898 | + | }{ | |
| 899 | + | {"strips registry prefix", "reg.example.com/myapp:latest", "reg.example.com", "myapp:latest"}, | |
| 900 | + | {"no prefix to strip", "myapp:latest", "reg.example.com", "myapp:latest"}, | |
| 901 | + | {"empty registry domain", "myapp:latest", "", "myapp:latest"}, | |
| 902 | + | {"registry with trailing slash", "reg.example.com/myapp:latest", "reg.example.com/", "myapp:latest"}, | |
| 903 | + | {"partial match is not stripped", "reg.example.com.evil/myapp:latest", "reg.example.com", "reg.example.com.evil/myapp:latest"}, | |
| 904 | + | {"bare name no registry", "myapp", "reg.example.com", "myapp"}, | |
| 905 | + | {"exact registry without image", "reg.example.com/", "reg.example.com", ""}, | |
| 906 | + | } | |
| 907 | + | ||
| 908 | + | for _, tt := range tests { | |
| 909 | + | t.Run(tt.name, func(t *testing.T) { | |
| 910 | + | result := NormalizeRegistryImage(tt.imageName, tt.registryDomain) | |
| 911 | + | assert.Equal(t, tt.expected, result) | |
| 912 | + | }) | |
| 913 | + | } | |
| 914 | + | } | |
| 915 | + | ||
| 916 | + | func TestService_FindRoutesByImage(t *testing.T) { | |
| 917 | + | t.Run("exact match with tag", func(t *testing.T) { | |
| 918 | + | v := viper.New() | |
| 919 | + | v.Set("routes", map[string]interface{}{ | |
| 920 | + | "app.example.com": "myapp:latest", | |
| 921 | + | }) | |
| 922 | + | ||
| 923 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 924 | + | svc := NewService(v, eventBus) | |
| 925 | + | ctx := testContext() | |
| 926 | + | _ = svc.Load(ctx) | |
| 927 | + | ||
| 928 | + | routes := svc.FindRoutesByImage(ctx, "myapp:latest") | |
| 929 | + | assert.Len(t, routes, 1) | |
| 930 | + | assert.Equal(t, "app.example.com", routes[0].Domain) | |
| 931 | + | assert.Equal(t, "myapp:latest", routes[0].Image) | |
| 932 | + | }) | |
| 933 | + | ||
| 934 | + | t.Run("bare name matches route with tag", func(t *testing.T) { | |
| 935 | + | v := viper.New() | |
| 936 | + | v.Set("routes", map[string]interface{}{ | |
| 937 | + | "app.example.com": "myapp:latest", | |
| 938 | + | }) | |
| 939 | + | ||
| 940 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 941 | + | svc := NewService(v, eventBus) | |
| 942 | + | ctx := testContext() | |
| 943 | + | _ = svc.Load(ctx) | |
| 944 | + | ||
| 945 | + | routes := svc.FindRoutesByImage(ctx, "myapp") | |
| 946 | + | assert.Len(t, routes, 1) | |
| 947 | + | assert.Equal(t, "app.example.com", routes[0].Domain) | |
| 948 | + | }) | |
| 949 | + | ||
| 950 | + | t.Run("bare name matches route with version tag", func(t *testing.T) { | |
| 951 | + | v := viper.New() | |
| 952 | + | v.Set("routes", map[string]interface{}{ | |
| 953 | + | "app.example.com": "myapp:v2.0.0", | |
| 954 | + | }) | |
| 955 | + | ||
| 956 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 957 | + | svc := NewService(v, eventBus) | |
| 958 | + | ctx := testContext() | |
| 959 | + | _ = svc.Load(ctx) | |
| 960 | + | ||
| 961 | + | routes := svc.FindRoutesByImage(ctx, "myapp") | |
| 962 | + | assert.Len(t, routes, 1) | |
| 963 | + | assert.Equal(t, "app.example.com", routes[0].Domain) | |
| 964 | + | }) | |
| 965 | + | ||
| 966 | + | t.Run("tag mismatch does not match", func(t *testing.T) { | |
| 967 | + | v := viper.New() | |
| 968 | + | v.Set("routes", map[string]interface{}{ | |
| 969 | + | "app.example.com": "myapp:v1", | |
| 970 | + | }) | |
| 971 | + | ||
| 972 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 973 | + | svc := NewService(v, eventBus) | |
| 974 | + | ctx := testContext() | |
| 975 | + | _ = svc.Load(ctx) | |
| 976 | + | ||
| 977 | + | routes := svc.FindRoutesByImage(ctx, "myapp:v2") | |
| 978 | + | assert.Empty(t, routes) | |
| 979 | + | }) | |
| 980 | + | ||
| 981 | + | t.Run("no match", func(t *testing.T) { | |
| 982 | + | v := viper.New() | |
| 983 | + | v.Set("routes", map[string]interface{}{ | |
| 984 | + | "app.example.com": "myapp:latest", | |
| 985 | + | }) | |
| 986 | + | ||
| 987 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 988 | + | svc := NewService(v, eventBus) | |
| 989 | + | ctx := testContext() | |
| 990 | + | _ = svc.Load(ctx) | |
| 991 | + | ||
| 992 | + | routes := svc.FindRoutesByImage(ctx, "otherapp") | |
| 993 | + | assert.Empty(t, routes) | |
| 994 | + | }) | |
| 995 | + | ||
| 996 | + | t.Run("strips registry prefix before matching", func(t *testing.T) { | |
| 997 | + | v := viper.New() | |
| 998 | + | v.Set("server.registry_domain", "reg.example.com") | |
| 999 | + | v.Set("routes", map[string]interface{}{ | |
| 1000 | + | "app.example.com": "reg.example.com/myapp:latest", | |
| 1001 | + | }) | |
| 1002 | + | ||
| 1003 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 1004 | + | svc := NewService(v, eventBus) | |
| 1005 | + | ctx := testContext() | |
| 1006 | + | _ = svc.Load(ctx) | |
| 1007 | + | ||
| 1008 | + | routes := svc.FindRoutesByImage(ctx, "myapp") | |
| 1009 | + | assert.Len(t, routes, 1) | |
| 1010 | + | assert.Equal(t, "app.example.com", routes[0].Domain) | |
| 1011 | + | }) | |
| 1012 | + | ||
| 1013 | + | t.Run("strips registry prefix from input too", func(t *testing.T) { | |
| 1014 | + | v := viper.New() | |
| 1015 | + | v.Set("server.registry_domain", "reg.example.com") | |
| 1016 | + | v.Set("routes", map[string]interface{}{ | |
| 1017 | + | "app.example.com": "reg.example.com/myapp:latest", | |
| 1018 | + | }) | |
| 1019 | + | ||
| 1020 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 1021 | + | svc := NewService(v, eventBus) | |
| 1022 | + | ctx := testContext() | |
| 1023 | + | _ = svc.Load(ctx) | |
| 1024 | + | ||
| 1025 | + | routes := svc.FindRoutesByImage(ctx, "reg.example.com/myapp:latest") | |
| 1026 | + | assert.Len(t, routes, 1) | |
| 1027 | + | assert.Equal(t, "app.example.com", routes[0].Domain) | |
| 1028 | + | }) | |
| 1029 | + | ||
| 1030 | + | t.Run("multiple routes for same image", func(t *testing.T) { | |
| 1031 | + | v := viper.New() | |
| 1032 | + | v.Set("routes", map[string]interface{}{ | |
| 1033 | + | "app1.example.com": "myapp:latest", | |
| 1034 | + | "app2.example.com": "myapp:latest", | |
| 1035 | + | "other.example.com": "otherapp:latest", | |
| 1036 | + | }) | |
| 1037 | + | ||
| 1038 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 1039 | + | svc := NewService(v, eventBus) | |
| 1040 | + | ctx := testContext() | |
| 1041 | + | _ = svc.Load(ctx) | |
| 1042 | + | ||
| 1043 | + | routes := svc.FindRoutesByImage(ctx, "myapp") | |
| 1044 | + | assert.Len(t, routes, 2) | |
| 1045 | + | ||
| 1046 | + | domains := []string{routes[0].Domain, routes[1].Domain} | |
| 1047 | + | assert.ElementsMatch(t, []string{"app1.example.com", "app2.example.com"}, domains) | |
| 1048 | + | }) | |
| 1049 | + | ||
| 1050 | + | t.Run("case insensitive match", func(t *testing.T) { | |
| 1051 | + | v := viper.New() | |
| 1052 | + | v.Set("routes", map[string]interface{}{ | |
| 1053 | + | "app.example.com": "MyApp:latest", | |
| 1054 | + | }) | |
| 1055 | + | ||
| 1056 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 1057 | + | svc := NewService(v, eventBus) | |
| 1058 | + | ctx := testContext() | |
| 1059 | + | _ = svc.Load(ctx) | |
| 1060 | + | ||
| 1061 | + | routes := svc.FindRoutesByImage(ctx, "myapp") | |
| 1062 | + | assert.Len(t, routes, 1) | |
| 1063 | + | }) | |
| 1064 | + | ||
| 1065 | + | t.Run("http prefix route sets HTTPS false", func(t *testing.T) { | |
| 1066 | + | v := viper.New() | |
| 1067 | + | v.Set("routes", map[string]interface{}{ | |
| 1068 | + | "http://insecure.example.com": "myapp:latest", | |
| 1069 | + | }) | |
| 1070 | + | ||
| 1071 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 1072 | + | svc := NewService(v, eventBus) | |
| 1073 | + | ctx := testContext() | |
| 1074 | + | _ = svc.Load(ctx) | |
| 1075 | + | ||
| 1076 | + | routes := svc.FindRoutesByImage(ctx, "myapp") | |
| 1077 | + | assert.Len(t, routes, 1) | |
| 1078 | + | assert.Equal(t, "insecure.example.com", routes[0].Domain) | |
| 1079 | + | assert.False(t, routes[0].HTTPS) | |
| 1080 | + | }) | |
| 1081 | + | ||
| 1082 | + | t.Run("empty routes", func(t *testing.T) { | |
| 1083 | + | v := viper.New() | |
| 1084 | + | eventBus := mocks.NewMockEventPublisher(t) | |
| 1085 | + | svc := NewService(v, eventBus) | |
| 1086 | + | ctx := testContext() | |
| 1087 | + | _ = svc.Load(ctx) | |
| 1088 | + | ||
| 1089 | + | routes := svc.FindRoutesByImage(ctx, "myapp") | |
| 1090 | + | assert.Empty(t, routes) | |
| 1091 | + | }) | |
| 1092 | + | } | |
| 1093 | + | ||
| 867 | 1094 | func TestExtractDomainFromImageName(t *testing.T) { | |
| 868 | 1095 | tests := []struct { | |
| 869 | 1096 | name string | |
| @@ -864,6 +864,233 @@ func TestService_GetExternalRoutes_Empty(t *testing.T) { | |||||
| 864 | assert.Empty(t, routes) | 864 | assert.Empty(t, routes) | ||
| 865 | } | 865 | } | ||
| 866 | 866 | ||||
| 867 | + | func TestSplitImageNameTag(t *testing.T) { | |||
| 868 | + | tests := []struct { | |||
| 869 | + | name string | |||
| 870 | + | image string | |||
| 871 | + | wantName string | |||
| 872 | + | wantHasTag bool | |||
| 873 | + | }{ | |||
| 874 | + | {"with tag", "myapp:latest", "myapp", true}, | |||
| 875 | + | {"with version tag", "myapp:v1.2.0", "myapp", true}, | |||
| 876 | + | {"no tag", "myapp", "myapp", false}, | |||
| 877 | + | {"empty string", "", "", false}, | |||
| 878 | + | {"tag only colon", "myapp:", "myapp", true}, | |||
| 879 | + | {"registry prefixed with tag", "reg.example.com/myapp:latest", "reg.example.com/myapp", true}, | |||
| 880 | + | {"registry prefixed no tag", "reg.example.com/myapp", "reg.example.com/myapp", false}, | |||
| 881 | + | } | |||
| 882 | + | ||||
| 883 | + | for _, tt := range tests { | |||
| 884 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 885 | + | name, hasTag := splitImageNameTag(tt.image) | |||
| 886 | + | assert.Equal(t, tt.wantName, name) | |||
| 887 | + | assert.Equal(t, tt.wantHasTag, hasTag) | |||
| 888 | + | }) | |||
| 889 | + | } | |||
| 890 | + | } | |||
| 891 | + | ||||
| 892 | + | func TestNormalizeRegistryImage(t *testing.T) { | |||
| 893 | + | tests := []struct { | |||
| 894 | + | name string | |||
| 895 | + | imageName string | |||
| 896 | + | registryDomain string | |||
| 897 | + | expected string | |||
| 898 | + | }{ | |||
| 899 | + | {"strips registry prefix", "reg.example.com/myapp:latest", "reg.example.com", "myapp:latest"}, | |||
| 900 | + | {"no prefix to strip", "myapp:latest", "reg.example.com", "myapp:latest"}, | |||
| 901 | + | {"empty registry domain", "myapp:latest", "", "myapp:latest"}, | |||
| 902 | + | {"registry with trailing slash", "reg.example.com/myapp:latest", "reg.example.com/", "myapp:latest"}, | |||
| 903 | + | {"partial match is not stripped", "reg.example.com.evil/myapp:latest", "reg.example.com", "reg.example.com.evil/myapp:latest"}, | |||
| 904 | + | {"bare name no registry", "myapp", "reg.example.com", "myapp"}, | |||
| 905 | + | {"exact registry without image", "reg.example.com/", "reg.example.com", ""}, | |||
| 906 | + | } | |||
| 907 | + | ||||
| 908 | + | for _, tt := range tests { | |||
| 909 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 910 | + | result := NormalizeRegistryImage(tt.imageName, tt.registryDomain) | |||
| 911 | + | assert.Equal(t, tt.expected, result) | |||
| 912 | + | }) | |||
| 913 | + | } | |||
| 914 | + | } | |||
| 915 | + | ||||
| 916 | + | func TestService_FindRoutesByImage(t *testing.T) { | |||
| 917 | + | t.Run("exact match with tag", func(t *testing.T) { | |||
| 918 | + | v := viper.New() | |||
| 919 | + | v.Set("routes", map[string]interface{}{ | |||
| 920 | + | "app.example.com": "myapp:latest", | |||
| 921 | + | }) | |||
| 922 | + | ||||
| 923 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 924 | + | svc := NewService(v, eventBus) | |||
| 925 | + | ctx := testContext() | |||
| 926 | + | _ = svc.Load(ctx) | |||
| 927 | + | ||||
| 928 | + | routes := svc.FindRoutesByImage(ctx, "myapp:latest") | |||
| 929 | + | assert.Len(t, routes, 1) | |||
| 930 | + | assert.Equal(t, "app.example.com", routes[0].Domain) | |||
| 931 | + | assert.Equal(t, "myapp:latest", routes[0].Image) | |||
| 932 | + | }) | |||
| 933 | + | ||||
| 934 | + | t.Run("bare name matches route with tag", func(t *testing.T) { | |||
| 935 | + | v := viper.New() | |||
| 936 | + | v.Set("routes", map[string]interface{}{ | |||
| 937 | + | "app.example.com": "myapp:latest", | |||
| 938 | + | }) | |||
| 939 | + | ||||
| 940 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 941 | + | svc := NewService(v, eventBus) | |||
| 942 | + | ctx := testContext() | |||
| 943 | + | _ = svc.Load(ctx) | |||
| 944 | + | ||||
| 945 | + | routes := svc.FindRoutesByImage(ctx, "myapp") | |||
| 946 | + | assert.Len(t, routes, 1) | |||
| 947 | + | assert.Equal(t, "app.example.com", routes[0].Domain) | |||
| 948 | + | }) | |||
| 949 | + | ||||
| 950 | + | t.Run("bare name matches route with version tag", func(t *testing.T) { | |||
| 951 | + | v := viper.New() | |||
| 952 | + | v.Set("routes", map[string]interface{}{ | |||
| 953 | + | "app.example.com": "myapp:v2.0.0", | |||
| 954 | + | }) | |||
| 955 | + | ||||
| 956 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 957 | + | svc := NewService(v, eventBus) | |||
| 958 | + | ctx := testContext() | |||
| 959 | + | _ = svc.Load(ctx) | |||
| 960 | + | ||||
| 961 | + | routes := svc.FindRoutesByImage(ctx, "myapp") | |||
| 962 | + | assert.Len(t, routes, 1) | |||
| 963 | + | assert.Equal(t, "app.example.com", routes[0].Domain) | |||
| 964 | + | }) | |||
| 965 | + | ||||
| 966 | + | t.Run("tag mismatch does not match", func(t *testing.T) { | |||
| 967 | + | v := viper.New() | |||
| 968 | + | v.Set("routes", map[string]interface{}{ | |||
| 969 | + | "app.example.com": "myapp:v1", | |||
| 970 | + | }) | |||
| 971 | + | ||||
| 972 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 973 | + | svc := NewService(v, eventBus) | |||
| 974 | + | ctx := testContext() | |||
| 975 | + | _ = svc.Load(ctx) | |||
| 976 | + | ||||
| 977 | + | routes := svc.FindRoutesByImage(ctx, "myapp:v2") | |||
| 978 | + | assert.Empty(t, routes) | |||
| 979 | + | }) | |||
| 980 | + | ||||
| 981 | + | t.Run("no match", func(t *testing.T) { | |||
| 982 | + | v := viper.New() | |||
| 983 | + | v.Set("routes", map[string]interface{}{ | |||
| 984 | + | "app.example.com": "myapp:latest", | |||
| 985 | + | }) | |||
| 986 | + | ||||
| 987 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 988 | + | svc := NewService(v, eventBus) | |||
| 989 | + | ctx := testContext() | |||
| 990 | + | _ = svc.Load(ctx) | |||
| 991 | + | ||||
| 992 | + | routes := svc.FindRoutesByImage(ctx, "otherapp") | |||
| 993 | + | assert.Empty(t, routes) | |||
| 994 | + | }) | |||
| 995 | + | ||||
| 996 | + | t.Run("strips registry prefix before matching", func(t *testing.T) { | |||
| 997 | + | v := viper.New() | |||
| 998 | + | v.Set("server.registry_domain", "reg.example.com") | |||
| 999 | + | v.Set("routes", map[string]interface{}{ | |||
| 1000 | + | "app.example.com": "reg.example.com/myapp:latest", | |||
| 1001 | + | }) | |||
| 1002 | + | ||||
| 1003 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 1004 | + | svc := NewService(v, eventBus) | |||
| 1005 | + | ctx := testContext() | |||
| 1006 | + | _ = svc.Load(ctx) | |||
| 1007 | + | ||||
| 1008 | + | routes := svc.FindRoutesByImage(ctx, "myapp") | |||
| 1009 | + | assert.Len(t, routes, 1) | |||
| 1010 | + | assert.Equal(t, "app.example.com", routes[0].Domain) | |||
| 1011 | + | }) | |||
| 1012 | + | ||||
| 1013 | + | t.Run("strips registry prefix from input too", func(t *testing.T) { | |||
| 1014 | + | v := viper.New() | |||
| 1015 | + | v.Set("server.registry_domain", "reg.example.com") | |||
| 1016 | + | v.Set("routes", map[string]interface{}{ | |||
| 1017 | + | "app.example.com": "reg.example.com/myapp:latest", | |||
| 1018 | + | }) | |||
| 1019 | + | ||||
| 1020 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 1021 | + | svc := NewService(v, eventBus) | |||
| 1022 | + | ctx := testContext() | |||
| 1023 | + | _ = svc.Load(ctx) | |||
| 1024 | + | ||||
| 1025 | + | routes := svc.FindRoutesByImage(ctx, "reg.example.com/myapp:latest") | |||
| 1026 | + | assert.Len(t, routes, 1) | |||
| 1027 | + | assert.Equal(t, "app.example.com", routes[0].Domain) | |||
| 1028 | + | }) | |||
| 1029 | + | ||||
| 1030 | + | t.Run("multiple routes for same image", func(t *testing.T) { | |||
| 1031 | + | v := viper.New() | |||
| 1032 | + | v.Set("routes", map[string]interface{}{ | |||
| 1033 | + | "app1.example.com": "myapp:latest", | |||
| 1034 | + | "app2.example.com": "myapp:latest", | |||
| 1035 | + | "other.example.com": "otherapp:latest", | |||
| 1036 | + | }) | |||
| 1037 | + | ||||
| 1038 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 1039 | + | svc := NewService(v, eventBus) | |||
| 1040 | + | ctx := testContext() | |||
| 1041 | + | _ = svc.Load(ctx) | |||
| 1042 | + | ||||
| 1043 | + | routes := svc.FindRoutesByImage(ctx, "myapp") | |||
| 1044 | + | assert.Len(t, routes, 2) | |||
| 1045 | + | ||||
| 1046 | + | domains := []string{routes[0].Domain, routes[1].Domain} | |||
| 1047 | + | assert.ElementsMatch(t, []string{"app1.example.com", "app2.example.com"}, domains) | |||
| 1048 | + | }) | |||
| 1049 | + | ||||
| 1050 | + | t.Run("case insensitive match", func(t *testing.T) { | |||
| 1051 | + | v := viper.New() | |||
| 1052 | + | v.Set("routes", map[string]interface{}{ | |||
| 1053 | + | "app.example.com": "MyApp:latest", | |||
| 1054 | + | }) | |||
| 1055 | + | ||||
| 1056 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 1057 | + | svc := NewService(v, eventBus) | |||
| 1058 | + | ctx := testContext() | |||
| 1059 | + | _ = svc.Load(ctx) | |||
| 1060 | + | ||||
| 1061 | + | routes := svc.FindRoutesByImage(ctx, "myapp") | |||
| 1062 | + | assert.Len(t, routes, 1) | |||
| 1063 | + | }) | |||
| 1064 | + | ||||
| 1065 | + | t.Run("http prefix route sets HTTPS false", func(t *testing.T) { | |||
| 1066 | + | v := viper.New() | |||
| 1067 | + | v.Set("routes", map[string]interface{}{ | |||
| 1068 | + | "http://insecure.example.com": "myapp:latest", | |||
| 1069 | + | }) | |||
| 1070 | + | ||||
| 1071 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 1072 | + | svc := NewService(v, eventBus) | |||
| 1073 | + | ctx := testContext() | |||
| 1074 | + | _ = svc.Load(ctx) | |||
| 1075 | + | ||||
| 1076 | + | routes := svc.FindRoutesByImage(ctx, "myapp") | |||
| 1077 | + | assert.Len(t, routes, 1) | |||
| 1078 | + | assert.Equal(t, "insecure.example.com", routes[0].Domain) | |||
| 1079 | + | assert.False(t, routes[0].HTTPS) | |||
| 1080 | + | }) | |||
| 1081 | + | ||||
| 1082 | + | t.Run("empty routes", func(t *testing.T) { | |||
| 1083 | + | v := viper.New() | |||
| 1084 | + | eventBus := mocks.NewMockEventPublisher(t) | |||
| 1085 | + | svc := NewService(v, eventBus) | |||
| 1086 | + | ctx := testContext() | |||
| 1087 | + | _ = svc.Load(ctx) | |||
| 1088 | + | ||||
| 1089 | + | routes := svc.FindRoutesByImage(ctx, "myapp") | |||
| 1090 | + | assert.Empty(t, routes) | |||
| 1091 | + | }) | |||
| 1092 | + | } | |||
| 1093 | + | ||||
| 867 | func TestExtractDomainFromImageName(t *testing.T) { | 1094 | func TestExtractDomainFromImageName(t *testing.T) { | ||
| 868 | tests := []struct { | 1095 | tests := []struct { | ||
| 869 | name string | 1096 | name string | ||
M internal/usecase/container/events.go +1 -33
| @@ -3,7 +3,6 @@ package container | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | - | "strings" | |
| 7 | 6 | ||
| 8 | 7 | "github.com/bnema/zerowrap" | |
| 9 | 8 | ||
| @@ -50,7 +49,7 @@ func (h *ImagePushedHandler) Handle(ctx context.Context, event domain.Event) err | |||
| 50 | 49 | ||
| 51 | 50 | log.Info().Str("image", fullImageName).Msg("processing image push event") | |
| 52 | 51 | ||
| 53 | - | routes := h.findRoutesForImage(fullImageName) | |
| 52 | + | routes := h.configSvc.FindRoutesByImage(ctx, fullImageName) | |
| 54 | 53 | if len(routes) == 0 { | |
| 55 | 54 | log.Debug().Str("image", fullImageName).Msg("no routes configured for pushed image") | |
| 56 | 55 | return nil | |
| @@ -74,37 +73,6 @@ func (h *ImagePushedHandler) CanHandle(eventType domain.EventType) bool { | |||
| 74 | 73 | return eventType == domain.EventImagePushed | |
| 75 | 74 | } | |
| 76 | 75 | ||
| 77 | - | func (h *ImagePushedHandler) findRoutesForImage(imageName string) []domain.Route { | |
| 78 | - | var routes []domain.Route | |
| 79 | - | ||
| 80 | - | registryDomain := h.configSvc.GetRegistryDomain() | |
| 81 | - | normalizedImage := normalizeRegistryImage(imageName, registryDomain) | |
| 82 | - | ||
| 83 | - | configuredRoutes := h.configSvc.GetRoutes(h.ctx) | |
| 84 | - | for _, route := range configuredRoutes { | |
| 85 | - | normalizedRouteImage := normalizeRegistryImage(route.Image, registryDomain) | |
| 86 | - | if strings.EqualFold(normalizedRouteImage, normalizedImage) { | |
| 87 | - | routes = append(routes, route) | |
| 88 | - | } | |
| 89 | - | } | |
| 90 | - | ||
| 91 | - | return routes | |
| 92 | - | } | |
| 93 | - | ||
| 94 | - | func normalizeRegistryImage(imageName, registryDomain string) string { | |
| 95 | - | registryDomain = strings.TrimSuffix(registryDomain, "/") | |
| 96 | - | if registryDomain == "" { | |
| 97 | - | return imageName | |
| 98 | - | } | |
| 99 | - | ||
| 100 | - | prefix := registryDomain + "/" | |
| 101 | - | if strings.HasPrefix(imageName, prefix) { | |
| 102 | - | return strings.TrimPrefix(imageName, prefix) | |
| 103 | - | } | |
| 104 | - | ||
| 105 | - | return imageName | |
| 106 | - | } | |
| 107 | - | ||
| 108 | 76 | // ConfigReloadHandler handles config.reload events. | |
| 109 | 77 | type ConfigReloadHandler struct { | |
| 110 | 78 | containerSvc in.ContainerService | |
| @@ -3,7 +3,6 @@ package container | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | - | "strings" | |||
| 7 | 6 | ||||
| 8 | "github.com/bnema/zerowrap" | 7 | "github.com/bnema/zerowrap" | ||
| 9 | 8 | ||||
| @@ -50,7 +49,7 @@ func (h *ImagePushedHandler) Handle(ctx context.Context, event domain.Event) err | |||||
| 50 | 49 | ||||
| 51 | log.Info().Str("image", fullImageName).Msg("processing image push event") | 50 | log.Info().Str("image", fullImageName).Msg("processing image push event") | ||
| 52 | 51 | ||||
| 53 | - | routes := h.findRoutesForImage(fullImageName) | 52 | + | routes := h.configSvc.FindRoutesByImage(ctx, fullImageName) |
| 54 | if len(routes) == 0 { | 53 | if len(routes) == 0 { | ||
| 55 | log.Debug().Str("image", fullImageName).Msg("no routes configured for pushed image") | 54 | log.Debug().Str("image", fullImageName).Msg("no routes configured for pushed image") | ||
| 56 | return nil | 55 | return nil | ||
| @@ -74,37 +73,6 @@ func (h *ImagePushedHandler) CanHandle(eventType domain.EventType) bool { | |||||
| 74 | return eventType == domain.EventImagePushed | 73 | return eventType == domain.EventImagePushed | ||
| 75 | } | 74 | } | ||
| 76 | 75 | ||||
| 77 | - | func (h *ImagePushedHandler) findRoutesForImage(imageName string) []domain.Route { | |||
| 78 | - | var routes []domain.Route | |||
| 79 | - | ||||
| 80 | - | registryDomain := h.configSvc.GetRegistryDomain() | |||
| 81 | - | normalizedImage := normalizeRegistryImage(imageName, registryDomain) | |||
| 82 | - | ||||
| 83 | - | configuredRoutes := h.configSvc.GetRoutes(h.ctx) | |||
| 84 | - | for _, route := range configuredRoutes { | |||
| 85 | - | normalizedRouteImage := normalizeRegistryImage(route.Image, registryDomain) | |||
| 86 | - | if strings.EqualFold(normalizedRouteImage, normalizedImage) { | |||
| 87 | - | routes = append(routes, route) | |||
| 88 | - | } | |||
| 89 | - | } | |||
| 90 | - | ||||
| 91 | - | return routes | |||
| 92 | - | } | |||
| 93 | - | ||||
| 94 | - | func normalizeRegistryImage(imageName, registryDomain string) string { | |||
| 95 | - | registryDomain = strings.TrimSuffix(registryDomain, "/") | |||
| 96 | - | if registryDomain == "" { | |||
| 97 | - | return imageName | |||
| 98 | - | } | |||
| 99 | - | ||||
| 100 | - | prefix := registryDomain + "/" | |||
| 101 | - | if strings.HasPrefix(imageName, prefix) { | |||
| 102 | - | return strings.TrimPrefix(imageName, prefix) | |||
| 103 | - | } | |||
| 104 | - | ||||
| 105 | - | return imageName | |||
| 106 | - | } | |||
| 107 | - | ||||
| 108 | // ConfigReloadHandler handles config.reload events. | 76 | // ConfigReloadHandler handles config.reload events. | ||
| 109 | type ConfigReloadHandler struct { | 77 | type ConfigReloadHandler struct { | ||
| 110 | containerSvc in.ContainerService | 78 | containerSvc in.ContainerService | ||
M internal/usecase/container/events_test.go +8 -18
| @@ -36,13 +36,10 @@ func TestImagePushedHandler_Handle_DeploysMatchingRoutes(t *testing.T) { | |||
| 36 | 36 | ||
| 37 | 37 | handler := NewImagePushedHandler(testCtx(), containerSvc, configSvc) | |
| 38 | 38 | ||
| 39 | - | configSvc.EXPECT().GetRegistryDomain().Return("") | |
| 40 | - | ||
| 41 | - | // Configure routes that match the pushed image | |
| 42 | - | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{ | |
| 39 | + | // FindRoutesByImage returns matching routes directly | |
| 40 | + | configSvc.EXPECT().FindRoutesByImage(mock.Anything, "myapp:latest").Return([]domain.Route{ | |
| 43 | 41 | {Domain: "app1.example.com", Image: "myapp:latest"}, | |
| 44 | 42 | {Domain: "app2.example.com", Image: "myapp:latest"}, | |
| 45 | - | {Domain: "other.example.com", Image: "otherapp:latest"}, | |
| 46 | 43 | }) | |
| 47 | 44 | ||
| 48 | 45 | // Expect Deploy to be called for both matching routes | |
| @@ -74,11 +71,8 @@ func TestImagePushedHandler_Handle_NoMatchingRoutes(t *testing.T) { | |||
| 74 | 71 | ||
| 75 | 72 | handler := NewImagePushedHandler(testCtx(), containerSvc, configSvc) | |
| 76 | 73 | ||
| 77 | - | configSvc.EXPECT().GetRegistryDomain().Return("") | |
| 78 | - | ||
| 79 | - | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{ | |
| 80 | - | {Domain: "other.example.com", Image: "otherapp:latest"}, | |
| 81 | - | }) | |
| 74 | + | // FindRoutesByImage returns no matching routes | |
| 75 | + | configSvc.EXPECT().FindRoutesByImage(mock.Anything, "myapp:latest").Return([]domain.Route{}) | |
| 82 | 76 | ||
| 83 | 77 | // No Deploy calls expected | |
| 84 | 78 | ||
| @@ -118,10 +112,8 @@ func TestImagePushedHandler_Handle_DefaultTag(t *testing.T) { | |||
| 118 | 112 | ||
| 119 | 113 | handler := NewImagePushedHandler(testCtx(), containerSvc, configSvc) | |
| 120 | 114 | ||
| 121 | - | configSvc.EXPECT().GetRegistryDomain().Return("") | |
| 122 | - | ||
| 123 | 115 | // Image name "myapp" with empty tag should become "myapp:latest" | |
| 124 | - | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{ | |
| 116 | + | configSvc.EXPECT().FindRoutesByImage(mock.Anything, "myapp:latest").Return([]domain.Route{ | |
| 125 | 117 | {Domain: "app.example.com", Image: "myapp:latest"}, | |
| 126 | 118 | }) | |
| 127 | 119 | ||
| @@ -148,9 +140,7 @@ func TestImagePushedHandler_Handle_DeployError(t *testing.T) { | |||
| 148 | 140 | ||
| 149 | 141 | handler := NewImagePushedHandler(testCtx(), containerSvc, configSvc) | |
| 150 | 142 | ||
| 151 | - | configSvc.EXPECT().GetRegistryDomain().Return("") | |
| 152 | - | ||
| 153 | - | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{ | |
| 143 | + | configSvc.EXPECT().FindRoutesByImage(mock.Anything, "myapp:latest").Return([]domain.Route{ | |
| 154 | 144 | {Domain: "app.example.com", Image: "myapp:latest"}, | |
| 155 | 145 | }) | |
| 156 | 146 | ||
| @@ -175,8 +165,8 @@ func TestImagePushedHandler_Handle_StripsRegistryDomain(t *testing.T) { | |||
| 175 | 165 | ||
| 176 | 166 | handler := NewImagePushedHandler(testCtx(), containerSvc, configSvc) | |
| 177 | 167 | ||
| 178 | - | configSvc.EXPECT().GetRegistryDomain().Return("registry.example.com") | |
| 179 | - | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{ | |
| 168 | + | // FindRoutesByImage handles registry domain stripping internally | |
| 169 | + | configSvc.EXPECT().FindRoutesByImage(mock.Anything, "docker.io/library/nginx:latest").Return([]domain.Route{ | |
| 180 | 170 | {Domain: "app.example.com", Image: "registry.example.com/docker.io/library/nginx:latest"}, | |
| 181 | 171 | }) | |
| 182 | 172 | ||
| @@ -36,13 +36,10 @@ func TestImagePushedHandler_Handle_DeploysMatchingRoutes(t *testing.T) { | |||||
| 36 | 36 | ||||
| 37 | handler := NewImagePushedHandler(testCtx(), containerSvc, configSvc) | 37 | handler := NewImagePushedHandler(testCtx(), containerSvc, configSvc) | ||
| 38 | 38 | ||||
| 39 | - | configSvc.EXPECT().GetRegistryDomain().Return("") | 39 | + | // FindRoutesByImage returns matching routes directly |
| 40 | - | 40 | + | configSvc.EXPECT().FindRoutesByImage(mock.Anything, "myapp:latest").Return([]domain.Route{ | |
| 41 | - | // Configure routes that match the pushed image | |||
| 42 | - | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{ | |||
| 43 | {Domain: "app1.example.com", Image: "myapp:latest"}, | 41 | {Domain: "app1.example.com", Image: "myapp:latest"}, | ||
| 44 | {Domain: "app2.example.com", Image: "myapp:latest"}, | 42 | {Domain: "app2.example.com", Image: "myapp:latest"}, | ||
| 45 | - | {Domain: "other.example.com", Image: "otherapp:latest"}, | |||
| 46 | }) | 43 | }) | ||
| 47 | 44 | ||||
| 48 | // Expect Deploy to be called for both matching routes | 45 | // Expect Deploy to be called for both matching routes | ||
| @@ -74,11 +71,8 @@ func TestImagePushedHandler_Handle_NoMatchingRoutes(t *testing.T) { | |||||
| 74 | 71 | ||||
| 75 | handler := NewImagePushedHandler(testCtx(), containerSvc, configSvc) | 72 | handler := NewImagePushedHandler(testCtx(), containerSvc, configSvc) | ||
| 76 | 73 | ||||
| 77 | - | configSvc.EXPECT().GetRegistryDomain().Return("") | 74 | + | // FindRoutesByImage returns no matching routes |
| 78 | - | 75 | + | configSvc.EXPECT().FindRoutesByImage(mock.Anything, "myapp:latest").Return([]domain.Route{}) | |
| 79 | - | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{ | |||
| 80 | - | {Domain: "other.example.com", Image: "otherapp:latest"}, | |||
| 81 | - | }) | |||
| 82 | 76 | ||||
| 83 | // No Deploy calls expected | 77 | // No Deploy calls expected | ||
| 84 | 78 | ||||
| @@ -118,10 +112,8 @@ func TestImagePushedHandler_Handle_DefaultTag(t *testing.T) { | |||||
| 118 | 112 | ||||
| 119 | handler := NewImagePushedHandler(testCtx(), containerSvc, configSvc) | 113 | handler := NewImagePushedHandler(testCtx(), containerSvc, configSvc) | ||
| 120 | 114 | ||||
| 121 | - | configSvc.EXPECT().GetRegistryDomain().Return("") | |||
| 122 | - | ||||
| 123 | // Image name "myapp" with empty tag should become "myapp:latest" | 115 | // Image name "myapp" with empty tag should become "myapp:latest" | ||
| 124 | - | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{ | 116 | + | configSvc.EXPECT().FindRoutesByImage(mock.Anything, "myapp:latest").Return([]domain.Route{ |
| 125 | {Domain: "app.example.com", Image: "myapp:latest"}, | 117 | {Domain: "app.example.com", Image: "myapp:latest"}, | ||
| 126 | }) | 118 | }) | ||
| 127 | 119 | ||||
| @@ -148,9 +140,7 @@ func TestImagePushedHandler_Handle_DeployError(t *testing.T) { | |||||
| 148 | 140 | ||||
| 149 | handler := NewImagePushedHandler(testCtx(), containerSvc, configSvc) | 141 | handler := NewImagePushedHandler(testCtx(), containerSvc, configSvc) | ||
| 150 | 142 | ||||
| 151 | - | configSvc.EXPECT().GetRegistryDomain().Return("") | 143 | + | configSvc.EXPECT().FindRoutesByImage(mock.Anything, "myapp:latest").Return([]domain.Route{ |
| 152 | - | ||||
| 153 | - | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{ | |||
| 154 | {Domain: "app.example.com", Image: "myapp:latest"}, | 144 | {Domain: "app.example.com", Image: "myapp:latest"}, | ||
| 155 | }) | 145 | }) | ||
| 156 | 146 | ||||
| @@ -175,8 +165,8 @@ func TestImagePushedHandler_Handle_StripsRegistryDomain(t *testing.T) { | |||||
| 175 | 165 | ||||
| 176 | handler := NewImagePushedHandler(testCtx(), containerSvc, configSvc) | 166 | handler := NewImagePushedHandler(testCtx(), containerSvc, configSvc) | ||
| 177 | 167 | ||||
| 178 | - | configSvc.EXPECT().GetRegistryDomain().Return("registry.example.com") | 168 | + | // FindRoutesByImage handles registry domain stripping internally |
| 179 | - | configSvc.EXPECT().GetRoutes(mock.Anything).Return([]domain.Route{ | 169 | + | configSvc.EXPECT().FindRoutesByImage(mock.Anything, "docker.io/library/nginx:latest").Return([]domain.Route{ |
| 180 | {Domain: "app.example.com", Image: "registry.example.com/docker.io/library/nginx:latest"}, | 170 | {Domain: "app.example.com", Image: "registry.example.com/docker.io/library/nginx:latest"}, | ||
| 181 | }) | 171 | }) | ||
| 182 | 172 | ||||