forked from toolshed/abra
update
This commit is contained in:
+98
-102
@@ -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:")
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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"],
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user