Compare commits

...
21 Commits
Author SHA1 Message Date
sixsmith b4c8733f90 update 2026-10-09 21:37:04 -04:00
sixsmith 0a8a599c9a update 2026-10-09 21:24:51 -04:00
sixsmith 353ceabfce update 2026-10-09 21:05:56 -04:00
sixsmith 6443e1b0d5 update 2026-10-09 21:00:34 -04:00
sixsmith 5fea6e0815 WIP: address digest pinning review comments 2026-10-04 23:19:21 -04:00
sixsmith 452cf1a468 update 2026-10-04 21:40:02 -04:00
sixsmith 14ede5af37 update 2026-10-03 04:01:41 -04:00
sixsmith 6c6294b2a2 update 2026-10-03 03:49:17 -04:00
sixsmith 3e1b275c88 update 2026-10-03 03:42:59 -04:00
sixsmith e1e910dbfb update 2026-10-03 02:03:05 -04:00
sixsmith 6814a52b6e update 2026-10-02 18:10:29 -04:00
sixsmith 3cc527101a update 2026-10-02 17:58:15 -04:00
sixsmith 45d1cb022b update; add tests 2026-10-02 17:01:35 -04:00
sixsmith 86ca816271 run make-i18n 2026-10-02 16:58:27 -04:00
sixsmith 7d90faa596 run make-i18n 2026-09-29 21:11:56 -04:00
sixsmith fadc327e28 use compose loader for image updates, add shared image test
Look up the service image with the compose loader instead of parsing
the yaml separately, and only rewrite image lines which match it
exactly. Read images back via GetComposeConfig in the tests, drop
testify from them, and add an integration test for pinning services
which use variants of the same image.
2026-09-29 21:11:56 -04:00
sixsmith 66242a3adf run make-i18n 2026-09-29 20:55:40 -04:00
sixsmith 4d60f6c2ce fix digest pinning issues in recipe upgrade
Update the image of a single service instead of every service using
the same image name, only rewriting the tag and digest of its image
value so the registry host and the rest of the file are kept as is.

- skip images which use variables, warn to update them manually
- error on images which fail to parse instead of panicking
- drop a stale digest when upgrading a pinned image without
  --pindigests, since it points at the image of the old tag
- error when the registry does not return a digest
- use docker cli credentials when fetching the digest, so private
  registries work after docker login

