From 8a81dedf3b44187fac0b6e89d10b747614aed1a4 Mon Sep 17 00:00:00 2001 From: Linus Gasser Date: Thu, 22 Oct 2026 18:37:19 +0200 Subject: [PATCH] Allow digests in docker images For the nextcloud update we need to be able to indicate the digest of the docker image, as the docker tag is the same for the vulnerable and the fixed docker image. This avoids 'abra update' to re-install with a warning an old image if the connection to docker hub fails. --- cli/recipe/upgrade.go | 51 +++++++++++++ go.mod | 2 +- pkg/client/registry.go | 53 ++++++++++++- pkg/i18n/locales/abra.pot | 149 ++++++++++++++++++++++--------------- pkg/recipe/compose.go | 54 ++++++++++++-- pkg/recipe/compose_test.go | 127 +++++++++++++++++++++++++++++++ 6 files changed, 367 insertions(+), 69 deletions(-) create mode 100644 pkg/recipe/compose_test.go diff --git a/cli/recipe/upgrade.go b/cli/recipe/upgrade.go index 9e458d0e..a99270e1 100644 --- a/cli/recipe/upgrade.go +++ b/cli/recipe/upgrade.go @@ -129,6 +129,9 @@ interface.`), log.Fatal(err) } + // digest pinned images shared between services are only checked once + digestChecked := make(map[string]bool) + for _, service := range config.Services { img, err := reference.ParseNormalizedNamed(service.Image) if err != nil { @@ -178,6 +181,13 @@ interface.`), sort.Sort(tagcmp.ByTagDesc(compatible)) + if canonical, ok := img.(reference.Canonical); ok && !digestChecked[service.Image] { + digestChecked[service.Image] = true + if err := refreshDigest(recipe, img, image, canonical.Digest().String()); err != nil { + log.Fatal(err) + } + } + if len(compatible) == 0 && !allTags { log.Info(i18n.G("no new versions available for %s, assuming %s is the latest (use -a/--all-tags to see all anyway)", image, tag)) continue // skip on to the next tag and don't update any compose files @@ -364,6 +374,47 @@ interface.`), }, } +// refreshDigest checks whether the current tag of a digest pinned image was +// re-published upstream with a new digest and offers to update the pin. +func refreshDigest(recipe recipePkg.Recipe, img reference.Named, image, digest string) error { + tag := img.(reference.NamedTagged).Tag() + + registryDigest, err := client.GetRegistryDigest(img, tag) + if err != nil { + return err + } + + if registryDigest == digest { + log.Debug(i18n.G("digest of %s:%s is up to date", image, tag)) + return nil + } + + log.Warn(i18n.G("%s:%s was re-published upstream (digest %s → %s)", image, tag, digest, registryDigest)) + + update := false + if !internal.NoInput { + prompt := &survey.Confirm{ + Message: i18n.G("update digest of %s:%s?", image, tag), + Default: true, + } + if err := survey.AskOne(prompt, &update); err != nil { + return err + } + } + + if !update { + return nil + } + + if _, err := recipe.UpdateTag(image, tag); err != nil { + return err + } + + log.Info(i18n.G("digest updated for %s:%s", image, tag)) + + return nil +} + var ( allTags bool createCommit bool diff --git a/go.mod b/go.mod index ce0550b9..c5d89025 100644 --- a/go.mod +++ b/go.mod @@ -21,6 +21,7 @@ require ( github.com/leonelquinteros/gotext v1.8.0 github.com/moby/sys/signal v0.7.1 github.com/moby/term v0.5.2 + github.com/opencontainers/image-spec v1.1.1 github.com/pkg/errors v0.9.1 github.com/schollz/progressbar/v3 v3.19.1 github.com/spf13/cobra v1.10.1 @@ -105,7 +106,6 @@ require ( github.com/muesli/termenv v0.16.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect - github.com/opencontainers/image-spec v1.1.1 // indirect github.com/opencontainers/runc v1.1.13 // indirect github.com/opencontainers/runtime-spec v1.1.0 // indirect github.com/pjbgf/sha1cd v0.7.0 // indirect diff --git a/pkg/client/registry.go b/pkg/client/registry.go index 47d57bdc..1b45483a 100644 --- a/pkg/client/registry.go +++ b/pkg/client/registry.go @@ -4,18 +4,35 @@ import ( "context" "errors" "fmt" + "slices" "coopcloud.tech/abra/pkg/i18n" "github.com/containers/image/docker" + "github.com/containers/image/manifest" "github.com/containers/image/types" "github.com/distribution/reference" + imgspecv1 "github.com/opencontainers/image-spec/specs-go/v1" ) +func init() { + // NOTE: containers/image does not request OCI image indexes by default. + // Many multi-arch images (e.g. on Docker Hub) are published as OCI indexes + // and the registry refuses to serve them without the matching Accept header. + if !slices.Contains(manifest.DefaultRequestedManifestMIMETypes, imgspecv1.MediaTypeImageIndex) { + manifest.DefaultRequestedManifestMIMETypes = append( + manifest.DefaultRequestedManifestMIMETypes, + imgspecv1.MediaTypeImageIndex, + ) + } +} + // GetRegistryTags retrieves all tags of an image from a container registry. func GetRegistryTags(img reference.Named) ([]string, error) { var tags []string - ref, err := docker.ParseReference(fmt.Sprintf("//%s", img)) + // NOTE: strip tag and digest, containers/image refuses references + // which have both (e.g. nextcloud:34.0.5-fpm@sha256:...) + ref, err := docker.ParseReference(fmt.Sprintf("//%s", reference.TrimNamed(img))) if err != nil { return tags, errors.New(i18n.G("failed to parse image %s, saw: %s", img, err.Error())) } @@ -28,3 +45,37 @@ func GetRegistryTags(img reference.Named) ([]string, error) { return tags, nil } + +// GetRegistryDigest retrieves the manifest digest of an image tag from a +// container registry. For multi-arch images, this is the digest of the image +// index, which is what Docker resolves "image:tag" to. +func GetRegistryDigest(img reference.Named, tag string) (string, error) { + tagged, err := reference.WithTag(reference.TrimNamed(img), tag) + if err != nil { + return "", errors.New(i18n.G("failed to parse image %s, saw: %s", img, err.Error())) + } + + ref, err := docker.ParseReference(fmt.Sprintf("//%s", tagged)) + if err != nil { + return "", errors.New(i18n.G("failed to parse image %s, saw: %s", tagged, err.Error())) + } + + ctx := context.Background() + src, err := ref.NewImageSource(ctx, &types.SystemContext{}) + if err != nil { + return "", err + } + defer src.Close() + + manifestBytes, _, err := src.GetManifest(ctx, nil) + if err != nil { + return "", err + } + + digest, err := manifest.Digest(manifestBytes) + if err != nil { + return "", err + } + + return digest.String(), nil +} diff --git a/pkg/i18n/locales/abra.pot b/pkg/i18n/locales/abra.pot index 9c5a6c8c..514bfad0 100644 --- a/pkg/i18n/locales/abra.pot +++ b/pkg/i18n/locales/abra.pot @@ -7,13 +7,13 @@ msgid "" msgstr "Project-Id-Version: \n" "Report-Msgid-Bugs-To: EMAIL\n" - "POT-Creation-Date: 2026-10-14 10:32-0400\n" + "POT-Creation-Date: 2026-10-22 21:25+0200\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" "Language: \n" "MIME-Version: 1.0\n" - "Content-Type: text/plain; charset=UTF-8\n" + "Content-Type: text/plain; charset=CHARSET\n" "Content-Transfer-Encoding: 8bit\n" #: ./cli/app/cp.go:38 @@ -219,7 +219,7 @@ msgstr "" msgid "%s created (version: %s)" msgstr "" -#: ./cli/recipe/upgrade.go:335 +#: ./cli/recipe/upgrade.go:345 #, c-format msgid "%s currently has these unstaged changes 👇" msgstr "" @@ -304,7 +304,7 @@ msgstr "" msgid "%s has no secrets to generate, skipping..." msgstr "" -#: ./pkg/recipe/compose.go:107 +#: ./pkg/recipe/compose.go:109 #, c-format msgid "%s has no version label? try running \"abra recipe sync %s\" first?" msgstr "" @@ -324,12 +324,12 @@ msgstr "" msgid "%s is already fetched" msgstr "" -#: ./pkg/recipe/compose.go:233 +#: ./pkg/recipe/compose.go:277 #, c-format msgid "%s is already set, nothing to do?" msgstr "" -#: ./pkg/recipe/compose.go:72 +#: ./pkg/recipe/compose.go:74 #, c-format msgid "%s is missing a compose.yml or compose.*.yml file?" msgstr "" @@ -379,7 +379,7 @@ msgstr "" msgid "%s must first be deployed on %s before moving" msgstr "" -#: ./cli/recipe/upgrade.go:150 +#: ./cli/recipe/upgrade.go:153 #, c-format msgid "%s not considered semver-like" msgstr "" @@ -549,6 +549,11 @@ msgstr "" msgid "%s: waiting %d seconds before next retry" msgstr "" +#: ./cli/recipe/upgrade.go:392 +#, c-format +msgid "%s:%s was re-published upstream (digest %s → %s)" +msgstr "" + #: ./cli/app/upgrade.go:425 #, c-format msgid "'%s' is not a known version" @@ -638,17 +643,17 @@ msgstr "" msgid "CHAOS DEPLOY" msgstr "" -#: ./pkg/recipe/compose.go:47 +#: ./pkg/recipe/compose.go:49 #, c-format msgid "COMPOSE_FILE (=\"%s\") parsing failed?" msgstr "" -#: ./pkg/recipe/compose.go:58 +#: ./pkg/recipe/compose.go:60 #, c-format msgid "COMPOSE_FILE detected (%s), loading %s" msgstr "" -#: ./pkg/recipe/compose.go:38 +#: ./pkg/recipe/compose.go:40 #, c-format msgid "COMPOSE_FILE detected, loading %s" msgstr "" @@ -1687,7 +1692,7 @@ msgctxt "recipe fetch" msgid "a" msgstr "" -#: ./cli/recipe/upgrade.go:408 +#: ./cli/recipe/upgrade.go:459 msgctxt "recipe upgrade" msgid "a" msgstr "" @@ -1737,7 +1742,7 @@ msgstr "" msgid "accepts at most 2 args with --local/-l" msgstr "" -#: ./pkg/recipe/compose.go:249 +#: ./pkg/recipe/compose.go:293 #, c-format msgid "add '- \"%s\"' manually to the 'app' service in %s" msgstr "" @@ -1780,7 +1785,7 @@ msgstr "" msgid "all-services" msgstr "" -#: ./cli/recipe/upgrade.go:407 +#: ./cli/recipe/upgrade.go:458 msgid "all-tags" msgstr "" @@ -1919,7 +1924,7 @@ msgstr "" msgid "c" msgstr "" -#: ./cli/recipe/upgrade.go:416 +#: ./cli/recipe/upgrade.go:467 msgctxt "recipe upgrade" msgid "c" msgstr "" @@ -1928,7 +1933,7 @@ msgstr "" msgid "can not insert from file and read from stdin" msgstr "" -#: ./cli/recipe/upgrade.go:323 +#: ./cli/recipe/upgrade.go:333 #, c-format msgid "can upgrade service: %s, image: %s, tag: %s ::" msgstr "" @@ -2108,7 +2113,7 @@ msgstr "" msgid "chore: publish new catalogue release changes" msgstr "" -#: ./cli/recipe/upgrade.go:352 +#: ./cli/recipe/upgrade.go:362 msgid "chore: update image tags" msgstr "" @@ -2170,19 +2175,19 @@ msgstr "" msgid "commandconn: starting %s with %v" msgstr "" -#: ./cli/recipe/upgrade.go:415 +#: ./cli/recipe/upgrade.go:466 msgid "commit" msgstr "" -#: ./cli/recipe/upgrade.go:418 +#: ./cli/recipe/upgrade.go:469 msgid "commit changes" msgstr "" -#: ./cli/recipe/upgrade.go:342 +#: ./cli/recipe/upgrade.go:352 msgid "commit changes?" msgstr "" -#: ./cli/recipe/upgrade.go:356 +#: ./cli/recipe/upgrade.go:366 #, c-format msgid "committed changes as '%s'" msgstr "" @@ -2230,12 +2235,12 @@ msgstr "" msgid "connection timed out for %s" msgstr "" -#: ./pkg/recipe/compose.go:191 +#: ./pkg/recipe/compose.go:235 #, c-format msgid "considering %s config(s) for label update" msgstr "" -#: ./pkg/recipe/compose.go:123 +#: ./pkg/recipe/compose.go:132 #, c-format msgid "considering %s config(s) for tag update" msgstr "" @@ -2258,7 +2263,7 @@ msgstr "" msgid "context lacks Docker endpoint" msgstr "" -#: ./pkg/recipe/compose.go:229 +#: ./pkg/recipe/compose.go:273 #, c-format msgid "coop-cloud.${STACK_NAME}.version=%s" msgstr "" @@ -2465,12 +2470,12 @@ msgstr "" msgid "detected ABRA_CI=1" msgstr "" -#: ./cli/recipe/upgrade.go:204 +#: ./cli/recipe/upgrade.go:214 #, c-format msgid "detected compatible upgradable tags %s for %s" msgstr "" -#: ./cli/recipe/upgrade.go:177 +#: ./cli/recipe/upgrade.go:180 #, c-format msgid "detected potential upgradable tags %s for %s" msgstr "" @@ -2509,6 +2514,16 @@ msgstr "" msgid "different versions for secret '%s', '%s' and %s'" msgstr "" +#: ./cli/recipe/upgrade.go:388 +#, c-format +msgid "digest of %s:%s is up to date" +msgstr "" + +#: ./cli/recipe/upgrade.go:413 +#, c-format +msgid "digest updated for %s:%s" +msgstr "" + #: ./pkg/recipe/recipe.go:202 #, c-format msgid "dir: %s, " @@ -2658,7 +2673,7 @@ msgstr "" msgid "ensuring env version %s" msgstr "" -#: ./cli/recipe/upgrade.go:285 +#: ./cli/recipe/upgrade.go:295 msgid "enter / return to confirm, choose 'skip' to not upgrade this tag, vim mode is enabled" msgstr "" @@ -2839,7 +2854,7 @@ msgstr "" msgid "failed to migrate app config: %s" msgstr "" -#: ./pkg/client/registry.go:20 +#: ./pkg/client/registry.go:37 ./pkg/client/registry.go:55 ./pkg/client/registry.go:60 #, c-format msgid "failed to parse image %s, saw: %s" msgstr "" @@ -3291,15 +3306,15 @@ msgstr "" msgid "including VOLUMES=%v in backupbot exec invocation" msgstr "" -#: ./cli/recipe/release.go:634 ./cli/recipe/upgrade.go:378 +#: ./cli/recipe/release.go:634 ./cli/recipe/upgrade.go:429 msgid "increase the major part of the version" msgstr "" -#: ./cli/recipe/release.go:642 ./cli/recipe/upgrade.go:386 +#: ./cli/recipe/release.go:642 ./cli/recipe/upgrade.go:437 msgid "increase the minor part of the version" msgstr "" -#: ./cli/recipe/release.go:650 ./cli/recipe/upgrade.go:394 +#: ./cli/recipe/release.go:650 ./cli/recipe/upgrade.go:445 msgid "increase the patch part of the version" msgstr "" @@ -3464,7 +3479,7 @@ msgstr "" msgid "list [flags]" msgstr "" -#: ./cli/recipe/upgrade.go:410 +#: ./cli/recipe/upgrade.go:461 msgid "list all tags, not just upgrades" msgstr "" @@ -3534,11 +3549,11 @@ msgstr "" #. with no spaces in between #. translators: `abra man` aliases. use a comma separated list of aliases #. with no spaces in between -#: ./cli/app/list.go:319 ./cli/app/move.go:34 ./cli/app/ps.go:205 ./cli/app/secret.go:553 ./cli/app/secret.go:649 ./cli/recipe/list.go:104 ./cli/recipe/upgrade.go:400 ./cli/recipe/version.go:139 ./cli/run.go:152 ./cli/server/list.go:106 +#: ./cli/app/list.go:319 ./cli/app/move.go:34 ./cli/app/ps.go:205 ./cli/app/secret.go:553 ./cli/app/secret.go:649 ./cli/recipe/list.go:104 ./cli/recipe/upgrade.go:451 ./cli/recipe/version.go:139 ./cli/run.go:152 ./cli/server/list.go:106 msgid "m" msgstr "" -#: ./cli/app/list.go:318 ./cli/app/ps.go:204 ./cli/app/secret.go:552 ./cli/app/secret.go:648 ./cli/recipe/list.go:103 ./cli/recipe/upgrade.go:399 ./cli/recipe/version.go:138 ./cli/server/list.go:105 +#: ./cli/app/list.go:318 ./cli/app/ps.go:204 ./cli/app/secret.go:552 ./cli/app/secret.go:648 ./cli/recipe/list.go:103 ./cli/recipe/upgrade.go:450 ./cli/recipe/version.go:138 ./cli/server/list.go:105 msgid "machine" msgstr "" @@ -3547,7 +3562,7 @@ msgstr "" msgid "main app service version for %s is empty?" msgstr "" -#: ./cli/internal/recipe.go:48 ./cli/internal/recipe.go:66 ./cli/internal/recipe.go:80 ./cli/recipe/release.go:631 ./cli/recipe/upgrade.go:375 +#: ./cli/internal/recipe.go:48 ./cli/internal/recipe.go:66 ./cli/internal/recipe.go:80 ./cli/recipe/release.go:631 ./cli/recipe/upgrade.go:426 msgid "major" msgstr "" @@ -3576,7 +3591,7 @@ msgstr "" msgid "migrating app config from %s to %s" msgstr "" -#: ./cli/internal/recipe.go:48 ./cli/internal/recipe.go:68 ./cli/internal/recipe.go:82 ./cli/recipe/release.go:639 ./cli/recipe/upgrade.go:383 +#: ./cli/internal/recipe.go:48 ./cli/internal/recipe.go:68 ./cli/internal/recipe.go:82 ./cli/recipe/release.go:639 ./cli/recipe/upgrade.go:434 msgid "minor" msgstr "" @@ -3705,7 +3720,7 @@ msgstr "" msgid "no .git directory in %s?" msgstr "" -#: ./pkg/recipe/compose.go:29 +#: ./pkg/recipe/compose.go:31 #, c-format msgid "no COMPOSE_FILE detected, loading default: %s" msgstr "" @@ -3731,7 +3746,7 @@ msgstr "" msgid "no changes discovered in %s, nothing to publish?" msgstr "" -#: ./cli/recipe/upgrade.go:361 +#: ./cli/recipe/upgrade.go:371 msgid "no changes, skip creating commit" msgstr "" @@ -3756,7 +3771,7 @@ msgstr "" msgid "no domain provided" msgstr "" -#: ./pkg/recipe/compose.go:248 +#: ./pkg/recipe/compose.go:292 msgid "no existing label found, automagic insertion not supported yet" msgstr "" @@ -3765,7 +3780,7 @@ msgstr "" msgid "no git tags found for %s" msgstr "" -#: ./cli/recipe/upgrade.go:182 +#: ./cli/recipe/upgrade.go:192 #, c-format msgid "no new versions available for %s, assuming %s is the latest (use -a/--all-tags to see all anyway)" msgstr "" @@ -3897,12 +3912,12 @@ msgstr "" msgid "not requesting a remote TTY" msgstr "" -#: ./cli/recipe/upgrade.go:305 +#: ./cli/recipe/upgrade.go:315 #, c-format msgid "not upgrading %s, skipping as requested" msgstr "" -#: ./cli/recipe/upgrade.go:244 +#: ./cli/recipe/upgrade.go:254 #, c-format msgid "not upgrading from %s to %s for %s, because the upgrade type is more serious than what user wants" msgstr "" @@ -3956,12 +3971,12 @@ msgstr "" msgid "p" msgstr "" -#: ./cli/recipe/upgrade.go:163 +#: ./cli/recipe/upgrade.go:166 #, c-format msgid "parsed %s for %s" msgstr "" -#: ./pkg/recipe/compose.go:159 +#: ./pkg/recipe/compose.go:178 #, c-format msgid "parsed %s from %s" msgstr "" @@ -3999,7 +4014,7 @@ msgstr "" msgid "pass command not found on $PATH, is it installed?" msgstr "" -#: ./cli/internal/recipe.go:48 ./cli/internal/recipe.go:70 ./cli/internal/recipe.go:84 ./cli/recipe/release.go:647 ./cli/recipe/upgrade.go:391 +#: ./cli/internal/recipe.go:48 ./cli/internal/recipe.go:70 ./cli/internal/recipe.go:84 ./cli/recipe/release.go:647 ./cli/recipe/upgrade.go:442 msgid "patch" msgstr "" @@ -4053,7 +4068,7 @@ msgstr "" msgid "prefer offline & filesystem access" msgstr "" -#: ./cli/app/list.go:321 ./cli/app/ps.go:207 ./cli/app/secret.go:555 ./cli/app/secret.go:651 ./cli/recipe/list.go:106 ./cli/recipe/upgrade.go:402 ./cli/recipe/version.go:141 ./cli/server/list.go:108 +#: ./cli/app/list.go:321 ./cli/app/ps.go:207 ./cli/app/secret.go:555 ./cli/app/secret.go:651 ./cli/recipe/list.go:106 ./cli/recipe/upgrade.go:453 ./cli/recipe/version.go:141 ./cli/server/list.go:108 msgid "print machine-readable output" msgstr "" @@ -4436,7 +4451,7 @@ msgstr "" msgid "retrieved %s as target container on %s" msgstr "" -#: ./pkg/recipe/compose.go:59 +#: ./pkg/recipe/compose.go:61 #, c-format msgid "retrieved %s configs for %s" msgstr "" @@ -4446,7 +4461,7 @@ msgstr "" msgid "retrieved %s for %s" msgstr "" -#: ./cli/recipe/upgrade.go:144 +#: ./cli/recipe/upgrade.go:147 #, c-format msgid "retrieved %s from remote registry for %s" msgstr "" @@ -4723,12 +4738,12 @@ msgstr "" msgid "serverAdd: cleanUp: unable to list files in %s: %s" msgstr "" -#: ./cli/recipe/upgrade.go:227 +#: ./cli/recipe/upgrade.go:237 #, c-format msgid "service %s is at version %s, but pinned to %s, please correct your compose.yml file manually!" msgstr "" -#: ./cli/recipe/upgrade.go:223 +#: ./cli/recipe/upgrade.go:233 #, c-format msgid "service %s, image %s pinned to %s, no compatible upgrade found" msgstr "" @@ -5007,7 +5022,7 @@ msgstr "" msgid "sync [flags]" msgstr "" -#: ./pkg/recipe/compose.go:243 +#: ./pkg/recipe/compose.go:287 #, c-format msgid "synced label %s to service %s" msgstr "" @@ -5025,7 +5040,7 @@ msgstr "" msgid "tag at commit %s is unannotated or otherwise broken" msgstr "" -#: ./cli/recipe/upgrade.go:301 +#: ./cli/recipe/upgrade.go:311 #, c-format msgid "tag upgraded from %s to %s for %s" msgstr "" @@ -5212,7 +5227,7 @@ msgstr "" msgid "unable to determine server of app %s, please pass --server/-s" msgstr "" -#: ./cli/recipe/upgrade.go:252 +#: ./cli/recipe/upgrade.go:262 #, c-format msgid "unable to determine versioning semantics of %s, listing all tags" msgstr "" @@ -5286,12 +5301,12 @@ msgstr "" msgid "unable to open worktree of %s: %s" msgstr "" -#: ./cli/recipe/upgrade.go:159 +#: ./cli/recipe/upgrade.go:162 #, c-format msgid "unable to parse %s, error was: %s, skipping upgrade for %s" msgstr "" -#: ./pkg/recipe/compose.go:153 +#: ./pkg/recipe/compose.go:167 #, c-format msgid "unable to parse %s, skipping" msgstr "" @@ -5331,7 +5346,7 @@ msgstr "" msgid "unable to read remotes in %s: %s" msgstr "" -#: ./cli/recipe/upgrade.go:153 +#: ./cli/recipe/upgrade.go:156 #, c-format msgid "unable to read tag for image %s, is it missing? skipping upgrade for %s" msgstr "" @@ -5381,6 +5396,11 @@ msgstr "" msgid "unable to retrieve container for %s: %s" msgstr "" +#: ./pkg/recipe/compose.go:193 +#, c-format +msgid "unable to retrieve digest for %s: %s" +msgstr "" + #: ./cli/app/list.go:157 #, c-format msgid "unable to retrieve tags for %s: %s" @@ -5513,12 +5533,17 @@ msgstr "" msgid "up" msgstr "" +#: ./cli/recipe/upgrade.go:397 +#, c-format +msgid "update digest of %s:%s?" +msgstr "" + #: ./pkg/upstream/stack/stack.go:477 #, c-format msgid "updating %s" msgstr "" -#: ./pkg/recipe/compose.go:171 ./pkg/recipe/compose.go:237 +#: ./pkg/recipe/compose.go:213 ./pkg/recipe/compose.go:281 #, c-format msgid "updating %s to %s in %s" msgstr "" @@ -5543,17 +5568,17 @@ msgstr "" msgid "upgrade [flags]" msgstr "" -#: ./cli/recipe/upgrade.go:248 +#: ./cli/recipe/upgrade.go:258 #, c-format msgid "upgrade to which tag? (service: %s, image: %s, tag: %s)" msgstr "" -#: ./cli/recipe/upgrade.go:254 +#: ./cli/recipe/upgrade.go:264 #, c-format msgid "upgrade to which tag? (service: %s, tag: %s)" msgstr "" -#: ./cli/recipe/upgrade.go:221 +#: ./cli/recipe/upgrade.go:231 #, c-format msgid "upgrading service %s from %s to %s (pinned tag: %s)" msgstr "" @@ -5806,11 +5831,11 @@ msgstr "" msgid "writing recipe version failed: %s" msgstr "" -#: ./cli/recipe/release.go:632 ./cli/recipe/upgrade.go:376 +#: ./cli/recipe/release.go:632 ./cli/recipe/upgrade.go:427 msgid "x" msgstr "" -#: ./cli/recipe/release.go:640 ./cli/recipe/upgrade.go:384 +#: ./cli/recipe/release.go:640 ./cli/recipe/upgrade.go:435 msgid "y" msgstr "" @@ -5822,7 +5847,7 @@ msgstr "" msgid "you can only use one version flag: --major, --minor or --patch" msgstr "" -#: ./cli/recipe/release.go:648 ./cli/recipe/upgrade.go:392 +#: ./cli/recipe/release.go:648 ./cli/recipe/upgrade.go:443 msgid "z" msgstr "" diff --git a/pkg/recipe/compose.go b/pkg/recipe/compose.go index 6faea26c..c85444a2 100644 --- a/pkg/recipe/compose.go +++ b/pkg/recipe/compose.go @@ -6,8 +6,10 @@ import ( "io/ioutil" "os" "path/filepath" + "regexp" "strings" + "coopcloud.tech/abra/pkg/client" "coopcloud.tech/abra/pkg/formatter" "coopcloud.tech/abra/pkg/i18n" "coopcloud.tech/abra/pkg/log" @@ -110,7 +112,14 @@ func (r Recipe) GetVersionLabelLocal() (string, error) { return label, nil } +// getRegistryDigest is a variable so that it can be stubbed in tests. +var getRegistryDigest = client.GetRegistryDigest + // UpdateTag updates an image tag in-place on file system local compose files. +// Images which are pinned to a digest (e.g. "nextcloud:34.0.5-fpm@sha256:...") +// have their digest updated to the one of the new tag, as retrieved from the +// container registry. Passing the current tag of a digest pinned image +// refreshes the digest, e.g. when upstream re-published the same tag. func (r Recipe) UpdateTag(image, tag string) (bool, error) { fullPattern := fmt.Sprintf("%s/compose**yml", r.Dir) image = formatter.StripTagMeta(image) @@ -122,6 +131,11 @@ func (r Recipe) UpdateTag(image, tag string) (bool, error) { log.Debug(i18n.G("considering %s config(s) for tag update", strings.Join(composeFiles, ", "))) + var ( + updated bool + newDigest string + ) + for _, composeFile := range composeFiles { opts := stack.Deploy{Composefiles: []string{composeFile}} @@ -140,7 +154,7 @@ func (r Recipe) UpdateTag(image, tag string) (bool, error) { continue // may be a compose.$optional.yml file } - img, _ := reference.ParseNormalizedNamed(service.Image) + img, err := reference.ParseNormalizedNamed(service.Image) if err != nil { return false, err } @@ -154,30 +168,60 @@ func (r Recipe) UpdateTag(image, tag string) (bool, error) { continue } + var composeDigest string + if canonical, ok := img.(reference.Canonical); ok { + composeDigest = canonical.Digest().String() + } + 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) + bytes, err := os.ReadFile(composeFile) if err != nil { return false, err } old := fmt.Sprintf("%s:%s", composeImage, composeTag) new := fmt.Sprintf("%s:%s", composeImage, tag) - replacedBytes := strings.Replace(string(bytes), old, new, -1) + + if composeDigest != "" { + if newDigest == "" { + newDigest, err = getRegistryDigest(img, tag) + if err != nil { + return false, errors.New(i18n.G("unable to retrieve digest for %s: %s", new, err)) + } + } + + old = fmt.Sprintf("%s@%s", old, composeDigest) + new = fmt.Sprintf("%s@%s", new, newDigest) + } + + if old == new { + continue + } + + // NOTE: only match the full image reference, so that "foo:1.2" does + // not match "foo:1.2.3" or the "foo:1.2" part of "foo:1.2@sha256:..." + pattern := regexp.MustCompile(regexp.QuoteMeta(old) + `([^@\w.\-]|$)`) + replaced := pattern.ReplaceAllString(string(bytes), new+"${1}") + if replaced == string(bytes) { + continue + } 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 { + if err := os.WriteFile(compose.Filename, []byte(replaced), 0o764); err != nil { return false, err } + + updated = true } } } - return false, nil + return updated, nil } // UpdateLabel updates a label in-place on file system local compose files. diff --git a/pkg/recipe/compose_test.go b/pkg/recipe/compose_test.go new file mode 100644 index 00000000..7aa2b165 --- /dev/null +++ b/pkg/recipe/compose_test.go @@ -0,0 +1,127 @@ +package recipe + +import ( + "os" + "path" + "testing" + + "github.com/distribution/reference" + "github.com/stretchr/testify/assert" +) + +const ( + oldDigest = "sha256:1111111111111111111111111111111111111111111111111111111111111111" + newDigest = "sha256:2222222222222222222222222222222222222222222222222222222222222222" +) + +func setupUpdateTagRecipe(t *testing.T, compose string) (Recipe, string) { + t.Helper() + + dir := t.TempDir() + composePath := path.Join(dir, "compose.yml") + envPath := path.Join(dir, ".env.sample") + + assert.NoError(t, os.WriteFile(composePath, []byte(compose), 0o644)) + assert.NoError(t, os.WriteFile(envPath, []byte("TYPE=foo\n"), 0o644)) + + return Recipe{ + Name: "foo", + Dir: dir, + ComposePath: composePath, + SampleEnvPath: envPath, + }, composePath +} + +func stubRegistryDigest(t *testing.T, digest string) *[]string { + t.Helper() + + var calls []string + orig := getRegistryDigest + getRegistryDigest = func(img reference.Named, tag string) (string, error) { + calls = append(calls, reference.Path(img)+":"+tag) + return digest, nil + } + t.Cleanup(func() { getRegistryDigest = orig }) + + return &calls +} + +func TestUpdateTag(t *testing.T) { + testcases := []struct { + name string + compose string + image string + tag string + expected string + updated bool + lookups int + }{ + { + name: "plain tag", + compose: "version: \"3.8\"\nservices:\n app:\n image: nginx:1.31.7\n", + image: "nginx", + tag: "1.31.8", + expected: "version: \"3.8\"\nservices:\n app:\n image: nginx:1.31.8\n", + updated: true, + }, + { + name: "shorter tag does not mangle longer tag", + compose: "version: \"3.8\"\nservices:\n app:\n image: nginx:1.31\n web:\n image: nginx:1.31.7\n", + image: "nginx", + tag: "1.32", + expected: "version: \"3.8\"\nservices:\n app:\n image: nginx:1.32\n web:\n image: nginx:1.32\n", + updated: true, + }, + { + name: "digest is updated with tag", + compose: "version: \"3.8\"\nservices:\n app:\n image: nextcloud:34.0.5-fpm@" + oldDigest + "\n", + image: "nextcloud", + tag: "34.0.6-fpm", + expected: "version: \"3.8\"\nservices:\n app:\n image: nextcloud:34.0.6-fpm@" + newDigest + "\n", + updated: true, + lookups: 1, + }, + { + name: "digest is refreshed for same tag", + compose: "version: \"3.8\"\nservices:\n app:\n image: \"nextcloud:34.0.5-fpm@" + oldDigest + "\"\n", + image: "nextcloud", + tag: "34.0.5-fpm", + expected: "version: \"3.8\"\nservices:\n app:\n image: \"nextcloud:34.0.5-fpm@" + newDigest + "\"\n", + updated: true, + lookups: 1, + }, + { + name: "same tag without digest is a no-op", + compose: "version: \"3.8\"\nservices:\n app:\n image: nextcloud:34.0.5-fpm\n", + image: "nextcloud", + tag: "34.0.5-fpm", + expected: "version: \"3.8\"\nservices:\n app:\n image: nextcloud:34.0.5-fpm\n", + updated: false, + }, + { + name: "pinned and unpinned services sharing an image", + compose: "version: \"3.8\"\nservices:\n app:\n image: nextcloud:34.0.5-fpm@" + oldDigest + "\n cron:\n image: nextcloud:34.0.5-fpm\n", + image: "nextcloud", + tag: "34.0.6-fpm", + expected: "version: \"3.8\"\nservices:\n app:\n image: nextcloud:34.0.6-fpm@" + newDigest + "\n cron:\n image: nextcloud:34.0.6-fpm\n", + updated: true, + lookups: 1, + }, + } + + for _, tc := range testcases { + t.Run(tc.name, func(t *testing.T) { + r, composePath := setupUpdateTagRecipe(t, tc.compose) + calls := stubRegistryDigest(t, newDigest) + + updated, err := r.UpdateTag(tc.image, tc.tag) + assert.NoError(t, err) + assert.Equal(t, tc.updated, updated) + assert.Len(t, *calls, tc.lookups) + + content, err := os.ReadFile(composePath) + assert.NoError(t, err) + assert.Equal(t, tc.expected, string(content)) + }) + } +} -- 2.54.0