Compare commits

...
1 Commits
Author SHA1 Message Date
Linus Gasser 8a81dedf3b Allow digests in docker images
continuous-integration/drone/pr Build is passing
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.
2026-10-22 21:26:50 +02:00
6 changed files with 367 additions and 69 deletions
+51
View File
@@ -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
+1 -1
View File
@@ -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
+52 -1
View File
@@ -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
}
+87 -62
View File
@@ -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 <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\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 ""
+49 -5
View File
@@ -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.
+127
View File
@@ -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))
})
}
}