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)) + }) + } +}