Compare commits

...

58 Commits

Author SHA1 Message Date
8c64a8049d chore: 0.13.0-rc1-beta x 3 [ci skip]
Some checks failed
continuous-integration/drone/tag Build is failing
That didn't work. Doing it manually *again*.
2026-02-18 09:28:30 +01:00
b073072489 chore: 0.13.0-rc1-beta x 2
Some checks failed
continuous-integration/drone/tag Build is failing
continuous-integration/drone/push Build is passing
2026-02-18 09:14:11 +01:00
9daa4fee48 chore: 0.13.0-rc1-beta
Some checks failed
continuous-integration/drone/tag Build is failing
continuous-integration/drone/push Build is passing
2026-02-18 09:04:29 +01:00
ea48917e6c test: ensure ssh-agent is configured
All checks were successful
continuous-integration/drone/push Build is passing
See https://build.coopcloud.tech/toolshed/abra/3594/1/7
2026-02-17 12:54:40 +00:00
728f873a3e chore: translation using Weblate (Spanish)
All checks were successful
continuous-integration/drone/push Build is passing
Currently translated at 90.0% (1030 of 1144 strings)

Translation: Co-op Cloud/abra
Translate-URL: https://translate.coopcloud.tech/projects/co-op-cloud/abra/es/
2026-02-17 10:40:21 +00:00
ddb90dd44d docs: woops, remove that [ci skip] 2026-02-16 17:35:27 +01:00
7a8485492e test: give more space to CI machine for these tests [ci skip] 2026-02-16 17:28:44 +01:00
32bb05abba chore: update translation files
All checks were successful
continuous-integration/drone/push Build is passing
Updated by "Update PO files to match POT (msgmerge)" add-on in Weblate.

Translation: Co-op Cloud/abra
Translate-URL: https://translate.coopcloud.tech/projects/co-op-cloud/abra/
2026-02-16 11:06:33 +00:00
3d2006a696 chore: i18n
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-16 12:06:24 +01:00
521f5c1647 feat: optionally commit changes with recipe upgrade
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-16 09:18:41 +00:00
5eb41bc803 feat!: require clean working copy for recipe release cmd 2026-02-16 09:18:41 +00:00
fc39721501 feat!: merge recipe sync into recipe release 2026-02-16 09:18:41 +00:00
44bacc582b feat!: always publish on recipe release 2026-02-16 09:18:41 +00:00
53e8b52717 docs: more authors [ci skip] 2026-02-15 19:40:24 +01:00
0aba922dda test: fix error [ci skip] 2026-02-15 18:58:23 +01:00
4e0eb739b4 feat: ls requires --chaos due to Ensure logic
All checks were successful
continuous-integration/drone/push Build is passing
Follows #771
2026-02-15 17:57:31 +00:00
6b661dd7a7 chore: update translation files
All checks were successful
continuous-integration/drone/push Build is passing
Updated by "Update PO files to match POT (msgmerge)" add-on in Weblate.

Translation: Co-op Cloud/abra
Translate-URL: https://translate.coopcloud.tech/projects/co-op-cloud/abra/
2026-02-15 17:57:28 +00:00
39102752c0 fix: more graceful bailing if borked .env.sample
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2026-02-15 18:26:05 +01:00
5cfc1c076c fix: remove timeout
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-15 16:34:18 +00:00
10f7ed74b0 chore: update translation files
All checks were successful
continuous-integration/drone/push Build is passing
Updated by "Update PO files to match POT (msgmerge)" add-on in Weblate.

Translation: Co-op Cloud/abra
Translate-URL: https://translate.coopcloud.tech/projects/co-op-cloud/abra/
2026-02-15 16:34:05 +00:00
227d37dc26 chore: i18n
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2026-02-15 17:08:14 +01:00
0ccf3d2b12 fix: ensure and fail for updated recipes
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-15 17:07:46 +01:00
f87ce74027 fix: ensure borked tag handled for deploy/ls 2026-02-15 17:07:45 +01:00
4349ee82bc chore: update translation files
All checks were successful
continuous-integration/drone/push Build is passing
Updated by "Update PO files to match POT (msgmerge)" add-on in Weblate.

Translation: Co-op Cloud/abra
Translate-URL: https://translate.coopcloud.tech/projects/co-op-cloud/abra/
2026-02-15 13:47:09 +00:00
f9ea7506d0 test: #761
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-15 14:46:54 +01:00
1fe2d0421b chore: i18n 2026-02-15 14:41:03 +01:00
59c0d1f4c5 ensure recipe is up to date before creating new app
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-15 13:40:34 +00:00
7c3364f87a test: do not wait for converge checks
All checks were successful
continuous-integration/drone/push Build is passing
See #750 (comment)
2026-02-15 14:39:07 +01:00
2c5a273fa7 chore: update translation files
All checks were successful
continuous-integration/drone/push Build is passing
Updated by "Update PO files to match POT (msgmerge)" add-on in Weblate.

Translation: Co-op Cloud/abra
Translate-URL: https://translate.coopcloud.tech/projects/co-op-cloud/abra/
2026-02-15 13:08:19 +00:00
c54fe3ef85 chore: i18n
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-15 14:08:08 +01:00
bda0d23d39 chore: translation using Weblate (Spanish)
Some checks failed
continuous-integration/drone/push Build is failing
Currently translated at 87.8% (1002 of 1141 strings)

Translation: Co-op Cloud/abra
Translate-URL: https://translate.coopcloud.tech/projects/co-op-cloud/abra/es/
2026-02-15 09:40:56 +00:00
b9dc7b8437 chore: translation using Weblate (Spanish)
Some checks failed
continuous-integration/drone/push Build is failing
Currently translated at 87.7% (1001 of 1141 strings)

Translation: Co-op Cloud/abra
Translate-URL: https://translate.coopcloud.tech/projects/co-op-cloud/abra/es/
2026-02-14 13:52:56 +00:00
8f7dbfedbc add integration test that does not use old recipe version when recipe is broken
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-13 21:51:46 +00:00
4b863f1e15 change usage of tags to recipe versions in deploy.go 2026-02-13 21:51:46 +00:00
064c9f5d65 fix: breaking GetRecipeVersions when an invalid recipe version exists 2026-02-13 21:51:46 +00:00
98e48c95c7 fix: duplicate R015 rule number
Some checks failed
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is failing
2026-02-13 15:57:21 +01:00
c85d8ee6d1 small fixes 2026-02-13 12:13:50 +01:00
23268a0e92 fix: Does not crash when an image has no tag
Some checks failed
continuous-integration/drone/pr Build is failing
continuous-integration/drone/push Build is failing
2026-02-13 11:24:22 +01:00
d60d426752 moved fork
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-13 09:29:01 +00:00
0e273de8f6 fix: Allows multiple protocols on one port 2026-02-13 09:29:01 +00:00
ab32118bfe chore: translation using Weblate (Spanish)
All checks were successful
continuous-integration/drone/push Build is passing
Currently translated at 75.0% (856 of 1141 strings)

Translation: Co-op Cloud/abra
Translate-URL: https://translate.coopcloud.tech/projects/co-op-cloud/abra/es/
2026-02-13 05:52:56 +00:00
683396d75a chore: translation using Weblate (Spanish)
All checks were successful
continuous-integration/drone/push Build is passing
Currently translated at 58.7% (670 of 1141 strings)

Translation: Co-op Cloud/abra
Translate-URL: https://translate.coopcloud.tech/projects/co-op-cloud/abra/es/
2026-02-12 05:13:22 +00:00
4db6755f0d chore: translation using Weblate (Spanish)
All checks were successful
continuous-integration/drone/push Build is passing
Currently translated at 30.2% (345 of 1141 strings)

Translation: Co-op Cloud/abra
Translate-URL: https://translate.coopcloud.tech/projects/co-op-cloud/abra/es/
2026-02-05 14:52:56 +00:00
4c132e30f6 chore: translation using Weblate (Spanish)
All checks were successful
continuous-integration/drone/push Build is passing
Currently translated at 29.8% (341 of 1141 strings)

Translation: Co-op Cloud/abra
Translate-URL: https://translate.coopcloud.tech/projects/co-op-cloud/abra/es/
2026-02-01 03:52:59 +00:00
f5aeae30c7 chore: translation using Weblate (Spanish)
All checks were successful
continuous-integration/drone/push Build is passing
Currently translated at 23.3% (266 of 1141 strings)

Translation: Co-op Cloud/abra
Translate-URL: https://translate.coopcloud.tech/projects/co-op-cloud/abra/es/
2026-01-30 16:52:56 +00:00
1d24107956 fix regexp for _remove_tags bats output
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-26 07:05:51 +00:00
f835b87255 chore: update translation files
All checks were successful
continuous-integration/drone/push Build is passing
Updated by "Update PO files to match POT (msgmerge)" add-on in Weblate.