Add tests for UpdatePinnedTag, UpdateTag and
GetImageMultiplatformDigest.
2026-09-29 20:55:40 -04:00
sixsmith 626e2cbf38 add lint rule for images with digest 2026-09-10 00:13:35 -04:00
sixsmith 9fdb570855 run make-i18n 2026-09-10 00:13:35 -04:00
sixsmith 3de49a7b70 add --pindigests flag
* handle digests for the recipe subcommand
* add make parameter to pass args
2026-09-10 00:11:52 -04:00
17 changed files with 1184 additions and 449 deletions
+1
View File
@@ -19,5 +19,6 @@ var (
Minor bool
NoDomainChecks bool
Patch bool
PinDigests bool
ShowUnchanged bool
)
+5 -4
View File
@@ -92,7 +92,7 @@ your private key and enter your passphrase beforehand.
log.Fatal(err)
}
mainAppVersion := imagesTmp[mainApp]
mainAppVersion := formatter.RemoveSha(imagesTmp[mainApp])
if mainAppVersion == "" {
log.Fatal(i18n.G("main app service version for %s is empty?", recipe.Name))
}
@@ -192,6 +192,9 @@ likely to change.
serviceMeta := serviceVersions[serviceName]
existingImageTag := fmt.Sprintf("%s:%s", serviceMeta.Image, serviceMeta.Tag)
if serviceMeta.Digest != "" {
existingImageTag += "@" + serviceMeta.Digest
}
newImageTag := fmt.Sprintf("%s:%s", serviceMeta.Image, imagesTmp[serviceMeta.Image])
if existingImageTag == newImageTag {
@@ -331,10 +334,8 @@ func GetImageVersions(recipe recipePkg.Recipe) (map[string]string, error) {
path = formatter.StripTagMeta(path)
var tag string
switch img.(type) {
case reference.NamedTagged:
tag = img.(reference.NamedTagged).Tag()
case reference.Named:
if service.Name == "app" {
missingTag = true
@@ -342,7 +343,7 @@ func GetImageVersions(recipe recipePkg.Recipe) (map[string]string, error) {
continue
}
services[path] = tag
services[path] = formatter.GetTagAndDigest(img)
}
if missingTag {
+42 -8
View File
@@ -130,7 +130,7 @@ interface.`),
}
for _, service := range config.Services {
img, err := reference.ParseNormalizedNamed(service.Image)
img, err := reference.ParseNormalizedNamed(formatter.RemoveSha(service.Image))
if err != nil {
log.Fatal(err)
}
@@ -178,9 +178,14 @@ interface.`),
sort.Sort(tagcmp.ByTagDesc(compatible))
var upgradeTag string
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
if !shouldPin(service.Image, internal.PinDigests) {
continue
}
upgradeTag = tag.String()
}
catlVersions, err := recipePkg.VersionsOfService(recipe.Name, service.Name, internal.Offline)
@@ -203,9 +208,8 @@ interface.`),
log.Debug(i18n.G("detected compatible upgradable tags %s for %s", compatibleStrings, service.Name))
var upgradeTag string
_, ok := servicePins[service.Name]
if versionsPresent && ok {
if upgradeTag == "" && versionsPresent && ok {
pinnedTag := servicePins[service.Name].version
if tag.IsLessThan(pinnedTag) {
pinnedTagString := pinnedTag.String()
@@ -227,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)
@@ -242,7 +246,10 @@ interface.`),
}
if upgradeTag == "" {
log.Warn(i18n.G("not upgrading from %s to %s for %s, because the upgrade type is more serious than what user wants", tag.String(), compatible[0].String(), image))
continue
if !shouldPin(service.Image, internal.PinDigests) {
continue
}
upgradeTag = tag.String()
}
} else {
msg := i18n.G("upgrade to which tag? (service: %s, image: %s, tag: %s)", service.Name, image, tag)
@@ -293,12 +300,25 @@ interface.`),
}
}
if upgradeTag != "skip" {
ok, err := recipe.UpdateTag(image, upgradeTag)
var resolvedDigest string
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()))
}
}
ok, err := recipe.UpdateTag(service.Image, upgradeTag, resolvedDigest)
if err != nil {
log.Fatal(err)
}
if ok {
log.Info(i18n.G("tag upgraded from %s to %s for %s", tag.String(), upgradeTag, image))
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))
}
}
} else {
if !internal.NoInput {
@@ -364,6 +384,12 @@ interface.`),
},
}
// shouldPin reports whether a service image should be pinned to a digest.
// An image that is already pinned stays pinned, even without --pindigests.
func shouldPin(image string, pinDigestsFlag bool) bool {
return pinDigestsFlag || strings.Contains(image, "@sha256:")
}
var (
allTags bool
createCommit bool
@@ -417,4 +443,12 @@ func init() {
false,
i18n.G("commit changes"),
)
RecipeUpgradeCommand.Flags().BoolVarP(
&internal.PinDigests,
i18n.G("pindigests"),
i18n.GC("p", "pin container image by digest"),
true,
i18n.G("pin the container image version by manifest digest (image:tag@sha256:...)"),
)
}
+22
View File
@@ -0,0 +1,22 @@
package recipe
import "testing"
func TestShouldPin(t *testing.T) {
tests := []struct {
image string
pinDigests bool
want bool
}{
{"nginx:1.21.0", false, false},
{"nginx:1.21.0", true, true},
{"nginx:1.21.0@sha256:2bcabc23b45489fb0885d69a06ba1d648aeda973fae7bb981bafbb884165e514", false, true},
{"nginx:1.21.0@sha256:2bcabc23b45489fb0885d69a06ba1d648aeda973fae7bb981bafbb884165e514", true, true},
}
for _, test := range tests {
if got := shouldPin(test.image, test.pinDigests); got != test.want {
t.Errorf("shouldPin(%q, %v) = %v, want %v", test.image, test.pinDigests, got, test.want)
}
}
}
+15 -6
View File
@@ -13,8 +13,6 @@ import (
"github.com/regclient/regclient/types/ref"
)
var rc = regclient.New()
// GetRegistryTags retrieves all tags of an image from a container registry.
func GetRegistryTags(img reference.Named) ([]string, error) {
var tags []string
@@ -39,15 +37,26 @@ 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) {
r, err := ref.New(fmt.Sprintf("%s:%s", img.Name(), tag))
tagged, err := reference.WithTag(reference.TrimNamed(img), tag)
if err != nil {
return "", fmt.Errorf("parsing reference %s:%s: %w", img.Name(), tag, err)
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()))
}
rc := regclient.New(regclient.WithDockerCreds())
m, err := rc.ManifestHead(context.Background(), r)
if err != nil {
return "", fmt.Errorf("fetching manifest for %s:%s: %w", img.Name(), tag, err)
return "", errors.New(i18n.G("failed to fetch manifest for %s:%s, saw: %s", img.Name(), tag, err.Error()))
}
return m.GetDescriptor().Digest.String(), nil
digest := m.GetDescriptor().Digest.String()
if digest == "" {
return "", errors.New(i18n.G("registry did not return a digest for %s:%s", img.Name(), tag))
}
return digest, nil
}
+6 -2
View File
@@ -171,7 +171,7 @@ func GetImagesForStack(cl *dockerClient.Client, app appPkg.App) (map[string]stri
images[imageBaseName] = ""
continue
}
imageTag := namedTag.Tag()
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 := namedTag.Tag()
imageTag := formatter.GetTagAndDigest(namedTag)
existingImageVersion, ok := newImages[imageBaseName]
if !ok {
@@ -312,6 +312,10 @@ func GatherImagesForDeploy(cl *dockerClient.Client, app appPkg.App, compose *com
var imageInfo []string
for newImageName, newImageVersion := range newImages {
if currentVersion, exists := currentImages[newImageName]; exists {
// Swarm adds a resolved digest even for unpinned compose images.
if !strings.Contains(newImageVersion, "@") {
currentVersion = formatter.RemoveSha(currentVersion)
}
if currentVersion == newImageVersion {
if showUnchanged {
imageInfo = append(imageInfo, i18n.G("%s: %s (unchanged)", formatter.StripTagMeta(newImageName), newImageVersion))
+15 -1
View File
@@ -11,6 +11,7 @@ import (
"coopcloud.tech/abra/pkg/i18n"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/lipgloss/table"
"github.com/distribution/reference"
"github.com/docker/go-units"
"golang.org/x/term"
@@ -40,6 +41,18 @@ func RemoveSha(str string) string {
return strings.Split(str, "@")[0]
}
// 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()
}
if pinned, ok := image.(reference.Digested); ok {
version += "@" + pinned.Digest().String()
}
return version
}
// HumanDuration from docker/cli RunningFor() to be accessible outside of the class
func HumanDuration(timestamp int64) string {
date := time.Unix(timestamp, 0)
@@ -223,7 +236,8 @@ func CreateProgressbar(length int, title string) *progressbar.ProgressBar {
)
}
// StripTagMeta strips front-matter image tag data that we don't need for parsing.
// StripTagMeta strips front-matter image tag data that we don't need for parsing,
// like registry host and library prefix.
func StripTagMeta(image string) string {
originalImage := image
+21 -2
View File
@@ -3,12 +3,13 @@ package formatter
import (
"testing"
"github.com/distribution/reference"
"github.com/stretchr/testify/assert"
)
func TestRemoveSha(t *testing.T) {
assert.Equal(t, "ubuntu:latest", RemoveSha("ubuntu:latest@sha256:1234567890abcdef"))
assert.Equal(t, "ubuntu:latest", RemoveSha("ubuntu:latest"))
assert.Equal(t, "nginx:latest", RemoveSha("nginx:latest@sha256:1234567890abcdef"))
assert.Equal(t, "nginx:latest", RemoveSha("nginx:latest"))
assert.Equal(t, "my-repo/my-image:v1", RemoveSha("my-repo/my-image:v1@sha256:abcdef1234567890"))
assert.Equal(t, "my-repo/my-image", RemoveSha("my-repo/my-image@sha256:abcdef1234567890"))
}
@@ -16,3 +17,21 @@ func TestRemoveSha(t *testing.T) {
func TestBoldDirtyDefault(t *testing.T) {
assert.Equal(t, "foo", BoldDirtyDefault("foo"))
}
func TestGetTagAndDigest(t *testing.T) {
digest := "sha256:908dc23e643a1447dbfb2e189ed268bfde6a51a5bf9a34d3dd3440a24f58ccf7"
for _, tc := range []struct{ image, want string }{
{"nginx:1.21.6", "1.21.6"},
{"ghcr.io/foo/bar:1.0@" + digest, "1.0@" + digest},
{"nginx@" + digest, "@" + digest},
{"nginx", ""},
} {
img, err := reference.ParseNormalizedNamed(tc.image)
if err != nil {
t.Fatal(err)
}
if got := GetTagAndDigest(img); got != tc.want {
t.Errorf("%s: got %s, want %s", tc.image, got, tc.want)
}
}
}
+160 -94
View File
@@ -7,7 +7,7 @@
msgid ""
msgstr "Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-08-31 19:21+0000\n"
"POT-Creation-Date: 2026-10-02 16:58-0400\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"
@@ -165,7 +165,7 @@ msgid " # standard rollback\n"
" abra app rollback 1312.net 2.0.0+1.2.3"
msgstr ""
#: ./pkg/formatter/formatter.go:46
#: ./pkg/formatter/formatter.go:47
msgid " ago"
msgstr ""
@@ -219,7 +219,7 @@ msgstr ""
msgid "%s created (version: %s)"
msgstr ""
#: ./cli/recipe/upgrade.go:335
#: ./cli/recipe/upgrade.go:364
#, 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:110
#, 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:374
#, c-format
msgid "%s is already set, nothing to do?"
msgstr ""
#: ./pkg/recipe/compose.go:72
#: ./pkg/recipe/compose.go:75
#, c-format
msgid "%s is missing a compose.yml or compose.*.yml file?"
msgstr ""
@@ -568,12 +568,12 @@ msgstr ""
msgid "--local detected, running %s on local work station"
msgstr ""
#: ./pkg/lint/recipe.go:261 ./pkg/recipe/files.go:18
#: ./pkg/lint/recipe.go:268 ./pkg/recipe/files.go:18
#, c-format
msgid ".env.sample for %s couldn't be read: %s"
msgstr ""
#: ./pkg/lint/recipe.go:133
#: ./pkg/lint/recipe.go:140
msgid ".env.sample provided"
msgstr ""
@@ -638,17 +638,17 @@ msgstr ""
msgid "CHAOS DEPLOY"
msgstr ""
#: ./pkg/recipe/compose.go:47
#: ./pkg/recipe/compose.go:50
#, c-format
msgid "COMPOSE_FILE (=\"%s\") parsing failed?"
msgstr ""
#: ./pkg/recipe/compose.go:58
#: ./pkg/recipe/compose.go:61
#, c-format
msgid "COMPOSE_FILE detected (%s), loading %s"
msgstr ""
#: ./pkg/recipe/compose.go:38
#: ./pkg/recipe/compose.go:41
#, c-format
msgid "COMPOSE_FILE detected, loading %s"
msgstr ""
@@ -739,7 +739,7 @@ msgid "Create a new version of a recipe.\n"
" ssh-add ~/.ssh/<my-ssh-private-key-for-git-coopcloud-tech>"
msgstr ""
#: ./pkg/formatter/formatter.go:134
#: ./pkg/formatter/formatter.go:135
msgid "CreateOverview: only accepts rows of len == 2"
msgstr ""
@@ -1644,7 +1644,7 @@ msgid "\n"
"\n"
msgstr ""
#: ./pkg/lint/recipe.go:203
#: ./pkg/lint/recipe.go:210
#, c-format
msgid "\n"
"lint %s: %s"
@@ -1687,7 +1687,7 @@ msgctxt "recipe fetch"
msgid "a"
msgstr ""
#: ./cli/recipe/upgrade.go:408
#: ./cli/recipe/upgrade.go:444
msgctxt "recipe upgrade"
msgid "a"
msgstr ""
@@ -1737,7 +1737,7 @@ msgstr ""
msgid "accepts at most 2 args with --local/-l"
msgstr ""
#: ./pkg/recipe/compose.go:249
#: ./pkg/recipe/compose.go:390
#, c-format
msgid "add '- \"%s\"' manually to the 'app' service in %s"
msgstr ""
@@ -1764,11 +1764,15 @@ msgstr ""
msgid "all containers did really go away"
msgstr ""
#: ./pkg/lint/recipe.go:131
msgid "all images use a digest"
msgstr ""
#: ./pkg/lint/recipe.go:82
msgid "all images use a tag"
msgstr ""
#: ./pkg/lint/recipe.go:162
#: ./pkg/lint/recipe.go:169
msgid "all services have images"
msgstr ""
@@ -1780,7 +1784,7 @@ msgstr ""
msgid "all-services"
msgstr ""
#: ./cli/recipe/upgrade.go:407
#: ./cli/recipe/upgrade.go:443
msgid "all-tags"
msgstr ""
@@ -1919,7 +1923,7 @@ msgstr ""
msgid "c"
msgstr ""
#: ./cli/recipe/upgrade.go:416
#: ./cli/recipe/upgrade.go:452
msgctxt "recipe upgrade"
msgid "c"
msgstr ""
@@ -1928,7 +1932,7 @@ msgstr ""
msgid "can not insert from file and read from stdin"
msgstr ""
#: ./cli/recipe/upgrade.go:323
#: ./cli/recipe/upgrade.go:352
#, c-format
msgid "can upgrade service: %s, image: %s, tag: %s ::"
msgstr ""
@@ -2108,7 +2112,7 @@ msgstr ""
msgid "chore: publish new catalogue release changes"
msgstr ""
#: ./cli/recipe/upgrade.go:352
#: ./cli/recipe/upgrade.go:381
msgid "chore: update image tags"
msgstr ""
@@ -2170,19 +2174,19 @@ msgstr ""
msgid "commandconn: starting %s with %v"
msgstr ""
#: ./cli/recipe/upgrade.go:415
#: ./cli/recipe/upgrade.go:451
msgid "commit"
msgstr ""
#: ./cli/recipe/upgrade.go:418
#: ./cli/recipe/upgrade.go:454
msgid "commit changes"
msgstr ""
#: ./cli/recipe/upgrade.go:342
#: ./cli/recipe/upgrade.go:371
msgid "commit changes?"
msgstr ""
#: ./cli/recipe/upgrade.go:356
#: ./cli/recipe/upgrade.go:385
#, c-format
msgid "committed changes as '%s'"
msgstr ""
@@ -2221,7 +2225,7 @@ msgstr ""
msgid "config not found: %s"
msgstr ""
#: ./pkg/lint/recipe.go:169
#: ./pkg/lint/recipe.go:176
msgid "config version are vendored"
msgstr ""
@@ -2230,14 +2234,14 @@ msgstr ""
msgid "connection timed out for %s"
msgstr ""
#: ./pkg/recipe/compose.go:191
#: ./pkg/recipe/compose.go:146
#, c-format
msgid "considering %s config(s) for label update"
msgid "considering %s config(s) for image update"
msgstr ""
#: ./pkg/recipe/compose.go:123
#: ./pkg/recipe/compose.go:332
#, c-format
msgid "considering %s config(s) for tag update"
msgid "considering %s config(s) for label update"
msgstr ""
#: ./cli/app/undeploy.go:141 ./cli/server/prune.go:53
@@ -2258,7 +2262,7 @@ msgstr ""
msgid "context lacks Docker endpoint"
msgstr ""
#: ./pkg/recipe/compose.go:229
#: ./pkg/recipe/compose.go:370
#, c-format
msgid "coop-cloud.${STACK_NAME}.version=%s"
msgstr ""
@@ -2303,7 +2307,7 @@ msgstr ""
msgid "create <domain> [flags]"
msgstr ""
#: ./pkg/lint/recipe.go:134
#: ./pkg/lint/recipe.go:141
msgid "create an example .env.sample"
msgstr ""
@@ -2418,7 +2422,7 @@ msgstr ""
msgid "deploy in progress 🟠"
msgstr ""
#: ./pkg/lint/recipe.go:147
#: ./pkg/lint/recipe.go:154
msgid "deploy labels stanza present"
msgstr ""
@@ -2461,11 +2465,11 @@ msgstr ""
msgid "detected %s as tags for recipe %s"
msgstr ""
#: ./pkg/formatter/formatter.go:80
#: ./pkg/formatter/formatter.go:81
msgid "detected ABRA_CI=1"
msgstr ""
#: ./cli/recipe/upgrade.go:204
#: ./cli/recipe/upgrade.go:208
#, c-format
msgid "detected compatible upgradable tags %s for %s"
msgstr ""
@@ -2644,7 +2648,7 @@ msgstr ""
msgid "ensure 'version: \"3.8\"' in compose configs"
msgstr ""
#: ./pkg/lint/recipe.go:163
#: ./pkg/lint/recipe.go:170
msgid "ensure \"image: ...\" set on all services"
msgstr ""
@@ -2658,7 +2662,7 @@ msgstr ""
msgid "ensuring env version %s"
msgstr ""
#: ./cli/recipe/upgrade.go:285
#: ./cli/recipe/upgrade.go:291
msgid "enter / return to confirm, choose 'skip' to not upgrade this tag, vim mode is enabled"
msgstr ""
@@ -2682,7 +2686,7 @@ msgstr ""
msgid "err: %v, "
msgstr ""
#: ./cli/recipe/lint.go:82 ./cli/recipe/lint.go:144 ./pkg/lint/recipe.go:132 ./pkg/lint/recipe.go:139 ./pkg/lint/recipe.go:146 ./pkg/lint/recipe.go:153 ./pkg/lint/recipe.go:161 ./pkg/lint/recipe.go:168 ./pkg/lint/recipe.go:175
#: ./cli/recipe/lint.go:82 ./cli/recipe/lint.go:144 ./pkg/lint/recipe.go:139 ./pkg/lint/recipe.go:146 ./pkg/lint/recipe.go:153 ./pkg/lint/recipe.go:160 ./pkg/lint/recipe.go:168 ./pkg/lint/recipe.go:175 ./pkg/lint/recipe.go:182
msgid "error"
msgstr ""
@@ -2801,6 +2805,11 @@ msgstr ""
msgid "failed to create volume %s on %s: %s"
msgstr ""
#: ./pkg/client/registry.go:54
#, c-format
msgid "failed to fetch manifest for %s:%s, saw: %s"
msgstr ""
#: ./pkg/secret/secret.go:104
#, c-format
msgid "failed to generate random bytes: %w"
@@ -2839,11 +2848,16 @@ msgstr ""
msgid "failed to migrate app config: %s"
msgstr ""
#: ./pkg/client/registry.go:20
#: ./pkg/client/registry.go:22
#, c-format
msgid "failed to parse image %s, saw: %s"
msgstr ""
#: ./pkg/client/registry.go:45
#, c-format
msgid "failed to parse image %s:%s, saw: %s"
msgstr ""
#: ./pkg/recipe/git.go:437
#, c-format
msgid "failed to parse image for %s in %s: %s"
@@ -2898,6 +2912,11 @@ msgstr ""
msgid "failed to resize tty, using default size"
msgstr ""
#: ./cli/recipe/upgrade.go:313
#, c-format
msgid "failed to resolve digest for %s:%s: %s"
msgstr ""
#: ./cli/app/new.go:138
#, c-format
msgid "failed to retrieve latest commit for %s: %s"
@@ -3206,16 +3225,26 @@ msgstr ""
msgid "ignore uncommitted recipes changes"
msgstr ""
#: ./pkg/recipe/compose.go:248
#, c-format
msgid "image %s for %s uses a variable, skipping update, please update it manually"
msgstr ""
#: ./pkg/recipe/compose.go:183
#, c-format
msgid "image for %s uses a variable, skipping update, please update %s manually"
msgstr ""
#: ./cli/app/undeploy.go:156 ./cli/server/prune.go:74
#, c-format
msgid "images pruned: %d; space reclaimed: %s"
msgstr ""
#: ./pkg/lint/recipe.go:148
#: ./pkg/lint/recipe.go:155
msgid "include \"deploy: labels: ...\" stanza"
msgstr ""
#: ./pkg/lint/recipe.go:155
#: ./pkg/lint/recipe.go:162
msgid "include \"traefik.enable=true\" deploy label"
msgstr ""
@@ -3291,15 +3320,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:414
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:422
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:430
msgid "increase the patch part of the version"
msgstr ""
@@ -3369,7 +3398,12 @@ msgstr ""
msgid "invalid detach escape keys, using default: %s"
msgstr ""
#: ./pkg/lint/recipe.go:504
#: ./pkg/recipe/compose.go:308
#, c-format
msgid "invalid digest %s, want sha256:<64 hex digits>"
msgstr ""
#: ./pkg/lint/recipe.go:533
msgid "invalid lightweight tag detected"
msgstr ""
@@ -3430,12 +3464,12 @@ msgstr ""
msgid "lint <recipe> [flags]"
msgstr ""
#: ./pkg/lint/recipe.go:187
#: ./pkg/lint/recipe.go:194
#, c-format
msgid "linting for critical errors in %s configs"
msgstr ""
#: ./pkg/lint/recipe.go:215
#: ./pkg/lint/recipe.go:222
#, c-format
msgid "linting successful, %s is well configured"
msgstr ""
@@ -3464,7 +3498,7 @@ msgstr ""
msgid "list [flags]"
msgstr ""
#: ./cli/recipe/upgrade.go:410
#: ./cli/recipe/upgrade.go:446
msgid "list all tags, not just upgrades"
msgstr ""
@@ -3534,11 +3568,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:436 ./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:435 ./cli/recipe/version.go:138 ./cli/server/list.go:105
msgid "machine"
msgstr ""
@@ -3547,7 +3581,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:411
msgid "major"
msgstr ""
@@ -3576,7 +3610,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:419
msgid "minor"
msgstr ""
@@ -3627,7 +3661,7 @@ msgstr ""
msgid "name"
msgstr ""
#: ./pkg/lint/recipe.go:141
#: ./pkg/lint/recipe.go:148
msgid "name a servce 'app'"
msgstr ""
@@ -3705,7 +3739,7 @@ msgstr ""
msgid "no .git directory in %s?"
msgstr ""
#: ./pkg/recipe/compose.go:29
#: ./pkg/recipe/compose.go:32
#, c-format
msgid "no COMPOSE_FILE detected, loading default: %s"
msgstr ""
@@ -3731,7 +3765,7 @@ msgstr ""
msgid "no changes discovered in %s, nothing to publish?"
msgstr ""
#: ./cli/recipe/upgrade.go:361
#: ./cli/recipe/upgrade.go:390
msgid "no changes, skip creating commit"
msgstr ""
@@ -3756,7 +3790,7 @@ msgstr ""
msgid "no domain provided"
msgstr ""
#: ./pkg/recipe/compose.go:248
#: ./pkg/recipe/compose.go:389
msgid "no existing label found, automagic insertion not supported yet"
msgstr ""
@@ -3765,7 +3799,7 @@ msgstr ""
msgid "no git tags found for %s"
msgstr ""
#: ./cli/recipe/upgrade.go:182
#: ./cli/recipe/upgrade.go:183
#, 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 +3931,12 @@ msgstr ""
msgid "not requesting a remote TTY"
msgstr ""
#: ./cli/recipe/upgrade.go:305
#: ./cli/recipe/upgrade.go:334
#, c-format
msgid "not upgrading %s, skipping as requested"
msgstr ""
#: ./cli/recipe/upgrade.go:244
#: ./cli/recipe/upgrade.go:247
#, c-format
msgid "not upgrading from %s to %s for %s, because the upgrade type is more serious than what user wants"
msgstr ""
@@ -3928,11 +3962,11 @@ msgstr ""
msgid "one of <src>/<dest> arguments must take $SERVICE:$PATH form"
msgstr ""
#: ./pkg/lint/recipe.go:140
#: ./pkg/lint/recipe.go:147
msgid "one service named 'app'"
msgstr ""
#: ./pkg/lint/recipe.go:176
#: ./pkg/lint/recipe.go:183
msgid "only annotated tags used for recipe version"
msgstr ""
@@ -3956,16 +3990,16 @@ msgstr ""
msgid "p"
msgstr ""
#: ./cli/recipe/upgrade.go:460
msgctxt "pin container image by digest"
msgid "p"
msgstr ""
#: ./cli/recipe/upgrade.go:163
#, c-format
msgid "parsed %s for %s"
msgstr ""
#: ./pkg/recipe/compose.go:159
#, c-format
msgid "parsed %s from %s"
msgstr ""
#: ./cli/app/cmd.go:134 ./cli/app/cmd.go:180
#, c-format
msgid "parsed following command arguments: %s"
@@ -3999,7 +4033,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:427
msgid "patch"
msgstr ""
@@ -4015,6 +4049,14 @@ msgstr ""
msgid "perform action without further prompt"
msgstr ""
#: ./cli/recipe/upgrade.go:462
msgid "pin the container image version by manifest digest (image:tag@sha256:...)"
msgstr ""
#: ./cli/recipe/upgrade.go:459
msgid "pindigests"
msgstr ""
#. translators: `abra app env pull` aliases. use a comma separated list of
#. aliases with no spaces in between
#: ./cli/app/env.go:39
@@ -4053,7 +4095,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:438 ./cli/recipe/version.go:141 ./cli/server/list.go:108
msgid "print machine-readable output"
msgstr ""
@@ -4206,7 +4248,7 @@ msgstr ""
msgid "recipe %s does not exist?"
msgstr ""
#: ./pkg/lint/recipe.go:212
#: ./pkg/lint/recipe.go:219
#, c-format
msgid "recipe '%s' failed lint checks:\n"
"%s"
@@ -4230,6 +4272,11 @@ msgstr ""
msgid "ref"
msgstr ""
#: ./pkg/client/registry.go:59
#, c-format
msgid "registry did not return a digest for %s:%s"
msgstr ""
#. translators: `recipe release` command
#: ./cli/recipe/release.go:35
msgid "release <recipe> [version] [flags]"
@@ -4328,6 +4375,11 @@ msgstr ""
msgid "removing configs failed: %s"
msgstr ""
#: ./pkg/recipe/compose.go:122
#, c-format
msgid "removing digest from %s for %s, use --pindigests to pin the new tag"
msgstr ""
#: ./pkg/upstream/stack/remove.go:148
#, c-format
msgid "removing network %s"
@@ -4353,7 +4405,7 @@ msgstr ""
msgid "removing volumes failed: %s"
msgstr ""
#: ./pkg/lint/recipe.go:177
#: ./pkg/lint/recipe.go:184
msgid "replace lightweight tag with annotated tag"
msgstr ""
@@ -4436,7 +4488,7 @@ msgstr ""
msgid "retrieved %s as target container on %s"
msgstr ""
#: ./pkg/recipe/compose.go:59
#: ./pkg/recipe/compose.go:62
#, c-format
msgid "retrieved %s configs for %s"
msgstr ""
@@ -4607,7 +4659,7 @@ msgstr ""
msgid "secret %s is > %d chars when combined with %s"
msgstr ""
#: ./pkg/lint/recipe.go:481
#: ./pkg/lint/recipe.go:510
#, c-format
msgid "secret %s is longer than 12 characters"
msgstr ""
@@ -4723,12 +4775,12 @@ msgstr ""
msgid "serverAdd: cleanUp: unable to list files in %s: %s"
msgstr ""
#: ./cli/recipe/upgrade.go:227
#: ./cli/recipe/upgrade.go:230
#, 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:226
#, c-format
msgid "service %s, image %s pinned to %s, no compatible upgrade found"
msgstr ""
@@ -4969,7 +5021,7 @@ msgstr ""
msgid "store secrets in a local pass store"
msgstr ""
#: ./pkg/formatter/formatter.go:238
#: ./pkg/formatter/formatter.go:239
#, c-format
msgid "stripped %s to %s for parsing"
msgstr ""
@@ -5007,7 +5059,7 @@ msgstr ""
msgid "sync [flags]"
msgstr ""
#: ./pkg/recipe/compose.go:243
#: ./pkg/recipe/compose.go:384
#, c-format
msgid "synced label %s to service %s"
msgstr ""
@@ -5025,7 +5077,12 @@ msgstr ""
msgid "tag at commit %s is unannotated or otherwise broken"
msgstr ""
#: ./cli/recipe/upgrade.go:301
#: ./cli/recipe/upgrade.go:327
#, c-format
msgid "tag upgraded and pinned from %s to %s@%s for %s"
msgstr ""
#: ./cli/recipe/upgrade.go:329
#, c-format
msgid "tag upgraded from %s to %s for %s"
msgstr ""
@@ -5099,7 +5156,7 @@ msgstr ""
msgid "toggle non-interactive mode"
msgstr ""
#: ./pkg/lint/recipe.go:154
#: ./pkg/lint/recipe.go:161
msgid "traefik routing enabled"
msgstr ""
@@ -5212,7 +5269,7 @@ msgstr ""
msgid "unable to determine server of app %s, please pass --server/-s"
msgstr ""
#: ./cli/recipe/upgrade.go:252
#: ./cli/recipe/upgrade.go:258
#, c-format
msgid "unable to determine versioning semantics of %s, listing all tags"
msgstr ""
@@ -5227,6 +5284,11 @@ msgstr ""
msgid "unable to fetch tags in %s: %s"
msgstr ""
#: ./pkg/recipe/compose.go:238 ./pkg/recipe/compose.go:245 ./pkg/recipe/compose.go:255 ./pkg/recipe/compose.go:260
#, c-format
msgid "unable to find image %s for %s, please update it manually"
msgstr ""
#: ./cli/app/move.go:301
#, c-format
msgid "unable to get container matching %s: %s"
@@ -5247,7 +5309,7 @@ msgstr ""
msgid "unable to inspect container for %s: %s"
msgstr ""
#: ./pkg/lint/recipe.go:496
#: ./pkg/lint/recipe.go:525
#, c-format
msgid "unable to list local tags for %s"
msgstr ""
@@ -5261,7 +5323,7 @@ msgstr ""
msgid "unable to look up server context for %s: %s"
msgstr ""
#: ./cli/recipe/fetch.go:77 ./pkg/git/read.go:26 ./pkg/lint/recipe.go:491 ./pkg/recipe/git.go:243
#: ./cli/recipe/fetch.go:77 ./pkg/git/read.go:26 ./pkg/lint/recipe.go:520 ./pkg/recipe/git.go:243
#, c-format
msgid "unable to open %s: %s"
msgstr ""
@@ -5291,11 +5353,6 @@ msgstr ""
msgid "unable to parse %s, error was: %s, skipping upgrade for %s"
msgstr ""
#: ./pkg/recipe/compose.go:153
#, c-format
msgid "unable to parse %s, skipping"
msgstr ""
#: ./cli/app/list.go:167
#, c-format
msgid "unable to parse %s, skipping as upgrade option"
@@ -5311,6 +5368,11 @@ msgstr ""
msgid "unable to parse deploy version: %s"
msgstr ""
#: ./pkg/recipe/compose.go:305
#, c-format
msgid "unable to parse image %s: %s"
msgstr ""
#: ./cli/run.go:176
#, c-format
msgid "unable to proceed, %s does not exist?"
@@ -5518,7 +5580,7 @@ msgstr ""
msgid "updating %s"
msgstr ""
#: ./pkg/recipe/compose.go:171 ./pkg/recipe/compose.go:237
#: ./pkg/recipe/compose.go:203 ./pkg/recipe/compose.go:378
#, c-format
msgid "updating %s to %s in %s"
msgstr ""
@@ -5543,17 +5605,17 @@ msgstr ""
msgid "upgrade [flags]"
msgstr ""
#: ./cli/recipe/upgrade.go:248
#: ./cli/recipe/upgrade.go:254
#, c-format
msgid "upgrade to which tag? (service: %s, image: %s, tag: %s)"
msgstr ""
#: ./cli/recipe/upgrade.go:254
#: ./cli/recipe/upgrade.go:260
#, c-format
msgid "upgrade to which tag? (service: %s, tag: %s)"
msgstr ""
#: ./cli/recipe/upgrade.go:221
#: ./cli/recipe/upgrade.go:224
#, c-format
msgid "upgrading service %s from %s to %s (pinned tag: %s)"
msgstr ""
@@ -5562,6 +5624,10 @@ msgstr ""
msgid "upload your recipe to git.coopcloud.tech/coop-cloud/..."
msgstr ""
#: ./pkg/lint/recipe.go:132
msgid "use a digest for all images"
msgstr ""
#: ./pkg/lint/recipe.go:83
msgid "use a tag for all images"
msgstr ""
@@ -5612,7 +5678,7 @@ msgstr ""
msgid "validated %s as server argument"
msgstr ""
#: ./pkg/lint/recipe.go:170
#: ./pkg/lint/recipe.go:177
msgid "vendor config versions in an abra.sh"
msgstr ""
@@ -5763,7 +5829,7 @@ msgstr ""
msgid "waiting for %d task(s) to reach terminal state"
msgstr ""
#: ./pkg/lint/recipe.go:21 ./pkg/lint/recipe.go:67 ./pkg/lint/recipe.go:74 ./pkg/lint/recipe.go:81 ./pkg/lint/recipe.go:88 ./pkg/lint/recipe.go:95 ./pkg/lint/recipe.go:102 ./pkg/lint/recipe.go:109 ./pkg/lint/recipe.go:116 ./pkg/lint/recipe.go:123
#: ./pkg/lint/recipe.go:21 ./pkg/lint/recipe.go:67 ./pkg/lint/recipe.go:74 ./pkg/lint/recipe.go:81 ./pkg/lint/recipe.go:88 ./pkg/lint/recipe.go:95 ./pkg/lint/recipe.go:102 ./pkg/lint/recipe.go:109 ./pkg/lint/recipe.go:116 ./pkg/lint/recipe.go:123 ./pkg/lint/recipe.go:130
msgid "warn"
msgstr ""
@@ -5806,11 +5872,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:412
msgid "x"
msgstr ""
#: ./cli/recipe/release.go:640 ./cli/recipe/upgrade.go:384
#: ./cli/recipe/release.go:640 ./cli/recipe/upgrade.go:420
msgid "y"
msgstr ""
@@ -5822,7 +5888,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:428
msgid "z"
msgstr ""
Binary file not shown.
+672 -311
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -125,6 +125,13 @@ var LintRules = map[string][]LintRule{
HowToResolve: i18n.G("reduce length of secret names to 12 chars"),
Function: LintSecretLengths,
},
{
Ref: "R016",
Level: i18n.G("warn"),
Description: i18n.G("all images use a digest"),
HowToResolve: i18n.G("use a digest for all images"),
Function: LintAllImagesTaggedWithDigest,
},
},
"error": {
{
@@ -333,6 +340,28 @@ func LintAllImagesTagged(recipe recipe.Recipe) (bool, error) {
return true, nil
}
func LintAllImagesTaggedWithDigest(recipe recipe.Recipe) (bool, error) {
config, err := recipe.GetComposeConfig(nil)
if err != nil {
return false, err
}
for _, service := range config.Services {
img, err := reference.ParseNormalizedNamed(service.Image)
if err != nil {
return false, err
}
if reference.IsNameOnly(img) {
return false, nil
}
_, ok := img.(reference.Digested)
if !ok {
return false, nil
}
}
return true, nil
}
func LintNoUnstableTags(recipe recipe.Recipe) (bool, error) {
config, err := recipe.GetComposeConfig(nil)
if err != nil {
+82
View File
@@ -0,0 +1,82 @@
package recipe
import (
"encoding/json"
"strings"
"testing"
"coopcloud.tech/abra/pkg/config"
gitPkg "coopcloud.tech/abra/pkg/git"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
)
// Use the test recipe at the given commit, in a temporary recipes directory.
func setupTestRecipe(t *testing.T, commit string) Recipe {
t.Helper()
// Other tests in this package use the real RECIPES_DIR.
recipesDir := config.RECIPES_DIR
config.RECIPES_DIR = t.TempDir()
t.Cleanup(func() { config.RECIPES_DIR = recipesDir })
r := Get("abra-test-recipe")
if err := gitPkg.Clone(r.Dir, "https://git.coopcloud.tech/toolshed/abra-test-recipe"); err != nil {
t.Fatal(err)
}
if _, err := r.EnsureVersion(commit); err != nil {
t.Fatal(err)
}
return r
}
func TestGetRecipeVersionsIncludesDigest(t *testing.T) {
if testing.Short() {
t.Skip("skipping e2e test in short mode")
}
// https://git.coopcloud.tech/toolshed/abra-test-recipe/commit/4f0642719d2e2a568f4350cf7eea182438d58be3
r := setupTestRecipe(t, "4f0642719d2e2a568f4350cf7eea182438d58be3")
repo, err := git.PlainOpen(r.Dir)
if err != nil {
t.Fatal(err)
}
for version, commit := range map[string]string{
"9.0.0+1.21.5": "4f0642719d2e2a568f4350cf7eea182438d58be3",
"9.0.1+1.21.6": "77d752b3c61a71bfb2c45f40ec07c9d626a0e580",
} {
if _, err := repo.CreateTag(version, plumbing.NewHash(commit), nil); err != nil {
t.Fatal(err)
}
}
versions, _, err := r.GetRecipeVersions()
if err != nil {
t.Fatal(err)
}
found := map[string]ServiceMeta{}
for _, version := range versions {
for tag, services := range version {
if strings.HasPrefix(tag, "9.0.") {
found[tag] = services["app"]
}
}
}
pinned := found["9.0.0+1.21.5"]
if pinned.Tag != "1.21.5" || pinned.Digest != "sha256:0d17b565c37bcbd895e9d92315a05c1c3c9a29f762b011a10c54a66cd53c9b31" {
t.Fatalf("got pinned metadata %+v", pinned)
}
unpinned := found["9.0.1+1.21.6"]
if unpinned.Tag != "1.21.6" || unpinned.Digest != "" {
t.Fatalf("got unpinned metadata %+v", unpinned)
}
for _, tc := range []struct {
meta ServiceMeta
wantDigest bool
}{{pinned, true}, {unpinned, false}} {
encoded, err := json.Marshal(tc.meta)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(encoded), `"digest"`) != tc.wantDigest {
t.Fatalf("unexpected JSON %s", encoded)
}
}
}
+42 -13
View File
@@ -6,6 +6,7 @@ import (
"io/ioutil"
"os"
"path/filepath"
"regexp"
"strings"
"coopcloud.tech/abra/pkg/formatter"
@@ -110,10 +111,23 @@ func (r Recipe) GetVersionLabelLocal() (string, error) {
return label, nil
}
var sha256Digest = regexp.MustCompile(`^sha256:[a-f0-9]{64}$`)
// UpdateTag updates an image tag in-place on file system local compose files.
func (r Recipe) UpdateTag(image, tag string) (bool, error) {
// A non-empty digest pins the tag (image:tag@sha256:...).
func (r Recipe) UpdateTag(image, tag, digest string) (bool, error) {
// Optional parameter, but must be a valid sha256 digest if provided.
if digest != "" && !sha256Digest.MatchString(digest) {
return false, errors.New(i18n.G("invalid digest %s, want sha256:<64 hex digits>", digest))
}
fullPattern := fmt.Sprintf("%s/compose**yml", r.Dir)
image = formatter.StripTagMeta(image)
source, parseErr := reference.ParseNormalizedNamed(image)
_, exactImage := source.(reference.NamedTagged)
if parseErr != nil {
return false, parseErr
}
imageName := formatter.StripTagMeta(reference.Path(source))
composeFiles, err := filepath.Glob(fullPattern)
if err != nil {
@@ -122,6 +136,7 @@ func (r Recipe) UpdateTag(image, tag string) (bool, error) {
log.Debug(i18n.G("considering %s config(s) for tag update", strings.Join(composeFiles, ", ")))
updated := false
for _, composeFile := range composeFiles {
opts := stack.Deploy{Composefiles: []string{composeFile}}
@@ -140,44 +155,58 @@ 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(formatter.RemoveSha(service.Image))
if err != nil {
return false, err
}
var composeTag string
switch img.(type) {
case reference.NamedTagged:
composeTag = img.(reference.NamedTagged).Tag()
default:
log.Debug(i18n.G("unable to parse %s, skipping", img))
continue
}
// Found image that needs replacing
composeImage := formatter.StripTagMeta(reference.Path(img))
log.Debug(i18n.G("parsed %s from %s", composeTag, service.Image))
if image == composeImage {
if imageName == composeImage && (!exactImage || service.Image == image) {
bytes, err := ioutil.ReadFile(composeFile)
if err != nil {
return false, err
}
old := fmt.Sprintf("%s:%s", composeImage, composeTag)
new := fmt.Sprintf("%s:%s", composeImage, tag)
replacedBytes := strings.Replace(string(bytes), old, new, -1)
old := service.Image
tagged, err := reference.WithTag(reference.TrimNamed(img), tag)
if err != nil {
return false, err
}
new := reference.FamiliarString(tagged)
if digest != "" {
new += "@" + digest
}
if old == new {
continue
}
// Don't confuse digests if the image isn't an exact match. E.g. if old is postgres:13, don't
// match postgres:13-alpine
pattern := regexp.MustCompile(`(?m)(^\s*image:\s*["']?)` + regexp.QuoteMeta(old) + `(["']?\s*(?:#.*)?$)`)
replacedBytes := pattern.ReplaceAllString(string(bytes), "${1}"+new+"${2}")
if replacedBytes == 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 {
return false, err
}
updated = true
}
}
}
return false, nil
return updated, nil
}
// UpdateLabel updates a label in-place on file system local compose files.
+7 -6
View File
@@ -164,7 +164,7 @@ func (r Recipe) EnsureVersion(version string) (bool, error) {
hash, err := repo.ResolveRevision(plumbing.Revision(version))
if err != nil {
log.Fatal(i18n.G("unable to resolve '%s': %s", version, err))
return isChaosCommit, errors.New(i18n.G("unable to resolve '%s': %s", version, err))
}
opts = &git.CheckoutOptions{Hash: *hash, Create: false, Force: true}
@@ -175,11 +175,11 @@ func (r Recipe) EnsureVersion(version string) (bool, error) {
worktree, err := repo.Worktree()
if err != nil {
return isChaosCommit, nil
return isChaosCommit, err
}
if err := worktree.Checkout(opts); err != nil {
return isChaosCommit, nil
return isChaosCommit, err
}
log.Debug(i18n.G("successfully checked %s out to %s in %s", r.Name, tagRef.Short(), r.Dir))
@@ -452,10 +452,11 @@ func (r Recipe) GetRecipeVersions() (RecipeVersions, []string, error) {
continue
}
versionMeta[service.Name] = ServiceMeta{
Image: path,
Tag: tag,
meta := ServiceMeta{Image: path, Tag: tag}
if pinned, ok := img.(reference.Digested); ok {
meta.Digest = pinned.Digest().String()
}
versionMeta[service.Name] = meta
}
versions = append(versions, map[string]map[string]ServiceMeta{tag: versionMeta})
+3 -2
View File
@@ -39,8 +39,9 @@ type service = string
// ServiceMeta represents meta info associated with a service.
type ServiceMeta struct {
Image string `json:"image"`
Tag string `json:"tag"`
Image string `json:"image"`
Tag string `json:"tag"`
Digest string `json:"digest,omitempty"`
}
// RecipeVersions are the versions associated with a recipe.
+62
View File
@@ -702,3 +702,65 @@ teardown(){
assert_failure
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"
# 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
# Manually upgrade and pin the next release to 1.21.6.
run docker pull nginx:1.21.6
assert_success
run docker image inspect --format '{{index .RepoDigests 0}}' nginx:1.21.6
assert_success
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 "$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 "$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"
}