diff --git a/cli/recipe/upgrade.go b/cli/recipe/upgrade.go index d95a6419..af68dc6d 100644 --- a/cli/recipe/upgrade.go +++ b/cli/recipe/upgrade.go @@ -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:") } diff --git a/cli/recipe/upgrade_e2e_test.go b/cli/recipe/upgrade_e2e_test.go index 390e4b6a..1bb27812 100644 --- a/cli/recipe/upgrade_e2e_test.go +++ b/cli/recipe/upgrade_e2e_test.go @@ -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) } diff --git a/pkg/recipe/compose.go b/pkg/recipe/compose.go index 52b9c0be..b0183412 100644 --- a/pkg/recipe/compose.go +++ b/pkg/recipe/compose.go @@ -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) diff --git a/pkg/recipe/compose_e2e_test.go b/pkg/recipe/compose_e2e_test.go index 34486378..f2dc6dd2 100644 --- a/pkg/recipe/compose_e2e_test.go +++ b/pkg/recipe/compose_e2e_test.go @@ -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"], - }) - }) - } -}