Translation: Co-op Cloud/abra
Translate-URL: https://translate.coopcloud.tech/projects/co-op-cloud/abra/
2026-01-16 23:45:14 +00:00
dba21d6a29 chore: lowercase, i18n
All checks were successful
continuous-integration/drone/push Build is passing
2026-01-17 00:44:55 +01:00
182fc41c58 added integration test
Some checks failed
continuous-integration/drone/push Build is failing
2026-01-16 14:27:27 -08:00
304ac87cec ensure repo is up to date before printing status
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2026-01-14 16:58:21 -08:00
5b3929d885 modified install script to output a safe PATH-update (fixes #735)
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2026-01-02 10:19:10 +01:00
c41df874d1 chore: update translation files
Some checks failed
continuous-integration/drone/push Build is failing
Updated by "Update PO files to match POT (msgmerge)" add-on in Weblate.

Translation: Co-op Cloud/abra
Translate-URL: https://translate.coopcloud.tech/projects/co-op-cloud/abra/
2025-12-21 15:39:13 +00:00
b721adbf9c chore: i18n
Some checks failed
continuous-integration/drone/push Build is failing
2025-12-21 16:39:02 +01:00
42f9e6d458 return error instead of handling it inside getLatestVersion
Some checks failed
continuous-integration/drone/push Build is failing
2025-12-19 06:50:35 -08:00
9e7bc31d4d avoiding #732 by checking for empty versions list for recipe sync
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2025-12-18 14:41:58 -08:00
b79c4f33b6 chore: update translation files
Some checks failed
continuous-integration/drone/push Build is failing
Updated by "Update PO files to match POT (msgmerge)" add-on in Weblate.

Translation: Co-op Cloud/abra
Translate-URL: https://translate.coopcloud.tech/projects/co-op-cloud/abra/
2025-12-14 15:02:41 +00:00
cc87d5b3da chore: remove reference to wizard mode from recipe upgrade cli docs
Some checks failed
continuous-integration/drone/push Build is failing
2025-12-13 15:26:12 +11:00
8b5e3f3c78 chore: translation using Weblate (Spanish)
Currently translated at 9.5% (109 of 1140 strings)

Translation: Co-op Cloud/abra
Translate-URL: https://translate.coopcloud.tech/projects/co-op-cloud/abra/es/
2025-12-10 01:43:58 +00:00
50 changed files with 2655 additions and 2666 deletions

View File

@ -1,4 +1,6 @@
---
version: 2
gitea_urls:
api: https://git.coopcloud.tech/api/v1
download: https://git.coopcloud.tech/

View File

@ -10,14 +10,17 @@
- cassowary
- chasqui
- codegod100
- cyrnel
- decentral1se
- fauno
- frando
- iexos
- jade
- kawaiipunk
- knoflook
- mayel
- moritz
- namnatulco
- p4u1
- rix
- roxxers

View File

@ -10,6 +10,7 @@ import (
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/secret"
"coopcloud.tech/tagcmp"
appPkg "coopcloud.tech/abra/pkg/app"
"coopcloud.tech/abra/pkg/client"
@ -107,6 +108,15 @@ checkout as-is. Recipe commit hashes are also supported as values for
log.Fatal(err)
}
isChaosCommit, err := app.Recipe.IsChaosCommit(toDeployVersion)
if err != nil {
log.Fatal(i18n.G("unable to determine if %s is a chaos commit: %s", toDeployVersion, err))
}
if !isChaosCommit && !tagcmp.IsParsable(toDeployVersion) {
log.Fatal(i18n.G("unable to parse deploy version: %s", toDeployVersion))
}
if !internal.Chaos {
isChaosCommit, err := app.Recipe.EnsureVersion(toDeployVersion)
if err != nil {
@ -281,13 +291,21 @@ checkout as-is. Recipe commit hashes are also supported as values for
}
func getLatestVersionOrCommit(app appPkg.App) (string, error) {
versions, err := app.Recipe.Tags()
recipeVersions, warnings, err := app.Recipe.GetRecipeVersions()
if err != nil {
return "", err
}
if len(versions) > 0 && !internal.Chaos {
return versions[len(versions)-1], nil
for _, warning := range warnings {
log.Warn(warning)
}
if len(recipeVersions) > 0 && !internal.Chaos {
latest := recipeVersions[len(recipeVersions)-1]
for tag := range latest {
log.Debug(i18n.G("selected latest recipe version: %s (from %d available versions)", tag, len(recipeVersions)))
return tag, nil
}
}
head, err := app.Recipe.Head()

View File

@ -113,6 +113,10 @@ Use "--status/-S" flag to query all servers for the live deployment status.`),
totalAppsCount++
if status {
if err := app.Recipe.Ensure(internal.GetEnsureContext()); err != nil {
log.Fatal(err)
}
status := i18n.G("unknown")
version := i18n.G("unknown")
chaos := i18n.G("unknown")
@ -325,6 +329,14 @@ func init() {
i18n.G("show apps of a specific server"),
)
AppListCommand.Flags().BoolVarP(
&internal.Chaos,
i18n.G("chaos"),
i18n.G("C"),
false,
i18n.G("ignore uncommitted recipes changes"),
)
AppListCommand.RegisterFlagCompletionFunc(
i18n.G("server"),
func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {

View File

@ -72,6 +72,10 @@ var AppNewCommand = &cobra.Command{
Run: func(cmd *cobra.Command, args []string) {
recipe := internal.ValidateRecipe(args, cmd.Name())
if err := recipe.Ensure(internal.GetEnsureContext()); err != nil {
log.Fatal(err)
}
if len(args) == 2 && internal.Chaos {
log.Fatal(i18n.G("cannot use [version] and --chaos together"))
}
@ -98,10 +102,14 @@ var AppNewCommand = &cobra.Command{
var recipeVersions recipePkg.RecipeVersions
if recipeVersion == "" {
var err error
recipeVersions, _, err = recipe.GetRecipeVersions()
var warnings []string
recipeVersions, warnings, err = recipe.GetRecipeVersions()
if err != nil {
log.Fatal(err)
}
for _, warning := range warnings {
log.Warn(warning)
}
}
if len(recipeVersions) > 0 {
@ -110,6 +118,8 @@ var AppNewCommand = &cobra.Command{
recipeVersion = tag
}
log.Debug(i18n.G("selected recipe version: %s (from %d available versions)", recipeVersion, len(recipeVersions)))
if _, err := recipe.EnsureVersion(recipeVersion); err != nil {
log.Fatal(err)
}

View File

@ -14,7 +14,7 @@ import (
gitPkg "coopcloud.tech/abra/pkg/git"
"coopcloud.tech/abra/pkg/i18n"
"coopcloud.tech/abra/pkg/log"
"coopcloud.tech/abra/pkg/recipe"
recipePkg "coopcloud.tech/abra/pkg/recipe"
"coopcloud.tech/tagcmp"
"github.com/AlecAivazis/survey/v2"
"github.com/distribution/reference"
@ -23,6 +23,9 @@ import (
"github.com/spf13/cobra"
)
// Errors
var errEmptyVersionsInCatalogue = errors.New(i18n.G("catalogue versions list is unexpectedly empty"))
// translators: `abra recipe release` aliases. use a comma separated list of
// aliases with no spaces in between
var recipeReleaseAliases = i18n.G("rl")
@ -50,7 +53,7 @@ recipe updates are properly communicated. I.e. developers of an app might
publish a minor version but that might lead to changes in the recipe which are
major and therefore require intervention while doing the upgrade work.
Publish your new release to git.coopcloud.tech with "--publish/-p". This
This command will publish your new release to git.coopcloud.tech. This
requires that you have permission to git push to these repositories and have
your SSH keys configured on your account. Enable ssh-agent and make sure to add
your private key and enter your passphrase beforehand.
@ -60,12 +63,13 @@ your private key and enter your passphrase beforehand.
Example: ` # publish release
eval ` + "`ssh-agent`" + `
ssh-add ~/.ssh/id_ed25519
abra recipe release gitea -p`,
abra recipe release gitea`,
Args: cobra.RangeArgs(1, 2),
ValidArgsFunction: func(
cmd *cobra.Command,
args []string,
toComplete string) ([]string, cobra.ShellCompDirective) {
toComplete string,
) ([]string, cobra.ShellCompDirective) {
switch l := len(args); l {
case 0:
return autocomplete.RecipeNameComplete()
@ -93,19 +97,207 @@ your private key and enter your passphrase beforehand.
log.Fatal(i18n.G("main app service version for %s is empty?", recipe.Name))
}
isClean, err := gitPkg.IsClean(recipe.Dir)
if err != nil {
log.Fatal(err)
}
if !isClean {
log.Fatal(i18n.G("working directory not clean in %s, aborting", recipe.Dir))
}
tags, err := recipe.Tags()
if err != nil {
log.Fatal(err)
}
var tagString string
if len(args) == 2 {
tagString = args[1]
}
if tagString != "" {
if _, err := tagcmp.Parse(tagString); err != nil {
log.Fatal(i18n.G("cannot parse %s, invalid tag specified?", tagString))
if (internal.Major || internal.Minor || internal.Patch) && tagString != "" {
log.Fatal(i18n.G("cannot specify tag and bump type at the same time"))
}
if len(tags) == 0 && tagString == "" {
log.Warn(i18n.G("no git tags found for %s", recipe.Name))
if internal.NoInput {
log.Fatal(i18n.G("unable to continue, input required for initial version"))
}
fmt.Println(i18n.G(`
The following options are two types of initial semantic version that you can
pick for %s that will be published in the recipe catalogue. This follows the
semver convention (more on https://semver.org), here is a short cheatsheet
0.1.0: development release, still hacking. when you make a major upgrade
you increment the "y" part (i.e. 0.1.0 -> 0.2.0) and only move to
using the "x" part when things are stable.
1.0.0: public release, assumed to be working. you already have a stable
and reliable deployment of this app and feel relatively confident
about it.
If you want people to be able alpha test your current config for %s but don't
think it is quite reliable, go with 0.1.0 and people will know that things are
likely to change.
`, recipe.Name, recipe.Name))
var chosenVersion string
edPrompt := &survey.Select{
Message: i18n.G("which version do you want to begin with?"),
Options: []string{"0.1.0", "1.0.0"},
}
if err := survey.AskOne(edPrompt, &chosenVersion); err != nil {
log.Fatal(err)
}
tagString = fmt.Sprintf("%s+%s", chosenVersion, mainAppVersion)
}
if tagString == "" && (!internal.Major && !internal.Minor && !internal.Patch) {
catl, err := recipePkg.ReadRecipeCatalogue(false)
if err != nil {
log.Fatal(err)
}
changesTable, err := formatter.CreateTable()
if err != nil {
log.Fatal(err)
}
latestRelease := tags[len(tags)-1]
latestRecipeVersion, err := getLatestVersion(recipe, catl)
if err != nil && err != errEmptyVersionsInCatalogue {
log.Fatal(err)
}
changesTable.Headers(i18n.G("SERVICE"), latestRelease, i18n.G("PROPOSED CHANGES"))
allRecipeVersions := catl[recipe.Name].Versions
for _, recipeVersion := range allRecipeVersions {
if serviceVersions, ok := recipeVersion[latestRecipeVersion]; ok {
for serviceName := range serviceVersions {
serviceMeta := serviceVersions[serviceName]
existingImageTag := fmt.Sprintf("%s:%s", serviceMeta.Image, serviceMeta.Tag)
newImageTag := fmt.Sprintf("%s:%s", serviceMeta.Image, imagesTmp[serviceMeta.Image])
if existingImageTag == newImageTag {
continue
}
changesTable.Row([]string{serviceName, existingImageTag, newImageTag}...)
}
}
}
changeOverview := changesTable.Render()
if err := internal.PromptBumpType("", latestRelease, changeOverview); err != nil {
log.Fatal(err)
}
}
if (internal.Major || internal.Minor || internal.Patch) && tagString != "" {
log.Fatal(i18n.G("cannot specify tag and bump type at the same time"))
if tagString == "" {
repo, err := git.PlainOpen(recipe.Dir)
if err != nil {
log.Fatal(err)
}
var lastGitTag tagcmp.Tag
iter, err := repo.Tags()
if err != nil {
log.Fatal(err)
}
if err := iter.ForEach(func(ref *plumbing.Reference) error {
obj, err := repo.TagObject(ref.Hash())
if err != nil {
log.Fatal(i18n.G("tag at commit %s is unannotated or otherwise broken", ref.Hash()))
return err
}
tagcmpTag, err := tagcmp.Parse(obj.Name)
if err != nil {
return err
}
if (lastGitTag == tagcmp.Tag{}) {
lastGitTag = tagcmpTag
} else if tagcmpTag.IsGreaterThan(lastGitTag) {
lastGitTag = tagcmpTag
}
return nil
}); err != nil {
log.Fatal(err)
}
// bumpType is used to decide what part of the tag should be incremented
bumpType := btoi(internal.Major)*4 + btoi(internal.Minor)*2 + btoi(internal.Patch)
if bumpType != 0 {
// a bitwise check if the number is a power of 2
if (bumpType & (bumpType - 1)) != 0 {
log.Fatal(i18n.G("you can only use one version flag: --major, --minor or --patch"))
}
}
newTag := lastGitTag
if bumpType > 0 {
if internal.Patch {
now, err := strconv.Atoi(newTag.Patch)
if err != nil {
log.Fatal(err)
}
newTag.Patch = strconv.Itoa(now + 1)
} else if internal.Minor {
now, err := strconv.Atoi(newTag.Minor)
if err != nil {
log.Fatal(err)
}
newTag.Patch = "0"
newTag.Minor = strconv.Itoa(now + 1)
} else if internal.Major {
now, err := strconv.Atoi(newTag.Major)
if err != nil {
log.Fatal(err)
}
newTag.Patch = "0"
newTag.Minor = "0"
newTag.Major = strconv.Itoa(now + 1)
}
}
newTag.Metadata = mainAppVersion
log.Debug(i18n.G("choosing %s as new version for %s", newTag.String(), recipe.Name))
tagString = newTag.String()
}
if _, err := tagcmp.Parse(tagString); err != nil {
log.Fatal(i18n.G("invalid version %s specified", tagString))
}
mainService := "app"
label := i18n.G("coop-cloud.${STACK_NAME}.version=%s", tagString)
if !internal.Dry {
if err := recipe.UpdateLabel("compose.y*ml", mainService, label); err != nil {
log.Fatal(err)
}
} else {
log.Info(i18n.G("dry run: not syncing label %s for recipe %s", tagString, recipe.Name))
}
for _, tag := range tags {
previousTagLeftHand := strings.Split(tag, "+")[0]
newTagStringLeftHand := strings.Split(tagString, "+")[0]
if previousTagLeftHand == newTagStringLeftHand {
log.Fatal(i18n.G("%s+... conflicts with a previous release: %s", newTagStringLeftHand, tag))
}
}
repo, err := git.PlainOpen(recipe.Dir)
@ -118,84 +310,20 @@ your private key and enter your passphrase beforehand.
log.Fatal(err)
}
if tagString != "" {
if err := createReleaseFromTag(recipe, tagString, mainAppVersion); err != nil {
if cleanErr := cleanTag(recipe, tagString); cleanErr != nil {
log.Fatal(cleanErr)
}
if cleanErr := cleanCommit(recipe, preCommitHead); cleanErr != nil {
log.Fatal(cleanErr)
}
log.Fatal(err)
if err := createReleaseFromTag(recipe, tagString, mainAppVersion); err != nil {
if cleanErr := cleanTag(recipe, tagString); cleanErr != nil {
log.Fatal(cleanErr)
}
if cleanErr := cleanCommit(recipe, preCommitHead); cleanErr != nil {
log.Fatal(cleanErr)
}
}
tags, err := recipe.Tags()
if err != nil {
log.Fatal(err)
}
labelVersion, err := getLabelVersion(recipe, false)
if err != nil {
log.Fatal(err)
}
for _, tag := range tags {
previousTagLeftHand := strings.Split(tag, "+")[0]
newTagStringLeftHand := strings.Split(labelVersion, "+")[0]
if previousTagLeftHand == newTagStringLeftHand {
log.Fatal(i18n.G("%s+... conflicts with a previous release: %s", newTagStringLeftHand, tag))
}
}
if tagString == "" && (!internal.Major && !internal.Minor && !internal.Patch) {
tagString = labelVersion
}
isClean, err := gitPkg.IsClean(recipe.Dir)
if err != nil {
log.Fatal(err)
}
if !isClean {
log.Info(i18n.G("%s currently has these unstaged changes 👇", recipe.Name))
if err := gitPkg.DiffUnstaged(recipe.Dir); err != nil {
log.Fatal(err)
}
}
if len(tags) > 0 {
log.Warn(i18n.G("previous git tags detected, assuming new semver release"))
if err := createReleaseFromPreviousTag(tagString, mainAppVersion, recipe, tags); err != nil {
if cleanErr := cleanTag(recipe, tagString); cleanErr != nil {
log.Fatal(cleanErr)
}
if cleanErr := cleanCommit(recipe, preCommitHead); cleanErr != nil {
log.Fatal(cleanErr)
}
log.Fatal(err)
}
} else {
log.Warn(i18n.G("no tag specified and no previous tag available for %s, assuming initial release", recipe.Name))
if err := createReleaseFromTag(recipe, tagString, mainAppVersion); err != nil {
if cleanErr := cleanTag(recipe, tagString); cleanErr != nil {
log.Fatal(cleanErr)
}
if cleanErr := cleanCommit(recipe, preCommitHead); cleanErr != nil {
log.Fatal(cleanErr)
}
log.Fatal(err)
}
}
return
},
}
// GetImageVersions retrieves image versions for a recipe
func GetImageVersions(recipe recipe.Recipe) (map[string]string, error) {
func GetImageVersions(recipe recipePkg.Recipe) (map[string]string, error) {
services := make(map[string]string)
config, err := recipe.GetComposeConfig(nil)
@ -239,7 +367,7 @@ func GetImageVersions(recipe recipe.Recipe) (map[string]string, error) {
}
// createReleaseFromTag creates a new release based on a supplied recipe version string
func createReleaseFromTag(recipe recipe.Recipe, tagString, mainAppVersion string) error {
func createReleaseFromTag(recipe recipePkg.Recipe, tagString, mainAppVersion string) error {
var err error
repo, err := git.PlainOpen(recipe.Dir)
@ -302,7 +430,7 @@ func getTagCreateOptions(tag string) (git.CreateTagOptions, error) {
// addReleaseNotes checks if the release/next release note exists and moves the
// file to release/<tag>.
func addReleaseNotes(recipe recipe.Recipe, tag string) error {
func addReleaseNotes(recipe recipePkg.Recipe, tag string) error {
releaseDir := path.Join(recipe.Dir, "release")
if _, err := os.Stat(releaseDir); errors.Is(err, os.ErrNotExist) {
if err := os.Mkdir(releaseDir, 0755); err != nil {
@ -387,7 +515,7 @@ func addReleaseNotes(recipe recipe.Recipe, tag string) error {
return nil
}
func commitRelease(recipe recipe.Recipe, tag string) error {
func commitRelease(recipe recipePkg.Recipe, tag string) error {
if internal.Dry {
log.Debug(i18n.G("dry run: no changes committed"))
return nil
@ -439,140 +567,29 @@ func tagRelease(tagString string, repo *git.Repository) error {
return nil
}
func pushRelease(recipe recipe.Recipe, tagString string) error {
func pushRelease(recipe recipePkg.Recipe, tagString string) error {
if internal.Dry {
log.Info(i18n.G("dry run: no changes published"))
return nil
}
if !publish && !internal.NoInput {
prompt := &survey.Confirm{
Message: i18n.G("publish new release?"),
}
if err := survey.AskOne(prompt, &publish); err != nil {
return err
}
if os.Getenv("SSH_AUTH_SOCK") == "" {
return errors.New(i18n.G("ssh-agent not found. see \"abra recipe release --help\" and try again"))
}
if publish {
if os.Getenv("SSH_AUTH_SOCK") == "" {
return errors.New(i18n.G("ssh-agent not found. see \"abra recipe release --help\" and try again"))
}
if err := recipe.Push(internal.Dry); err != nil {
return err
}
url := fmt.Sprintf("%s/src/tag/%s", recipe.GitURL, tagString)
log.Info(i18n.G("new release published: %s", url))
} else {
log.Info(i18n.G("no -p/--publish passed, not publishing"))
}
return nil
}
func createReleaseFromPreviousTag(tagString, mainAppVersion string, recipe recipe.Recipe, tags []string) error {
repo, err := git.PlainOpen(recipe.Dir)
if err != nil {
if err := recipe.Push(internal.Dry); err != nil {
return err
}
bumpType := btoi(internal.Major)*4 + btoi(internal.Minor)*2 + btoi(internal.Patch)
if bumpType != 0 {
if (bumpType & (bumpType - 1)) != 0 {
return errors.New(i18n.G("you can only use one of: --major, --minor, --patch"))
}
}
var lastGitTag tagcmp.Tag
for _, tag := range tags {
parsed, err := tagcmp.Parse(tag)
if err != nil {
return err
}
if (lastGitTag == tagcmp.Tag{}) {
lastGitTag = parsed
} else if parsed.IsGreaterThan(lastGitTag) {
lastGitTag = parsed
}
}
newTag := lastGitTag
if internal.Patch {
now, err := strconv.Atoi(newTag.Patch)
if err != nil {
return err
}
newTag.Patch = strconv.Itoa(now + 1)
} else if internal.Minor {
now, err := strconv.Atoi(newTag.Minor)
if err != nil {
return err
}
newTag.Patch = "0"
newTag.Minor = strconv.Itoa(now + 1)
} else if internal.Major {
now, err := strconv.Atoi(newTag.Major)
if err != nil {
return err
}
newTag.Patch = "0"
newTag.Minor = "0"
newTag.Major = strconv.Itoa(now + 1)
}
if internal.Major || internal.Minor || internal.Patch {
newTag.Metadata = mainAppVersion
tagString = newTag.String()
}
if lastGitTag.String() == tagString {
return errors.New(i18n.G("latest git tag (%s) and synced label (%s) are the same?", lastGitTag, tagString))
}
if !internal.NoInput {
prompt := &survey.Confirm{
Message: i18n.G("current: %s, new: %s, correct?", lastGitTag, tagString),
}
var ok bool
if err := survey.AskOne(prompt, &ok); err != nil {
return err
}
if !ok {
return errors.New(i18n.G("exiting as requested"))
}
}
if err := addReleaseNotes(recipe, tagString); err != nil {
return errors.New(i18n.G("failed to add release notes: %s", err.Error()))
}
if err := commitRelease(recipe, tagString); err != nil {
return errors.New(i18n.G("failed to commit changes: %s", err.Error()))
}
if err := tagRelease(tagString, repo); err != nil {
return errors.New(i18n.G("failed to tag release: %s", err.Error()))
}
if err := pushRelease(recipe, tagString); err != nil {
return errors.New(i18n.G("failed to publish new release: %s", err.Error()))
}
url := fmt.Sprintf("%s/src/tag/%s", recipe.GitURL, tagString)
log.Info(i18n.G("new release published: %s", url))
return nil
}
// cleanCommit soft removes the latest release commit. No change are lost the
// the commit itself is removed. This is the equivalent of `git reset HEAD~1`.
func cleanCommit(recipe recipe.Recipe, head *plumbing.Reference) error {
func cleanCommit(recipe recipePkg.Recipe, head *plumbing.Reference) error {
repo, err := git.PlainOpen(recipe.Dir)
if err != nil {
return errors.New(i18n.G("unable to open repo in %s: %s", recipe.Dir, err))
@ -594,7 +611,7 @@ func cleanCommit(recipe recipe.Recipe, head *plumbing.Reference) error {
}
// cleanTag removes a freshly created tag
func cleanTag(recipe recipe.Recipe, tag string) error {
func cleanTag(recipe recipePkg.Recipe, tag string) error {
repo, err := git.PlainOpen(recipe.Dir)
if err != nil {
return errors.New(i18n.G("unable to open repo in %s: %s", recipe.Dir, err))
@ -611,37 +628,17 @@ func cleanTag(recipe recipe.Recipe, tag string) error {
return nil
}
func getLabelVersion(recipe recipe.Recipe, prompt bool) (string, error) {
initTag, err := recipe.GetVersionLabelLocal()
func getLatestVersion(recipe recipePkg.Recipe, catl recipePkg.RecipeCatalogue) (string, error) {
versions, err := recipePkg.GetRecipeCatalogueVersions(recipe.Name, catl)
if err != nil {
return "", err
}
if initTag == "" {
return "", errors.New(i18n.G("unable to read version for %s from synced label. Did you try running \"abra recipe sync %s\" already?", recipe.Name, recipe.Name))
if len(versions) > 0 {
return versions[len(versions)-1], nil
}
log.Warn(i18n.G("discovered %s as currently synced recipe label", initTag))
if prompt && !internal.NoInput {
var response bool
prompt := &survey.Confirm{Message: i18n.G("use %s as the new version?", initTag)}
if err := survey.AskOne(prompt, &response); err != nil {
return "", err
}
if !response {
return "", errors.New(i18n.G("please fix your synced label for %s and re-run this command", recipe.Name))
}
}
return initTag, nil
return "", errEmptyVersionsInCatalogue
}
var (
publish bool
)
func init() {
RecipeReleaseCommand.Flags().BoolVarP(
&internal.Dry,
@ -674,12 +671,4 @@ func init() {
false,
i18n.G("increase the patch part of the version"),
)
RecipeReleaseCommand.Flags().BoolVarP(
&publish,
i18n.G("publish"),
i18n.G("p"),
false,
i18n.G("publish changes to git.coopcloud.tech"),
)
}

View File

@ -0,0 +1,33 @@
package recipe
import (
"testing"
recipePkg "coopcloud.tech/abra/pkg/recipe"
"github.com/stretchr/testify/assert"
)
func TestGetLatestVersionReturnsErrorWhenVersionsIsEmpty(t *testing.T) {
recipe := recipePkg.Recipe{}
catalogue := recipePkg.RecipeCatalogue{}
_, err := getLatestVersion(recipe, catalogue)
assert.Equal(t, err, errEmptyVersionsInCatalogue)
}
func TestGetLatestVersionReturnsLastVersion(t *testing.T) {
recipe := recipePkg.Recipe{
Name: "test",
}
versions := []map[string]map[string]recipePkg.ServiceMeta{
make(map[string]map[string]recipePkg.ServiceMeta),
make(map[string]map[string]recipePkg.ServiceMeta),
}
versions[0]["0.0.3"] = make(map[string]recipePkg.ServiceMeta)
versions[1]["0.0.2"] = make(map[string]recipePkg.ServiceMeta)
catalogue := make(recipePkg.RecipeCatalogue)
catalogue["test"] = recipePkg.RecipeMeta{
Versions: versions,
}
version, _ := getLatestVersion(recipe, catalogue)
assert.Equal(t, version, "0.0.3")
}

View File

@ -1,300 +0,0 @@
package recipe
import (
"fmt"
"strconv"
"strings"
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/autocomplete"
"coopcloud.tech/abra/pkg/formatter"
gitPkg "coopcloud.tech/abra/pkg/git"
"coopcloud.tech/abra/pkg/i18n"
"coopcloud.tech/abra/pkg/log"
recipePkg "coopcloud.tech/abra/pkg/recipe"
"coopcloud.tech/tagcmp"
"github.com/AlecAivazis/survey/v2"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/spf13/cobra"
)
// translators: `abra recipe reset` aliases. use a comma separated list of
// aliases with no spaces in between
var recipeSyncAliases = i18n.G("s")
var RecipeSyncCommand = &cobra.Command{
// translators: `recipe sync` command
Use: i18n.G("sync <recipe> [version] [flags]"),
Aliases: strings.Split(recipeSyncAliases, ","),
// translators: Short description for `recipe sync` command
Short: i18n.G("Sync recipe version label"),
Long: i18n.G(`Generate labels for the main recipe service.
By convention, the service named "app" using the following format:
coop-cloud.${STACK_NAME}.version=<version>
Where [version] can be specifed on the command-line or Abra can attempt to
auto-generate it for you. The <recipe> configuration will be updated on the
local file system.`),
Args: cobra.RangeArgs(1, 2),
ValidArgsFunction: func(
cmd *cobra.Command,
args []string,
toComplete string) ([]string, cobra.ShellCompDirective) {
switch l := len(args); l {
case 0:
return autocomplete.RecipeNameComplete()
case 1:
return autocomplete.RecipeVersionComplete(args[0])
default:
return nil, cobra.ShellCompDirectiveError
}
},
Run: func(cmd *cobra.Command, args []string) {
recipe := internal.ValidateRecipe(args, cmd.Name())
mainApp, err := internal.GetMainAppImage(recipe)
if err != nil {
log.Fatal(err)
}
imagesTmp, err := GetImageVersions(recipe)
if err != nil {
log.Fatal(err)
}
mainAppVersion := imagesTmp[mainApp]
tags, err := recipe.Tags()
if err != nil {
log.Fatal(err)
}
var nextTag string
if len(args) == 2 {
nextTag = args[1]
}
if len(tags) == 0 && nextTag == "" {
log.Warn(i18n.G("no git tags found for %s", recipe.Name))
if internal.NoInput {
log.Fatal(i18n.G("unable to continue, input required for initial version"))
}
fmt.Println(i18n.G(`
The following options are two types of initial semantic version that you can
pick for %s that will be published in the recipe catalogue. This follows the
semver convention (more on https://semver.org), here is a short cheatsheet
0.1.0: development release, still hacking. when you make a major upgrade
you increment the "y" part (i.e. 0.1.0 -> 0.2.0) and only move to
using the "x" part when things are stable.
1.0.0: public release, assumed to be working. you already have a stable
and reliable deployment of this app and feel relatively confident
about it.
If you want people to be able alpha test your current config for %s but don't
think it is quite reliable, go with 0.1.0 and people will know that things are
likely to change.
`, recipe.Name, recipe.Name))
var chosenVersion string
edPrompt := &survey.Select{
Message: i18n.G("which version do you want to begin with?"),
Options: []string{"0.1.0", "1.0.0"},
}
if err := survey.AskOne(edPrompt, &chosenVersion); err != nil {
log.Fatal(err)
}
nextTag = fmt.Sprintf("%s+%s", chosenVersion, mainAppVersion)
}
if nextTag == "" && (!internal.Major && !internal.Minor && !internal.Patch) {
var changeOverview string
catl, err := recipePkg.ReadRecipeCatalogue(false)
if err != nil {
log.Fatal(err)
}
versions, err := recipePkg.GetRecipeCatalogueVersions(recipe.Name, catl)
if err != nil {
log.Fatal(err)
}
changesTable, err := formatter.CreateTable()
if err != nil {
log.Fatal(err)
}
latestRelease := tags[len(tags)-1]
changesTable.Headers(i18n.G("SERVICE"), latestRelease, i18n.G("PROPOSED CHANGES"))
latestRecipeVersion := versions[len(versions)-1]
allRecipeVersions := catl[recipe.Name].Versions
for _, recipeVersion := range allRecipeVersions {
if serviceVersions, ok := recipeVersion[latestRecipeVersion]; ok {
for serviceName := range serviceVersions {
serviceMeta := serviceVersions[serviceName]
existingImageTag := fmt.Sprintf("%s:%s", serviceMeta.Image, serviceMeta.Tag)
newImageTag := fmt.Sprintf("%s:%s", serviceMeta.Image, imagesTmp[serviceMeta.Image])
if existingImageTag == newImageTag {
continue
}
changesTable.Row([]string{serviceName, existingImageTag, newImageTag}...)
}
}
}
changeOverview = changesTable.Render()
if err := internal.PromptBumpType("", latestRelease, changeOverview); err != nil {
log.Fatal(err)
}
}
if nextTag == "" {
repo, err := git.PlainOpen(recipe.Dir)
if err != nil {
log.Fatal(err)
}
var lastGitTag tagcmp.Tag
iter, err := repo.Tags()
if err != nil {
log.Fatal(err)
}
if err := iter.ForEach(func(ref *plumbing.Reference) error {
obj, err := repo.TagObject(ref.Hash())
if err != nil {
log.Fatal(i18n.G("tag at commit %s is unannotated or otherwise broken", ref.Hash()))
return err
}
tagcmpTag, err := tagcmp.Parse(obj.Name)
if err != nil {
return err
}
if (lastGitTag == tagcmp.Tag{}) {
lastGitTag = tagcmpTag
} else if tagcmpTag.IsGreaterThan(lastGitTag) {
lastGitTag = tagcmpTag
}
return nil
}); err != nil {
log.Fatal(err)
}
// bumpType is used to decide what part of the tag should be incremented
bumpType := btoi(internal.Major)*4 + btoi(internal.Minor)*2 + btoi(internal.Patch)
if bumpType != 0 {
// a bitwise check if the number is a power of 2
if (bumpType & (bumpType - 1)) != 0 {
log.Fatal(i18n.G("you can only use one version flag: --major, --minor or --patch"))
}
}
newTag := lastGitTag
if bumpType > 0 {
if internal.Patch {
now, err := strconv.Atoi(newTag.Patch)
if err != nil {
log.Fatal(err)
}
newTag.Patch = strconv.Itoa(now + 1)
} else if internal.Minor {
now, err := strconv.Atoi(newTag.Minor)
if err != nil {
log.Fatal(err)
}
newTag.Patch = "0"
newTag.Minor = strconv.Itoa(now + 1)
} else if internal.Major {
now, err := strconv.Atoi(newTag.Major)
if err != nil {
log.Fatal(err)
}
newTag.Patch = "0"
newTag.Minor = "0"
newTag.Major = strconv.Itoa(now + 1)
}
}
newTag.Metadata = mainAppVersion
log.Debug(i18n.G("choosing %s as new version for %s", newTag.String(), recipe.Name))
nextTag = newTag.String()
}
if _, err := tagcmp.Parse(nextTag); err != nil {
log.Fatal(i18n.G("invalid version %s specified", nextTag))
}
mainService := "app"
label := i18n.G("coop-cloud.${STACK_NAME}.version=%s", nextTag)
if !internal.Dry {
if err := recipe.UpdateLabel("compose.y*ml", mainService, label); err != nil {
log.Fatal(err)
}
} else {
log.Info(i18n.G("dry run: not syncing label %s for recipe %s", nextTag, recipe.Name))
}
isClean, err := gitPkg.IsClean(recipe.Dir)
if err != nil {
log.Fatal(err)
}
if !isClean {
log.Info(i18n.G("%s currently has these unstaged changes 👇", recipe.Name))
if err := gitPkg.DiffUnstaged(recipe.Dir); err != nil {
log.Fatal(err)
}
}
},
}
func init() {
RecipeSyncCommand.Flags().BoolVarP(
&internal.Dry,
i18n.G("dry-run"),
i18n.G("r"),
false,
i18n.G("report changes that would be made"),
)
RecipeSyncCommand.Flags().BoolVarP(
&internal.Major,
i18n.G("major"),
i18n.G("x"),
false,
i18n.G("increase the major part of the version"),
)
RecipeSyncCommand.Flags().BoolVarP(
&internal.Minor,
i18n.G("minor"),
i18n.G("y"),
false,
i18n.G("increase the minor part of the version"),
)
RecipeSyncCommand.Flags().BoolVarP(
&internal.Patch,
i18n.G("patch"),
i18n.G("z"),
false,
i18n.G("increase the patch part of the version"),
)
}

View File

@ -57,14 +57,13 @@ is up to the end-user to decide.
The command is interactive and will show a select input which allows you to
make a seclection. Use the "?" key to see more help on navigating this
interface.
You may invoke this command in "wizard" mode and be prompted for input.`),
interface.`),
Args: cobra.RangeArgs(0, 1),
ValidArgsFunction: func(
cmd *cobra.Command,
args []string,
toComplete string) ([]string, cobra.ShellCompDirective) {
toComplete string,
) ([]string, cobra.ShellCompDirective) {
return autocomplete.RecipeNameComplete()
},
Run: func(cmd *cobra.Command, args []string) {
@ -337,12 +336,37 @@ You may invoke this command in "wizard" mode and be prompted for input.`),
if err := gitPkg.DiffUnstaged(recipe.Dir); err != nil {
log.Fatal(err)
}
if !internal.NoInput && !createCommit {
prompt := &survey.Confirm{
Message: i18n.G("commit changes?"),
Default: true,
}
if err := survey.AskOne(prompt, &createCommit); err != nil {
log.Fatal(err)
}
}
if createCommit {
msg := i18n.G("chore: update image tags")
if err := gitPkg.Commit(recipe.Dir, msg, internal.Dry); err != nil {
log.Fatal(err)
}
log.Info(i18n.G("committed changes as '%s'", msg))
}
} else {
if createCommit {
log.Warn(i18n.G("no changes, skip creating commit"))
}
}
},
}
var (
allTags bool
allTags bool
createCommit bool
)
func init() {
@ -385,4 +409,12 @@ func init() {
false,
i18n.G("list all tags, not just upgrades"),
)
RecipeUpgradeCommand.Flags().BoolVarP(
&createCommit,
i18n.G("commit"),
i18n.GC("c", "recipe upgrade"),
false,
i18n.G("commit changes"),
)
}

View File

@ -245,7 +245,6 @@ Config:
recipe.RecipeNewCommand,
recipe.RecipeReleaseCommand,
recipe.RecipeResetCommand,
recipe.RecipeSyncCommand,
recipe.RecipeUpgradeCommand,
recipe.RecipeVersionCommand,
)

4
go.mod
View File

@ -13,7 +13,7 @@ require (
github.com/charmbracelet/log v0.4.2
github.com/distribution/reference v0.6.0
github.com/docker/cli v28.4.0+incompatible
github.com/docker/docker v28.4.0+incompatible
github.com/docker/docker v28.5.2+incompatible
github.com/docker/go-units v0.5.0
github.com/go-git/go-git/v5 v5.16.2
github.com/google/go-cmp v0.7.0
@ -157,3 +157,5 @@ require (
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
golang.org/x/sys v0.36.0
)
replace github.com/docker/cli v28.4.0+incompatible => git.coopcloud.tech/toolshed/docker-cli v28.5.3-0.20260202112816-30df2d0b3a00+incompatible

8
go.sum
View File

@ -27,6 +27,8 @@ coopcloud.tech/tagcmp v0.0.0-20250818180036-0ec1b205b5ca/go.mod h1:ESVm0wQKcbcFi
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
git.coopcloud.tech/toolshed/docker-cli v28.5.3-0.20260202112816-30df2d0b3a00+incompatible h1:YdW2uK5sHj545lGz/FrozPueINkQ7fUjlsNd8aYcqik=
git.coopcloud.tech/toolshed/docker-cli v28.5.3-0.20260202112816-30df2d0b3a00+incompatible/go.mod h1:PY19bHY5R4DLmRuCrv4TR7etURn/+tSTFuam4FUTiD8=
git.coopcloud.tech/toolshed/godotenv v1.5.2-0.20250103171850-4d0ca41daa5c h1:oeKnUB79PKYD8D0/unYuu7MRcWryQQWOns8+JL+acrs=
git.coopcloud.tech/toolshed/godotenv v1.5.2-0.20250103171850-4d0ca41daa5c/go.mod h1:fQuhwrpg6qb9NlFXKYi/LysWu1wxjraS8sxyW12CUF0=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
@ -318,16 +320,14 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/dnaeon/go-vcr v1.0.1/go.mod h1:aBB1+wY4s93YsC3HHjMBMrwTj2R9FHDzUr9KyGc8n1E=
github.com/docker/cli v0.0.0-20191017083524-a8ff7f821017/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/cli v28.4.0+incompatible h1:RBcf3Kjw2pMtwui5V0DIMdyeab8glEw5QY0UUU4C9kY=
github.com/docker/cli v28.4.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/distribution v0.0.0-20190905152932-14b96e55d84c/go.mod h1:0+TTO4EOBfRPhZXAeF1Vu+W3hHZ8eLp8PgKVZlcvtFY=
github.com/docker/distribution v2.7.1-0.20190205005809-0d3efadf0154+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk=
github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
github.com/docker/docker v1.4.2-0.20190924003213-a8608b5b67c7/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/docker v28.4.0+incompatible h1:KVC7bz5zJY/4AZe/78BIvCnPsLaC9T/zh72xnlrTTOk=
github.com/docker/docker v28.4.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/docker-credential-helpers v0.6.3/go.mod h1:WRaJzqw3CTB9bk10avuGsjVBZsD05qeibJ1/TYlvc0Y=
github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8=
github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo=

View File

@ -8,7 +8,6 @@ import (
"os"
"path"
"strings"
"time"
"coopcloud.tech/abra/pkg/config"
contextPkg "coopcloud.tech/abra/pkg/context"
@ -84,8 +83,7 @@ func New(serverName string, opts ...Opt) (*client.Client, error) {
httpClient := &http.Client{
Transport: &http.Transport{
DialContext: helper.Dialer,
IdleConnTimeout: 30 * time.Second,
DialContext: helper.Dialer,
},
}

View File

@ -165,7 +165,13 @@ func GetImagesForStack(cl *dockerClient.Client, app appPkg.App) (map[string]stri
}
imageBaseName := reference.Path(imageParsed)
imageTag := imageParsed.(reference.NamedTagged).Tag()
namedTag, ok := imageParsed.(reference.NamedTagged)
if !ok {
// This is an image without a tag
images[imageBaseName] = ""
continue
}
imageTag := namedTag.Tag()
existingImageVersion, ok := images[imageBaseName]
if !ok {
@ -282,7 +288,13 @@ func GatherImagesForDeploy(cl *dockerClient.Client, app appPkg.App, compose *com
}
imageBaseName := reference.Path(imageParsed)
imageTag := imageParsed.(reference.NamedTagged).Tag()
namedTag, ok := imageParsed.(reference.NamedTagged)
if !ok {
// This is an image without a tag
newImages[imageBaseName] = ""
continue
}
imageTag := namedTag.Tag()
existingImageVersion, ok := newImages[imageBaseName]
if !ok {

View File

@ -35,7 +35,7 @@ func Commit(repoPath, commitMessage string, dryRun bool) error {
if !dryRun {
// NOTE(d1): `All: true` does not include untracked files
_, err = commitWorktree.Commit(commitMessage, &git.CommitOptions{All: true})
_, err := commitWorktree.Commit(commitMessage, &git.CommitOptions{All: true})
if err != nil {
return err
}

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

View File

@ -142,7 +142,7 @@ var LintRules = map[string][]LintRule{
Function: LintAppService,
},
{
Ref: "R015",
Ref: "R016",
Level: i18n.G("error"),
Description: i18n.G("deploy labels stanza present"),
HowToResolve: i18n.G("include \"deploy: labels: ...\" stanza"),
@ -258,7 +258,7 @@ func LintAppService(recipe recipe.Recipe) (bool, error) {
func LintTraefikEnabledSkipCondition(r recipe.Recipe) (bool, error) {
sampleEnv, err := r.SampleEnv()
if err != nil {
return false, errors.New(i18n.G("unable to discover .env.sample for %s", r.Name))
return false, errors.New(i18n.G(".env.sample for %s couldn't be read: %s", r.Name, err))
}
if _, ok := sampleEnv["DOMAIN"]; !ok {

View File

@ -15,7 +15,7 @@ import (
func (r Recipe) SampleEnv() (map[string]string, error) {
sampleEnv, err := envfile.ReadEnv(r.SampleEnvPath)
if err != nil {
return sampleEnv, errors.New(i18n.G("unable to discover .env.sample for %s", r.Name))
return sampleEnv, errors.New(i18n.G(".env.sample for %s couldn't be read: %s", r.Name, err))
}
return sampleEnv, nil
}

View File

@ -32,6 +32,12 @@ func (r Recipe) Ensure(ctx EnsureContext) error {
return err
}
// NOTE(d1): if we cannot parse the .env.sample then there is a
// fundamental problem which requires solving right now
if _, err := r.SampleEnv(); err != nil {
return err
}
if ctx.Chaos {
return nil
}
@ -403,15 +409,18 @@ func (r Recipe) GetRecipeVersions() (RecipeVersions, []string, error) {
Branch: plumbing.ReferenceName(ref.Name()),
}
if err := worktree.Checkout(checkOutOpts); err != nil {
log.Debug(i18n.G("failed to check out %s in %s", tag, r.Dir))
return err
log.Debug(i18n.G("failed to check out %s in %s: %s", tag, r.Dir, err))
warnMsg = append(warnMsg, i18n.G("skipping tag %s: checkout failed: %s", tag, err))
return nil
}
log.Debug(i18n.G("git checkout: %s in %s", ref.Name(), r.Dir))
config, err := r.GetComposeConfig(nil)
if err != nil {
return err
log.Debug(i18n.G("failed to get compose config for %s: %s", tag, err))
warnMsg = append(warnMsg, i18n.G("skipping tag %s: invalid compose config: %s", tag, err))
return nil
}
versionMeta := make(map[string]ServiceMeta)
@ -419,7 +428,9 @@ func (r Recipe) GetRecipeVersions() (RecipeVersions, []string, error) {
img, err := reference.ParseNormalizedNamed(service.Image)
if err != nil {
return err
log.Debug(i18n.G("failed to parse image for %s in %s: %s", service.Name, tag, err))
warnMsg = append(warnMsg, i18n.G("skipping tag %s: invalid image reference in service %s: %s", tag, service.Name, err))
return nil
}
path := reference.Path(img)
@ -445,6 +456,7 @@ func (r Recipe) GetRecipeVersions() (RecipeVersions, []string, error) {
return nil
}); err != nil {
log.Warn(i18n.G("GetRecipeVersions encountered error for %s: %s (collected %d versions)", r.Name, err, len(versions)))
return versions, warnMsg, nil
}

View File

@ -2,7 +2,7 @@
ABRA_VERSION="0.12.0-beta"
ABRA_RELEASE_URL="https://git.coopcloud.tech/api/v1/repos/toolshed/abra/releases/tags/$ABRA_VERSION"
RC_VERSION="0.12.0-beta"
RC_VERSION="0.13.0-rc1-beta"
RC_VERSION_URL="https://git.coopcloud.tech/api/v1/repos/toolshed/abra/releases/tags/$RC_VERSION"
for arg in "$@"; do
@ -89,7 +89,7 @@ function install_abra_release {
if [ $? -ne 0 ]; then
echo "$(tput setaf 3)WARNING: $HOME/.local/bin/ is not in \$PATH! If you want to run abra by just typing "abra" you should add it to your \$PATH! To do that run this once and restart your terminal:$(tput sgr0)"
p=$HOME/.local/bin
com="echo PATH=\$PATH:$p"
com='echo PATH="$PATH:'"$p"'"'
if [[ $SHELL =~ "bash" ]]; then
echo "$com >> $HOME/.bashrc"
elif [[ $SHELL =~ "fizsh" ]]; then

View File

@ -106,7 +106,7 @@ teardown(){
run $ABRA app check "$TEST_APP_DOMAIN" --chaos
assert_failure
assert_output --partial 'unable to discover .env.sample'
assert_output --partial 'no such file or directory'
}
@test "error if missing env var" {

View File

@ -23,12 +23,24 @@ teardown(){
_reset_recipe
_undeploy_app
_undeploy_app2 "gitea.$TEST_SERVER"
_undeploy_app2 "zammad.$TEST_SERVER"
_reset_app
_reset_tags
run rm -rf "$ABRA_DIR/recipes/$TEST_RECIPE/foo"
assert_not_exists "$ABRA_DIR/recipes/$TEST_RECIPE/foo"
if [[ -d "$ABRA_DIR/recipes/foo" ]]; then
run rm -rf "$ABRA_DIR/recipes/foo"
assert_not_exists "$ABRA_DIR/recipes/foo"
fi
# NOTE(d1): give some extra space for the pure chaos that we are unleashing
# on the CI machine with these deploy tests. the hope is to prevent
# lock-ups and network failures which are common in flaky swarm
# mode
sleep 1
}
@test "validate app argument" {
@ -602,3 +614,89 @@ teardown(){
refute_output --partial "WITH_COMMENT=foo"
assert_output --partial "WITH_COMMENT=bar"
}
# bats test_tags=slow
@test "deploy with udp and tcp on same port" {
run sed -i 's/TYPE=abra-test-recipe:.*/TYPE=git.coopcloud.tech\/p4u1\/abra-test-recipe:030e8a1cb1a0f17281847b3e55d829220ad32c50/g' \
"$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
assert_success
run bash -c "printf '\nCOMPOSE_FILE=\"\$COMPOSE_FILE:compose.udp-and-tcp.yml\"' >> $ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
assert_success
run $ABRA app deploy "$TEST_APP_DOMAIN" --no-input
assert_success
run docker service inspect --format '{{ range .Endpoint.Ports }}{{ .Protocol }}={{ .PublishedPort }}{{ end }}' \
"${TEST_APP_DOMAIN//./_}_app"
assert_success
assert_output --partial "tcp=1312"
assert_output --partial "udp=1312"
}
# bats test_tags=slow
@test "does not crash when docker image has no tag" {
run sed -i 's/TYPE=abra-test-recipe:.*/TYPE=git.coopcloud.tech\/p4u1\/abra-test-recipe:b29422d5a344ea45df271443182f775ea82b4da8/g' \
"$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
assert_success
run bash -c "printf '\nCOMPOSE_FILE=\"\$COMPOSE_FILE:compose.no-image-tag.yml\"' >> $ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
assert_success
run $ABRA app deploy "$TEST_APP_DOMAIN" --no-input
assert_success
}
@test "does not use old recipe version when recipe is broken" {
run $ABRA app new zammad \
--no-input \
--server "$TEST_SERVER" \
--domain "zammad.$TEST_SERVER" \
--secrets
assert_success
assert_exists "$ABRA_DIR/servers/$TEST_SERVER/zammad.$TEST_SERVER.env"
# NOTE(d1): --no-converge-checks because the zammad recipe is a beast and we
# mostly only care about the correct version being used
run $ABRA app deploy "zammad.$TEST_SERVER" \
--no-input --no-converge-checks
assert_success
refute_output --partial "1.0.0+6.3.1-95"
}
# bats test_tags=slow
@test "unable to deploy borked tag" {
_remove_tags
run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" tag \
-a "2.4.8_1" -m "feat: completely borked tag"
assert_success
run $ABRA app deploy "$TEST_APP_DOMAIN" "2.4.8_1" \
--no-input --no-converge-checks --debug
assert_failure
assert_output --partial "unable to parse"
}
# bats test_tags=slow
@test "app deploy with borked sample env gives useful error" {
run $ABRA recipe new foo --no-input
assert_success
run $ABRA app new foo \
--no-input \
--server "$TEST_SERVER" \
--domain "foo.$TEST_SERVER" \
--chaos
assert_success
assert_exists "$ABRA_DIR/servers/$TEST_SERVER/foo.$TEST_SERVER.env"
run bash -c "printf '\nEVIL-VAR=EVIL' >> $ABRA_DIR/recipes/foo/.env.sample"
assert_success
run $ABRA app deploy "foo.$TEST_SERVER" \
--no-input --no-converge-checks --chaos
assert_failure
assert_output --partial "unexpected character"
}

View File

@ -67,6 +67,16 @@ teardown(){
assert_output --partial "$TEST_SERVER"
assert_output --partial "$TEST_APP_DOMAIN"
assert_output --partial "deployed"
assert_output --partial "latest"
_remove_tags
run $ABRA app ls --status
assert_success
assert_output --partial "$TEST_SERVER"
assert_output --partial "$TEST_APP_DOMAIN"
assert_output --partial "deployed"
assert_output --partial "latest"
}
@test "filter by server" {
@ -151,7 +161,7 @@ teardown(){
--no-input --no-converge-checks --chaos
assert_success
run $ABRA app ls --status
run $ABRA app ls --status --chaos
assert_success
assert_output --partial "+U"
@ -180,13 +190,16 @@ teardown(){
# bats test_tags=slow
@test "list ignores borked tags" {
run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" tag \
-a "2.4.8_1" -m "feat: completely borked tag"
run $ABRA app deploy "$TEST_APP_DOMAIN" \
--no-input --no-converge-checks
assert_success
_deploy_app
# NOTE(d1): always upgradable tag which is also borked
run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" tag \
-a "100.100.100_1_2_3" -m "feat: completely borked tag"
assert_success
run $ABRA app ls --status --debug
assert_success
assert_output --partial "unable to parse 2.4.8_1"
assert_output --partial "unable to parse"
}

View File

@ -63,6 +63,30 @@ teardown(){
assert_success
}
@test "ensure recipe is up-to-date" {
run bash -c 'git -C "$ABRA_DIR/recipes/$TEST_RECIPE" tag -l'
assert_success
assert_output --partial '0.3.5+1.21.0'
run bash -c 'git -C "$ABRA_DIR/recipes/$TEST_RECIPE" tag -d 0.3.5+1.21.0'
assert_success
run bash -c 'git -C "$ABRA_DIR/recipes/$TEST_RECIPE" tag -l'
assert_success
refute_output --partial '0.3.5+1.21.0'
run $ABRA app new "$TEST_RECIPE" \
--no-input \
--server "$TEST_SERVER" \
--domain "$TEST_APP_DOMAIN"
assert_success
assert_exists "$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
run grep -q "TYPE=$TEST_RECIPE:0.3.5+1.21.0" \
"$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
assert_success
}
@test "create new app with version commit" {
tagHash=$(_get_tag_hash "0.3.0+1.21.0")

View File

@ -13,7 +13,6 @@ _common_setup() {
load "$PWD/tests/integration/helpers/docker"
export ABRA="$PWD/abra"
export KADABRA="$PWD/kadabra"
export TEST_APP_NAME="$(basename "${BATS_TEST_FILENAME//./_}")"
export TEST_APP_DOMAIN="$TEST_APP_NAME.$TEST_SERVER"
@ -21,4 +20,20 @@ _common_setup() {
export TEST_RECIPE="abra-test-recipe"
_ensure_swarm
_ensure_ssh_agent
}
_ensure_ssh_agent() {
if ! command -v ssh-agent >/dev/null 2>&1
then
echo "ssh-agent is missing, please install it"
exit 1
fi
export SSH_AUTH_SOCK="$HOME/.ssh/ssh_auth_sock"
if [ ! -S ~/.ssh/ssh_auth_sock ]; then
eval `ssh-agent`
ln -sf "$SSH_AUTH_SOCK" ~/.ssh/ssh_auth_sock
fi
}

View File

@ -17,7 +17,12 @@ _remove_tags(){
run bash -c 'git -C "$ABRA_DIR/recipes/$TEST_RECIPE" tag -l | wc -l'
assert_success
assert_output '0'
# If this was done without the --regexp I get this error:
# -- output differs --
# expected : 0
# actual : 0
# --
assert_output --regexp '[[:space:]]*0'
}
_reset_tags() {

View File

@ -5,11 +5,22 @@ _latest_release(){
}
_fetch_recipe() {
# clone first to a bare repo which will serve as origin-ssh
# this enables simulating git push in recipe release
if [[ ! -d "$ABRA_DIR/recipes/$TEST_RECIPE" ]]; then
run mkdir -p "$ABRA_DIR/origin-recipes"
assert_success
run git clone "https://git.coopcloud.tech/toolshed/$TEST_RECIPE" "$ABRA_DIR/origin-recipes/$TEST_RECIPE.git" --bare
assert_success
run mkdir -p "$ABRA_DIR/recipes"
assert_success
run git clone "https://git.coopcloud.tech/toolshed/$TEST_RECIPE" "$ABRA_DIR/recipes/$TEST_RECIPE"
run git clone "$ABRA_DIR/origin-recipes/$TEST_RECIPE.git" "$ABRA_DIR/recipes/$TEST_RECIPE"
assert_success
run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" remote add origin-ssh "$ABRA_DIR/origin-recipes/$TEST_RECIPE.git"
assert_success
fi
}
@ -19,6 +30,10 @@ _reset_recipe(){
assert_success
assert_not_exists "$ABRA_DIR/recipes/$TEST_RECIPE"
run rm -rf "$ABRA_DIR/origin-recipes/$TEST_RECIPE.git"
assert_success
assert_not_exists "$ABRA_DIR/origin-recipes/$TEST_RECIPE.git"
_fetch_recipe
}

View File

@ -1,18 +1,18 @@
#!/usr/bin/env bash
setup_file(){
setup_file() {
load "$PWD/tests/integration/helpers/common"
_common_setup
_add_server
_new_app
}
teardown_file(){
teardown_file() {
_rm_server
_reset_recipe
}
setup(){
setup() {
load "$PWD/tests/integration/helpers/common"
_common_setup
_set_git_author
@ -21,6 +21,14 @@ setup(){
teardown() {
_reset_recipe
_reset_tags
if [[ -d "$ABRA_DIR/recipes/foobar" ]]; then
run rm -rf "$ABRA_DIR/recipes/foobar"
assert_success
fi
if [[ -d "$ABRA_DIR/origin-recipes/foobar.git" ]]; then
run rm -rf "$ABRA_DIR/origin-recipes/foobar.git"
assert_success
fi
}
@test "validate recipe argument" {
@ -32,10 +40,10 @@ teardown() {
}
@test "release patch bump" {
run $ABRA recipe upgrade "$TEST_RECIPE" --no-input --patch
run $ABRA recipe upgrade "$TEST_RECIPE" --no-input --patch --commit
assert_success
run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" diff
run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" show
assert_success
assert_output --partial 'image: nginx:1.21.6'
@ -45,17 +53,9 @@ teardown() {
-a "0.3.0+1.21.0" -m "fake: 0.3.0+1.21.0"
assert_success
run $ABRA recipe sync "$TEST_RECIPE" --no-input --patch
assert_success
assert_output --partial 'synced label'
run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" diff
assert_success
assert_output --partial 'coop-cloud.${STACK_NAME}.version=0.3.1+1.21.6'
run $ABRA recipe release "$TEST_RECIPE" --no-input --patch
assert_success
assert_output --partial 'no -p/--publish passed, not publishing'
assert_output --partial 'INFO new release published:'
run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" tag --list
assert_success
@ -63,10 +63,10 @@ teardown() {
}
@test "release minor bump" {
run $ABRA recipe upgrade "$TEST_RECIPE" --no-input --minor
run $ABRA recipe upgrade "$TEST_RECIPE" --no-input --minor --commit
assert_success
run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" diff
run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" show
assert_success
assert_output --regexp 'image: nginx:1.2.*'
@ -76,58 +76,57 @@ teardown() {
-a "0.3.0+1.21.0" -m "fake: 0.3.0+1.21.0"
assert_success
run $ABRA recipe sync "$TEST_RECIPE" --no-input --minor
assert_success
assert_output --partial 'synced label'
run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" diff
assert_success
assert_output --regexp 'coop-cloud\.\$\{STACK_NAME\}\.version=0\.4\.0\+1\.2.*'
run $ABRA recipe release "$TEST_RECIPE" --no-input --minor
assert_success
assert_output --partial 'no -p/--publish passed, not publishing'
assert_output --partial 'INFO new release published:'
run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" tag --list
assert_success
assert_output --regexp '0\.4\.0\+1\.2.*'
}
@test "unknown files not committed" {
run $ABRA recipe upgrade "$TEST_RECIPE" --no-input --patch
@test "release with unstaged changes" {
run bash -c 'echo "# unstaged changes" >> "$ABRA_DIR/recipes/$TEST_RECIPE/compose.yml"'
assert_success
run bash -c 'echo "unstaged changes" >> "$ABRA_DIR/recipes/$TEST_RECIPE/foo"'
assert_success
assert_exists "$ABRA_DIR/recipes/$TEST_RECIPE/foo"
run $ABRA recipe sync "$TEST_RECIPE" --no-input --patch
assert_success
run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" diff --quiet
assert_failure
run $ABRA recipe release "$TEST_RECIPE" --no-input --patch
assert_success
assert_output --partial 'no -p/--publish passed, not publishing'
run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" rm foo
assert_failure
assert_output --partial "fatal: pathspec 'foo' did not match any files"
assert_output --partial "working directory not clean"
}
@test "release with staged changes" {
run bash -c 'echo "# staged changes" >> "$ABRA_DIR/recipes/$TEST_RECIPE/compose.yml"'
assert_success
run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" add compose.yml
assert_success
run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" diff --quiet --cached
assert_failure
run $ABRA recipe release "$TEST_RECIPE" --no-input --patch
assert_failure
assert_output --partial "working directory not clean"
}
@test "release with next release note" {
_mkfile "$ABRA_DIR/recipes/$TEST_RECIPE/release/next" "those are some release notes for the next release"
run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" checkout main
assert_success
run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" add release/next
assert_success
run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" commit -m "added some release notes"
assert_success
run $ABRA recipe sync "$TEST_RECIPE" --no-input --patch
run $ABRA recipe release "$TEST_RECIPE" --no-input --minor
assert_success
run $ABRA recipe release "$TEST_RECIPE" --no-input --minor
assert_success
assert_output --partial 'no -p/--publish passed, not publishing'
assert_output --partial 'new release published:'
assert_not_exists "$ABRA_DIR/recipes/$TEST_RECIPE/release/next"
assert_exists "$ABRA_DIR/recipes/$TEST_RECIPE/release/0.4.0+1.21.0"
@ -146,14 +145,75 @@ teardown() {
assert_success
assert_output --regexp 'nginx:1.29.1'
run sed -i "s/0.2.0+1.21.0/0.2.0+1.29.1/g" "$ABRA_DIR/recipes/$TEST_RECIPE/compose.yml"
run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" commit -am "updated nginx"
assert_success
run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" diff
assert_success
assert_output --regexp 'coop-cloud\.\$\{STACK_NAME\}\.version=0\.2\.0\+1\.29\.1'
run $ABRA recipe release "$TEST_RECIPE" --no-input --minor
run $ABRA recipe release "$TEST_RECIPE" --no-input "0.2.0+1.29.1"
assert_failure
assert_output --partial '0.2.0+... conflicts with a previous release: 0.2.0+1.21.0'
}
@test "error if recipe release --no-input and no initial version" {
_remove_tags
run $ABRA recipe release "$TEST_RECIPE" --no-input --patch
assert_failure
assert_output --partial 'unable to continue'
assert_output --partial 'initial version'
}
@test "recipe release without input fails with prompt" {
run $ABRA recipe new foobar
assert_success
assert_exists "$ABRA_DIR/recipes/foobar"
run $ABRA recipe release foobar --no-input --patch
assert_failure
assert_output --partial "input required for initial version"
}
@test "release new recipe: fail without input" {
run $ABRA recipe new foobar
assert_success
assert_exists "$ABRA_DIR/recipes/foobar"
run bash -c "$ABRA recipe release foobar --no-input"
assert_failure
assert_output --partial 'unable to continue, input required for initial version'
}
# note: piping 0.1.0 from stdin is not testable right now because release notes also wants input
# survey lib used for prompts breaks multi-line stdin for multi-prompt
@test "release new recipe: development release" {
run $ABRA recipe new foobar
assert_success
assert_exists "$ABRA_DIR/recipes/foobar"
# fake origin
git clone "$ABRA_DIR/recipes/foobar" "$ABRA_DIR/origin-recipes/foobar.git" --bare
assert_success
run git -C "$ABRA_DIR/recipes/foobar" remote add origin-ssh "$ABRA_DIR/origin-recipes/foobar.git"
assert_success
run bash -c "$ABRA recipe release foobar 0.1.0+1.2.0 --no-input"
assert_success
assert_output --regexp 'coop-cloud\.\$\{STACK_NAME\}\.version=0\.1\.0\+1\.2.*'
}
@test "release newly created recipe with no version label" {
run $ABRA recipe new foobar
assert_success
assert_exists "$ABRA_DIR/recipes/foobar"
run sed -i 's/- "coop-cloud.${STACK_NAME}.version="/#- "coop-cloud.${STACK_NAME}.version="/g' \
"$ABRA_DIR/recipes/foobar/compose.yml"
assert_success
run git -C "$ABRA_DIR/recipes/foobar" commit -am "updated nginx"
assert_success
run bash -c "echo 0.1.0 | $ABRA recipe release foobar --patch"
assert_failure
assert_output --partial "automagic insertion not supported yet"
}

View File

@ -1,200 +0,0 @@
#!/usr/bin/env bash
setup_file(){
load "$PWD/tests/integration/helpers/common"
_common_setup
_add_server
_new_app
}
teardown_file(){
_rm_server
}
setup(){
load "$PWD/tests/integration/helpers/common"
_common_setup
}
teardown(){
_reset_recipe
_reset_tags
if [[ -d "$ABRA_DIR/recipes/foobar" ]]; then
run rm -rf "$ABRA_DIR/recipes/foobar"
assert_success
fi
}
@test "validate recipe argument" {
run $ABRA recipe sync --no-input
assert_failure
run $ABRA recipe sync DOESNTEXIST --no-input
assert_failure
}
@test "allow unstaged changes" {
run echo "unstaged changes" >> "$ABRA_DIR/recipes/$TEST_RECIPE/foo"
assert_success
run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" status
assert_success
assert_output --partial 'foo'
run $ABRA recipe sync "$TEST_RECIPE" --no-input --patch
assert_success
assert_exists "$ABRA_DIR/recipes/$TEST_RECIPE/foo"
assert_equal "$(_git_status)" "M compose.yml ?? foo"
run rm -rf "$ABRA_DIR/recipes/$TEST_RECIPE/foo"
assert_success
assert_not_exists "$ABRA_DIR/recipes/$TEST_RECIPE/foo"
}
@test "detect unstaged label changes" {
run $ABRA recipe fetch "$TEST_RECIPE"
assert_success
run $ABRA recipe sync "$TEST_RECIPE" --patch
assert_success
run $ABRA recipe sync "$TEST_RECIPE" --patch
assert_success
assert_output --partial 'is already set, nothing to do?'
}
@test "sync patch label bump" {
run $ABRA recipe upgrade "$TEST_RECIPE" --no-input --patch
assert_success
run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" diff
assert_success
assert_output --partial 'image: nginx:1.21.6'
# NOTE(d1): ensure the latest tag is the one we expect
_remove_tags
run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" tag \
-a "0.3.0+1.21.0" -m "fake: 0.3.0+1.21.0"
assert_success
run $ABRA recipe sync "$TEST_RECIPE" --no-input --patch
assert_success
run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" diff
assert_success
assert_output --regexp 'coop-cloud\.\$\{STACK_NAME\}\.version=0\.3\.1\+1\.2.*'
}
@test "sync minor label bump" {
run $ABRA recipe upgrade "$TEST_RECIPE" --no-input --minor
assert_success
run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" diff
assert_success
assert_output --regexp 'image: nginx:1.2.*'
# NOTE(d1): ensure the latest tag is the one we expect
_remove_tags
run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" tag \
-a "0.3.0+1.21.0" -m "fake: 0.3.0+1.21.0"
assert_success
run $ABRA recipe sync "$TEST_RECIPE" --no-input --minor
assert_success
run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" diff
assert_success
assert_output --regexp 'coop-cloud\.\$\{STACK_NAME\}\.version=0\.4\.0\+1\.2.*'
}
@test "error if --no-input and no initial version" {
_remove_tags
run $ABRA recipe sync "$TEST_RECIPE" --no-input --patch
assert_failure
assert_output --partial 'unable to continue'
assert_output --partial 'initial version'
}
@test "output label sync only once" {
run $ABRA recipe upgrade "$TEST_RECIPE" --no-input --minor
assert_success
run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" diff
assert_success
assert_output --regexp 'image: nginx:1.2.*'
run $ABRA recipe sync "$TEST_RECIPE" --no-input --minor
assert_success
assert_line --index 0 --partial 'synced label'
refute_line --index 1 --partial 'synced label'
}
@test "sync with no tags or previous release" {
_remove_tags
run $ABRA recipe upgrade "$TEST_RECIPE" --no-input --patch
assert_success
run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" diff
assert_success
assert_output --partial 'image: nginx:1.21.6'
# NOTE(d1): ensure the latest tag is the one we expect
_remove_tags
run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" tag \
-a "0.3.0+1.21.0" -m "fake: 0.3.0+1.21.0"
assert_success
run $ABRA recipe sync "$TEST_RECIPE" --no-input --patch
assert_success
run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" diff
assert_success
assert_output --regexp 'coop-cloud\.\$\{STACK_NAME\}\.version=0\.3\.1\+1\.2.*'
}
@test "sync recipe without input fails with prompt" {
run $ABRA recipe new foobar
assert_success
assert_exists "$ABRA_DIR/recipes/foobar"
run $ABRA recipe sync foobar --no-input --patch
assert_failure
assert_output --partial "input required for initial version"
}
@test "sync new recipe: development release" {
run $ABRA recipe new foobar
assert_success
assert_exists "$ABRA_DIR/recipes/foobar"
run bash -c "echo 0.1.0 | $ABRA recipe sync foobar --patch"
assert_success
assert_output --regexp 'coop-cloud\.\$\{STACK_NAME\}\.version=0\.1\.0\+1\.2.*'
}
@test "sync new recipe: public release" {
run $ABRA recipe new foobar
assert_success
assert_exists "$ABRA_DIR/recipes/foobar"
run bash -c "echo 1.0.0 | $ABRA recipe sync foobar --patch"
assert_success
assert_output --regexp 'coop-cloud\.\$\{STACK_NAME\}\.version=1\.0\.0\+1\.2.*'
}
@test "sync newly created recipe with no version label" {
run $ABRA recipe new foobar
assert_success
assert_exists "$ABRA_DIR/recipes/foobar"
run sed -i 's/- "coop-cloud.${STACK_NAME}.version="/#- "coop-cloud.${STACK_NAME}.version="/g' \
"$ABRA_DIR/recipes/foobar/compose.yml"
assert_success
run bash -c "echo 0.1.0 | $ABRA recipe sync foobar --patch"
assert_failure
assert_output --partial "automagic insertion not supported yet"
}

View File

@ -106,3 +106,37 @@ teardown(){
assert_success
assert_output --regexp 'image: nginx:1.2.*'
}
@test "upgrade and commit" {
run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" rev-list --count HEAD
assert_success
expected_count="$((output + 1))"
run $ABRA recipe upgrade "$TEST_RECIPE" --no-input --minor --commit
assert_success
assert_output --partial 'committed changes as'
run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" diff --quiet
assert_success
run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" rev-list --count HEAD
assert_success
assert_output "$expected_count"
}
@test "upgrade nothing, skip commit" {
run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" rev-list --count HEAD
assert_success
expected_count="$output"
run $ABRA recipe upgrade "$TEST_RECIPE" --no-input --commit
assert_success
assert_output --partial "no changes, skip creating commit"
run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" diff --quiet
assert_success
run git -C "$ABRA_DIR/recipes/$TEST_RECIPE" rev-list --count HEAD
assert_success
assert_output "$expected_count"
}

View File

@ -33,4 +33,6 @@ type Metadata struct {
ShortDescription string `json:",omitempty"`
// URL is a pointer to the plugin's homepage.
URL string `json:",omitempty"`
// Hidden hides the plugin in completion and help message output.
Hidden bool `json:",omitempty"`
}

View File

@ -376,13 +376,10 @@ func orchestratorSubCommands(cmd *cobra.Command) []*cobra.Command {
func allManagementSubCommands(cmd *cobra.Command) []*cobra.Command {
cmds := []*cobra.Command{}
for _, sub := range cmd.Commands() {
if isPlugin(sub) {
if invalidPluginReason(sub) == "" {
cmds = append(cmds, sub)
}
if invalidPluginReason(sub) != "" {
continue
}
if sub.IsAvailableCommand() && sub.HasSubCommands() {
if sub.IsAvailableCommand() && (isPlugin(sub) || sub.HasSubCommands()) {
cmds = append(cmds, sub)
}
}

View File

@ -48,9 +48,7 @@ type Cli interface {
Apply(ops ...CLIOption) error
config.Provider
ServerInfo() ServerInfo
DefaultVersion() string
CurrentVersion() string
ContentTrustEnabled() bool
BuildKitEnabled() (bool, error)
ContextStore() store.Store
CurrentContext() string
@ -78,6 +76,7 @@ type DockerCli struct {
dockerEndpoint docker.Endpoint
contextStoreConfig *store.Config
initTimeout time.Duration
userAgent string
res telemetryResource
// baseCtx is the base context used for internal operations. In the future
@ -89,6 +88,8 @@ type DockerCli struct {
}
// DefaultVersion returns [api.DefaultVersion].
//
// Deprecated: this function is no longer used and will be removed in the next release.
func (*DockerCli) DefaultVersion() string {
return api.DefaultVersion
}
@ -159,6 +160,8 @@ func (cli *DockerCli) ServerInfo() ServerInfo {
// ContentTrustEnabled returns whether content trust has been enabled by an
// environment variable.
//
// Deprecated: check the value of the DOCKER_CONTENT_TRUST environment variable to detect whether content-trust is enabled.
func (cli *DockerCli) ContentTrustEnabled() bool {
return cli.contentTrust
}
@ -269,7 +272,7 @@ func (cli *DockerCli) Initialize(opts *cliflags.ClientOptions, ops ...CLIOption)
cli.contextStore = &ContextStoreWithDefault{
Store: store.New(config.ContextStoreDir(), *cli.contextStoreConfig),
Resolver: func() (*DefaultContext, error) {
return ResolveDefaultContext(cli.options, *cli.contextStoreConfig)
return resolveDefaultContext(cli.options, *cli.contextStoreConfig)
},
}
@ -306,17 +309,17 @@ func NewAPIClientFromFlags(opts *cliflags.ClientOptions, configFile *configfile.
contextStore := &ContextStoreWithDefault{
Store: store.New(config.ContextStoreDir(), storeConfig),
Resolver: func() (*DefaultContext, error) {
return ResolveDefaultContext(opts, storeConfig)
return resolveDefaultContext(opts, storeConfig)
},
}
endpoint, err := resolveDockerEndpoint(contextStore, resolveContextName(opts, configFile))
if err != nil {
return nil, errors.Wrap(err, "unable to resolve docker endpoint")
}
return newAPIClientFromEndpoint(endpoint, configFile)
return newAPIClientFromEndpoint(endpoint, configFile, client.WithUserAgent(UserAgent()))
}
func newAPIClientFromEndpoint(ep docker.Endpoint, configFile *configfile.ConfigFile) (client.APIClient, error) {
func newAPIClientFromEndpoint(ep docker.Endpoint, configFile *configfile.ConfigFile, extraOpts ...client.Opt) (client.APIClient, error) {
opts, err := ep.ClientOpts()
if err != nil {
return nil, err
@ -324,7 +327,14 @@ func newAPIClientFromEndpoint(ep docker.Endpoint, configFile *configfile.ConfigF
if len(configFile.HTTPHeaders) > 0 {
opts = append(opts, client.WithHTTPHeaders(configFile.HTTPHeaders))
}
opts = append(opts, withCustomHeadersFromEnv(), client.WithUserAgent(UserAgent()))
withCustomHeaders, err := withCustomHeadersFromEnv()
if err != nil {
return nil, err
}
if withCustomHeaders != nil {
opts = append(opts, withCustomHeaders)
}
opts = append(opts, extraOpts...)
return client.NewClientWithOpts(opts...)
}
@ -545,7 +555,8 @@ func (cli *DockerCli) initialize() error {
return
}
if cli.client == nil {
if cli.client, cli.initErr = newAPIClientFromEndpoint(cli.dockerEndpoint, cli.configFile); cli.initErr != nil {
ops := []client.Opt{client.WithUserAgent(cli.userAgent)}
if cli.client, cli.initErr = newAPIClientFromEndpoint(cli.dockerEndpoint, cli.configFile, ops...); cli.initErr != nil {
return
}
}
@ -558,6 +569,8 @@ func (cli *DockerCli) initialize() error {
}
// Apply all the operation on the cli
//
// Deprecated: this method is no longer used and will be removed in the next release if there are no remaining users.
func (cli *DockerCli) Apply(ops ...CLIOption) error {
for _, op := range ops {
if err := op(cli); err != nil {
@ -589,15 +602,18 @@ type ServerInfo struct {
// environment.
func NewDockerCli(ops ...CLIOption) (*DockerCli, error) {
defaultOps := []CLIOption{
WithContentTrustFromEnv(),
withContentTrustFromEnv(),
WithDefaultContextStoreConfig(),
WithStandardStreams(),
WithUserAgent(UserAgent()),
}
ops = append(defaultOps, ops...)
cli := &DockerCli{baseCtx: context.Background()}
if err := cli.Apply(ops...); err != nil {
return nil, err
for _, op := range ops {
if err := op(cli); err != nil {
return nil, err
}
}
return cli, nil
}
@ -613,7 +629,7 @@ func getServerHost(hosts []string, defaultToTLS bool) (string, error) {
}
}
// UserAgent returns the user agent string used for making API requests
// UserAgent returns the default user agent string used for making API requests.
func UserAgent() string {
return "Docker-Client/" + version.Version + " (" + runtime.GOOS + ")"
}

View File

@ -75,8 +75,8 @@ func WithErrorStream(err io.Writer) CLIOption {
}
}
// WithContentTrustFromEnv enables content trust on a cli from environment variable DOCKER_CONTENT_TRUST value.
func WithContentTrustFromEnv() CLIOption {
// withContentTrustFromEnv enables content trust on a cli from environment variable DOCKER_CONTENT_TRUST value.
func withContentTrustFromEnv() CLIOption {
return func(cli *DockerCli) error {
cli.contentTrust = false
if e := os.Getenv("DOCKER_CONTENT_TRUST"); e != "" {
@ -89,7 +89,16 @@ func WithContentTrustFromEnv() CLIOption {
}
}
// WithContentTrustFromEnv enables content trust on a cli from environment variable DOCKER_CONTENT_TRUST value.
//
// Deprecated: this option is no longer used, and will be removed in the next release.
func WithContentTrustFromEnv() CLIOption {
return withContentTrustFromEnv()
}
// WithContentTrust enables content trust on a cli.
//
// Deprecated: this option is no longer used, and will be removed in the next release.
func WithContentTrust(enabled bool) CLIOption {
return func(cli *DockerCli) error {
cli.contentTrust = enabled
@ -180,61 +189,70 @@ const envOverrideHTTPHeaders = "DOCKER_CUSTOM_HEADERS"
// override headers with the same name).
//
// TODO(thaJeztah): this is a client Option, and should be moved to the client. It is non-exported for that reason.
func withCustomHeadersFromEnv() client.Opt {
return func(apiClient *client.Client) error {
value := os.Getenv(envOverrideHTTPHeaders)
if value == "" {
return nil
}
csvReader := csv.NewReader(strings.NewReader(value))
fields, err := csvReader.Read()
if err != nil {
return invalidParameter(errors.Errorf(
"failed to parse custom headers from %s environment variable: value must be formatted as comma-separated key=value pairs",
envOverrideHTTPHeaders,
func withCustomHeadersFromEnv() (client.Opt, error) {
value := os.Getenv(envOverrideHTTPHeaders)
if value == "" {
return nil, nil
}
csvReader := csv.NewReader(strings.NewReader(value))
fields, err := csvReader.Read()
if err != nil {
return nil, invalidParameter(errors.Errorf(
"failed to parse custom headers from %s environment variable: value must be formatted as comma-separated key=value pairs",
envOverrideHTTPHeaders,
))
}
if len(fields) == 0 {
return nil, nil
}
env := map[string]string{}
for _, kv := range fields {
k, v, hasValue := strings.Cut(kv, "=")
// Only strip whitespace in keys; preserve whitespace in values.
k = strings.TrimSpace(k)
if k == "" {
return nil, invalidParameter(errors.Errorf(
`failed to set custom headers from %s environment variable: value contains a key=value pair with an empty key: '%s'`,
envOverrideHTTPHeaders, kv,
))
}
if len(fields) == 0 {
return nil
// We don't currently allow empty key=value pairs, and produce an error.
// This is something we could allow in future (e.g. to read value
// from an environment variable with the same name). In the meantime,
// produce an error to prevent users from depending on this.
if !hasValue {
return nil, invalidParameter(errors.Errorf(
`failed to set custom headers from %s environment variable: missing "=" in key=value pair: '%s'`,
envOverrideHTTPHeaders, kv,
))
}
env := map[string]string{}
for _, kv := range fields {
k, v, hasValue := strings.Cut(kv, "=")
env[http.CanonicalHeaderKey(k)] = v
}
// Only strip whitespace in keys; preserve whitespace in values.
k = strings.TrimSpace(k)
if len(env) == 0 {
// We should probably not hit this case, as we don't skip values
// (only return errors), but we don't want to discard existing
// headers with an empty set.
return nil, nil
}
if k == "" {
return invalidParameter(errors.Errorf(
`failed to set custom headers from %s environment variable: value contains a key=value pair with an empty key: '%s'`,
envOverrideHTTPHeaders, kv,
))
}
// TODO(thaJeztah): add a client.WithExtraHTTPHeaders() function to allow these headers to be _added_ to existing ones, instead of _replacing_
// see https://github.com/docker/cli/pull/5098#issuecomment-2147403871 (when updating, also update the WARNING in the function and env-var GoDoc)
return client.WithHTTPHeaders(env), nil
}
// We don't currently allow empty key=value pairs, and produce an error.
// This is something we could allow in future (e.g. to read value
// from an environment variable with the same name). In the meantime,
// produce an error to prevent users from depending on this.
if !hasValue {
return invalidParameter(errors.Errorf(
`failed to set custom headers from %s environment variable: missing "=" in key=value pair: '%s'`,
envOverrideHTTPHeaders, kv,
))
}
env[http.CanonicalHeaderKey(k)] = v
// WithUserAgent configures the User-Agent string for cli HTTP requests.
func WithUserAgent(userAgent string) CLIOption {
return func(cli *DockerCli) error {
if userAgent == "" {
return errors.New("user agent cannot be blank")
}
if len(env) == 0 {
// We should probably not hit this case, as we don't skip values
// (only return errors), but we don't want to discard existing
// headers with an empty set.
return nil
}
// TODO(thaJeztah): add a client.WithExtraHTTPHeaders() function to allow these headers to be _added_ to existing ones, instead of _replacing_
// see https://github.com/docker/cli/pull/5098#issuecomment-2147403871 (when updating, also update the WARNING in the function and env-var GoDoc)
return client.WithHTTPHeaders(env)(apiClient)
cli.userAgent = userAgent
return nil
}
}

View File

@ -52,7 +52,14 @@ type EndpointDefaultResolver interface {
}
// ResolveDefaultContext creates a Metadata for the current CLI invocation parameters
//
// Deprecated: this function is exported for testing and meant for internal use. It will be removed in the next release.
func ResolveDefaultContext(opts *cliflags.ClientOptions, config store.Config) (*DefaultContext, error) {
return resolveDefaultContext(opts, config)
}
// resolveDefaultContext creates a Metadata for the current CLI invocation parameters
func resolveDefaultContext(opts *cliflags.ClientOptions, config store.Config) (*DefaultContext, error) {
contextTLSData := store.ContextTLSData{
Endpoints: make(map[string]store.EndpointTLSData),
}

View File

@ -12,7 +12,7 @@
// based on https://github.com/golang/go/blob/master/src/text/tabwriter/tabwriter.go Last modified 690ac40 on 31 Jan
//nolint:gocyclo,nakedret,unused // ignore linting errors, so that we can stick close to upstream
//nolint:gocyclo,gofumpt,nakedret,unused // ignore linting errors, so that we can stick close to upstream
package tabwriter
import (

View File

@ -77,7 +77,16 @@ func ResolveAuthConfig(cfg *configfile.ConfigFile, index *registrytypes.IndexInf
}
a, _ := cfg.GetAuthConfig(configKey)
return registrytypes.AuthConfig(a)
return registrytypes.AuthConfig{
Username: a.Username,
Password: a.Password,
ServerAddress: a.ServerAddress,
// TODO(thaJeztah): Are these expected to be included?
Auth: a.Auth,
IdentityToken: a.IdentityToken,
RegistryToken: a.RegistryToken,
}
}
// GetDefaultAuthConfig gets the default auth config given a serverAddress
@ -86,19 +95,27 @@ func GetDefaultAuthConfig(cfg *configfile.ConfigFile, checkCredStore bool, serve
if !isDefaultRegistry {
serverAddress = credentials.ConvertToHostname(serverAddress)
}
authconfig := configtypes.AuthConfig{}
authCfg := configtypes.AuthConfig{}
var err error
if checkCredStore {
authconfig, err = cfg.GetAuthConfig(serverAddress)
authCfg, err = cfg.GetAuthConfig(serverAddress)
if err != nil {
return registrytypes.AuthConfig{
ServerAddress: serverAddress,
}, err
}
}
authconfig.ServerAddress = serverAddress
authconfig.IdentityToken = ""
return registrytypes.AuthConfig(authconfig), nil
return registrytypes.AuthConfig{
Username: authCfg.Username,
Password: authCfg.Password,
ServerAddress: serverAddress,
// TODO(thaJeztah): Are these expected to be included?
Auth: authCfg.Auth,
IdentityToken: "",
RegistryToken: authCfg.RegistryToken,
}, nil
}
// PromptUserForCredentials handles the CLI prompt for the user to input
@ -213,7 +230,16 @@ func RetrieveAuthTokenFromImage(cfg *configfile.ConfigFile, image string) (strin
return "", err
}
encodedAuth, err := registrytypes.EncodeAuthConfig(registrytypes.AuthConfig(authConfig))
encodedAuth, err := registrytypes.EncodeAuthConfig(registrytypes.AuthConfig{
Username: authConfig.Username,
Password: authConfig.Password,
ServerAddress: authConfig.ServerAddress,
// TODO(thaJeztah): Are these expected to be included?
Auth: authConfig.Auth,
IdentityToken: authConfig.IdentityToken,
RegistryToken: authConfig.RegistryToken,
})
if err != nil {
return "", err
}

View File

@ -4,6 +4,7 @@
package loader
import (
"fmt"
"reflect"
"sort"
@ -116,7 +117,11 @@ func toServicePortConfigsMap(s any) (map[any]any, error) {
}
m := map[any]any{}
for _, p := range ports {
m[p.Published] = p
protocol := "tcp"
if p.Protocol != "" {
protocol = p.Protocol
}
m[fmt.Sprintf("%d%s", p.Published, protocol)] = p
}
return m, nil
}

View File

@ -3,7 +3,6 @@
package memorystore
import (
"errors"
"fmt"
"maps"
"os"
@ -13,12 +12,17 @@ import (
"github.com/docker/cli/cli/config/types"
)
var errValueNotFound = errors.New("value not found")
// notFoundErr is the error returned when a plugin could not be found.
type notFoundErr string
func IsErrValueNotFound(err error) bool {
return errors.Is(err, errValueNotFound)
func (notFoundErr) NotFound() {}
func (e notFoundErr) Error() string {
return string(e)
}
var errValueNotFound notFoundErr = "value not found"
type Config struct {
lock sync.RWMutex
memoryCredentials map[string]types.AuthConfig

View File

@ -101,7 +101,22 @@ func (ep *Endpoint) ClientOpts() ([]client.Opt, error) {
if err != nil {
return nil, err
}
result = append(result, withHTTPClient(tlsConfig))
// If there's no tlsConfig available, we use the default HTTPClient.
if tlsConfig != nil {
result = append(result,
client.WithHTTPClient(&http.Client{
Transport: &http.Transport{
TLSClientConfig: tlsConfig,
DialContext: (&net.Dialer{
KeepAlive: 30 * time.Second,
Timeout: 30 * time.Second,
}).DialContext,
},
CheckRedirect: client.CheckRedirect,
}),
)
}
}
result = append(result, client.WithHost(ep.Host))
} else {
@ -133,25 +148,6 @@ func isSocket(addr string) bool {
}
}
func withHTTPClient(tlsConfig *tls.Config) func(*client.Client) error {
return func(c *client.Client) error {
if tlsConfig == nil {
// Use the default HTTPClient
return nil
}
return client.WithHTTPClient(&http.Client{
Transport: &http.Transport{
TLSClientConfig: tlsConfig,
DialContext: (&net.Dialer{
KeepAlive: 30 * time.Second,
Timeout: 30 * time.Second,
}).DialContext,
},
CheckRedirect: client.CheckRedirect,
})(c)
}
}
// EndpointFromContext parses a context docker endpoint metadata into a typed EndpointMeta structure
func EndpointFromContext(metadata store.Metadata) (EndpointMeta, error) {
ep, ok := metadata.Endpoints[DockerEndpoint]

View File

@ -60,6 +60,8 @@ func (opts *ListOpts) Set(value string) error {
}
// Delete removes the specified element from the slice.
//
// Deprecated: this method is no longer used and will be removed in the next release.
func (opts *ListOpts) Delete(key string) {
for i, k := range *opts.values {
if k == key {
@ -264,6 +266,8 @@ func ValidateIPAddress(val string) (string, error) {
}
// ValidateMACAddress validates a MAC address.
//
// Deprecated: use [net.ParseMAC]. This function will be removed in the next release.
func ValidateMACAddress(val string) (string, error) {
_, err := net.ParseMAC(strings.TrimSpace(val))
if err != nil {

View File

@ -71,7 +71,7 @@ var HeaderFunctions = template.FuncMap{
// Parse creates a new anonymous template with the basic functions
// and parses the given format.
func Parse(format string) (*template.Template, error) {
return NewParse("", format)
return template.New("").Funcs(basicFunctions).Parse(format)
}
// New creates a new empty template with the provided tag and built-in
@ -82,8 +82,10 @@ func New(tag string) *template.Template {
// NewParse creates a new tagged template with the basic functions
// and parses the given format.
//
// Deprecated: this function is unused and will be removed in the next release. Use [New] if you need to set a tag, or [Parse] instead.
func NewParse(tag, format string) (*template.Template, error) {
return New(tag).Parse(format)
return template.New(tag).Funcs(basicFunctions).Parse(format)
}
// padWithSpace adds whitespace to the input if the input is non-empty

View File

@ -81,7 +81,6 @@ info:
{
"username": "string",
"password": "string",
"email": "string",
"serveraddress": "string"
}
```
@ -637,6 +636,9 @@ definitions:
by the default (runc) runtime.
This field is omitted when empty.
**Deprecated**: This field is deprecated as kernel 6.12 has deprecated `memory.kmem.tcp.limit_in_bytes` field
for cgroups v1. This field will be removed in a future release.
type: "integer"
format: "int64"
MemoryReservation:
@ -1531,37 +1533,6 @@ definitions:
items:
type: "string"
example: ["/bin/sh", "-c"]
# FIXME(thaJeztah): temporarily using a full example to remove some "omitempty" fields. Remove once the fields are removed.
example:
"User": "web:web"
"ExposedPorts": {
"80/tcp": {},
"443/tcp": {}
}
"Env": ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"]
"Cmd": ["/bin/sh"]
"Healthcheck": {
"Test": ["string"],
"Interval": 0,
"Timeout": 0,
"Retries": 0,
"StartPeriod": 0,
"StartInterval": 0
}
"ArgsEscaped": true
"Volumes": {
"/app/data": {},
"/app/config": {}
}
"WorkingDir": "/public/"
"Entrypoint": []
"OnBuild": []
"Labels": {
"com.example.some-label": "some-value",
"com.example.some-other-label": "some-other-value"
}
"StopSignal": "SIGTERM"
"Shell": ["/bin/sh", "-c"]
NetworkingConfig:
description: |
@ -1967,6 +1938,11 @@ definitions:
Depending on how the image was created, this field may be empty and
is only set for images that were built/created locally. This field
is empty if the image was pulled from an image registry.
> **Deprecated**: This field is only set when using the deprecated
> legacy builder. It is included in API responses for informational
> purposes, but should not be depended on as it will be omitted
> once the legacy builder is removed.
type: "string"
x-nullable: false
example: ""
@ -1992,6 +1968,11 @@ definitions:
The version of Docker that was used to build the image.
Depending on how the image was created, this field may be empty.
> **Deprecated**: This field is only set when using the deprecated
> legacy builder. It is included in API responses for informational
> purposes, but should not be depended on as it will be omitted
> once the legacy builder is removed.
type: "string"
x-nullable: false
example: "27.0.1"
@ -2036,14 +2017,6 @@ definitions:
format: "int64"
x-nullable: false
example: 1239828
VirtualSize:
description: |
Total size of the image including all layers it is composed of.
Deprecated: this field is omitted in API v1.44, but kept for backward compatibility. Use Size instead.
type: "integer"
format: "int64"
example: 1239828
GraphDriver:
$ref: "#/definitions/DriverData"
RootFS:
@ -2176,14 +2149,6 @@ definitions:
format: "int64"
x-nullable: false
example: 1239828
VirtualSize:
description: |-
Total size of the image including all layers it is composed of.
Deprecated: this field is omitted in API v1.44, but kept for backward compatibility. Use Size instead.
type: "integer"
format: "int64"
example: 172064416
Labels:
description: "User-defined key/value metadata."
type: "object"
@ -2688,14 +2653,6 @@ definitions:
description: |
Unique ID of the build cache record.
example: "ndlpt0hhvkqcdfkputsk4cq9c"
Parent:
description: |
ID of the parent build cache record.
> **Deprecated**: This field is deprecated, and omitted if empty.
type: "string"
x-nullable: true
example: ""
Parents:
description: |
List of parent build cache record IDs.
@ -3177,10 +3134,15 @@ definitions:
- Args
properties:
DockerVersion:
description: "Docker Version used to create the plugin"
description: |-
Docker Version used to create the plugin.
Depending on how the plugin was created, this field may be empty or omitted.
Deprecated: this field is no longer set, and will be removed in the next API version.
type: "string"
x-nullable: false
example: "17.06.0-ce"
x-omitempty: true
Description:
type: "string"
x-nullable: false
@ -6382,6 +6344,8 @@ definitions:
Kernel memory TCP limits are not supported when using cgroups v2, which
does not support the corresponding `memory.kmem.tcp.limit_in_bytes` cgroup.
**Deprecated**: This field is deprecated as kernel 6.12 has deprecated kernel memory TCP accounting.
type: "boolean"
example: true
CpuCfsPeriod:
@ -6419,29 +6383,6 @@ definitions:
description: "Indicates IPv4 forwarding is enabled."
type: "boolean"
example: true
BridgeNfIptables:
description: |
Indicates if `bridge-nf-call-iptables` is available on the host when
the daemon was started.
<p><br /></p>
> **Deprecated**: netfilter module is now loaded on-demand and no longer
> during daemon startup, making this field obsolete. This field is always
> `false` and will be removed in a API v1.49.
type: "boolean"
example: false
BridgeNfIp6tables:
description: |
Indicates if `bridge-nf-call-ip6tables` is available on the host.
<p><br /></p>
> **Deprecated**: netfilter module is now loaded on-demand, and no longer
> during daemon startup, making this field obsolete. This field is always
> `false` and will be removed in a API v1.49.
type: "boolean"
example: false
Debug:
description: |
Indicates if the daemon is running in debug-mode / with debug-level

View File

@ -394,7 +394,12 @@ type Resources struct {
// KernelMemory specifies the kernel memory limit (in bytes) for the container.
// Deprecated: kernel 5.4 deprecated kmem.limit_in_bytes.
KernelMemory int64 `json:",omitempty"`
KernelMemory int64 `json:",omitempty"`
// Hard limit for kernel TCP buffer memory (in bytes).
//
// Deprecated: This field is deprecated and will be removed in the next release.
// Starting with 6.12, the kernel has deprecated kernel memory tcp accounting
// for cgroups v1.
KernelMemoryTCP int64 `json:",omitempty"` // Hard limit for kernel TCP buffer memory (in bytes)
MemoryReservation int64 // Memory soft limit (in bytes)
MemorySwap int64 // Total memory usage (memory + swap); set `-1` to enable unlimited swap

View File

@ -48,6 +48,8 @@ type InspectResponse struct {
// Depending on how the image was created, this field may be empty and
// is only set for images that were built/created locally. This field
// is empty if the image was pulled from an image registry.
//
// Deprecated: this field is deprecated, and will be removed in the next release.
Parent string
// Comment is an optional message that can be set when committing or
@ -80,6 +82,8 @@ type InspectResponse struct {
// DockerVersion is the version of Docker that was used to build the image.
//
// Depending on how the image was created, this field may be empty.
//
// Deprecated: this field is deprecated, and will be removed in the next release.
DockerVersion string
// Author is the name of the author that was specified when committing the

View File

@ -42,7 +42,11 @@ type PluginConfig struct {
// Required: true
Description string `json:"Description"`
// Docker Version used to create the plugin
// Docker Version used to create the plugin.
//
// Depending on how the plugin was created, this field may be empty or omitted.
//
// Deprecated: this field is no longer set, and will be removed in the next API version.
DockerVersion string `json:"DockerVersion,omitempty"`
// documentation

View File

@ -9,19 +9,23 @@ import (
// Info contains response of Engine API:
// GET "/info"
type Info struct {
ID string
Containers int
ContainersRunning int
ContainersPaused int
ContainersStopped int
Images int
Driver string
DriverStatus [][2]string
SystemStatus [][2]string `json:",omitempty"` // SystemStatus is only propagated by the Swarm standalone API
Plugins PluginsInfo
MemoryLimit bool
SwapLimit bool
KernelMemory bool `json:",omitempty"` // Deprecated: kernel 5.4 deprecated kmem.limit_in_bytes
ID string
Containers int
ContainersRunning int
ContainersPaused int
ContainersStopped int
Images int
Driver string
DriverStatus [][2]string
SystemStatus [][2]string `json:",omitempty"` // SystemStatus is only propagated by the Swarm standalone API
Plugins PluginsInfo
MemoryLimit bool
SwapLimit bool
KernelMemory bool `json:",omitempty"` // Deprecated: kernel 5.4 deprecated kmem.limit_in_bytes
// KernelMemoryLimit is not supported on cgroups v2.
//
// Deprecated: This field is deprecated and will be removed in the next release.
// Starting with kernel 6.12, the kernel has deprecated kernel memory tcp accounting
KernelMemoryTCP bool `json:",omitempty"` // KernelMemoryTCP is not supported on cgroups v2.
CPUCfsPeriod bool `json:"CpuCfsPeriod"`
CPUCfsQuota bool `json:"CpuCfsQuota"`

4
vendor/modules.txt vendored
View File

@ -163,7 +163,7 @@ github.com/decentral1se/passgen
# github.com/distribution/reference v0.6.0
## explicit; go 1.20
github.com/distribution/reference
# github.com/docker/cli v28.4.0+incompatible
# github.com/docker/cli v28.4.0+incompatible => git.coopcloud.tech/toolshed/docker-cli v28.5.3-0.20260202112816-30df2d0b3a00+incompatible
## explicit
github.com/docker/cli/cli
github.com/docker/cli/cli-plugins/metadata
@ -213,7 +213,7 @@ github.com/docker/distribution/registry/client/auth/challenge
github.com/docker/distribution/registry/client/transport
github.com/docker/distribution/registry/storage/cache
github.com/docker/distribution/registry/storage/cache/memory
# github.com/docker/docker v28.4.0+incompatible
# github.com/docker/docker v28.5.2+incompatible
## explicit
github.com/docker/docker/api
github.com/docker/docker/api/types