chore: implement image template processing using git repo state (not enabled)

This commit is contained in:
Pasha Sviderski
2025-11-01 15:50:36 +10:00
parent 91bf135a4f
commit 328ace1fd1
4 changed files with 666 additions and 6 deletions
+3 -3
View File
@@ -10,14 +10,14 @@ import (
// GitState contains information about the Git repository state.
type GitState struct {
// Date is the current commit datetime.
Date time.Time
// IsDirty indicates whether there are uncommitted changes.
IsDirty bool
// IsRepo indicates whether the directory is a Git repository.
IsRepo bool
// SHA is the full SHA-1 (40 characters) of the current commit.
SHA string
// Time is the current commit datetime.
Time time.Time
}
// InspectGitState inspects the Git repo state from the specified directory.
@@ -52,7 +52,7 @@ func InspectGitState(dir string) (GitState, error) {
if err != nil {
return state, fmt.Errorf("parse current commit timestamp: %w", err)
}
state.Time = time.Unix(seconds, 0)
state.Date = time.Unix(seconds, 0).UTC()
// Check for uncommitted changes.
status, err := gitCommand(dir, "status", "--porcelain")
+3 -3
View File
@@ -22,7 +22,7 @@ func TestInspectGitState_GitNotAvailable(t *testing.T) {
assert.False(t, state.IsRepo)
assert.Empty(t, state.SHA)
assert.Empty(t, state.ShortSHA(7))
assert.True(t, state.Time.IsZero())
assert.True(t, state.Date.IsZero())
assert.False(t, state.IsDirty)
}
@@ -42,7 +42,7 @@ func TestInspectGitState_NotARepo(t *testing.T) {
assert.False(t, state.IsRepo)
assert.Empty(t, state.SHA)
assert.Empty(t, state.ShortSHA(7))
assert.True(t, state.Time.IsZero()) // Should have zero time if not a repo.
assert.True(t, state.Date.IsZero()) // Should have zero time if not a repo.
assert.False(t, state.IsDirty)
}
@@ -59,7 +59,7 @@ func TestInspectGitState_CleanRepo(t *testing.T) {
assert.True(t, state.IsRepo)
assert.Len(t, state.SHA, 40)
assert.Equal(t, state.ShortSHA(7), state.SHA[:7])
assert.False(t, state.Time.IsZero())
assert.False(t, state.Date.IsZero())
assert.False(t, state.IsDirty)
}
+194
View File
@@ -0,0 +1,194 @@
package compose
import (
"bytes"
"fmt"
"text/template"
"time"
"github.com/compose-spec/compose-go/v2/types"
"github.com/distribution/reference"
"github.com/psviderski/uncloud/internal/gitutil"
)
const (
// DefaultTagTemplate is the default tag template appended to images without a tag when building locally.
// Format:
// If the project workdir is a git repo: "2025-08-16-130734.84d33bb" or "2025-08-16-130734.84d33bb.dirty"
// If not a git repo: "2025-08-16-130734" which is the current date and time.
DefaultTagTemplate = `{{if .Git.IsRepo}}{{gitdate "2006-01-02-150405"}}.{{gitsha 7}}{{if .Git.IsDirty}}.dirty{{end}}{{else}}{{date "2006-01-02-150405"}}{{end}}`
// DefaultImageTemplate is the default image template used when no image is specified.
DefaultImageTemplate = "{{.Project}}/{{.Service}}:" + DefaultTagTemplate
)
// ImageTemplateContext contains structured data for image template processing.
type ImageTemplateContext struct {
// Project is the project name.
Project string
// Service is the service name where the image is defined.
Service string
// Git is the git repo state.
Git gitutil.GitState
// Tag is the rendered DefaultTagTemplate used for images without a tag.
Tag string
}
// newImageTemplateContext creates an ImageTemplateContext for the given project, service, and git state.
func newImageTemplateContext(
projectName, serviceName string, gitState gitutil.GitState,
) (ImageTemplateContext, error) {
ctx := ImageTemplateContext{
Project: projectName,
Service: serviceName,
Git: gitState,
}
tag, err := processImageTemplate(DefaultTagTemplate, ctx)
if err != nil {
return ImageTemplateContext{}, fmt.Errorf("process default tag template: %w", err)
}
ctx.Tag = tag
return ctx, nil
}
// ProcessImageTemplates processes image names in services to expand Go template expressions using
// git metadata. If a service has no image specified, uses the default template {{.Project}}/{{.Service}}:{{.Tag}}.
// If a service has a build section and an image without a tag, appends the default tag template.
// Fully specified images are left unchanged.
//
// Template context: ImageTemplateContext
// Template functions: gitsha(length?), gitdate(format, timezone?), date(format, timezone?)
//
// Examples:
//
// image: myapp:{{.Tag}} → myapp:2025-08-16-130734.84d33bb
// image: registry.io/api:v1.0-{{gitsha 7}} → registry.io/api:v1.0-84d33bb
// image: worker:{{gitdate "2006-01-02"}} → worker:2025-08-16
// build: . / image: mybackend → mybackend:2025-08-16-130734.84d33bb
// build: . / (no image) → myproject/myservice:2025-08-16-130734.84d33bb
// image: postgres → postgres (unchanged)
func ProcessImageTemplates(project *types.Project) (*types.Project, error) {
gitState, err := gitutil.InspectGitState(project.WorkingDir)
if err != nil {
return nil, fmt.Errorf("inspect git state at '%s': %w", project.WorkingDir, err)
}
ctx, err := newImageTemplateContext(project.Name, "", gitState)
if err != nil {
return nil, err
}
// Process each service's image.
for name, service := range project.Services {
ctx.Service = service.Name
processedImage := ""
image := service.Image
if image == "" {
image = DefaultImageTemplate
} else {
// Check if image contains any template markers.
processed, err := processImageTemplate(image, ctx)
if err != nil {
return nil, fmt.Errorf("process image template for service '%s': %w", name, err)
}
if processed == image {
// Image doesn't contain template markers.
// If it doesn't have a tag and has a build section, use the default template for the tag.
tagged, err := hasTag(image)
if err != nil {
return nil, err
}
if !tagged && service.Build != nil {
image = image + ":" + DefaultTagTemplate
}
} else {
// Image contains a template which has been processed successfully so use the result.
processedImage = processed
}
}
if processedImage == "" {
if processedImage, err = processImageTemplate(image, ctx); err != nil {
return nil, fmt.Errorf("process image template for service '%s': %w", name, err)
}
}
service.Image = processedImage
project.Services[name] = service
}
return project, nil
}
// processImageTemplate processes a single image string through the template engine.
func processImageTemplate(image string, ctx ImageTemplateContext) (string, error) {
funcMap := template.FuncMap{
"gitsha": func(length ...int) string {
l := 0
if len(length) > 0 {
l = length[0]
}
return ctx.Git.ShortSHA(l)
},
"gitdate": func(layout string, timezone ...string) string {
if !ctx.Git.IsRepo || ctx.Git.Date.IsZero() {
return ""
}
date := ctx.Git.Date
if len(timezone) > 0 {
if loc, err := time.LoadLocation(timezone[0]); err == nil {
date = date.In(loc)
}
}
return date.Format(layout)
},
"date": func(layout string, timezone ...string) string {
t := time.Now().UTC()
if len(timezone) > 0 {
if loc, err := time.LoadLocation(timezone[0]); err == nil {
t = t.In(loc)
}
}
return t.Format(layout)
},
}
// Parse and execute the template.
tmpl, err := template.New("image").Funcs(funcMap).Parse(image)
if err != nil {
return "", fmt.Errorf("parse image template '%s': %w", image, err)
}
var buf bytes.Buffer
if err = tmpl.Execute(&buf, ctx); err != nil {
return "", fmt.Errorf("execute image template: %w", err)
}
return buf.String(), nil
}
// hasTag checks if an image name already has an explicit tag or digest.
// Returns an error if the image reference is invalid.
func hasTag(image string) (bool, error) {
ref, err := reference.ParseNormalizedNamed(image)
if err != nil {
return false, fmt.Errorf("parse image reference '%s': %w", image, err)
}
// Check if it has an explicit tag (e.g., "myapp:v1.0").
if _, ok := ref.(reference.Tagged); ok {
return true, nil
}
// Check if it has a digest (e.g., "myapp@sha256:...").
if _, ok := ref.(reference.Digested); ok {
return true, nil
}
return false, nil
}
+466
View File
@@ -0,0 +1,466 @@
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"), 0644)
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"), 0644)
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)
}