package compose import ( "os" "os/exec" "path/filepath" "regexp" "strings" "testing" "time" "github.com/compose-spec/compose-go/v2/types" "github.com/psviderski/uncloud/internal/gitutil" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestProcessImageTemplates_Integration(t *testing.T) { // Create a temporary directory for the test git repository. tmpDir := t.TempDir() initGitRepo(t, tmpDir) // Create an initial commit. testFile := filepath.Join(tmpDir, "test.txt") err := os.WriteFile(testFile, []byte("initial content"), 0o644) require.NoError(t, err) runGitCommand(t, tmpDir, "add", "test.txt") runGitCommand(t, tmpDir, "commit", "-m", "Initial commit") gitState, err := gitutil.InspectGitState(tmpDir) require.NoError(t, err) require.True(t, gitState.IsRepo) require.False(t, gitState.IsDirty) require.NotEmpty(t, gitState.SHA) expectedTag := gitState.Date.UTC().Format("2006-01-02-150405") + "." + gitState.SHA[:7] expectedDate := gitState.Date.UTC().Format("2006-01-02") t.Run("clean repository", func(t *testing.T) { project := &types.Project{ Name: "myproject", WorkingDir: tmpDir, Services: types.Services{ "service1": { Name: "service1", Image: "myapp:{{.Tag}}", Build: &types.BuildConfig{Context: "."}, }, "service2": { Name: "service2", Image: `myapp:v1.0-{{gitsha 7}}`, Build: &types.BuildConfig{Context: "."}, }, "service3": { Name: "service3", Image: "myapp", // No tag, has build, should get default tag template appended. Build: &types.BuildConfig{Context: "."}, }, "service4": { Name: "service4", Image: "", // Empty image, has build, should use default template. Build: &types.BuildConfig{Context: "."}, }, "service5": { Name: "service5", Image: "{{.Project}}/{{.Service}}:custom", // Custom template. Build: &types.BuildConfig{Context: "."}, }, "service6": { Name: "service6", Image: `registry.example.com/{{.Project}}/{{.Service}}:{{gitdate "2006-01-02"}}-{{gitsha 7}}`, Build: &types.BuildConfig{Context: "."}, }, "service7": { Name: "service7", Image: `myapp:{{if .Git.IsRepo}}git-{{.Git.SHA}}{{else}}no-git{{end}}`, Build: &types.BuildConfig{Context: "."}, }, "service8": { Name: "service8", Image: `myapp:{{gitdate "20060102"}}.{{gitsha 10}}{{if .Git.IsDirty}}.dirty{{end}}`, Build: &types.BuildConfig{Context: "."}, }, "postgres": { Name: "postgres", Image: "postgres", // No build section, should NOT get tag appended. }, "redis": { Name: "redis", Image: "redis:7.2", // Third-party with tag, should be unchanged. }, }, } p, err := ProcessImageTemplates(project) require.NoError(t, err) assert.Equal(t, "myapp:"+expectedTag, p.Services["service1"].Image) assert.Equal(t, "myapp:v1.0-"+gitState.SHA[:7], p.Services["service2"].Image) assert.Equal(t, "myapp:"+expectedTag, p.Services["service3"].Image) assert.Equal(t, "myproject/service4:"+expectedTag, p.Services["service4"].Image) assert.Equal(t, "myproject/service5:custom", p.Services["service5"].Image) expectedService6 := "registry.example.com/myproject/service6:" + expectedDate + "-" + gitState.SHA[:7] assert.Equal(t, expectedService6, p.Services["service6"].Image) assert.Equal(t, "myapp:git-"+gitState.SHA, p.Services["service7"].Image) expectedService8 := "myapp:" + gitState.Date.UTC().Format("20060102") + "." + gitState.SHA[:10] assert.Equal(t, expectedService8, p.Services["service8"].Image) // Images without build section should be unchanged. assert.Equal(t, "postgres", p.Services["postgres"].Image, "postgres image should not have tag appended") assert.Equal(t, "redis:7.2", p.Services["redis"].Image, "redis image should be unchanged") }) t.Run("dirty repository", func(t *testing.T) { // Make the repository dirty by creating an uncommitted file. dirtyFile := filepath.Join(tmpDir, "uncommitted.txt") err := os.WriteFile(dirtyFile, []byte("uncommitted changes"), 0o644) require.NoError(t, err) dirtyGitState, err := gitutil.InspectGitState(tmpDir) require.NoError(t, err) require.True(t, dirtyGitState.IsDirty) require.Equal(t, gitState.SHA, dirtyGitState.SHA) project := &types.Project{ Name: "myproject", WorkingDir: tmpDir, Services: types.Services{ "default": { Name: "default", Build: &types.BuildConfig{Context: "."}, }, "app": { Name: "app", Image: `myapp:{{gitdate "2006-01-02"}}-{{gitsha 7}}{{if .Git.IsDirty}}.dirty{{end}}`, Build: &types.BuildConfig{Context: "."}, }, }, } p, err := ProcessImageTemplates(project) require.NoError(t, err) expectedDirtyDate := dirtyGitState.Date.UTC().Format("2006-01-02") assert.Equal(t, "myproject/default:"+expectedTag+".dirty", p.Services["default"].Image) expectedApp := "myapp:" + expectedDirtyDate + "-" + dirtyGitState.SHA[:7] + ".dirty" assert.Equal(t, expectedApp, p.Services["app"].Image) }) t.Run("non-git directory", func(t *testing.T) { nonGitDir := t.TempDir() project := &types.Project{ Name: "myproject", WorkingDir: nonGitDir, Services: types.Services{ "default": { Name: "default", Build: &types.BuildConfig{Context: "."}, }, "app": { Name: "app", Image: `myapp:{{if .Git.IsRepo}}git{{else}}{{date "2006-01-02"}}{{end}}`, Build: &types.BuildConfig{Context: "."}, }, }, } p, err := ProcessImageTemplates(project) require.NoError(t, err) assert.Regexp(t, regexp.MustCompile("^myproject/default:"+expectedDate+`-\d{6}$`), p.Services["default"].Image) assert.Equal(t, "myapp:"+expectedDate, p.Services["app"].Image) }) } func TestProcessImageTemplate(t *testing.T) { // Fixed time for testing: 2025-08-16 13:07:34 UTC. fixedTime := time.Date(2025, 8, 16, 13, 7, 34, 0, time.UTC) gitState := gitutil.GitState{ Date: fixedTime, IsDirty: false, IsRepo: true, SHA: "84d33bb1234567890abcdef1234567890abcdef", } ctx, err := newImageTemplateContext("myproject", "myservice", gitState) require.NoError(t, err) tests := []struct { name string image string expected string wantErr bool }{ { name: "no template markers", image: "myapp:v1.0", expected: "myapp:v1.0", }, { name: "git SHA function with length", image: "myapp:{{gitsha 10}}", expected: "myapp:84d33bb123", }, { name: "git SHA function full length", image: "myapp:{{gitsha}}", expected: "myapp:84d33bb1234567890abcdef1234567890abcdef", }, { name: "git SHA function negative length", image: "myapp:{{gitsha -1}}", expected: "myapp:84d33bb1234567890abcdef1234567890abcdef", }, { name: "git date", image: `myapp:{{gitdate "2006-01-02"}}`, expected: "myapp:2025-08-16", }, { name: "git date with time", image: `myapp:{{gitdate "2006-01-02-150405"}}`, expected: "myapp:2025-08-16-130734", }, { name: "git date with timezone", image: `myapp:{{gitdate "2006-01-02-150405" "Australia/Brisbane"}}`, expected: "myapp:2025-08-16-230734", }, { name: "current date", image: `myapp:{{date "2006-01-02"}}`, expected: `^myapp:\d{4}-\d{2}-\d{2}$`, }, { name: "current date with time", image: `myapp:{{date "20060102-150405"}}`, expected: `^myapp:\d{8}-\d{6}$`, }, { name: "current date with timezone", image: `myapp:{{date "20060102-150405" "Local"}}`, expected: `^myapp:\d{8}-\d{6}$`, }, { name: "multiple template functions", image: `myapp:v1.0-{{gitdate "2006-01-02"}}-{{gitsha 7}}`, expected: "myapp:v1.0-2025-08-16-84d33bb", }, { name: "project and service variables", image: "{{.Project}}/{{.Service}}:{{gitsha 7}}", expected: "myproject/myservice:84d33bb", }, { name: "git SHA variable", image: "myapp:{{.Git.SHA}}", expected: "myapp:84d33bb1234567890abcdef1234567890abcdef", }, { name: "tag variable", image: "myapp:{{.Tag}}", expected: "myapp:2025-08-16-130734.84d33bb", }, { name: "default image template short", image: "{{.Project}}/{{.Service}}:{{.Tag}}", expected: "myproject/myservice:2025-08-16-130734.84d33bb", }, { name: "default image template long", image: `{{.Project}}/{{.Service}}:{{if .Git.IsRepo}}{{gitdate "2006-01-02-150405"}}.{{gitsha 7}}{{if .Git.IsDirty}}.dirty{{end}}{{else}}{{date "2006-01-02-150405"}}{{end}}`, expected: "myproject/myservice:2025-08-16-130734.84d33bb", }, { name: "whitespace only", image: " ", expected: " ", }, { name: "invalid template syntax", image: "myapp:{{.Git.NonExistent.Field}}", wantErr: true, }, { name: "unclosed template", image: "myapp:{{.Tag", wantErr: true, }, { name: "invalid gitsha argument - not a number", image: `myapp:{{gitsha "abc"}}`, wantErr: true, }, { name: "invalid date format - missing quotes", image: "myapp:{{date 2006-01-02}}", wantErr: true, }, { name: "division by zero in template", image: "myapp:{{divide 1 0}}", wantErr: true, }, { name: "undefined function", image: "myapp:{{undefined_func}}", wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result, err := processImageTemplate(tt.image, ctx) if tt.wantErr { assert.Error(t, err) } else { assert.NoError(t, err) if strings.HasPrefix(tt.expected, "^") { assert.Regexp(t, tt.expected, result) } else { assert.Equal(t, tt.expected, result) } } }) } } func TestHasTag(t *testing.T) { tests := []struct { name string image string want bool wantErr bool }{ { name: "image with tag", image: "myapp:v1.0", want: true, }, { name: "image without tag", image: "myapp", want: false, }, { name: "registry with port and tag", image: "localhost:5000/myapp:v1.0", want: true, }, { name: "registry with port, no tag", image: "localhost:5000/myapp", want: false, }, { name: "registry path with tag", image: "registry.example.com/org/myapp:v1.0", want: true, }, { name: "registry path without tag", image: "registry.example.com/org/myapp", want: false, }, { name: "image with digest", image: "myapp@sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", want: true, }, { name: "registry with tag and digest", image: "registry.example.com/myapp:v1.0@sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", want: true, }, // Invalid image references. { name: "image with empty tag", image: "myapp:", want: false, wantErr: true, }, { name: "empty string", image: "", want: false, wantErr: true, }, { name: "invalid characters - spaces", image: "my app:v1.0", want: false, wantErr: true, }, { name: "invalid characters - uppercase in name", image: "MyApp:v1.0", want: false, wantErr: true, }, { name: "invalid digest format", image: "myapp@invalid-digest", want: false, wantErr: true, }, { name: "double colon", image: "myapp::tag", want: false, wantErr: true, }, { name: "starts with slash", image: "/myapp:v1.0", want: false, wantErr: true, }, { name: "ends with slash", image: "myapp/:v1.0", want: false, wantErr: true, }, { name: "invalid tag characters", image: "myapp:v1.0@latest", want: false, wantErr: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result, err := hasTag(tt.image) if tt.wantErr { assert.Error(t, err) } else { assert.NoError(t, err) assert.Equal(t, tt.want, result) } }) } } // Helper functions. func initGitRepo(t *testing.T, dir string) { t.Helper() // Initialize git repo. runGitCommand(t, dir, "init") runGitCommand(t, dir, "config", "user.email", "test@example.com") runGitCommand(t, dir, "config", "user.name", "Test User") } func runGitCommand(t *testing.T, dir string, args ...string) { t.Helper() cmd := exec.Command("git", args...) cmd.Dir = dir output, err := cmd.CombinedOutput() require.NoError(t, err, "git command failed: %s", output) }