diff --git a/.gitignore b/.gitignore index 5efc96b2..6a78b513 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ /bin dist/ tests/integration/.bats +.profile/ diff --git a/cli/recipe/release.go b/cli/recipe/release.go index 8263983f..0d902894 100644 --- a/cli/recipe/release.go +++ b/cli/recipe/release.go @@ -343,7 +343,7 @@ func GetImageVersions(recipe recipePkg.Recipe) (map[string]string, error) { continue } - services[path] = formatter.ImageVersion(img) + services[path] = formatter.GetTagAndDigest(img) } if missingTag { diff --git a/cli/recipe/upgrade.go b/cli/recipe/upgrade.go index 5142bc75..2049ef36 100644 --- a/cli/recipe/upgrade.go +++ b/cli/recipe/upgrade.go @@ -209,9 +209,7 @@ interface.`), log.Debug(i18n.G("detected compatible upgradable tags %s for %s", compatibleStrings, service.Name)) _, ok := servicePins[service.Name] - if upgradeTag != "" { - // No newer tag: keep the current target for pinning. - } else if versionsPresent && ok { + if upgradeTag == "" && versionsPresent && ok { pinnedTag := servicePins[service.Name].version if tag.IsLessThan(pinnedTag) { pinnedTagString := pinnedTag.String() @@ -233,7 +231,7 @@ interface.`), 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 { + } else if upgradeTag == "" { if bumpType != 0 { for _, upTag := range compatible { upElement, err := tag.UpgradeDelta(upTag) @@ -302,26 +300,21 @@ interface.`), } } if upgradeTag != "skip" { - pin := shouldPin(service.Image, internal.PinDigests) - var ok bool - var err error var resolvedDigest string - - if pin { - 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())) + if shouldPin(service.Image, internal.PinDigests) { + resolvedDigest, err = client.GetImageMultiplatformDigest(img, upgradeTag) + if err != nil { + log.Fatal(i18n.G("failed to resolve digest for %s:%s: %s", image, upgradeTag, err.Error())) } - resolvedDigest = digest } - ok, err = recipe.UpdateTag(service.Image, upgradeTag, resolvedDigest) + ok, err := recipe.UpdateTag(service.Image, upgradeTag, resolvedDigest) if err != nil { log.Fatal(err) } if ok { - if pin { + if shouldPin(service.Image, internal.PinDigests) { log.Info(i18n.G("tag upgraded and pinned from %s to %s@%s for %s", tag.String(), upgradeTag, resolvedDigest, image)) } else { log.Info(i18n.G("tag upgraded from %s to %s for %s", tag.String(), upgradeTag, image)) diff --git a/pkg/client/registry.go b/pkg/client/registry.go index 69e182db..0b9713c3 100644 --- a/pkg/client/registry.go +++ b/pkg/client/registry.go @@ -37,12 +37,13 @@ func GetRegistryTags(img reference.Named) ([]string, error) { // See: https://docs.docker.com/build/building/multi-platform/#why-multi-platform-builds // and: https://docs.docker.com/dhi/explore/security-concepts/digests/#multi-platform-images-and-manifests func GetImageMultiplatformDigest(img reference.Named, tag string) (string, error) { - // Drop any digest. A reference with both a tag and a digest is fetched by - // digest, which would pin the new tag to the old image. - name := reference.TrimNamed(img).Name() - r, err := ref.New(fmt.Sprintf("%s:%s", name, tag)) + tagged, err := reference.WithTag(reference.TrimNamed(img), tag) if err != nil { - return "", errors.New(i18n.G("failed to parse image %s:%s, saw: %s", name, tag, err.Error())) + return "", errors.New(i18n.G("failed to parse image %s:%s, saw: %s", img.Name(), tag, err.Error())) + } + r, err := ref.New(tagged.String()) + if err != nil { + return "", errors.New(i18n.G("failed to parse image %s:%s, saw: %s", img.Name(), tag, err.Error())) } // Use the credentials of the docker cli, which enables using a private diff --git a/pkg/client/registry_e2e_test.go b/pkg/client/registry_e2e_test.go index 91f99f78..856ba5c0 100644 --- a/pkg/client/registry_e2e_test.go +++ b/pkg/client/registry_e2e_test.go @@ -38,4 +38,17 @@ func TestGetImageMultiplatformDigest(t *testing.T) { if older == newer { t.Errorf("expected different digests for 1.21.5 and 1.21.6, got %s", newer) } + + // A previous pin must not override the requested tag. + pinned, err := reference.ParseNormalizedNamed("nginx:1.21.5@" + older) + if err != nil { + t.Fatal(err) + } + digest, err := GetImageMultiplatformDigest(pinned, "1.21.6") + if err != nil { + t.Fatal(err) + } + if digest != newer { + t.Errorf("got digest %s, want %s for the new tag", digest, newer) + } } diff --git a/pkg/deploy/utils.go b/pkg/deploy/utils.go index a18a1197..683d91dc 100644 --- a/pkg/deploy/utils.go +++ b/pkg/deploy/utils.go @@ -171,7 +171,7 @@ func GetImagesForStack(cl *dockerClient.Client, app appPkg.App) (map[string]stri images[imageBaseName] = "" continue } - imageTag := formatter.ImageVersion(namedTag) + imageTag := formatter.GetTagAndDigest(namedTag) existingImageVersion, ok := images[imageBaseName] if !ok { @@ -294,7 +294,7 @@ func GatherImagesForDeploy(cl *dockerClient.Client, app appPkg.App, compose *com newImages[imageBaseName] = "" continue } - imageTag := formatter.ImageVersion(namedTag) + imageTag := formatter.GetTagAndDigest(namedTag) existingImageVersion, ok := newImages[imageBaseName] if !ok { diff --git a/pkg/formatter/formatter.go b/pkg/formatter/formatter.go index 5db74d9e..a1df4292 100644 --- a/pkg/formatter/formatter.go +++ b/pkg/formatter/formatter.go @@ -41,8 +41,8 @@ func RemoveSha(str string) string { return strings.Split(str, "@")[0] } -// ImageVersion returns the tag and any digest for comparison and display. -func ImageVersion(image reference.Named) string { +// GetTagAndDigest returns the tag and any digest for comparison and display. +func GetTagAndDigest(image reference.Named) string { var version string if tagged, ok := image.(reference.NamedTagged); ok { version = tagged.Tag() diff --git a/pkg/formatter/formatter_test.go b/pkg/formatter/formatter_test.go index 29885f87..55f0e0c5 100644 --- a/pkg/formatter/formatter_test.go +++ b/pkg/formatter/formatter_test.go @@ -18,7 +18,7 @@ func TestBoldDirtyDefault(t *testing.T) { assert.Equal(t, "foo", BoldDirtyDefault("foo")) } -func TestImageVersion(t *testing.T) { +func TestGetTagAndDigest(t *testing.T) { digest := "sha256:908dc23e643a1447dbfb2e189ed268bfde6a51a5bf9a34d3dd3440a24f58ccf7" for _, tc := range []struct{ image, want string }{ {"nginx:1.21.6", "1.21.6"}, @@ -30,7 +30,7 @@ func TestImageVersion(t *testing.T) { if err != nil { t.Fatal(err) } - if got := ImageVersion(img); got != tc.want { + if got := GetTagAndDigest(img); got != tc.want { t.Errorf("%s: got %s, want %s", tc.image, got, tc.want) } } diff --git a/pkg/recipe/compose.go b/pkg/recipe/compose.go index d441dc5a..275ba05f 100644 --- a/pkg/recipe/compose.go +++ b/pkg/recipe/compose.go @@ -16,7 +16,6 @@ import ( loader "coopcloud.tech/abra/pkg/upstream/stack" "github.com/distribution/reference" composetypes "github.com/docker/cli/cli/compose/types" - godigest "github.com/opencontainers/go-digest" ) // GetComposeFiles gets the list of compose files for an app (or recipe if you @@ -181,19 +180,15 @@ func (r Recipe) UpdateTag(image, tag, digest string) (bool, error) { if err != nil { return false, err } - var target reference.Named = tagged + new := reference.FamiliarString(tagged) if digest != "" { - target, err = reference.WithDigest(tagged, godigest.Digest(digest)) - if err != nil { - return false, err - } + new += "@" + digest } - new := reference.FamiliarString(target) if old == new { continue } - // Match a complete image value, not a prefix of another image. + // Match complete image values so sibling tags and existing pins aren't rewritten. pattern := regexp.MustCompile(`(?m)(^[\t ]*image:[\t ]*["']?)` + regexp.QuoteMeta(old) + `(["']?[\t ]*(?:#.*)?$)`) replacedBytes := pattern.ReplaceAllStringFunc(string(bytes), func(line string) string { match := pattern.FindStringSubmatch(line) diff --git a/tests/integration/app_deploy.bats b/tests/integration/app_deploy.bats index 542f58ea..18c5ca4a 100644 --- a/tests/integration/app_deploy.bats +++ b/tests/integration/app_deploy.bats @@ -703,49 +703,64 @@ teardown(){ assert_output --partial "unexpected character" } +# Deploy pinned 1.21.5, upgrade to pinned 1.21.6, then roll back to 1.21.5. # bats test_tags=slow @test "deploy, upgrade and rollback digest-pinned images" { recipeDir="$ABRA_DIR/recipes/$TEST_RECIPE" service="${TEST_APP_DOMAIN//./_}_app" - # https://git.coopcloud.tech/toolshed/abra-test-recipe/commit/4f0642719d2e2a568f4350cf7eea182438d58be3 - run git -C "$recipeDir" checkout 4f0642719d2e2a568f4350cf7eea182438d58be3 + # Manually upgrade the unpinned 0.3.5 recipe to 1.21.5 and pin it. + run git -C "$recipeDir" checkout "0.3.5+1.21.0" + assert_success + run docker pull nginx:1.21.5 + assert_success + run docker image inspect --format '{{index .RepoDigests 0}}' nginx:1.21.5 + assert_success + oldImage="nginx:1.21.5@${output#*@}" + run sed -i "s|image: nginx:1.21.0|image: $oldImage|" "$recipeDir/compose.yml" assert_success + # Label and tag the local recipe release so app commands can track it. run sed -i 's/version=0.3.5+1.21.0/version=0.4.0+1.21.5/' "$recipeDir/compose.yml" assert_success _git_commit run git -C "$recipeDir" tag "0.4.0+1.21.5" assert_success - # https://git.coopcloud.tech/toolshed/abra-test-recipe/commit/77d752b3c61a71bfb2c45f40ec07c9d626a0e580 - run git -C "$recipeDir" checkout 77d752b3c61a71bfb2c45f40ec07c9d626a0e580 + # Manually upgrade and pin the next release to 1.21.6. + run docker pull nginx:1.21.6 assert_success - run $ABRA recipe upgrade "$TEST_RECIPE" --patch --pindigests --chaos --offline --no-input + run docker image inspect --format '{{index .RepoDigests 0}}' nginx:1.21.6 assert_success - run sed -i 's/version=0.3.5+1.21.0/version=0.4.1+1.21.6/' "$recipeDir/compose.yml" + newImage="nginx:1.21.6@${output#*@}" + run sed -i "s|image: $oldImage|image: $newImage|" "$recipeDir/compose.yml" + assert_success + + # Update the release label to match the next recipe tag. + run sed -i 's/version=0.4.0+1.21.5/version=0.4.1+1.21.6/' "$recipeDir/compose.yml" assert_success _git_commit run git -C "$recipeDir" tag "0.4.1+1.21.6" assert_success + # Deploy recipe 0.4.0 with the exact pinned 1.21.5 image. run $ABRA app deploy "$TEST_APP_DOMAIN" "0.4.0+1.21.5" --offline --no-input --no-converge-checks assert_success run docker service inspect --format '{{.Spec.TaskTemplate.ContainerSpec.Image}}' "$service" assert_success - assert_output --regexp '^nginx:1\.21\.5@sha256:[a-f0-9]{64}$' - oldImage="$output" + assert_output "$oldImage" + # Upgrade to recipe 0.4.1 and the exact pinned 1.21.6 image. run $ABRA app upgrade "$TEST_APP_DOMAIN" "0.4.1+1.21.6" --offline --no-input --no-converge-checks assert_success run docker service inspect --format '{{.Spec.TaskTemplate.ContainerSpec.Image}}' "$service" assert_success - assert_output --regexp '^nginx:1\.21\.6@sha256:[a-f0-9]{64}$' + assert_output "$newImage" + # Roll back to recipe 0.4.0 and its original pinned image. run $ABRA app rollback "$TEST_APP_DOMAIN" "0.4.0+1.21.5" --offline --no-input --no-converge-checks assert_success run docker service inspect --format '{{.Spec.TaskTemplate.ContainerSpec.Image}}' "$service" assert_success assert_output "$oldImage" } -