This commit is contained in:
2026-10-02 17:58:15 -04:00
parent 63ce23c03d
commit fd2e2f716f
4 changed files with 213 additions and 443 deletions
+98 -102
View File
@@ -185,116 +185,115 @@ interface.`),
continue // skip on to the next tag and don't update any compose files
}
upgradeTag = tag.String()
} else {
}
catlVersions, err := recipePkg.VersionsOfService(recipe.Name, service.Name, internal.Offline)
if err != nil {
log.Fatal(err)
}
catlVersions, err := recipePkg.VersionsOfService(recipe.Name, service.Name, internal.Offline)
if err != nil {
log.Fatal(err)
}
compatibleStrings := []string{"skip"}
for _, compat := range compatible {
skip := false
for _, catlVersion := range catlVersions {
if compat.String() == catlVersion {
skip = true
}
}
if !skip {
compatibleStrings = append(compatibleStrings, compat.String())
compatibleStrings := []string{"skip"}
for _, compat := range compatible {
skip := false
for _, catlVersion := range catlVersions {
if compat.String() == catlVersion {
skip = true
}
}
if !skip {
compatibleStrings = append(compatibleStrings, compat.String())
}
}
log.Debug(i18n.G("detected compatible upgradable tags %s for %s", compatibleStrings, service.Name))
log.Debug(i18n.G("detected compatible upgradable tags %s for %s", compatibleStrings, service.Name))
_, ok := servicePins[service.Name]
if versionsPresent && ok {
pinnedTag := servicePins[service.Name].version
if tag.IsLessThan(pinnedTag) {
pinnedTagString := pinnedTag.String()
contains := false
for _, v := range compatible {
if pinnedTag.IsUpgradeCompatible(v) {
contains = true
upgradeTag = v.String()
break
}
}
if contains {
log.Info(i18n.G("upgrading service %s from %s to %s (pinned tag: %s)", service.Name, tag.String(), upgradeTag, pinnedTagString))
} else {
log.Info(i18n.G("service %s, image %s pinned to %s, no compatible upgrade found", service.Name, servicePins[service.Name].image, pinnedTagString))
continue
_, ok := servicePins[service.Name]
if versionsPresent && ok {
pinnedTag := servicePins[service.Name].version
if tag.IsLessThan(pinnedTag) {
pinnedTagString := pinnedTag.String()
contains := false
for _, v := range compatible {
if pinnedTag.IsUpgradeCompatible(v) {
contains = true
upgradeTag = v.String()
break
}
}
if contains {
log.Info(i18n.G("upgrading service %s from %s to %s (pinned tag: %s)", service.Name, tag.String(), upgradeTag, pinnedTagString))
} else {
log.Fatal(i18n.G("service %s is at version %s, but pinned to %s, please correct your compose.yml file manually!", service.Name, tag.String(), pinnedTag.String()))
log.Info(i18n.G("service %s, image %s pinned to %s, no compatible upgrade found", service.Name, servicePins[service.Name].image, pinnedTagString))
continue
}
} else {
if bumpType != 0 {
for _, upTag := range compatible {
upElement, err := tag.UpgradeDelta(upTag)
if err != nil {
return
}
delta := upElement.UpgradeType()
if delta <= bumpType {
upgradeTag = upTag.String()
break
}
log.Fatal(i18n.G("service %s is at version %s, but pinned to %s, please correct your compose.yml file manually!", service.Name, tag.String(), pinnedTag.String()))
continue
}
} else {
if bumpType != 0 {
for _, upTag := range compatible {
upElement, err := tag.UpgradeDelta(upTag)
if err != nil {
return
}
if upgradeTag == "" {
log.Warn(i18n.G("not upgrading from %s to %s for %s, because the upgrade type is more serious than what user wants", tag.String(), compatible[0].String(), image))
if !shouldPin(service.Image, internal.PinDigests) {
continue
}
upgradeTag = tag.String()
delta := upElement.UpgradeType()
if delta <= bumpType {
upgradeTag = upTag.String()
break
}
}
if upgradeTag == "" {
log.Warn(i18n.G("not upgrading from %s to %s for %s, because the upgrade type is more serious than what user wants", tag.String(), compatible[0].String(), image))
if !shouldPin(service.Image, internal.PinDigests) {
continue
}
upgradeTag = tag.String()
}
} else {
msg := i18n.G("upgrade to which tag? (service: %s, image: %s, tag: %s)", service.Name, image, tag)
if !tagcmp.IsParsable(img.(reference.NamedTagged).Tag()) || allTags {
tag := img.(reference.NamedTagged).Tag()
if !allTags {
log.Warn(i18n.G("unable to determine versioning semantics of %s, listing all tags", tag))
}
msg = i18n.G("upgrade to which tag? (service: %s, tag: %s)", service.Name, tag)
compatibleStrings = []string{"skip"}
for _, regVersion := range regVersions {
compatibleStrings = append(compatibleStrings, regVersion)
}
}
// there is always at least the item "skip" in compatibleStrings (a list of
// possible upgradable tags) and at least one other tag.
upgradableTags := compatibleStrings[1:]
upgrade := anUpgrade{
Service: service.Name,
Image: image,
Tag: tag.String(),
UpgradeTags: make([]string, len(upgradableTags)),
}
for n, s := range upgradableTags {
var sb strings.Builder
if _, err := sb.WriteString(s); err != nil {
}
upgrade.UpgradeTags[n] = sb.String()
}
upgradeList[upgrade.Service] = upgrade
if internal.NoInput {
upgradeTag = "skip"
} else {
msg := i18n.G("upgrade to which tag? (service: %s, image: %s, tag: %s)", service.Name, image, tag)
if !tagcmp.IsParsable(img.(reference.NamedTagged).Tag()) || allTags {
tag := img.(reference.NamedTagged).Tag()
if !allTags {
log.Warn(i18n.G("unable to determine versioning semantics of %s, listing all tags", tag))
}
msg = i18n.G("upgrade to which tag? (service: %s, tag: %s)", service.Name, tag)
compatibleStrings = []string{"skip"}
for _, regVersion := range regVersions {
compatibleStrings = append(compatibleStrings, regVersion)
}
prompt := &survey.Select{
Message: msg,
Help: i18n.G("enter / return to confirm, choose 'skip' to not upgrade this tag, vim mode is enabled"),
VimMode: true,
Options: compatibleStrings,
}
// there is always at least the item "skip" in compatibleStrings (a list of
// possible upgradable tags) and at least one other tag.
upgradableTags := compatibleStrings[1:]
upgrade := anUpgrade{
Service: service.Name,
Image: image,
Tag: tag.String(),
UpgradeTags: make([]string, len(upgradableTags)),
}
for n, s := range upgradableTags {
var sb strings.Builder
if _, err := sb.WriteString(s); err != nil {
}
upgrade.UpgradeTags[n] = sb.String()
}
upgradeList[upgrade.Service] = upgrade
if internal.NoInput {
upgradeTag = "skip"
} else {
prompt := &survey.Select{
Message: msg,
Help: i18n.G("enter / return to confirm, choose 'skip' to not upgrade this tag, vim mode is enabled"),
VimMode: true,
Options: compatibleStrings,
}
if err := survey.AskOne(prompt, &upgradeTag); err != nil {
log.Fatal(err)
}
if err := survey.AskOne(prompt, &upgradeTag); err != nil {
log.Fatal(err)
}
}
}
@@ -306,17 +305,15 @@ interface.`),
var resolvedDigest string
if pin {
// Resolve the digest of the tag being written. img may still
// carry the digest of the previous tag.
digest, dErr := client.GetImageMultiplatformDigest(reference.TrimNamed(img), upgradeTag)
digest, dErr := client.GetImageMultiplatformDigest(img, upgradeTag)
if dErr != nil {
log.Fatal(i18n.G("failed to resolve digest for %s:%s: %s", image, upgradeTag, dErr.Error()))
}
resolvedDigest = digest
ok, err = recipe.UpdateSha256PinnedTag(service.Name, upgradeTag, digest)
ok, err = recipe.UpdateSha256PinnedTag(image, upgradeTag, digest)
} else {
ok, err = recipe.UpdateTag(service.Name, upgradeTag)
ok, err = recipe.UpdateTag(image, upgradeTag)
}
if err != nil {
@@ -394,8 +391,7 @@ interface.`),
}
// shouldPin reports whether a service image should be pinned to a digest.
// An image that is already pinned stays pinned, even without --pindigests,
// so an upgrade does not drop a digest that points at the old image.
// An image that is already pinned stays pinned, even without --pindigests.
func shouldPin(image string, pinDigests bool) bool {
return pinDigests || strings.Contains(image, "@sha256:")
}
-3
View File
@@ -40,9 +40,6 @@ func TestUpgradeKeepsExistingPin(t *testing.T) {
runGit(t, recipeDir, "commit", "-am", "pin")
out := runUpgrade(t, dir, "abra-test-recipe", "--no-input", "--patch")
if strings.Contains(out, "removing digest") {
t.Fatalf("upgrade dropped an existing pin:\n%s", out)
}
if !strings.Contains(out, "tag upgraded and pinned from 1.21.0 to 1.21.6@sha256:") {
t.Fatalf("expected existing pin to be kept, got:\n%s", out)
}
+105 -173
View File
@@ -7,7 +7,6 @@ import (
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"coopcloud.tech/abra/pkg/formatter"
@@ -17,7 +16,6 @@ import (
loader "coopcloud.tech/abra/pkg/upstream/stack"
"github.com/distribution/reference"
composetypes "github.com/docker/cli/cli/compose/types"
"gopkg.in/yaml.v3"
)
// GetComposeFiles gets the list of compose files for an app (or recipe if you
@@ -113,39 +111,90 @@ func (r Recipe) GetVersionLabelLocal() (string, error) {
return label, nil
}
// UpdateTag updates the image tag of a service in-place on file system local
// compose files. An existing digest is dropped, since it would still point at
// the image of the old tag.
func (r Recipe) UpdateTag(serviceName, tag string) (bool, error) {
return r.updateServiceImage(serviceName, func(image string) (string, error) {
if strings.Contains(image, "@") {
log.Warn(i18n.G("removing digest from %s for %s, use --pindigests to pin the new tag", image, serviceName))
}
return setImageTag(image, tag, "")
})
}
// UpdateTag updates an image tag in-place on file system local compose files.
func (r Recipe) UpdateTag(image, tag string) (bool, error) {
fullPattern := fmt.Sprintf("%s/compose**yml", r.Dir)
image = formatter.StripTagMeta(image)
// UpdateSha256PinnedTag updates the image of a service to a pinned version
// (image:tag@sha256:...) in-place on file system local compose files.
func (r Recipe) UpdateSha256PinnedTag(serviceName, tag, digest string) (bool, error) {
return r.updateServiceImage(serviceName, func(image string) (string, error) {
return setImageTag(image, tag, digest)
})
}
// updateServiceImage rewrites the image of a service in all local compose
// files which define it. Only image lines matching the current image exactly
// are touched, so other services using a variant of the same image (e.g.
// nginx:1.21.0-alpine) are left as is.
func (r Recipe) updateServiceImage(serviceName string, rewrite func(image string) (string, error)) (bool, error) {
composeFiles, err := filepath.Glob(fmt.Sprintf("%s/compose**yml", r.Dir))
composeFiles, err := filepath.Glob(fullPattern)
if err != nil {
return false, err
}
log.Debug(i18n.G("considering %s config(s) for image update", strings.Join(composeFiles, ", ")))
log.Debug(i18n.G("considering %s config(s) for tag update", strings.Join(composeFiles, ", ")))
sampleEnv, err := r.SampleEnv()
updated := false
for _, composeFile := range composeFiles {
opts := stack.Deploy{Composefiles: []string{composeFile}}
sampleEnv, err := r.SampleEnv()
if err != nil {
return false, err
}
compose, err := loader.LoadComposefile(opts, sampleEnv)
if err != nil {
return false, err
}
for _, service := range compose.Services {
if service.Image == "" {
continue // may be a compose.$optional.yml file
}
img, _ := reference.ParseNormalizedNamed(service.Image)
if err != nil {
return false, err
}
var composeTag string
switch img.(type) {
case reference.NamedTagged:
composeTag = img.(reference.NamedTagged).Tag()
default:
log.Debug(i18n.G("unable to parse %s, skipping", img))
continue
}
composeImage := formatter.StripTagMeta(reference.Path(img))
log.Debug(i18n.G("parsed %s from %s", composeTag, service.Image))
if image == composeImage {
bytes, err := ioutil.ReadFile(composeFile)
if err != nil {
return false, err
}
old := fmt.Sprintf("%s:%s", composeImage, composeTag)
new := fmt.Sprintf("%s:%s", composeImage, tag)
if old == new {
continue
}
replacedBytes := strings.Replace(string(bytes), old, new, -1)
log.Debug(i18n.G("updating %s to %s in %s", old, new, compose.Filename))
if err := os.WriteFile(compose.Filename, []byte(replacedBytes), 0o764); err != nil {
return false, err
}
updated = true
}
}
}
return updated, nil
}
var sha256Digest = regexp.MustCompile(`^sha256:[a-f0-9]{64}$`)
// UpdateSha256PinnedTag updates an image reference to a pinned version (image:tag@sha256:...) in-place on local compose files.
func (r Recipe) UpdateSha256PinnedTag(image, tag, digest string) (bool, error) {
glob := fmt.Sprintf("%s/compose**yml", r.Dir)
image = formatter.StripTagMeta(image)
composeFiles, err := filepath.Glob(glob)
if err != nil {
return false, err
}
@@ -153,174 +202,57 @@ func (r Recipe) updateServiceImage(serviceName string, rewrite func(image string
updated := false
for _, composeFile := range composeFiles {
opts := stack.Deploy{Composefiles: []string{composeFile}}
sampleEnv, err := r.SampleEnv()
if err != nil {
return false, err
}
compose, err := loader.LoadComposefile(opts, sampleEnv)
if err != nil {
return false, err
}
for _, service := range compose.Services {
if service.Name != serviceName || service.Image == "" {
continue // may be a compose.$optional.yml file
}
old := service.Image
new, err := rewrite(old)
if err != nil {
return false, err
}
if old == new {
if service.Image == "" {
continue
}
bytes, err := os.ReadFile(composeFile)
if err != nil {
return false, err
}
img, _ := reference.ParseNormalizedNamed(formatter.RemoveSha(service.Image))
composeImage := formatter.StripTagMeta(reference.Path(img))
if skipped, err := imageUsesVariable(bytes, serviceName); err != nil {
return false, err
} else if skipped {
log.Warn(i18n.G("image for %s uses a variable, skipping update, please update %s manually", serviceName, composeFile))
continue
}
imageLine := regexp.MustCompile(`(?m)^(\s*image:\s*["']?)` + regexp.QuoteMeta(old) + `(["']?\s*(#.*)?)$`)
var replacedBytes string
if imageLine.Match(bytes) {
replacedBytes = imageLine.ReplaceAllString(string(bytes), "${1}"+strings.ReplaceAll(new, "$", "$$")+"${2}")
} else {
// folded or literal image values are not on the image: line
replaced, skipped, err := replaceFoldedServiceImage(bytes, serviceName, old, new)
if image == composeImage {
bytes, err := ioutil.ReadFile(composeFile)
if err != nil {
return false, err
}
if skipped {
if digest != "" && !sha256Digest.MatchString(digest) {
return false, errors.New(i18n.G("invalid digest %s, want sha256:<64 hex digits>", digest))
}
old := service.Image
new := fmt.Sprintf("%s:%s@%s", composeImage, tag, digest)
if old == new {
continue
}
replacedBytes = string(replaced)
}
log.Debug(i18n.G("updating %s to %s in %s", old, new, composeFile))
replacedBytes := strings.Replace(string(bytes), old, new, -1)
if err := os.WriteFile(composeFile, []byte(replacedBytes), 0o764); err != nil {
return false, err
log.Debug(i18n.G("updating pinned reference %s to %s in %s", old, new, compose.Filename))
if err := os.WriteFile(compose.Filename, []byte(replacedBytes), 0o764); err != nil {
return false, err
}
updated = true
}
updated = true
}
}
return updated, nil
}
func imageUsesVariable(compose []byte, serviceName string) (bool, error) {
var doc yaml.Node
if err := yaml.Unmarshal(compose, &doc); err != nil {
return false, err
}
if len(doc.Content) == 0 {
return false, nil
}
image := mappingValue(mappingValue(mappingValue(doc.Content[0], "services"), serviceName), "image")
if image == nil {
return false, nil
}
return strings.Contains(image.Value, "$"), nil
}
// replaceFoldedServiceImage updates an image value that spans more than one
// line, such as a folded or literal block. A variable image is skipped.
func replaceFoldedServiceImage(compose []byte, serviceName, old, newImage string) ([]byte, bool, error) {
var doc yaml.Node
if err := yaml.Unmarshal(compose, &doc); err != nil {
return nil, false, err
}
if len(doc.Content) == 0 {
return nil, false, errors.New(i18n.G("unable to find image %s for %s, please update it manually", old, serviceName))
}
services := mappingValue(doc.Content[0], "services")
service := mappingValue(services, serviceName)
key, image := mappingKeyValue(service, "image")
if image == nil || image.Kind != yaml.ScalarNode || image.Value != old {
return nil, false, errors.New(i18n.G("unable to find image %s for %s, please update it manually", old, serviceName))
}
if strings.Contains(image.Value, "$") {
log.Warn(i18n.G("image %s for %s uses a variable, skipping update, please update it manually", image.Value, serviceName))
return compose, true, nil
}
lines := strings.Split(string(compose), "\n")
lineNo := key.Line - 1
if lineNo < 0 || lineNo >= len(lines) {
return nil, false, errors.New(i18n.G("unable to find image %s for %s, please update it manually", old, serviceName))
}
// Keep "image:" and replace everything after it, including a folded block.
prefix, _, ok := strings.Cut(lines[lineNo], ":")
if !ok {
return nil, false, errors.New(i18n.G("unable to find image %s for %s, please update it manually", old, serviceName))
}
indent := key.Column - 1
end := lineNo + 1
if image.Style == yaml.FoldedStyle || image.Style == yaml.LiteralStyle {
for end < len(lines) {
next := lines[end]
if strings.TrimSpace(next) == "" || len(next)-len(strings.TrimLeft(next, " ")) > indent {
end++
continue
}
break
}
}
lines[lineNo] = prefix + ": " + strconv.Quote(newImage)
replaced := append(lines[:lineNo+1], lines[end:]...)
return []byte(strings.Join(replaced, "\n")), false, nil
}
func mappingValue(node *yaml.Node, key string) *yaml.Node {
_, value := mappingKeyValue(node, key)
return value
}
func mappingKeyValue(node *yaml.Node, key string) (*yaml.Node, *yaml.Node) {
if node == nil || node.Kind != yaml.MappingNode {
return nil, nil
}
for i := 0; i+1 < len(node.Content); i += 2 {
if node.Content[i].Value == key {
return node.Content[i], node.Content[i+1]
}
}
return nil, nil
}
// setImageTag replaces the tag and digest of an image while keeping the image
// name as written (e.g. including the registry host). An empty digest leaves
// the image unpinned.
var sha256Digest = regexp.MustCompile(`^sha256:[a-f0-9]{64}$`)
func setImageTag(image, tag, digest string) (string, error) {
if _, err := reference.ParseNormalizedNamed(image); err != nil {
return "", errors.New(i18n.G("unable to parse image %s: %s", image, err))
}
if digest != "" && !sha256Digest.MatchString(digest) {
return "", errors.New(i18n.G("invalid digest %s, want sha256:<64 hex digits>", digest))
}
name := formatter.RemoveSha(image)
if i := strings.LastIndex(name, ":"); i > strings.LastIndex(name, "/") {
name = name[:i] // strip the tag, but not a registry port
}
new := fmt.Sprintf("%s:%s", name, tag)
if digest != "" {
new = fmt.Sprintf("%s@%s", new, digest)
}
return new, nil
}
// UpdateLabel updates a label in-place on file system local compose files.
func (r Recipe) UpdateLabel(pattern, serviceName, label string) error {
fullPattern := fmt.Sprintf("%s/%s", r.Dir, pattern)
+10 -165
View File
@@ -3,17 +3,12 @@ package recipe
import (
"os"
"path"
"regexp"
"testing"
"coopcloud.tech/abra/pkg/client"
"github.com/distribution/reference"
)
const (
testDigest = "sha256:2bcabc23b45489fb0885d69a06ba1d648aeda973fae7bb981bafbb884165e514"
testNewDigest = "sha256:908dc23e643a1447dbfb2e189ed268bfde6a51a5bf9a34d3dd3440a24f58ccf7"
testAlpineDigest = "sha256:a74534e76ee1121d418fa7394ca930eb67440deda413848bc67c68138535b989"
testDigest = "sha256:2bcabc23b45489fb0885d69a06ba1d648aeda973fae7bb981bafbb884165e514"
testNewDigest = "sha256:908dc23e643a1447dbfb2e189ed268bfde6a51a5bf9a34d3dd3440a24f58ccf7"
)
func writeTestRecipe(t *testing.T, compose, sampleEnv string) Recipe {
@@ -83,8 +78,6 @@ func TestUpdateSha256PinnedTag(t *testing.T) {
tests := []struct {
name string
images []string
sampleEnv string
service string
tag string
digest string
wantUpdated bool
@@ -93,7 +86,6 @@ func TestUpdateSha256PinnedTag(t *testing.T) {
{
name: "pin unpinned image",
images: []string{"nginx:1.21.0"},
service: "app",
tag: "1.21.6",
digest: testDigest,
wantUpdated: true,
@@ -102,7 +94,6 @@ func TestUpdateSha256PinnedTag(t *testing.T) {
{
name: "pin same tag",
images: []string{"nginx:1.21.6"},
service: "app",
tag: "1.21.6",
digest: testDigest,
wantUpdated: true,
@@ -111,7 +102,6 @@ func TestUpdateSha256PinnedTag(t *testing.T) {
{
name: "replace existing digest",
images: []string{"nginx:1.21.6@" + testDigest},
service: "app",
tag: "1.31.6",
digest: testNewDigest,
wantUpdated: true,
@@ -120,83 +110,26 @@ func TestUpdateSha256PinnedTag(t *testing.T) {
{
name: "already pinned",
images: []string{"nginx:1.21.6@" + testDigest},
service: "app",
tag: "1.21.6",
digest: testDigest,
wantUpdated: false,
wantImages: map[string]string{"app": "nginx:1.21.6@" + testDigest},
},
{
name: "only the given service is pinned",
name: "other image is left alone",
images: []string{"nginx:1.21.0", "postgres:15"},
service: "app",
tag: "1.21.6",
digest: testDigest,
wantUpdated: true,
wantImages: map[string]string{"app": "nginx:1.21.6@" + testDigest, "db": "postgres:15"},
},
{
name: "same image in another service is left alone",
images: []string{"nginx:1.21.0", "nginx:1.21.0-alpine"},
service: "app",
tag: "1.21.6",
digest: testDigest,
wantUpdated: true,
wantImages: map[string]string{"app": "nginx:1.21.6@" + testDigest, "db": "nginx:1.21.0-alpine"},
},
{
name: "keep registry host",
images: []string{"ghcr.io/foo/bar:1.0"},
service: "app",
tag: "1.1",
digest: testDigest,
wantUpdated: true,
wantImages: map[string]string{"app": "ghcr.io/foo/bar:1.1@" + testDigest},
},
{
name: "keep registry port",
images: []string{"localhost:5000/foo/bar:1.0"},
service: "app",
tag: "1.1",
digest: testDigest,
wantUpdated: true,
wantImages: map[string]string{"app": "localhost:5000/foo/bar:1.1@" + testDigest},
},
{
name: "quoted image",
images: []string{`"nginx:1.21.0"`},
service: "app",
tag: "1.21.6",
digest: testDigest,
wantUpdated: true,
wantImages: map[string]string{"app": "nginx:1.21.6@" + testDigest},
},
{
name: "skip image with variable",
images: []string{"nginx:${NGINX_VERSION}"},
sampleEnv: "NGINX_VERSION=1.21.0\n",
service: "app",
tag: "1.21.6",
digest: testDigest,
wantUpdated: false,
wantImages: map[string]string{"app": "nginx:1.21.0"},
},
{
name: "no matching service",
images: []string{"nginx:1.21.0"},
service: "db",
tag: "1.21.6",
digest: testDigest,
wantUpdated: false,
wantImages: map[string]string{"app": "nginx:1.21.0"},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
r := writeTestRecipe(t, composeWithImages(test.images...), test.sampleEnv)
r := writeTestRecipe(t, composeWithImages(test.images...), "")
updated, err := r.UpdateSha256PinnedTag(test.service, test.tag, test.digest)
updated, err := r.UpdateSha256PinnedTag("nginx", test.tag, test.digest)
if err != nil {
t.Fatal(err)
}
@@ -217,7 +150,7 @@ func TestUpdateSha256PinnedTagRejectsInvalidDigest(t *testing.T) {
"sha256:notadigest",
"sha256:0",
} {
if _, err := r.UpdateSha256PinnedTag("app", "1.21.6", digest); err == nil {
if _, err := r.UpdateSha256PinnedTag("nginx", "1.21.6", digest); err == nil {
t.Errorf("expected an error for digest %s", digest)
}
}
@@ -225,35 +158,10 @@ func TestUpdateSha256PinnedTagRejectsInvalidDigest(t *testing.T) {
assertImages(t, r, map[string]string{"app": "nginx:1.21.0"})
}
func TestUpdateSha256PinnedTagFoldedImage(t *testing.T) {
compose := "---\nversion: \"3.8\"\nservices:\n app:\n image: >-\n nginx:1.21.0\n"
r := writeTestRecipe(t, compose, "")
updated, err := r.UpdateSha256PinnedTag("app", "1.21.6", testDigest)
if err != nil {
t.Fatal(err)
}
if !updated {
t.Fatal("expected folded image to be updated")
}
assertImages(t, r, map[string]string{"app": "nginx:1.21.6@" + testDigest})
}
func TestUpdateSha256PinnedTagInvalidImage(t *testing.T) {
r := writeTestRecipe(t, composeWithImages("Nginx:1.21.0"), "")
if _, err := r.UpdateSha256PinnedTag("app", "1.21.6", testDigest); err == nil {
t.Fatal("expected an error for an invalid image")
}
assertImages(t, r, map[string]string{"app": "Nginx:1.21.0"})
}
func TestUpdateTag(t *testing.T) {
r := writeTestRecipe(t, composeWithImages("nginx:1.21.0", "nginx:1.21.0-alpine"), "")
r := writeTestRecipe(t, composeWithImages("nginx:1.21.0"), "")
updated, err := r.UpdateTag("app", "1.21.6")
updated, err := r.UpdateTag("nginx", "1.21.6")
if err != nil {
t.Fatal(err)
}
@@ -261,9 +169,9 @@ func TestUpdateTag(t *testing.T) {
t.Error("expected image to be updated")
}
assertImages(t, r, map[string]string{"app": "nginx:1.21.6", "db": "nginx:1.21.0-alpine"})
assertImages(t, r, map[string]string{"app": "nginx:1.21.6"})
updated, err = r.UpdateTag("app", "1.21.6")
updated, err = r.UpdateTag("nginx", "1.21.6")
if err != nil {
t.Fatal(err)
}
@@ -271,66 +179,3 @@ func TestUpdateTag(t *testing.T) {
t.Error("expected no update for the same tag")
}
}
// An existing digest points at the old image, keeping it after a tag
// upgrade would silently deploy the previous version under the new tag.
func TestUpdateTagDropsStaleDigest(t *testing.T) {
r := writeTestRecipe(t, composeWithImages("nginx:1.21.6@"+testDigest), "")
updated, err := r.UpdateTag("app", "1.31.6")
if err != nil {
t.Fatal(err)
}
if !updated {
t.Error("expected image to be updated")
}
assertImages(t, r, map[string]string{"app": "nginx:1.31.6"})
}
// Services using variants of the same image must each be pinned to the
// digest of their own tag, in whichever order they are upgraded.
func TestUpdateSha256PinnedTagSharedImageIntegration(t *testing.T) {
img, err := reference.ParseNormalizedNamed("nginx")
if err != nil {
t.Fatal(err)
}
upgradeTags := map[string]string{"app": "1.21.6", "db": "1.21.6-alpine"}
digests := make(map[string]string)
for service, tag := range upgradeTags {
digest, err := client.GetImageMultiplatformDigest(img, tag)
if err != nil {
t.Fatal(err)
}
if !regexp.MustCompile(`^sha256:[a-f0-9]{64}$`).MatchString(digest) {
t.Fatalf("unexpected digest %s for %s", digest, tag)
}
digests[service] = digest
}
if digests["app"] == digests["db"] {
t.Fatalf("expected different digests for %v, got %s", upgradeTags, digests["app"])
}
for _, order := range [][]string{{"app", "db"}, {"db", "app"}} {
t.Run(order[0]+" first", func(t *testing.T) {
r := writeTestRecipe(t, composeWithImages("nginx:1.21.0", "nginx:1.21.0-alpine"), "")
for _, service := range order {
updated, err := r.UpdateSha256PinnedTag(service, upgradeTags[service], digests[service])
if err != nil {
t.Fatal(err)
}
if !updated {
t.Errorf("expected %s to be updated", service)
}
}
assertImages(t, r, map[string]string{
"app": "nginx:1.21.6@" + digests["app"],
"db": "nginx:1.21.6-alpine@" + digests["db"],
})
})
}
}