Skip to content

Commit b0a50b3

Browse files
committed
fix image name matching logic
1 parent a530725 commit b0a50b3

File tree

2 files changed

+92
-145
lines changed

2 files changed

+92
-145
lines changed

pkg/skaffold/deploy/docker/deploy.go

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -636,6 +636,28 @@ func (d *Deployer) getComposeFilePath() string {
636636
return "docker-compose.yml"
637637
}
638638

639+
// imageNameMatches checks if a compose image name matches an artifact's built image.
640+
// It handles cases like:
641+
// - "frontend" matching "frontend:tag" or "gcr.io/project/frontend:tag"
642+
// - "gcr.io/project/frontend" matching "gcr.io/project/frontend:tag"
643+
// But rejects false positives like:
644+
// - "app" should NOT match "my-app"
645+
func imageNameMatches(composeImage, artifactTag string) bool {
646+
// Remove tags from both images
647+
composeBase := strings.Split(composeImage, ":")[0]
648+
artifactBase := strings.Split(artifactTag, ":")[0]
649+
650+
// Exact match (most common case)
651+
if composeBase == artifactBase {
652+
return true
653+
}
654+
655+
// Check if artifact image ends with compose image (handles registry prefixes)
656+
// e.g., "frontend" matches "gcr.io/project/frontend"
657+
// But "app" does NOT match "my-app" (no "/" separator)
658+
return strings.HasSuffix(artifactBase, "/"+composeBase)
659+
}
660+
639661
// replaceComposeImages replaces image names in compose config with skaffold-built images
640662
func (d *Deployer) replaceComposeImages(composeConfig map[string]interface{}, artifact graph.Artifact) error {
641663
services, ok := composeConfig["services"].(map[string]interface{})
@@ -652,8 +674,8 @@ func (d *Deployer) replaceComposeImages(composeConfig map[string]interface{}, ar
652674

653675
// Check if service has an image field
654676
if imageName, ok := service["image"].(string); ok {
655-
// Check if this image matches the artifact's image name
656-
if imageName == artifact.ImageName || strings.Contains(artifact.ImageName, imageName) {
677+
// Check if this image matches the artifact's built image
678+
if imageNameMatches(imageName, artifact.Tag) {
657679
olog.Entry(context.Background()).Debugf("Replacing image for service %s: %s -> %s", serviceName, imageName, artifact.Tag)
658680
service["image"] = artifact.Tag
659681
}

pkg/skaffold/deploy/docker/deploy_test.go

Lines changed: 68 additions & 143 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,8 @@ func (m mockConfig) ContainerDebugging() bool { return true }
284284
func (m mockConfig) GetInsecureRegistries() map[string]bool { return nil }
285285
func (m mockConfig) GetKubeContext() string { return "" }
286286
func (m mockConfig) GlobalConfig() string { return "" }
287+
func (m mockConfig) Mode() config.RunMode { return "" }
288+
func (m mockConfig) Prune() bool { return false }
287289

288290
// Tests for Docker Compose deployer
289291

@@ -407,6 +409,72 @@ func TestReplaceComposeImages(t *testing.T) {
407409
},
408410
shouldErr: false,
409411
},
412+
{
413+
name: "reject false positive: 'app' should not match 'my-app'",
414+
composeConfig: map[string]interface{}{
415+
"services": map[string]interface{}{
416+
"myapp": map[string]interface{}{
417+
"image": "app",
418+
},
419+
},
420+
},
421+
artifact: graph.Artifact{
422+
ImageName: "my-app",
423+
Tag: "my-app:v1.0.0",
424+
},
425+
expectedConfig: map[string]interface{}{
426+
"services": map[string]interface{}{
427+
"myapp": map[string]interface{}{
428+
"image": "app", // Should NOT be replaced
429+
},
430+
},
431+
},
432+
shouldErr: false,
433+
},
434+
{
435+
name: "match with registry prefix",
436+
composeConfig: map[string]interface{}{
437+
"services": map[string]interface{}{
438+
"frontend": map[string]interface{}{
439+
"image": "frontend",
440+
},
441+
},
442+
},
443+
artifact: graph.Artifact{
444+
ImageName: "frontend",
445+
Tag: "gcr.io/project/frontend:sha256-abc",
446+
},
447+
expectedConfig: map[string]interface{}{
448+
"services": map[string]interface{}{
449+
"frontend": map[string]interface{}{
450+
"image": "gcr.io/project/frontend:sha256-abc",
451+
},
452+
},
453+
},
454+
shouldErr: false,
455+
},
456+
{
457+
name: "match with tag in compose file",
458+
composeConfig: map[string]interface{}{
459+
"services": map[string]interface{}{
460+
"backend": map[string]interface{}{
461+
"image": "backend:latest",
462+
},
463+
},
464+
},
465+
artifact: graph.Artifact{
466+
ImageName: "backend",
467+
Tag: "backend:v2.0.0",
468+
},
469+
expectedConfig: map[string]interface{}{
470+
"services": map[string]interface{}{
471+
"backend": map[string]interface{}{
472+
"image": "backend:v2.0.0",
473+
},
474+
},
475+
},
476+
shouldErr: false,
477+
},
410478
}
411479

412480
for _, test := range tests {
@@ -542,146 +610,3 @@ func TestDeployWithComposeFileOperations(t *testing.T) {
542610
})
543611
}
544612
}
545-
546-
func (m mockConfig) MinikubeProfile() string { return "" }
547-
func (m mockConfig) Mode() config.RunMode { return "" }
548-
func (m mockConfig) Prune() bool { return false }
549-
550-
// createMockDockerCommand creates a fake 'docker' command that records invocations
551-
// This allows us to test command execution without modifying production code
552-
func createMockDockerCommand(t *testing.T, tmpDir string) (binPath string, counterFile string) {
553-
t.Helper()
554-
555-
// Create a bin directory for our fake docker command
556-
binDir := tmpDir + "/bin"
557-
if err := os.MkdirAll(binDir, 0755); err != nil {
558-
t.Fatalf("Failed to create bin directory: %v", err)
559-
}
560-
561-
// Counter file to track invocations
562-
counterFile = tmpDir + "/docker-call-count.txt"
563-
564-
// Create a fake docker script that records calls
565-
dockerScript := binDir + "/docker"
566-
scriptContent := `#!/bin/bash
567-
# Mock docker command that tracks invocations
568-
echo "1" >> "` + counterFile + `"
569-
570-
# Log the arguments for debugging
571-
echo "$@" >> "` + tmpDir + `/docker-args.log"
572-
573-
# Exit successfully
574-
exit 0
575-
`
576-
if err := os.WriteFile(dockerScript, []byte(scriptContent), 0755); err != nil {
577-
t.Fatalf("Failed to create mock docker script: %v", err)
578-
}
579-
580-
return binDir, counterFile
581-
}
582-
583-
// TestDeployWithCompose_MultipleArtifacts_CallsComposeUpOnce verifies that docker compose up
584-
// is called only once when deploying multiple artifacts, with all image replacements done together.
585-
// This test uses a mock docker command to track invocations without modifying production code.
586-
func TestDeployWithCompose_MultipleArtifacts_CallsComposeUpOnce(t *testing.T) {
587-
testutil.Run(t, "multiple artifacts trigger compose up only once", func(tt *testutil.T) {
588-
// Create temporary directory for test files
589-
tmpDir := t.TempDir()
590-
591-
// Create mock docker command
592-
binDir, counterFile := createMockDockerCommand(t, tmpDir)
593-
594-
// Modify PATH to use our mock docker command
595-
originalPath := os.Getenv("PATH")
596-
os.Setenv("PATH", binDir+":"+originalPath)
597-
defer os.Setenv("PATH", originalPath)
598-
599-
// Create a temporary compose file for testing
600-
composeFile := tmpDir + "/docker-compose.yml"
601-
composeContent := `version: '3'
602-
services:
603-
frontend:
604-
image: frontend
605-
backend:
606-
image: backend
607-
`
608-
if err := os.WriteFile(composeFile, []byte(composeContent), 0644); err != nil {
609-
t.Fatalf("Failed to write compose file: %v", err)
610-
}
611-
612-
// Set environment variable to use our test compose file
613-
originalEnv := os.Getenv("SKAFFOLD_COMPOSE_FILE")
614-
os.Setenv("SKAFFOLD_COMPOSE_FILE", composeFile)
615-
defer func() {
616-
if originalEnv != "" {
617-
os.Setenv("SKAFFOLD_COMPOSE_FILE", originalEnv)
618-
} else {
619-
os.Unsetenv("SKAFFOLD_COMPOSE_FILE")
620-
}
621-
}()
622-
623-
// Create a minimal deployer
624-
d := &Deployer{
625-
cfg: &latest.DockerDeploy{
626-
UseCompose: true,
627-
Images: []string{"frontend", "backend"},
628-
},
629-
labeller: &label.DefaultLabeller{},
630-
tracker: tracker.NewContainerTracker(),
631-
logger: &log.NoopLogger{},
632-
}
633-
634-
// Skip network creation
635-
d.once.Do(func() {})
636-
637-
// Create multiple artifacts
638-
builds := []graph.Artifact{
639-
{
640-
ImageName: "frontend",
641-
Tag: "frontend:v1.0.0",
642-
},
643-
{
644-
ImageName: "backend",
645-
Tag: "backend:v1.0.0",
646-
},
647-
}
648-
649-
// Call Deploy
650-
err := d.Deploy(context.Background(), os.Stdout, builds, manifest.ManifestListByConfig{})
651-
if err != nil {
652-
t.Fatalf("Deploy failed: %v", err)
653-
}
654-
655-
// Read the counter file to check how many times docker was called
656-
counterData, err := os.ReadFile(counterFile)
657-
if err != nil {
658-
t.Fatalf("Failed to read counter file: %v", err)
659-
}
660-
661-
// Count lines (each invocation adds one line)
662-
lines := 0
663-
for _, b := range counterData {
664-
if b == '\n' {
665-
lines++
666-
}
667-
}
668-
669-
// FIXED: docker compose up should be called only once with all artifacts
670-
if lines != 1 {
671-
t.Errorf("Expected docker compose up to be called 1 time, but was called %d times", lines)
672-
}
673-
674-
// Log arguments for verification
675-
argsData, _ := os.ReadFile(tmpDir + "/docker-args.log")
676-
t.Logf("SUCCESS: docker compose up was called %d time for %d artifacts", lines, len(builds))
677-
t.Logf("Docker command arguments:\n%s", string(argsData))
678-
679-
// Verify it was a compose up command
680-
if lines == 1 {
681-
argsStr := string(argsData)
682-
if !strings.Contains(argsStr, "compose") || !strings.Contains(argsStr, "up") {
683-
t.Errorf("Expected 'docker compose up' command, got: %s", argsStr)
684-
}
685-
}
686-
})
687-
}

0 commit comments

Comments
 (0)