mirror of
https://github.com/psviderski/uncloud.git
synced 2026-08-26 11:03:34 +00:00
chore: implement image template processing using git repo state (not enabled)
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user