refactor(cli): rewrite push command to resolve domain from image
- commit
- ab55833f1e68a5570bafdec27d509015eeec9618
- author
- bnema · · 2026-02-09 20:22 UTC
- parent
- 62502c9
- signature
- unsigned
2 files · +470 -22
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 | + | } | |||