Compare commits

..
25 Commits
Author SHA1 Message Date
devydave 82a5dc35cc chore: adds missing translation deps 2026-08-14 18:14:16 +02:00
devydave 16a789fd64 chore: update translations 2026-08-14 18:13:52 +02:00
devydave 2733b20d42 docs: explains reading stackname 2026-08-14 18:09:01 +02:00
devydave d01c5e7828 refactor: simplify code 2026-08-14 16:53:44 +02:00
devydave 8eba47a3e1 refactor: adjust replace to replaceall and log file name that will be written 2026-08-14 16:28:44 +02:00
devydave 527813c924 fix: updating labels and tags 2026-08-14 12:46:45 +02:00
devydave 38f7720bd6 fix: prefixes volumes with stack name instead of default fallback for deployments 2026-08-14 11:09:12 +02:00
devydave 8c72890a63 fix: deploy labels nil check 2026-08-14 01:02:28 +02:00
devydave 0e5b42da17 chore: mod vendor 2026-08-13 23:09:45 +02:00
devydave 4c72dcb49c merge 2026-08-13 23:08:36 +02:00
devydave a28cc7afce docs: more formatting and typo 2026-08-13 20:41:04 +02:00
devydave 21dccc895d docs: formatting 2026-08-13 20:38:20 +02:00
devydave bb3c7576f8 docs: add setup documentation 2026-08-13 20:34:14 +02:00
devydave 02cbf7c014 refactor: make path editable without nix and impure 2026-08-13 19:57:51 +02:00
devydave a8c8df8604 fix: remove personal terminal choice 2026-08-12 23:01:12 +02:00
devydave 5766a25144 feat: adds host module 2026-08-12 22:43:16 +02:00
devydave 1ccbb31b70 refactor: simplify config and remove hom-manager dependency 2026-08-07 15:15:04 +02:00
devydave 5e0dac6508 refactor: adjusting hosts generation 2026-08-07 14:11:00 +02:00
devydave 38d13eae73 refactor: better variable name 2026-08-07 13:42:22 +02:00
devydave b5e22790be refactor: better name for inspect function 2026-08-07 13:36:25 +02:00
devydave beee7bd3f2 refactor: only export ssh auth socket when not existing 2026-08-07 11:09:30 +02:00
devydave 0c0e16914a refactor: remove traefik deployment because it is not needed 2026-08-07 10:36:23 +02:00
devydave 9bd8f4962a feat: adds running integrations test suite 2026-08-06 22:00:29 +02:00
devydave 4e42d4fb9e feat: adds local vm setup that can reach each other 2026-07-30 16:53:34 +02:00
decentral1se 94624bb16d WIP: feat: use compose-go
See toolshed/abra#492
2026-04-04 19:39:24 +02:00
374 changed files with 72181 additions and 11669 deletions
-1
View File
@@ -7,4 +7,3 @@
/bin
dist/
tests/integration/.bats
.devbox
+42
View File
@@ -84,3 +84,45 @@ build-mo:
release:
@goreleaser release --clean
CLIENT_VM := abra-client
vm-client-status:
sudo systemctl status microvm@$(CLIENT_VM).service
vm-client-create:test-integration-hosts
sudo microvm -f git+file://$$(pwd) -c $(CLIENT_VM)
vm-client-start:
sudo systemctl start microvm@$(CLIENT_VM).service
vm-client-update:test-integration-hosts
sudo microvm -R -f git+file://$$(pwd) -u $(CLIENT_VM)
vm-client-run:
sudo microvm -f git+file://$$(pwd) -r $(CLIENT_VM)
vm-client-stop:
sudo systemctl stop microvm@$(CLIENT_VM)
vm-client-delete: vm-client-stop
sudo rm -rf /var/lib/microvms/$(CLIENT_VM)
vm-client-connect:
ssh abra@10.0.0.2 -i ./tests/resources/local_integration_ssh
SERVER_VM := abra-server
vm-server-status:
sudo systemctl status microvm@$(SERVER_VM).service
vm-server-create:
sudo microvm -f git+file://$$(pwd) -c $(SERVER_VM)
vm-server-start:
sudo systemctl start microvm@$(SERVER_VM).service
vm-server-update:
sudo microvm -R -f git+file://$$(pwd) -u $(SERVER_VM)
vm-server-run:
sudo microvm -f git+file://$$(pwd) -r $(SERVER_VM)
vm-server-stop:
sudo systemctl stop microvm@$(SERVER_VM)
vm-server-delete: vm-server-stop
sudo rm -rf /var/lib/microvms/$(SERVER_VM)
vm-server-connect:
ssh abra@10.0.0.3 -i ./tests/resources/local_integration_ssh
test-integration-hosts:
./scripts/tests/extra_hosts
test-integration:
ssh-add ./tests/resources/local_integration_ssh
@bats -Tp tests/integration --verbose-run
+3 -1
View File
@@ -158,7 +158,9 @@ checkout as-is. Recipe commit hashes are also supported as values for
Detach: false,
SendRegistryAuth: true,
}
compose, err := appPkg.GetAppComposeConfig(app.Name, deployOpts, app.Env)
app.Env["STACK_NAME"] = stackName
compose, err := appPkg.GetAppComposeConfig(composeFiles, app.Env)
if err != nil {
log.Fatal(err)
}
+1 -7
View File
@@ -222,13 +222,7 @@ synchronize your local app environment values with what is deployed live.`),
mergedEnv[k] = v
}
// The recipe version is always carried by TYPE (RECIPE, when present,
// is a bare source pointer). Stamp the deployed version onto TYPE and
// leave RECIPE untouched so the two never drift apart.
if t, ok := mergedEnv["TYPE"]; ok {
name, _, _ := strings.Cut(t, ":")
mergedEnv["TYPE"] = fmt.Sprintf("%s:%s", name, version)
} else {
if !strings.Contains(recipeEnvVar, ":") {
mergedEnv[recipeKey] = fmt.Sprintf("%s:%s", mergedEnv[recipeKey], version)
}
+3 -3
View File
@@ -13,7 +13,7 @@ import (
"coopcloud.tech/abra/pkg/i18n"
"coopcloud.tech/abra/pkg/log"
"coopcloud.tech/abra/pkg/upstream/convert"
composetypes "github.com/docker/cli/cli/compose/types"
composeGoTypes "github.com/compose-spec/compose-go/v2/types"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/filters"
dockerClient "github.com/docker/docker/client"
@@ -80,13 +80,13 @@ var AppLabelsCommand = &cobra.Command{
rows = append(rows, []string{i18n.G("RECIPE LABELS"), "---"})
config, err := app.Recipe.GetComposeConfig(app.Env)
config, err := app.Recipe.GetComposeConfig()
if err != nil {
log.Fatal(err)
}
var localLabelKeys []string
var appServiceConfig composetypes.ServiceConfig
var appServiceConfig composeGoTypes.ServiceConfig
for _, service := range config.Services {
if service.Name == "app" {
appServiceConfig = service
+2 -2
View File
@@ -262,8 +262,8 @@ func getAppResources(cl *dockerclient.Client, app app.App) (*AppResources, error
return nil, err
}
opts := stack.Deploy{Composefiles: composeFiles, Namespace: app.StackName()}
compose, err := appPkg.GetAppComposeConfig(app.Name, opts, app.Env)
app.Env["STACK_NAME"] = app.StackName()
compose, err := appPkg.GetAppComposeConfig(composeFiles, app.Env)
if err != nil {
return nil, err
}
+1 -17
View File
@@ -32,18 +32,6 @@ deploy <domain>" to do so.
You can see what recipes are available (i.e. values for the [recipe] argument)
by running "abra recipe ls".
In addition to short catalogue names, [recipe] also accepts arbitrary git
URLs to use a recipe from outside the catalogue (e.g. a fork or work in
progress). Any of these forms is accepted:
abra app new git.example.com/user/recipe
abra app new https://git.example.com/user/recipe
abra app new git@git.example.com:user/recipe
In that case a RECIPE=<canonical name> line is written to the app's .env
file so a subsequent "abra app deploy" (on this or another machine) will
re-fetch the recipe from the same git source.
Recipe commit hashes are supported values for "[version]".
Passing the "--secrets/-S" flag will automatically generate secrets for your
@@ -307,7 +295,7 @@ func ensureDomainFlag(recipe recipePkg.Recipe, server string) error {
if appDomain == "" && !internal.NoInput {
prompt := &survey.Input{
Message: i18n.G("Specify app domain"),
Default: fmt.Sprintf("%s.%s", recipe.ShortName(), server),
Default: fmt.Sprintf("%s.%s", recipe.Name, server),
}
if err := survey.AskOne(prompt, &appDomain); err != nil {
return err
@@ -318,10 +306,6 @@ func ensureDomainFlag(recipe recipePkg.Recipe, server string) error {
return errors.New(i18n.G("no domain provided"))
}
if strings.ContainsAny(appDomain, "/\\") {
return errors.New(i18n.G("invalid domain '%s': must not contain '/' or '\\'", appDomain))
}
return nil
}
+7 -12
View File
@@ -4,7 +4,8 @@ import (
"context"
"encoding/json"
"fmt"
"sort"
"maps"
"slices"
"strings"
"coopcloud.tech/abra/cli/internal"
@@ -87,26 +88,20 @@ func showPSOutput(app appPkg.App, cl *dockerClient.Client, deployedVersion, chao
return
}
deployOpts := stack.Deploy{
Composefiles: composeFiles,
Namespace: app.StackName(),
Prune: false,
ResolveImage: stack.ResolveImageAlways,
}
compose, err := appPkg.GetAppComposeConfig(app.Name, deployOpts, app.Env)
app.Env["STACK_NAME"] = app.StackName()
compose, err := appPkg.GetAppComposeConfig(composeFiles, app.Env)
if err != nil {
log.Fatal(err)
return
}
services := compose.Services
sort.Slice(services, func(i, j int) bool {
return services[i].Name < services[j].Name
})
var rows [][]string
allContainerStats := make(map[string]map[string]string)
for _, service := range services {
for _, serviceName := range slices.Sorted(maps.Keys(compose.Services)) {
service := services[serviceName]
filters := filters.NewArgs()
filters.Add("name", fmt.Sprintf("^%s_%s", app.StackName(), service.Name))
+2 -7
View File
@@ -174,7 +174,8 @@ beforehand. See "abra app backup" for more.`),
SendRegistryAuth: true,
}
compose, err := appPkg.GetAppComposeConfig(app.Name, deployOpts, app.Env)
app.Env["STACK_NAME"] = stackName
compose, err := appPkg.GetAppComposeConfig(composeFiles, app.Env)
if err != nil {
log.Fatal(err)
}
@@ -188,12 +189,6 @@ beforehand. See "abra app backup" for more.`),
appPkg.SetChaosVersionLabel(compose, stackName, chosenDowngrade)
}
// NOTE: stamp the deployed version label ourselves rather than relying
// on the value baked into the recipe's compose.yml. Git-URL/WIP recipes
// often carry a stale or missing label, which would otherwise leave the
// live deployment reporting the wrong version after a rollback.
appPkg.SetVersionLabel(compose, stackName, chosenDowngrade)
// Gather secrets
secretInfo, err := deploy.GatherSecretsForDeploy(cl, app, internal.ShowUnchanged)
if err != nil {
+2 -2
View File
@@ -89,8 +89,8 @@ Passing "--prune/-p" does not remove those volumes.`),
log.Fatal(err)
}
opts := stack.Deploy{Composefiles: composeFiles, Namespace: stackName}
compose, err := appPkg.GetAppComposeConfig(app.Name, opts, app.Env)
app.Env["STACK_NAME"] = stackName
compose, err := appPkg.GetAppComposeConfig(composeFiles, app.Env)
if err != nil {
log.Fatal(err)
}
+6 -25
View File
@@ -186,7 +186,8 @@ beforehand. See "abra app backup" for more.`),
SendRegistryAuth: true,
}
compose, err := appPkg.GetAppComposeConfig(app.Name, deployOpts, app.Env)
app.Env["STACK_NAME"] = stackName
compose, err := appPkg.GetAppComposeConfig(composeFiles, app.Env)
if err != nil {
log.Fatal(err)
}
@@ -200,12 +201,6 @@ beforehand. See "abra app backup" for more.`),
appPkg.SetChaosVersionLabel(compose, stackName, chosenUpgrade)
}
// NOTE: stamp the deployed version label ourselves rather than relying
// on the value baked into the recipe's compose.yml. Git-URL/WIP recipes
// often carry a stale or missing label, which would otherwise leave the
// live deployment reporting the wrong version after an upgrade.
appPkg.SetVersionLabel(compose, stackName, chosenUpgrade)
envVars, err := appPkg.CheckEnv(app)
if err != nil {
log.Fatal(err)
@@ -352,17 +347,9 @@ func getReleaseNotes(
return errors.New(i18n.G("parsing chosen upgrade version failed: %s", err))
}
// The deployed version can be "unknown" (e.g. deployed without a version
// label, as is common for git-URL/work-in-progress recipes). In that case
// there is no lower bound to filter by, so we gather notes for every
// version below the chosen one instead of failing.
var parsedDeployedVersion tagcmp.Tag
haveDeployedVersion := deployMeta.Version != config.UNKNOWN_DEFAULT
if haveDeployedVersion {
parsedDeployedVersion, err = tagcmp.Parse(deployMeta.Version)
if err != nil {
return errors.New(i18n.G("parsing deployment version failed: %s", err))
}
parsedDeployedVersion, err := tagcmp.Parse(deployMeta.Version)
if err != nil {
return errors.New(i18n.G("parsing deployment version failed: %s", err))
}
for _, version := range internal.SortVersionsDesc(versions) {
@@ -371,7 +358,7 @@ func getReleaseNotes(
return errors.New(i18n.G("parsing recipe version failed: %s", err))
}
if (!haveDeployedVersion || parsedVersion.IsGreaterThan(parsedDeployedVersion)) &&
if parsedVersion.IsGreaterThan(parsedDeployedVersion) &&
parsedVersion.IsLessThan(parsedChosenUpgrade) {
note, err := app.Recipe.GetReleaseNotes(version, app.Domain)
if err != nil {
@@ -434,12 +421,6 @@ func validateUpgradeVersionArg(
return errors.New(i18n.G("'%s' is not a known version for %s", specificVersion, app.Recipe.Name))
}
// Without a known deployed version we cannot tell whether the requested
// version is an upgrade; trust the explicit choice rather than failing.
if deployMeta.Version == config.UNKNOWN_DEFAULT {
return nil
}
parsedDeployedVersion, err := tagcmp.Parse(deployMeta.Version)
if err != nil {
return errors.New(i18n.G("'%s' is not a known version", deployMeta.Version))
+2 -1
View File
@@ -92,10 +92,11 @@ func SetBumpType(bumpType string) {
func GetMainAppImage(recipe recipe.Recipe) (string, error) {
var path string
config, err := recipe.GetComposeConfig(nil)
config, err := recipe.GetComposeConfig()
if err != nil {
return "", err
}
for _, service := range config.Services {
if service.Name == "app" {
img, err := reference.ParseNormalizedNamed(service.Image)
+3 -24
View File
@@ -59,15 +59,9 @@ func ValidateRecipe(args []string, cmdName string) recipe.Recipe {
log.Fatal(i18n.G("no recipe name provided"))
}
recipeName = recipe.NormalizeRecipeName(recipeName)
lookupName := recipeName
if i := strings.LastIndex(lookupName, ":"); i >= 0 {
lookupName = lookupName[:i]
}
if _, ok := knownRecipes[lookupName]; !ok {
if !strings.Contains(lookupName, "/") {
log.Fatal(i18n.G("no recipe '%s' exists? pass a git URL (e.g. https://git.example.com/user/recipe) to use a recipe outside the catalogue", lookupName))
if _, ok := knownRecipes[recipeName]; !ok {
if !strings.Contains(recipeName, "/") {
log.Fatal(i18n.G("no recipe '%s' exists?", recipeName))
}
}
@@ -76,21 +70,6 @@ func ValidateRecipe(args []string, cmdName string) recipe.Recipe {
log.Fatal(err)
}
_, err = chosenRecipe.GetComposeConfig(nil)
if err != nil {
if cmdName == i18n.G("generate") {
if strings.Contains(err.Error(), "missing a compose") {
log.Fatal(err)
}
log.Warn(err)
} else {
if strings.Contains(err.Error(), "template_driver is not allowed") {
log.Warn(i18n.G("ensure %s recipe compose.* files include \"version: '3.8'\"", recipeName))
}
log.Fatal(i18n.G("unable to validate recipe: %s", err))
}
}
log.Debug(i18n.G("validated %s as recipe argument", recipeName))
return chosenRecipe
-45
View File
@@ -2,13 +2,11 @@ package recipe
import (
"fmt"
"path"
"sort"
"strconv"
"strings"
"coopcloud.tech/abra/cli/internal"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/formatter"
"coopcloud.tech/abra/pkg/i18n"
"coopcloud.tech/abra/pkg/log"
@@ -43,7 +41,6 @@ var RecipeListCommand = &cobra.Command{
headers := []string{
i18n.G("name"),
i18n.G("source"),
i18n.G("category"),
i18n.G("status"),
i18n.G("healthcheck"),
@@ -59,7 +56,6 @@ var RecipeListCommand = &cobra.Command{
for _, recipe := range recipes {
row := []string{
recipe.Name,
i18n.G("catalogue"),
recipe.Category,
strconv.Itoa(recipe.Features.Status),
recipe.Features.Healthcheck,
@@ -80,23 +76,6 @@ var RecipeListCommand = &cobra.Command{
}
}
externals := externalRecipes(catl)
sort.Strings(externals)
for _, name := range externals {
row := []string{
name,
i18n.G("external"),
"-", "-", "-", "-", "-", "-", "-",
}
if pattern != "" {
if !strings.Contains(name, pattern) {
continue
}
}
table.Row(row...)
rows = append(rows, row)
}
if len(rows) > 0 {
if internal.MachineReadable {
out, err := formatter.ToJSON(headers, rows)
@@ -114,30 +93,6 @@ var RecipeListCommand = &cobra.Command{
},
}
// externalRecipes returns canonical names of locally-cloned recipes that
// were sourced from an arbitrary git URL (i.e. they carry a .abra-source
// sidecar) and are not already listed in the catalogue.
func externalRecipes(catl recipe.RecipeCatalogue) []string {
dirs, err := recipe.GetRecipesLocal()
if err != nil {
log.Debug(i18n.G("can't read local recipes: %s", err))
return nil
}
var names []string
for _, dir := range dirs {
canonical := recipe.ReadRecipeSource(path.Join(config.RECIPES_DIR, dir))
if canonical == "" {
continue
}
if _, inCatalogue := catl[canonical]; inCatalogue {
continue
}
names = append(names, canonical)
}
return names
}
var (
pattern string
)
+2 -1
View File
@@ -312,10 +312,11 @@ likely to change.
func GetImageVersions(recipe recipePkg.Recipe) (map[string]string, error) {
services := make(map[string]string)
config, err := recipe.GetComposeConfig(nil)
config, err := recipe.GetComposeConfig()
if err != nil {
return nil, err
}
missingTag := false
for _, service := range config.Services {
if service.Image == "" {
+1 -1
View File
@@ -124,7 +124,7 @@ interface.`),
log.Debug(i18n.G("did not find versions file for %s", recipe.Name))
}
config, err := recipe.GetComposeConfig(nil)
config, err := recipe.GetComposeConfig()
if err != nil {
log.Fatal(err)
}
+1 -1
View File
@@ -16,7 +16,7 @@ func main() {
Version = "dev"
}
if Commit == "" {
Commit = "unknown-commit"
Commit = " "
}
cli.Run(Version, Commit)
Generated
+38
View File
@@ -18,6 +18,27 @@
"type": "github"
}
},
"microvm": {
"inputs": {
"nixpkgs": [
"nixpkgs"
],
"spectrum": "spectrum"
},
"locked": {
"lastModified": 1784666190,
"narHash": "sha256-xgfS6slV7J3baMooNN1UuBi51RIgg9y0DbCxfSA0668=",
"owner": "astro",
"repo": "microvm.nix",
"rev": "fa5340ac684cdce8a22b6d4a0bcebb0cc999275e",
"type": "github"
},
"original": {
"owner": "astro",
"repo": "microvm.nix",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1778443072,
@@ -37,9 +58,26 @@
"root": {
"inputs": {
"flake-utils": "flake-utils",
"microvm": "microvm",
"nixpkgs": "nixpkgs"
}
},
"spectrum": {
"flake": false,
"locked": {
"lastModified": 1783694892,
"narHash": "sha256-xO8f7Qng+18FK2UlB9vcrkxCaQMCt5WjCH24aW/11eg=",
"ref": "refs/heads/main",
"rev": "24c4346e30fdea8d8e80f34aec3554a15a667d24",
"revCount": 1410,
"type": "git",
"url": "https://spectrum-os.org/git/spectrum"
},
"original": {
"type": "git",
"url": "https://spectrum-os.org/git/spectrum"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
+88 -18
View File
@@ -4,34 +4,104 @@
inputs = {
nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
microvm = {
url = "github:astro/microvm.nix";
inputs.nixpkgs.follows = "nixpkgs";
};
};
outputs =
{
self,
nixpkgs,
microvm,
flake-utils,
}:
flake-utils.lib.eachDefaultSystem (
system:
let
pkgs = nixpkgs.legacyPackages.${system};
in
{
packages = rec {
abra = pkgs.callPackage ./package.nix { };
default = abra;
let
system = "x86_64-linux";
pkgs = nixpkgs.legacyPackages.${system};
publicKey = builtins.readFile ./tests/resources/local_integration_ssh.pub;
privateKey = builtins.readFile ./tests/resources/local_integration_ssh;
pathToRepo = builtins.readFile ./tests/resources/path_to_repo;
# local DNS aliases for the server host
extraHosts = builtins.readFile ./tests/resources/extra_hosts;
username = "abra";
password = "abra";
defaultDNS = [
# Quad9.net
"9.9.9.9"
"149.112.112.112"
"2620:fe::fe"
"2620:fe::9"
];
in
{
nixosConfigurations = {
abra-client = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
microvm.nixosModules.microvm
./nix/hosts/client/configuration.nix
];
specialArgs = {
inherit
publicKey
privateKey
username
password
defaultDNS
extraHosts
pathToRepo
;
};
};
apps = rec {
abra = flake-utils.lib.mkApp { drv = self.packages.${system}.abra; };
default = abra;
abra-server = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
microvm.nixosModules.microvm
./nix/hosts/server/configuration.nix
];
specialArgs = {
inherit
publicKey
privateKey
username
password
defaultDNS
;
};
};
devShells.default = pkgs.mkShell {
packages = with pkgs; [
go_1_26
gnumake
};
nixosModules = rec {
host = {
imports = [
microvm.nixosModules.host
./nix/modules/host.nix
];
};
}
);
default = host;
};
packages = rec {
abra = pkgs.callPackage ./nix/package.nix { };
default = abra;
};
apps = rec {
abra = flake-utils.lib.mkApp { drv = self.packages.${system}.abra; };
default = abra;
};
devShells.${system}.default = pkgs.mkShell {
# testing env variables
BATS_LIB_PATH = "~/.local/share/bats/";
TEST_SERVER = "abra.local";
ABRA_DIR = "$HOME/.abra_test";
packages = with pkgs; [
go_1_26
gnumake
gopls
gettext
];
};
};
}
+13 -7
View File
@@ -9,18 +9,20 @@ require (
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/charmbracelet/log v1.0.0
github.com/compose-spec/compose-go/v2 v2.10.1
github.com/distribution/reference v0.6.0
github.com/docker/cli 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.19.2
github.com/go-git/go-git/v5 v5.19.1
github.com/google/go-cmp v0.7.0
github.com/leonelquinteros/gotext v1.7.2
github.com/moby/sys/signal v0.7.1
github.com/moby/term v0.5.2
github.com/pkg/errors v0.9.1
github.com/schollz/progressbar/v3 v3.19.1
golang.org/x/term v0.45.0
github.com/sirupsen/logrus v1.9.4
golang.org/x/term v0.44.0
gopkg.in/yaml.v3 v3.0.1
gotest.tools/v3 v3.5.2
)
@@ -80,6 +82,7 @@ require (
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.21 // indirect
github.com/mattn/go-shellwords v1.0.12 // indirect
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
github.com/miekg/pkcs11 v1.1.1 // indirect
github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect
@@ -103,12 +106,13 @@ require (
github.com/prometheus/procfs v0.20.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 // indirect
github.com/skeema/knownhosts v1.3.2 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
github.com/xhit/go-str2duration/v2 v2.1.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
@@ -124,10 +128,12 @@ require (
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/crypto v0.53.0 // indirect
go.yaml.in/yaml/v4 v4.0.0-rc.3 // indirect
golang.org/x/crypto v0.50.0 // indirect
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/text v0.39.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/text v0.36.0 // indirect
golang.org/x/time v0.15.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
@@ -155,7 +161,7 @@ require (
github.com/stretchr/testify v1.11.1
github.com/theupdateframework/notary v0.7.0 // indirect
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
golang.org/x/sys v0.47.0
golang.org/x/sys v0.46.0
)
replace github.com/docker/cli v28.4.0+incompatible => git.coopcloud.tech/toolshed/docker-cli v28.5.3-0.20260202112816-30df2d0b3a00+incompatible
+25 -12
View File
@@ -176,6 +176,8 @@ github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGX
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
github.com/cockroachdb/datadriven v0.0.0-20190809214429-80d97fb3cbaa/go.mod h1:zn76sxSg3SzpJ0PPJaLDCu+Bu0Lg3sKTORVIj19EIF8=
github.com/compose-spec/compose-go/v2 v2.10.1 h1:mFbXobojGRFIVi1UknrvaDAZ+PkJfyjqkA1yseh+vAU=
github.com/compose-spec/compose-go/v2 v2.10.1/go.mod h1:Ohac1SzhO/4fXXrzWIztIVB6ckmKBv1Nt5Z5mGVESUg=
github.com/containerd/aufs v0.0.0-20200908144142-dab0cbea06f4/go.mod h1:nukgQABAEopAHvB6j7cnP5zJ+/3aVcE7hCYqvIwAHyE=
github.com/containerd/aufs v0.0.0-20201003224125-76a6863f2989/go.mod h1:AkGGQs9NM2vtYHaUen+NljV0/baGCAPELGm2q9ZXpWU=
github.com/containerd/aufs v0.0.0-20210316121734-20793ff83c97/go.mod h1:kL5kd6KM5TzQjR79jljyi4olc1Vrx6XBlcyj3gNv2PU=
@@ -318,6 +320,8 @@ github.com/dgrijalva/jwt-go v0.0.0-20170104182250-a601269ab70c/go.mod h1:E3ru+11
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI=
github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
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/distribution v0.0.0-20190905152932-14b96e55d84c/go.mod h1:0+TTO4EOBfRPhZXAeF1Vu+W3hHZ8eLp8PgKVZlcvtFY=
@@ -393,8 +397,8 @@ github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmm
github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
github.com/go-git/go-git/v5 v5.19.2 h1:wkfn7vOlUBu8ivAWKBWisTiwJK4jYHzTF8Ndv1LyGqY=
github.com/go-git/go-git/v5 v5.19.2/go.mod h1:QqCBE1EFN5ddFmrliLQ3/ntRCUjZU3EJuwuB/jWEHjk=
github.com/go-git/go-git/v5 v5.19.1 h1:nX27AnaU43/K5bKktKwgBmR9lawoYVe1Ckg0rgzzN00=
github.com/go-git/go-git/v5 v5.19.1/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhEGMH7zjDKQ=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
@@ -632,6 +636,7 @@ github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEj
github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mattn/go-shellwords v1.0.3/go.mod h1:3xCvwCdWdlDJUrvuMn7Wuy9eWs4pE8vqg+NOMyg4B2o=
github.com/mattn/go-shellwords v1.0.6/go.mod h1:3xCvwCdWdlDJUrvuMn7Wuy9eWs4pE8vqg+NOMyg4B2o=
github.com/mattn/go-shellwords v1.0.12 h1:M2zGm7EW6UQJvDeQxo4T51eKPurbeFbe8WtebGE2xrk=
github.com/mattn/go-shellwords v1.0.12/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y=
github.com/mattn/go-sqlite3 v1.6.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
@@ -807,6 +812,8 @@ github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQD
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/safchain/ethtool v0.0.0-20190326074333-42ed695e3de8/go.mod h1:Z0q5wiBQGYcxhMZ6gUqHn6pYNLypFAvaL3UvgZLR0U4=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 h1:PKK9DyHxif4LZo+uQSgXNqs0jj5+xZwwfKHgph2lxBw=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.1/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
github.com/schollz/progressbar/v3 v3.19.1 h1:iv8BgwOvdML/S3p84uBpy/IMigv4U9594vPZYa2EdrU=
github.com/schollz/progressbar/v3 v3.19.1/go.mod h1:LFL7jqimKxfhero4K1eCkUr/6R39AgQeiPCJtlTWIW8=
@@ -894,6 +901,8 @@ github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:
github.com/xeipuuv/gojsonschema v0.0.0-20180618132009-1d523034197f/go.mod h1:5yf86TLmAcydyeJq5YvxkGPE2fm/u4myDekKRoLuqhs=
github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc=
github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU=
github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
@@ -948,6 +957,8 @@ go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go=
go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
golang.org/x/crypto v0.0.0-20171113213409-9f005a07e0d3/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20181009213950-7c1a557ab941/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
@@ -966,8 +977,8 @@ golang.org/x/crypto v0.0.0-20201117144127-c1f2f97bffc9/go.mod h1:jdWPYTVW3xRLrWP
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -1043,8 +1054,8 @@ golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96b
golang.org/x/net v0.0.0-20210825183410-e898025ed96a/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -1062,6 +1073,8 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -1139,13 +1152,13 @@ golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -1155,8 +1168,8 @@ golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
+72
View File
@@ -0,0 +1,72 @@
# Hosts
These hosts are VMs used for running the integration tests on your local machine.
The hosts contain a client where abra with the tests will be run and a server
to simulate a remote machine.
## Prepare the host
For this setup to work you need a machine with NixOS and flakes enabled.
1. Import the abra flake
The install example is based on the [using nix flakes wiki page](https://nixos.wiki/wiki/flakes#Using_nix_flakes_with_NixOS).
```nix
inputs = {
abra = {
url = "git+https://git.coopcloud.tech/toolshed/abra.git";
};
};
```
2. Add the host module to your configuration
Now add the host module to your configuration. At the toplevel of a flake it could look like this:
```nix
desktop = inputs.nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
abra.nixosModules.host
./configuration.nix
];
specialArgs = inputs;
};
```
3. Adjust the required config
```nix
abra.testing = {
enable = true; # loads the required config, when set true
externalInterface = "eth1"; # adjust this to your network interface that has access to the internet
};
```
You can look up the interface with `ip link`. After that rebuild your system and you are ready to go.
## Get started
All commands are run on the repository root path.
1. Set path to repo
The VM will create a shared volume from the content of the file `tests/resources/path_to_repo`.
Set the content of the file to the path of your abra repository.
This will share the repository from your machine with the VM and you can edit files while testing without rebuiling
or restarting the VM.
2. Create the VMs
```sh
make vm-client-create
make vm-server-create
```
3. Start the VMs
```sh
make vm-client-start
make vm-server-start
```
After running that command you should be able to ping the machines.
The client runs on 10.0.0.2 and server 10.0.0.3
4. Connect to the client
Running the following command will connect to the client VM via SSH.
```sh
make vm-client-connect
```
5. Run the tests
```sh
make test-integration
```
+145
View File
@@ -0,0 +1,145 @@
{
lib,
pkgs,
publicKey,
username,
pathToRepo,
defaultDNS,
extraHosts,
...
}:
let
index = 2;
mac = "00:00:00:00:00:01";
serverIp = "10.0.0.3";
in
{
imports = [
../../modules/ssh.nix
../../modules/user.nix
../../modules/docker.nix
];
microvm = {
vcpu = 4;
mem = 2049; # see issue related in microvm repo with 2048, so add 1MB
interfaces = [
{
id = "vm${toString index}";
type = "tap";
inherit mac;
}
];
shares = [
{
proto = "virtiofs";
tag = "repo";
# Source path can be absolute or relative
# to /var/lib/microvms/$hostName
source = "${lib.trim pathToRepo}";
mountPoint = "/home/${username}/abra";
}
];
};
boot.tmp = {
useTmpfs = true;
tmpfsSize = "2G";
};
networking = {
hostName = "abra-client";
useNetworkd = true;
};
systemd.network.networks."10-eth" = {
matchConfig.MACAddress = mac;
# Static IP configuration
address = [
"10.0.0.${toString index}/32"
"fec0::${lib.toHexString index}/128"
];
routes = [
{
# A route to the host
Destination = "10.0.0.0/32";
GatewayOnLink = true;
}
{
# Route to server
Destination = "${serverIp}/32";
Gateway = "10.0.0.0";
GatewayOnLink = true;
}
{
# Default route
Destination = "0.0.0.0/0";
Gateway = "10.0.0.0";
GatewayOnLink = true;
}
{
# Default route
Destination = "::/0";
Gateway = "fec0::";
GatewayOnLink = true;
}
];
networkConfig = {
# DNS servers no longer come from DHCP nor Router
# Advertisements. Perhaps you want to change the defaults:
DNS = defaultDNS;
};
};
# open the ports to allow ssh access from the host
networking.firewall.allowedTCPPorts = [ 22 ];
networking.firewall.allowedUDPPorts = [ 22 ];
networking.extraHosts = extraHosts;
environment.variables = {
CGO_ENABLED = 0;
TEST_SERVER = "abra.local";
ABRA_DIR = "$HOME/.abra_test";
};
programs.ssh = {
startAgent = true;
knownHostsFiles = [
(pkgs.writeText "local.keys" ''
abra.local ${publicKey}
'')
];
extraConfig = ''
Host abra.local
HostName abra.local
Port 22
User ${username}
'';
};
programs.git = {
enable = true;
# many recipe commands need a preset git user
config = [
{
user = {
name = "abra dev";
email = "helo@coopcloud.tech";
};
}
];
};
environment.systemPackages = with pkgs; [
# build dependencies, copied from flake.nix
go_1_26
gnumake
# testing dependencies
(bats.withLibraries (p: [
p.bats-assert
p.bats-file
p.bats-support
]))
jq
wget
];
}
+42
View File
@@ -0,0 +1,42 @@
{
serverIp,
username,
...
}:
{
# Home Manager needs a bit of information about you and the
# paths it should manage.
home.username = username;
home.homeDirectory = "/home/${username}";
# This value determines the Home Manager release that your
# configuration is compatible with. This helps avoid breakage
# when a new Home Manager release introduces backwards
# incompatible changes.
#
# You can update Home Manager without changing this value. See
# the Home Manager release notes for a list of state version
# changes in each release.
home.stateVersion = "26.05";
# Let Home Manager install and manage itself.
programs.home-manager.enable = true;
programs.ssh = {
enable = true;
enableDefaultConfig = false;
settings = {
"abra.local" = {
HostName = serverIp;
User = username;
Port = 22;
IdentityFile = "/home/${username}/abra/tests/resources/local_integration_ssh";
};
"*.abra.local" = {
HostName = serverIp;
User = username;
Port = 22;
IdentityFile = "/home/${username}/abra/tests/resources/local_integration_sshn";
};
};
};
}
+91
View File
@@ -0,0 +1,91 @@
{
lib,
defaultDNS,
...
}:
let
index = 3;
mac = "00:00:00:00:00:02";
clientIp = "10.0.0.2";
in
{
imports = [
../../modules/ssh.nix
../../modules/user.nix
../../modules/docker.nix
];
microvm = {
vcpu = 4;
mem = 8097; # see issue related in microvm repo with 2048, so add 1MB
interfaces = [
{
id = "vm${toString index}";
type = "tap";
inherit mac;
}
];
};
boot.tmp = {
useTmpfs = true;
tmpfsSize = "8G";
};
networking = {
hostName = "abra-server";
useNetworkd = true;
};
systemd.network.networks."11-eth" = {
matchConfig.MACAddress = mac;
# Static IP configuration
address = [
"10.0.0.${toString index}/32"
"fec0::${lib.toHexString index}/128"
];
routes = [
{
# A route to the host
Destination = "10.0.0.0/32";
GatewayOnLink = true;
}
{
# Route to server
Destination = "${clientIp}/32";
Gateway = "10.0.0.0";
GatewayOnLink = true;
}
{
# Default route
Destination = "0.0.0.0/0";
Gateway = "10.0.0.0";
GatewayOnLink = true;
}
{
# Default route
Destination = "::/0";
Gateway = "fec0::";
GatewayOnLink = true;
}
];
networkConfig = {
# DNS servers no longer come from DHCP nor Router
# Advertisements. Perhaps you want to change the defaults:
DNS = defaultDNS;
};
};
# open the ports to allow ssh access from the host
networking.firewall.allowedTCPPorts = [
22
80
443
1312 # deploy with udp and tcp on same port test
];
networking.firewall.allowedUDPPorts = [
22
80
443
1312 # deploy with udp and tcp on same port test
];
}
+7
View File
@@ -0,0 +1,7 @@
{ ... }:
{
virtualisation.docker = {
enable = true;
liveRestore = false;
};
}
+59
View File
@@ -0,0 +1,59 @@
{
lib,
config,
...
}:
let
maxVMs = 3;
in
{
options = {
abra.testing.enable = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Enable the abra integration testing framework";
};
abra.testing.externalInterface = lib.mkOption {
type = lib.types.str;
default = null;
description = "Change this to the interface with upstream Internet access";
};
};
config = lib.mkIf config.abra.testing.enable {
networking.useNetworkd = true;
systemd.network.wait-online.enable = false;
systemd.network.networks = builtins.listToAttrs (
map (index: {
name = "30-vm${toString index}";
value = {
matchConfig.Name = "vm${toString index}";
# Host's addresses
address = [
"10.0.0.0/32"
"fec0::/128"
];
# Setup routes to the VM
routes = [
{
Destination = "10.0.0.${toString index}/32";
}
{
Destination = "fec0::${lib.toHexString index}/128";
}
];
# Enable routing
networkConfig = {
IPv4Forwarding = true;
IPv6Forwarding = true;
};
};
}) (lib.genList (i: i + 1) maxVMs)
);
networking.nat = {
enable = true;
internalIPs = [ "10.0.0.0/24" ];
externalInterface = config.abra.testing.externalInterface;
};
};
}
+33
View File
@@ -0,0 +1,33 @@
{
privateKey,
publicKey,
username,
...
}:
{
# default host key location
environment.etc."ssh/ssh_host_ed25519_key" = {
text = privateKey;
mode = "0600";
};
environment.etc."ssh/ssh_host_ed25519_key.pub" = {
text = publicKey;
mode = "0644";
};
services.openssh = {
enable = true;
settings = {
PasswordAuthentication = false;
PermitRootLogin = "no";
AllowUsers = [ "${username}" ];
};
generateHostKeys = false;
hostKeys = [
{
path = "/etc/ssh/ssh_host_ed25519_key";
type = "ed25519";
}
];
};
}
+22
View File
@@ -0,0 +1,22 @@
{
username,
password,
publicKey,
...
}:
{
users.users."${username}" = {
isNormalUser = true;
password = password;
home = "/home/${username}";
description = "Abra user";
extraGroups = [
"wheel"
"networkmanager"
"docker"
];
openssh.authorizedKeys.keys = [
publicKey
];
};
}
View File
+65 -120
View File
@@ -18,10 +18,10 @@ import (
"coopcloud.tech/abra/pkg/recipe"
"coopcloud.tech/abra/pkg/upstream/convert"
"coopcloud.tech/abra/pkg/upstream/stack"
composeGoTypes "github.com/compose-spec/compose-go/v2/types"
"coopcloud.tech/abra/pkg/log"
loader "coopcloud.tech/abra/pkg/upstream/stack"
composetypes "github.com/docker/cli/cli/compose/types"
"github.com/docker/docker/api/types/filters"
"github.com/schollz/progressbar/v3"
)
@@ -179,8 +179,8 @@ func (a App) Filters(appendServiceNames, exactMatch bool, services ...string) (f
return filters, err
}
opts := stack.Deploy{Composefiles: composeFiles}
compose, err := GetAppComposeConfig(a.Recipe.Name, opts, a.Env)
a.Env["STACK_NAME"] = a.StackName()
compose, err := GetAppComposeConfig(composeFiles, a.Env)
if err != nil {
return filters, err
}
@@ -260,30 +260,12 @@ func ReadAppEnvFile(appFile AppFile, name AppName) (App, error) {
func NewApp(env envfile.AppEnv, name string, appFile AppFile) (App, error) {
domain := env["DOMAIN"]
typeVar, hasType := env["TYPE"]
recipeVar, hasRecipe := env["RECIPE"]
if !hasType && !hasRecipe {
return App{}, errors.New(i18n.G("%s is missing the TYPE env var?", name))
}
// The recipe identity is taken from RECIPE when present (it records the
// canonical git source for externally-sourced recipes), otherwise from
// TYPE. The deployed version is always carried by TYPE when a TYPE line
// exists; only legacy apps that set RECIPE alone carry it there. RECIPE
// itself must stay a bare source pointer, never a versioned value.
identity := typeVar
if hasRecipe {
identity = recipeVar
}
versionSource := identity
if hasType {
versionSource = typeVar
}
recipeName, _, _ := strings.Cut(identity, ":")
if _, version, found := strings.Cut(versionSource, ":"); found && version != "" {
recipeName = fmt.Sprintf("%s:%s", recipeName, version)
recipeName, exists := env["RECIPE"]
if !exists {
recipeName, exists = env["TYPE"]
if !exists {
return App{}, errors.New(i18n.G("%s is missing the TYPE env var?", name))
}
}
return App{
@@ -351,8 +333,8 @@ func GetAppServiceNames(appName string) ([]string, error) {
return serviceNames, err
}
opts := stack.Deploy{Composefiles: composeFiles}
compose, err := GetAppComposeConfig(app.Recipe.Name, opts, app.Env)
app.Env["STACK_NAME"] = app.StackName()
compose, err := GetAppComposeConfig(composeFiles, app.Env)
if err != nil {
return serviceNames, err
}
@@ -410,15 +392,11 @@ func TemplateAppEnvSample(r recipe.Recipe, appName, server, domain string) error
newContents := strings.Replace(
string(read),
fmt.Sprintf("%s.example.com", r.ShortName()),
fmt.Sprintf("%s.example.com", r.Name),
domain,
-1,
)
if strings.Contains(r.Name, "/") {
newContents = injectRecipeLine(newContents, r.Name)
}
err = os.WriteFile(appEnvPath, []byte(newContents), 0)
if err != nil {
return err
@@ -429,37 +407,6 @@ func TemplateAppEnvSample(r recipe.Recipe, appName, server, domain string) error
return nil
}
// injectRecipeLine ensures the env file carries a RECIPE=<canonical name>
// line so a downstream `abra app deploy` (potentially on another machine)
// can re-fetch the recipe from its original git source. If a RECIPE= line
// already exists in the copied .env.sample it is replaced; otherwise a new
// line is inserted immediately after the TYPE= line, or appended at the
// end if no TYPE= line is present.
func injectRecipeLine(contents, canonicalName string) string {
lines := strings.Split(contents, "\n")
for i, line := range lines {
trimmed := strings.TrimLeft(line, " \t")
if strings.HasPrefix(trimmed, "RECIPE=") {
lines[i] = "RECIPE=" + canonicalName
return strings.Join(lines, "\n")
}
}
for i, line := range lines {
trimmed := strings.TrimLeft(line, " \t")
if strings.HasPrefix(trimmed, "TYPE=") {
out := make([]string, 0, len(lines)+1)
out = append(out, lines[:i+1]...)
out = append(out, "RECIPE="+canonicalName)
out = append(out, lines[i+1:]...)
return strings.Join(out, "\n")
}
}
if contents != "" && !strings.HasSuffix(contents, "\n") {
contents += "\n"
}
return contents + "RECIPE=" + canonicalName + "\n"
}
// SanitiseAppName makes a app name usable with Docker by replacing illegal
// characters.
func SanitiseAppName(name string) string {
@@ -543,13 +490,18 @@ func GetAppStatuses(apps []App, MachineReadable bool) (map[string]map[string]str
// GetAppComposeConfig retrieves a compose specification for a recipe. This
// specification is the result of a merge of all the compose.**.yml files in
// the recipe repository.
func GetAppComposeConfig(recipe string, opts stack.Deploy, appEnv envfile.AppEnv) (*composetypes.Config, error) {
compose, err := loader.LoadComposefile(opts, appEnv)
func GetAppComposeConfig(composeFiles []string, appEnv envfile.AppEnv) (*composeGoTypes.Project, error) {
compose, err := loader.LoadCompose(loader.LoadConf{ComposeFiles: composeFiles, AppEnv: appEnv})
if err != nil {
return &composetypes.Config{}, err
return &composeGoTypes.Project{}, err
}
log.Debug(i18n.G("retrieved %s for %s", compose.Filename, recipe))
recipeName, exists := appEnv["RECIPE"]
if !exists {
recipeName, _ = appEnv["TYPE"]
}
log.Debug(i18n.G("retrieved %s for %s", compose.Name, recipeName))
return compose, nil
}
@@ -557,7 +509,7 @@ func GetAppComposeConfig(recipe string, opts stack.Deploy, appEnv envfile.AppEnv
// ExposeAllEnv exposes all env variables to the app container
func ExposeAllEnv(
stackName string,
compose *composetypes.Config,
compose *composeGoTypes.Project,
appEnv envfile.AppEnv,
toDeployVersion string) {
for _, service := range compose.Services {
@@ -567,22 +519,10 @@ func ExposeAllEnv(
_, exists := service.Environment[k]
if !exists {
value := v
switch k {
case "TYPE":
if k == "TYPE" || k == "RECIPE" {
// NOTE(d1): don't use the wrong version from the app env
// since we are deploying a new version. Keep the
// recipe name already recorded in TYPE and only
// swap in the version being deployed.
name, _, _ := strings.Cut(v, ":")
if _, version, found := strings.Cut(toDeployVersion, ":"); found {
value = fmt.Sprintf("%s:%s", name, version)
} else {
value = name
}
case "RECIPE":
// RECIPE records the canonical git source only; the
// version is carried by TYPE, so keep it bare here.
value, _, _ = strings.Cut(toDeployVersion, ":")
// since we are deploying a new version
value = toDeployVersion
}
service.Environment[k] = &value
log.Debug(i18n.G("%s: %s: %s", stackName, k, value))
@@ -707,55 +647,60 @@ func (a App) WriteRecipeVersion(version string, dryRun bool) error {
if err != nil {
return err
}
defer file.Close()
var (
lines []string
hasType bool
scanner = bufio.NewScanner(file)
dirtyVersion string
skipped bool
lines []string
scanner = bufio.NewScanner(file)
)
for scanner.Scan() {
line := scanner.Text()
lines = append(lines, line)
if strings.HasPrefix(line, "TYPE=") {
hasType = true
}
}
if err := scanner.Err(); err != nil {
file.Close()
log.Fatal(err)
}
file.Close()
// The recipe version is carried by a single field: TYPE when present (the
// standard recipe key), otherwise the legacy RECIPE key. When both exist
// (git-URL recipes) RECIPE stays a bare source pointer and only TYPE is
// versioned, so the two can never drift apart.
targetPrefix := "RECIPE="
if hasType {
targetPrefix = "TYPE="
}
for i, line := range lines {
if !strings.HasPrefix(line, targetPrefix) || strings.HasPrefix(line, "#") {
if !strings.HasPrefix(line, "RECIPE=") && !strings.HasPrefix(line, "TYPE=") {
lines = append(lines, line)
continue
}
name, _, _ := strings.Cut(line, ":")
lines[i] = fmt.Sprintf("%s:%s", name, version)
if strings.HasPrefix(line, "#") {
lines = append(lines, line)
continue
}
if strings.Contains(line, version) && !a.Recipe.Dirty && !strings.HasSuffix(line, config.DIRTY_DEFAULT) {
skipped = true
lines = append(lines, line)
continue
}
splitted := strings.Split(line, ":")
line = fmt.Sprintf("%s:%s", splitted[0], version)
lines = append(lines, line)
}
if dryRun {
log.Debug(i18n.G("skipping writing version %s because dry run", version))
return nil
}
if err := os.WriteFile(a.Path, []byte(strings.Join(lines, "\n")), os.ModePerm); err != nil {
if err := scanner.Err(); err != nil {
log.Fatal(err)
}
log.Debug(i18n.G("version %s saved to %s.env", version, a.Domain))
if a.Recipe.Dirty && dirtyVersion != "" {
version = dirtyVersion
}
if !dryRun {
if err := os.WriteFile(a.Path, []byte(strings.Join(lines, "\n")), os.ModePerm); err != nil {
log.Fatal(err)
}
} else {
log.Debug(i18n.G("skipping writing version %s because dry run", version))
}
if !skipped {
log.Debug(i18n.G("version %s saved to %s.env", version, a.Domain))
} else {
log.Debug(i18n.G("skipping version %s write as already exists in %s.env", version, a.Domain))
}
return nil
}
-56
View File
@@ -3,8 +3,6 @@ package app_test
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"reflect"
"testing"
@@ -273,60 +271,6 @@ func TestWriteRecipeVersionOverwrite(t *testing.T) {
assert.Equal(t, "foo", app.Recipe.EnvVersion)
}
// TestWriteRecipeVersionSingleField ensures the version is written to exactly
// one field: TYPE when present (even alongside a RECIPE source pointer, as with
// git-URL recipes), otherwise the legacy RECIPE field. RECIPE must stay bare
// when a TYPE line exists so the two can never drift apart.
func TestWriteRecipeVersionSingleField(t *testing.T) {
cases := []struct {
name string
input string
want string
}{
{
name: "git recipe: TYPE and RECIPE both present",
input: "TYPE=myrecipe\nRECIPE=git.example.com/user/myrecipe\nDOMAIN=x\n",
want: "TYPE=myrecipe:1.2.3+4.5.6\nRECIPE=git.example.com/user/myrecipe\nDOMAIN=x",
},
{
name: "git recipe: re-versioning replaces only TYPE",
input: "TYPE=myrecipe:0.1.0+1.0.0\nRECIPE=git.example.com/user/myrecipe\nDOMAIN=x\n",
want: "TYPE=myrecipe:1.2.3+4.5.6\nRECIPE=git.example.com/user/myrecipe\nDOMAIN=x",
},
{
name: "legacy recipe: only RECIPE present",
input: "RECIPE=test_recipe\nDOMAIN=x\n",
want: "RECIPE=test_recipe:1.2.3+4.5.6\nDOMAIN=x",
},
{
name: "catalogue recipe: only TYPE present",
input: "TYPE=nextcloud\nDOMAIN=x\n",
want: "TYPE=nextcloud:1.2.3+4.5.6\nDOMAIN=x",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
p := filepath.Join(t.TempDir(), "app.env")
if err := os.WriteFile(p, []byte(tc.input), 0o644); err != nil {
t.Fatal(err)
}
app := appPkg.App{Path: p, Domain: "x"}
if err := app.WriteRecipeVersion("1.2.3+4.5.6", false); err != nil {
t.Fatal(err)
}
got, err := os.ReadFile(p)
if err != nil {
t.Fatal(err)
}
assert.Equal(t, tc.want, string(got))
})
}
}
func TestWriteRecipeVersionUnknown(t *testing.T) {
test.Setup()
t.Cleanup(func() { test.Teardown() })
+7 -7
View File
@@ -7,12 +7,12 @@ import (
"coopcloud.tech/abra/pkg/i18n"
"coopcloud.tech/abra/pkg/log"
composetypes "github.com/docker/cli/cli/compose/types"
composeGoTypes "github.com/compose-spec/compose-go/v2/types"
)
// SetRecipeLabel adds the label 'coop-cloud.${STACK_NAME}.recipe=${RECIPE}' to the app container
// to signal which recipe is connected to the deployed app
func SetRecipeLabel(compose *composetypes.Config, stackName string, recipe string) {
func SetRecipeLabel(compose *composeGoTypes.Project, stackName string, recipe string) {
for _, service := range compose.Services {
if service.Name == "app" {
log.Debug(i18n.G("set recipe label 'coop-cloud.%s.recipe' to %s for %s", stackName, recipe, stackName))
@@ -24,7 +24,7 @@ func SetRecipeLabel(compose *composetypes.Config, stackName string, recipe strin
// SetChaosLabel adds the label 'coop-cloud.${STACK_NAME}.chaos=true/false' to the app container
// to signal if the app is deployed in chaos mode
func SetChaosLabel(compose *composetypes.Config, stackName string, chaos bool) {
func SetChaosLabel(compose *composeGoTypes.Project, stackName string, chaos bool) {
for _, service := range compose.Services {
if service.Name == "app" {
log.Debug(i18n.G("set label 'coop-cloud.%s.chaos' to %v for %s", stackName, chaos, stackName))
@@ -35,7 +35,7 @@ func SetChaosLabel(compose *composetypes.Config, stackName string, chaos bool) {
}
// SetChaosVersionLabel adds the label 'coop-cloud.${STACK_NAME}.chaos-version=$(GIT_COMMIT)' to the app container
func SetChaosVersionLabel(compose *composetypes.Config, stackName string, chaosVersion string) {
func SetChaosVersionLabel(compose *composeGoTypes.Project, stackName string, chaosVersion string) {
for _, service := range compose.Services {
if service.Name == "app" {
log.Debug(i18n.G("set label 'coop-cloud.%s.chaos-version' to %v for %s", stackName, chaosVersion, stackName))
@@ -45,7 +45,7 @@ func SetChaosVersionLabel(compose *composetypes.Config, stackName string, chaosV
}
}
func SetVersionLabel(compose *composetypes.Config, stackName string, version string) {
func SetVersionLabel(compose *composeGoTypes.Project, stackName string, version string) {
for _, service := range compose.Services {
if service.Name == "app" {
log.Debug(i18n.G("set label 'coop-cloud.%s.version' to %v for %s", stackName, version, stackName))
@@ -56,7 +56,7 @@ func SetVersionLabel(compose *composetypes.Config, stackName string, version str
}
// GetLabel reads docker labels in the format of "coop-cloud.${STACK_NAME}.${LABEL}" from the local compose files
func GetLabel(compose *composetypes.Config, stackName string, label string) string {
func GetLabel(compose *composeGoTypes.Project, stackName string, label string) string {
for _, service := range compose.Services {
if service.Name == "app" {
labelKey := fmt.Sprintf("coop-cloud.%s.%s", stackName, label)
@@ -73,7 +73,7 @@ func GetLabel(compose *composetypes.Config, stackName string, label string) stri
// GetTimeoutFromLabel reads the timeout value from docker label
// `coop-cloud.${STACK_NAME}.timeout=...` if present. A value is present if the
// operator uses a `TIMEOUT=...` in their app env.
func GetTimeoutFromLabel(compose *composetypes.Config, stackName string) (int, error) {
func GetTimeoutFromLabel(compose *composeGoTypes.Project, stackName string) (int, error) {
var timeout int
if timeoutLabel := GetLabel(compose, stackName, "timeout"); timeoutLabel != "" {
+2 -10
View File
@@ -6,7 +6,6 @@ import (
appPkg "coopcloud.tech/abra/pkg/app"
"coopcloud.tech/abra/pkg/test"
testPkg "coopcloud.tech/abra/pkg/test"
stack "coopcloud.tech/abra/pkg/upstream/stack"
"github.com/stretchr/testify/assert"
)
@@ -40,15 +39,8 @@ func TestGetTimeoutFromLabel(t *testing.T) {
t.Fatal(err)
}
deployOpts := stack.Deploy{
Composefiles: composeFiles,
Namespace: app.StackName(),
Prune: false,
ResolveImage: stack.ResolveImageAlways,
Detach: false,
}
compose, err := appPkg.GetAppComposeConfig(app.Name, deployOpts, app.Env)
app.Env["STACK_NAME"] = app.StackName()
compose, err := appPkg.GetAppComposeConfig(composeFiles, app.Env)
if err != nil {
t.Fatal(err)
}
-30
View File
@@ -1,30 +0,0 @@
package app
import "testing"
func TestInjectRecipeLineAfterType(t *testing.T) {
in := "DOMAIN=example.com\nTYPE=foo\nVERSION=1.0\n"
want := "DOMAIN=example.com\nTYPE=foo\nRECIPE=org/foo\nVERSION=1.0\n"
got := injectRecipeLine(in, "org/foo")
if got != want {
t.Errorf("injectRecipeLine inserted RECIPE in wrong position\nwant:\n%q\ngot:\n%q", want, got)
}
}
func TestInjectRecipeLineReplacesExisting(t *testing.T) {
in := "TYPE=foo\nRECIPE=old\nDOMAIN=example.com\n"
want := "TYPE=foo\nRECIPE=org/foo\nDOMAIN=example.com\n"
got := injectRecipeLine(in, "org/foo")
if got != want {
t.Errorf("injectRecipeLine should replace existing RECIPE line\nwant:\n%q\ngot:\n%q", want, got)
}
}
func TestInjectRecipeLineNoTypeAppends(t *testing.T) {
in := "DOMAIN=example.com\n"
want := "DOMAIN=example.com\nRECIPE=org/foo\n"
got := injectRecipeLine(in, "org/foo")
if got != want {
t.Errorf("injectRecipeLine should append when TYPE is missing\nwant:\n%q\ngot:\n%q", want, got)
}
}
+1 -1
View File
@@ -120,7 +120,7 @@ func CommandNameComplete(appName string) ([]string, cobra.ShellCompDirective) {
func SecretComplete(recipeName string) ([]string, cobra.ShellCompDirective) {
r := recipe.Get(recipeName)
config, err := r.GetComposeConfig(nil)
config, err := r.GetComposeConfig()
if err != nil {
err := i18n.G("autocomplete failed: %s", err)
return []string{err}, cobra.ShellCompDirectiveError
+3 -3
View File
@@ -14,8 +14,8 @@ import (
"coopcloud.tech/abra/pkg/recipe"
"coopcloud.tech/abra/pkg/secret"
composeGoTypes "github.com/compose-spec/compose-go/v2/types"
"github.com/distribution/reference"
composetypes "github.com/docker/cli/cli/compose/types"
"github.com/docker/docker/api/types/swarm"
dockerClient "github.com/docker/docker/client"
)
@@ -229,7 +229,7 @@ func GatherSecretsForDeploy(cl *dockerClient.Client, app appPkg.App, showUnchang
return secretInfo, nil
}
func GatherConfigsForDeploy(cl *dockerClient.Client, app appPkg.App, compose *composetypes.Config, abraShEnv map[string]string, showUnchanged bool) ([]string, error) {
func GatherConfigsForDeploy(cl *dockerClient.Client, app appPkg.App, compose *composeGoTypes.Project, abraShEnv map[string]string, showUnchanged bool) ([]string, error) {
// Get current configs from existing deployment
currentConfigs, err := GetConfigsForStack(cl, app)
if err != nil {
@@ -268,7 +268,7 @@ func GatherConfigsForDeploy(cl *dockerClient.Client, app appPkg.App, compose *co
return configInfo, nil
}
func GatherImagesForDeploy(cl *dockerClient.Client, app appPkg.App, compose *composetypes.Config, showUnchanged bool) ([]string, error) {
func GatherImagesForDeploy(cl *dockerClient.Client, app appPkg.App, compose *composeGoTypes.Project, showUnchanged bool) ([]string, error) {
// Get current images from existing deployment
currentImages, err := GetImagesForStack(cl, app)
if err != nil {
-6
View File
@@ -27,16 +27,10 @@ var BoldUnderlineStyle = lipgloss.NewStyle().
Underline(true)
func ShortenID(str string) string {
if len(str) < 12 {
return str
}
return str[:12]
}
func SmallSHA(hash string) string {
if len(hash) < 8 {
return hash
}
return hash[:8]
}
-5
View File
@@ -36,11 +36,6 @@ func IsClean(repoPath string) (bool, error) {
return false, err
}
// Ignore the abra-managed sidecar file that records the canonical
// source URL for externally-cloned recipes; it lives alongside the
// recipe but is not part of the upstream tree.
patterns = append(patterns, gitignore.ParsePattern(".abra-source", nil))
if len(patterns) > 0 {
worktree.Excludes = append(patterns, worktree.Excludes...)
}
-36
View File
@@ -2,8 +2,6 @@ package git
import (
"errors"
"os"
"path/filepath"
"testing"
"github.com/go-git/go-git/v5"
@@ -15,37 +13,3 @@ func TestIsClean(t *testing.T) {
assert.Equal(t, isClean, false)
assert.True(t, errors.Is(err, git.ErrRepositoryNotExists))
}
// TestIsCleanIgnoresAbraSource confirms that the .abra-source sidecar
// file written by abra next to externally-cloned recipes does not cause
// IsClean to report the worktree as dirty.
func TestIsCleanIgnoresAbraSource(t *testing.T) {
dir := t.TempDir()
if _, err := git.PlainInit(dir, false); err != nil {
t.Fatalf("git init failed: %s", err)
}
sidecar := filepath.Join(dir, ".abra-source")
if err := os.WriteFile(sidecar, []byte("git.example.com/u/recipe\n"), 0o644); err != nil {
t.Fatalf("writing sidecar failed: %s", err)
}
isClean, err := IsClean(dir)
if err != nil {
t.Fatalf("IsClean returned error: %s", err)
}
assert.True(t, isClean, "expected worktree with only .abra-source to be reported clean")
// Sanity check: an unrelated untracked file should still mark it dirty.
other := filepath.Join(dir, "random.txt")
if err := os.WriteFile(other, []byte("hello"), 0o644); err != nil {
t.Fatalf("writing extra file failed: %s", err)
}
isClean, err = IsClean(dir)
if err != nil {
t.Fatalf("IsClean returned error: %s", err)
}
assert.False(t, isClean, "expected worktree with unrelated untracked file to be reported dirty")
}
+211 -249
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.
+695 -424
View File
File diff suppressed because it is too large Load Diff
+13 -29
View File
@@ -62,13 +62,6 @@ func (l LintRule) Skip(recipe recipe.Recipe) bool {
var LintRules = map[string][]LintRule{
"warn": {
{
Ref: "R001",
Level: i18n.G("warn"),
Description: i18n.G("compose config has expected version"),
HowToResolve: i18n.G("ensure 'version: \"3.8\"' in compose configs"),
Function: LintComposeVersion,
},
{
Ref: "R002",
Level: i18n.G("warn"),
@@ -217,18 +210,6 @@ func LintForErrors(recipe recipe.Recipe) error {
return nil
}
func LintComposeVersion(recipe recipe.Recipe) (bool, error) {
config, err := recipe.GetComposeConfig(nil)
if err != nil {
return false, err
}
if config.Version == "3.8" {
return true, nil
}
return true, nil
}
func LintEnvConfigPresent(r recipe.Recipe) (bool, error) {
if _, err := os.Stat(r.SampleEnvPath); !os.IsNotExist(err) {
return true, nil
@@ -238,7 +219,7 @@ func LintEnvConfigPresent(r recipe.Recipe) (bool, error) {
}
func LintAppService(recipe recipe.Recipe) (bool, error) {
config, err := recipe.GetComposeConfig(nil)
config, err := recipe.GetComposeConfig()
if err != nil {
return false, err
}
@@ -269,11 +250,14 @@ func LintTraefikEnabledSkipCondition(r recipe.Recipe) (bool, error) {
}
func LintTraefikEnabled(recipe recipe.Recipe) (bool, error) {
config, err := recipe.GetComposeConfig(nil)
config, err := recipe.GetComposeConfig()
if err != nil {
return false, err
}
for _, service := range config.Services {
if service.Deploy == nil {
continue
}
for label := range service.Deploy.Labels {
if label == "traefik.enable" {
if service.Deploy.Labels[label] == "true" {
@@ -287,7 +271,7 @@ func LintTraefikEnabled(recipe recipe.Recipe) (bool, error) {
}
func LintDeployLabelsPresent(recipe recipe.Recipe) (bool, error) {
config, err := recipe.GetComposeConfig(nil)
config, err := recipe.GetComposeConfig()
if err != nil {
return false, err
}
@@ -302,7 +286,7 @@ func LintDeployLabelsPresent(recipe recipe.Recipe) (bool, error) {
}
func LintHealthchecks(recipe recipe.Recipe) (bool, error) {
config, err := recipe.GetComposeConfig(nil)
config, err := recipe.GetComposeConfig()
if err != nil {
return false, err
}
@@ -316,7 +300,7 @@ func LintHealthchecks(recipe recipe.Recipe) (bool, error) {
}
func LintAllImagesTagged(recipe recipe.Recipe) (bool, error) {
config, err := recipe.GetComposeConfig(nil)
config, err := recipe.GetComposeConfig()
if err != nil {
return false, err
}
@@ -334,7 +318,7 @@ func LintAllImagesTagged(recipe recipe.Recipe) (bool, error) {
}
func LintNoUnstableTags(recipe recipe.Recipe) (bool, error) {
config, err := recipe.GetComposeConfig(nil)
config, err := recipe.GetComposeConfig()
if err != nil {
return false, err
}
@@ -361,7 +345,7 @@ func LintNoUnstableTags(recipe recipe.Recipe) (bool, error) {
}
func LintSemverLikeTags(recipe recipe.Recipe) (bool, error) {
config, err := recipe.GetComposeConfig(nil)
config, err := recipe.GetComposeConfig()
if err != nil {
return false, err
}
@@ -388,7 +372,7 @@ func LintSemverLikeTags(recipe recipe.Recipe) (bool, error) {
}
func LintImagePresent(recipe recipe.Recipe) (bool, error) {
config, err := recipe.GetComposeConfig(nil)
config, err := recipe.GetComposeConfig()
if err != nil {
return false, err
}
@@ -440,7 +424,7 @@ func LintMetadataFilledIn(r recipe.Recipe) (bool, error) {
}
func LintAbraShVendors(recipe recipe.Recipe) (bool, error) {
config, err := recipe.GetComposeConfig(nil)
config, err := recipe.GetComposeConfig()
if err != nil {
return false, err
}
@@ -472,7 +456,7 @@ func LintHasRecipeRepo(recipe recipe.Recipe) (bool, error) {
}
func LintSecretLengths(recipe recipe.Recipe) (bool, error) {
config, err := recipe.GetComposeConfig(nil)
config, err := recipe.GetComposeConfig()
if err != nil {
return false, err
}
+16 -39
View File
@@ -3,7 +3,6 @@ package recipe
import (
"errors"
"fmt"
"io/ioutil"
"os"
"path/filepath"
"strings"
@@ -11,10 +10,9 @@ import (
"coopcloud.tech/abra/pkg/formatter"
"coopcloud.tech/abra/pkg/i18n"
"coopcloud.tech/abra/pkg/log"
"coopcloud.tech/abra/pkg/upstream/stack"
loader "coopcloud.tech/abra/pkg/upstream/stack"
composeGoTypes "github.com/compose-spec/compose-go/v2/types"
"github.com/distribution/reference"
composetypes "github.com/docker/cli/cli/compose/types"
)
// GetComposeFiles gets the list of compose files for an app (or recipe if you
@@ -61,7 +59,7 @@ func (r Recipe) GetComposeFiles(appEnv map[string]string) ([]string, error) {
return composeFiles, nil
}
func (r Recipe) GetComposeConfig(env map[string]string) (*composetypes.Config, error) {
func (r Recipe) GetComposeConfig() (*composeGoTypes.Project, error) {
pattern := fmt.Sprintf("%s/compose**yml", r.Dir)
composeFiles, err := filepath.Glob(pattern)
if err != nil {
@@ -72,25 +70,18 @@ func (r Recipe) GetComposeConfig(env map[string]string) (*composetypes.Config, e
return nil, errors.New(i18n.G("%s is missing a compose.yml or compose.*.yml file?", r.Name))
}
if env == nil {
env, err = r.SampleEnv()
if err != nil {
return nil, err
}
}
opts := stack.Deploy{Composefiles: composeFiles}
config, err := loader.LoadComposefile(opts, env)
config, err := loader.LoadCompose(loader.LoadConf{ComposeFiles: composeFiles})
if err != nil {
return nil, err
}
return config, nil
}
// GetVersionLabelLocal retrieves the version label on the local recipe config
func (r Recipe) GetVersionLabelLocal() (string, error) {
var label string
config, err := r.GetComposeConfig(nil)
config, err := r.GetComposeConfig()
if err != nil {
return "", err
}
@@ -123,14 +114,7 @@ func (r Recipe) UpdateTag(image, tag string) (bool, error) {
log.Debug(i18n.G("considering %s config(s) for tag update", strings.Join(composeFiles, ", ")))
for _, composeFile := range composeFiles {
opts := stack.Deploy{Composefiles: []string{composeFile}}
sampleEnv, err := r.SampleEnv()
if err != nil {
return false, err
}
compose, err := loader.LoadComposefile(opts, sampleEnv)
compose, err := loader.LoadCompose(loader.LoadConf{ComposeFiles: []string{composeFile}, DisableConsistencyCheck: true})
if err != nil {
return false, err
}
@@ -159,18 +143,18 @@ func (r Recipe) UpdateTag(image, tag string) (bool, error) {
log.Debug(i18n.G("parsed %s from %s", composeTag, service.Image))
if image == composeImage {
bytes, err := ioutil.ReadFile(composeFile)
bytes, err := os.ReadFile(composeFile)
if err != nil {
return false, err
}
old := fmt.Sprintf("%s:%s", composeImage, composeTag)
new := fmt.Sprintf("%s:%s", composeImage, tag)
replacedBytes := strings.Replace(string(bytes), old, new, -1)
replacedBytes := strings.ReplaceAll(string(bytes), old, new)
log.Debug(i18n.G("updating %s to %s in %s", old, new, compose.Filename))
log.Debug(i18n.G("updating %s to %s in %s", old, new, composeFile))
if err := os.WriteFile(compose.Filename, []byte(replacedBytes), 0o764); err != nil {
if err := os.WriteFile(composeFile, []byte(replacedBytes), 0o764); err != nil {
return false, err
}
}
@@ -191,20 +175,13 @@ func (r Recipe) UpdateLabel(pattern, serviceName, label string) error {
log.Debug(i18n.G("considering %s config(s) for label update", strings.Join(composeFiles, ", ")))
for _, composeFile := range composeFiles {
opts := stack.Deploy{Composefiles: []string{composeFile}}
sampleEnv, err := r.SampleEnv()
if err != nil {
return err
}
compose, err := loader.LoadComposefile(opts, sampleEnv)
compose, err := loader.LoadCompose(loader.LoadConf{ComposeFiles: []string{composeFile}, DisableConsistencyCheck: true})
if err != nil {
return err
}
serviceExists := false
var service composetypes.ServiceConfig
var service composeGoTypes.ServiceConfig
for _, s := range compose.Services {
if s.Name == serviceName {
service = s
@@ -221,22 +198,22 @@ func (r Recipe) UpdateLabel(pattern, serviceName, label string) error {
if strings.HasPrefix(oldLabel, "coop-cloud") && strings.Contains(oldLabel, "version") {
discovered = true
bytes, err := ioutil.ReadFile(composeFile)
bytes, err := os.ReadFile(composeFile)
if err != nil {
return err
}
old := i18n.G("coop-cloud.${STACK_NAME}.version=%s", value)
replacedBytes := strings.Replace(string(bytes), old, label, -1)
replacedBytes := strings.ReplaceAll(string(bytes), old, label)
if old == label {
log.Warnf(i18n.G("%s is already set, nothing to do?", label))
return nil
}
log.Debug(i18n.G("updating %s to %s in %s", old, label, compose.Filename))
log.Debug(i18n.G("updating %s to %s in %s", old, label, composeFile))
if err := ioutil.WriteFile(compose.Filename, []byte(replacedBytes), 0o764); err != nil {
if err := os.WriteFile(composeFile, []byte(replacedBytes), 0o764); err != nil {
return err
}
+11 -104
View File
@@ -4,7 +4,6 @@ import (
"errors"
"fmt"
"os"
"path"
"slices"
"sort"
"strings"
@@ -17,15 +16,9 @@ import (
"coopcloud.tech/tagcmp"
"github.com/distribution/reference"
"github.com/go-git/go-git/v5"
gitCfg "github.com/go-git/go-git/v5/config"
"github.com/go-git/go-git/v5/plumbing"
)
// SourceFile is the sidecar file written next to an externally-cloned
// recipe so the canonical "host/path" name can be recovered later (the
// on-disk directory name escapes "/" and "." to "_", which is lossy).
const SourceFile = ".abra-source"
type EnsureContext struct {
Chaos bool
Offline bool
@@ -65,7 +58,7 @@ func (r Recipe) Ensure(ctx EnsureContext) error {
return errors.New(i18n.G(`cannot redeploy previous chaos version (%s), did you mean to use "--chaos"?`))
}
if _, err := r.EnsureVersionCtx(ctx, r.EnvVersion); err != nil {
if _, err := r.EnsureVersion(r.EnvVersion); err != nil {
return err
}
@@ -85,12 +78,6 @@ func (r Recipe) EnsureExists() error {
if err := gitPkg.Clone(r.Dir, r.GitURL); err != nil {
return err
}
if strings.Contains(r.Name, "/") {
sidecar := path.Join(r.Dir, SourceFile)
if err := os.WriteFile(sidecar, []byte(r.Name+"\n"), 0o644); err != nil {
log.Debug(i18n.G("failed to write recipe source sidecar %s: %s", sidecar, err))
}
}
}
if err := gitPkg.EnsureGitRepo(r.Dir); err != nil {
@@ -100,18 +87,6 @@ func (r Recipe) EnsureExists() error {
return nil
}
// ReadRecipeSource returns the canonical name recorded in the .abra-source
// sidecar inside the given recipe directory, or the empty string if no
// sidecar exists. This lets callers recover the unescaped "host/path"
// form for externally-cloned recipes.
func ReadRecipeSource(recipeDir string) string {
data, err := os.ReadFile(path.Join(recipeDir, SourceFile))
if err != nil {
return ""
}
return strings.TrimSpace(string(data))
}
// IsChaosCommit determines if a version sttring is a chaos commit or not.
func (r Recipe) IsChaosCommit(version string) (bool, error) {
isChaosCommit := false
@@ -149,13 +124,6 @@ func (r Recipe) IsChaosCommit(version string) (bool, error) {
// EnsureVersion checks whether a specific version exists for a recipe.
func (r Recipe) EnsureVersion(version string) (bool, error) {
return r.EnsureVersionCtx(EnsureContext{}, version)
}
// EnsureVersionCtx is like EnsureVersion but is aware of the surrounding
// EnsureContext, so that e.g. offline mode can skip network fetches when
// resolving a branch name that isn't already available locally.
func (r Recipe) EnsureVersionCtx(ctx EnsureContext, version string) (bool, error) {
isChaosCommit := false
if err := gitPkg.EnsureGitRepo(r.Dir); err != nil {
@@ -190,46 +158,30 @@ func (r Recipe) EnsureVersionCtx(ctx EnsureContext, version string) (bool, error
}
var opts *git.CheckoutOptions
var checkedOutRef string
if tagRef.String() == "" {
log.Debug(i18n.G("attempting to checkout '%s' as chaos commit", version))
hash, hashErr := repo.ResolveRevision(plumbing.Revision(version))
if hashErr != nil {
branchHash, found, branchErr := resolveBranch(repo, version, ctx.Offline)
if errors.Is(branchErr, errOfflineBranchUnresolved) {
return isChaosCommit, errors.New(i18n.G("'%s' is not available locally and cannot be fetched in offline mode; retry without --offline", version))
}
if branchErr != nil {
return isChaosCommit, branchErr
}
if !found {
return isChaosCommit, errors.New(i18n.G("unable to resolve '%s': %s", version, hashErr))
}
opts = &git.CheckoutOptions{Hash: branchHash, Create: false, Force: true}
isChaosCommit = true
checkedOutRef = version
} else {
opts = &git.CheckoutOptions{Hash: *hash, Create: false, Force: true}
isChaosCommit = true
checkedOutRef = hash.String()
hash, err := repo.ResolveRevision(plumbing.Revision(version))
if err != nil {
log.Fatal(i18n.G("unable to resolve '%s': %s", version, err))
}
opts = &git.CheckoutOptions{Hash: *hash, Create: false, Force: true}
isChaosCommit = true
} else {
opts = &git.CheckoutOptions{Branch: tagRef, Create: false, Force: true}
checkedOutRef = tagRef.Short()
}
worktree, err := repo.Worktree()
if err != nil {
return isChaosCommit, err
return isChaosCommit, nil
}
if err := worktree.Checkout(opts); err != nil {
return isChaosCommit, err
return isChaosCommit, nil
}
log.Debug(i18n.G("successfully checked %s out to %s in %s", r.Name, checkedOutRef, r.Dir))
log.Debug(i18n.G("successfully checked %s out to %s in %s", r.Name, tagRef.Short(), r.Dir))
return isChaosCommit, nil
}
@@ -464,7 +416,7 @@ func (r Recipe) GetRecipeVersions() (RecipeVersions, []string, error) {
log.Debug(i18n.G("git checkout: %s in %s", ref.Name(), r.Dir))
config, err := r.GetComposeConfig(nil)
config, err := r.GetComposeConfig()
if err != nil {
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))
@@ -527,51 +479,6 @@ func (r Recipe) GetRecipeVersions() (RecipeVersions, []string, error) {
return versions, uniqueWarnings, nil
}
// resolveBranch resolves name as a branch on the "origin" remote, fetching
// it on demand into a remote-tracking ref (never into refs/heads/*, which
// would corrupt GetDefaultBranch's local-branch-name heuristic). It returns
// (hash, true, nil) if name is a branch, (_, false, nil) if name is
// confirmed not to be a branch, and a non-nil error for any other failure
// (e.g. network/auth issues) that should not be silently treated as "not a
// branch". When offline is true, no fetch is attempted and only an
// already-fetched remote-tracking ref is consulted.
func resolveBranch(repo *git.Repository, name string, offline bool) (plumbing.Hash, bool, error) {
remoteTrackingRef := plumbing.NewRemoteReferenceName("origin", name)
if !offline {
refSpec := gitCfg.RefSpec(fmt.Sprintf("+refs/heads/%s:%s", name, remoteTrackingRef))
err := repo.Fetch(&git.FetchOptions{
RemoteName: "origin",
RefSpecs: []gitCfg.RefSpec{refSpec},
Force: true,
})
if err != nil {
var noMatch git.NoMatchingRefSpecError
switch {
case errors.Is(err, git.NoErrAlreadyUpToDate):
case errors.As(err, &noMatch):
return plumbing.ZeroHash, false, nil
default:
return plumbing.ZeroHash, false, errors.New(i18n.G("unable to fetch branch '%s': %s", name, err))
}
}
}
ref, err := repo.Reference(remoteTrackingRef, true)
if err != nil {
if offline {
return plumbing.ZeroHash, false, errOfflineBranchUnresolved
}
return plumbing.ZeroHash, false, errors.New(i18n.G("unable to resolve fetched branch '%s': %s", name, err))
}
return ref.Hash(), true, nil
}
// errOfflineBranchUnresolved is returned by resolveBranch when offline mode
// prevents fetching a branch that isn't already available locally.
var errOfflineBranchUnresolved = errors.New(i18n.G("not fetched and cannot resolve offline"))
// Head retrieves latest HEAD metadata.
func (r Recipe) Head() (*plumbing.Reference, error) {
repo, err := git.PlainOpen(r.Dir)
-121
View File
@@ -6,8 +6,6 @@ import (
"testing"
"coopcloud.tech/abra/pkg/test"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/stretchr/testify/assert"
)
@@ -38,122 +36,3 @@ func TestIsDirty(t *testing.T) {
assert.True(t, dirty)
}
func TestEnsureVersionBranch(t *testing.T) {
test.SetupWithUpstream()
t.Cleanup(func() { test.Teardown() })
upstream, err := git.PlainOpen(test.UpstreamRecipeDir)
if err != nil {
t.Fatal(err)
}
featureRef, err := upstream.Reference(plumbing.NewBranchReferenceName(test.FeatureBranch), true)
if err != nil {
t.Fatal(err)
}
r := Get(test.RecipeName)
isChaosCommit, err := r.EnsureVersion(test.FeatureBranch)
if err != nil {
t.Fatal(err)
}
assert.True(t, isChaosCommit)
head, err := r.Head()
if err != nil {
t.Fatal(err)
}
assert.Equal(t, featureRef.Hash(), head.Hash())
}
func TestEnsureVersionBranchRefetchesOnRedeploy(t *testing.T) {
test.SetupWithUpstream()
t.Cleanup(func() { test.Teardown() })
r := Get(test.RecipeName)
if _, err := r.EnsureVersion(test.FeatureBranch); err != nil {
t.Fatal(err)
}
if err := test.CreateBranchWithCommit(test.UpstreamRecipeDir, test.FeatureBranch, "feature2.txt", "more feature work"); err != nil {
t.Fatal(err)
}
upstream, err := git.PlainOpen(test.UpstreamRecipeDir)
if err != nil {
t.Fatal(err)
}
featureRef, err := upstream.Reference(plumbing.NewBranchReferenceName(test.FeatureBranch), true)
if err != nil {
t.Fatal(err)
}
if _, err := r.EnsureVersion(test.FeatureBranch); err != nil {
t.Fatal(err)
}
head, err := r.Head()
if err != nil {
t.Fatal(err)
}
assert.Equal(t, featureRef.Hash(), head.Hash())
}
func TestEnsureVersionOfflineUnfetchedBranch(t *testing.T) {
test.SetupWithUpstream()
t.Cleanup(func() { test.Teardown() })
r := Get(test.RecipeName)
headBefore, err := r.Head()
if err != nil {
t.Fatal(err)
}
_, err = r.EnsureVersionCtx(EnsureContext{Offline: true}, test.FeatureBranch)
assert.ErrorContains(t, err, "offline")
headAfter, err := r.Head()
if err != nil {
t.Fatal(err)
}
assert.Equal(t, headBefore.Hash(), headAfter.Hash())
}
func TestEnsureVersionUnknownNameErrors(t *testing.T) {
test.SetupWithUpstream()
t.Cleanup(func() { test.Teardown() })
r := Get(test.RecipeName)
_, err := r.EnsureVersion("does-not-exist-anywhere")
assert.Error(t, err)
}
func TestEnsureBranchPinnedRecipe(t *testing.T) {
test.SetupWithUpstream()
t.Cleanup(func() { test.Teardown() })
upstream, err := git.PlainOpen(test.UpstreamRecipeDir)
if err != nil {
t.Fatal(err)
}
featureRef, err := upstream.Reference(plumbing.NewBranchReferenceName(test.FeatureBranch), true)
if err != nil {
t.Fatal(err)
}
r := Get(test.RecipeName + ":" + test.FeatureBranch)
if err := r.Ensure(EnsureContext{}); err != nil {
t.Fatal(err)
}
head, err := r.Head()
if err != nil {
t.Fatal(err)
}
assert.Equal(t, featureRef.Hash(), head.Hash())
}
-88
View File
@@ -8,7 +8,6 @@ import (
"net/url"
"os"
"path"
"regexp"
"sort"
"strconv"
"strings"
@@ -122,84 +121,7 @@ type Features struct {
SSO string `json:"sso"`
}
// scpURLPattern matches SCP-style git URLs like git@host:path or git@host:port/path.
// Captures: 1=host(:port)?, 2=path.
var scpURLPattern = regexp.MustCompile(`^[\w.-]+@([\w.-]+(?::\d+)?):(.+)$`)
// NormalizeRecipeName canonicalizes a recipe identifier to a stable
// "host/path" form (or returns short catalog names unchanged). Accepts:
//
// - https://host/path[.git][:version]
// - http://host/path[.git][:version]
// - ssh://git@host[:port]/path[.git][:version]
// - git@host:path[.git][:version] (SCP-style)
// - host/path[.git][:version] (already canonical)
// - short-name[:version] (catalog recipe, pass through)
//
// The optional trailing :version suffix is preserved verbatim. The .git
// suffix and trailing slashes on the path are stripped so the four URL
// forms of the same repository collapse to one canonical value.
func NormalizeRecipeName(input string) string {
input = strings.TrimSpace(input)
// Split off the version suffix first, but only if it's not part of a
// scheme (https://) or SCP-style git@host: prefix. The simplest way is
// to detect those prefixes and treat the rest as the path-with-version.
var (
body = input
version string
)
switch {
case strings.HasPrefix(input, "https://") || strings.HasPrefix(input, "http://") || strings.HasPrefix(input, "ssh://"):
u, err := url.Parse(input)
if err != nil {
return input
}
host := u.Hostname() // strip port to keep canonical form colon-free
p := strings.TrimPrefix(u.Path, "/")
p, version = splitVersion(p)
body = host + "/" + p
case scpURLPattern.MatchString(input):
m := scpURLPattern.FindStringSubmatch(input)
host := m[1]
if i := strings.Index(host, ":"); i >= 0 {
host = host[:i] // strip port
}
p, v := splitVersion(m[2])
body = host + "/" + p
version = v
default:
body, version = splitVersion(input)
}
body = strings.TrimSuffix(body, "/")
body = strings.TrimSuffix(body, ".git")
if version != "" {
return body + ":" + version
}
return body
}
// splitVersion separates a trailing ":version" suffix from a recipe path.
// It only treats the final colon-separated segment as a version when it is
// non-empty and contains no slash (a slash means the colon belonged to the
// path, e.g. a host:port or scheme separator).
func splitVersion(s string) (string, string) {
idx := strings.LastIndex(s, ":")
if idx < 0 {
return s, ""
}
candidate := s[idx+1:]
if candidate == "" || strings.Contains(candidate, "/") {
return s, ""
}
return s[:idx], candidate
}
func Get(name string) Recipe {
name = NormalizeRecipeName(name)
version := ""
versionRaw := ""
if strings.Contains(name, ":") {
@@ -287,16 +209,6 @@ func (r Recipe) String() string {
return out
}
// ShortName returns the final path segment of the recipe name, i.e. the
// bare recipe name without any "host/org/" prefix carried by externally
// sourced (git URL) recipes. For catalogue recipes it returns Name unchanged.
func (r Recipe) ShortName() string {
if i := strings.LastIndex(r.Name, "/"); i >= 0 {
return r.Name[i+1:]
}
return r.Name
}
func escapeRecipeName(recipeName string) string {
recipeName = strings.ReplaceAll(recipeName, "/", "_")
recipeName = strings.ReplaceAll(recipeName, ".", "_")
-125
View File
@@ -88,88 +88,6 @@ func TestGet(t *testing.T) {
AbraShPath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/abra.sh"),
},
},
{
name: "https://mygit.org/myorg/cool-recipe",
recipe: Recipe{
Name: "mygit.org/myorg/cool-recipe",
Dir: path.Join(cfg.GetAbraDir(), "/recipes/mygit_org_myorg_cool-recipe"),
GitURL: "https://mygit.org/myorg/cool-recipe.git",
SSHURL: "ssh://git@mygit.org/myorg/cool-recipe.git",
ComposePath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/compose.yml"),
ReadmePath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/README.md"),
SampleEnvPath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/.env.sample"),
AbraShPath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/abra.sh"),
},
},
{
name: "https://mygit.org/myorg/cool-recipe.git",
recipe: Recipe{
Name: "mygit.org/myorg/cool-recipe",
Dir: path.Join(cfg.GetAbraDir(), "/recipes/mygit_org_myorg_cool-recipe"),
GitURL: "https://mygit.org/myorg/cool-recipe.git",
SSHURL: "ssh://git@mygit.org/myorg/cool-recipe.git",
ComposePath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/compose.yml"),
ReadmePath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/README.md"),
SampleEnvPath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/.env.sample"),
AbraShPath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/abra.sh"),
},
},
{
name: "https://mygit.org/myorg/cool-recipe.git:1.2.4",
recipe: Recipe{
Name: "mygit.org/myorg/cool-recipe",
EnvVersion: "1.2.4",
EnvVersionRaw: "1.2.4",
Dir: path.Join(cfg.GetAbraDir(), "/recipes/mygit_org_myorg_cool-recipe"),
GitURL: "https://mygit.org/myorg/cool-recipe.git",
SSHURL: "ssh://git@mygit.org/myorg/cool-recipe.git",
ComposePath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/compose.yml"),
ReadmePath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/README.md"),
SampleEnvPath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/.env.sample"),
AbraShPath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/abra.sh"),
},
},
{
name: "ssh://git@mygit.org/myorg/cool-recipe.git",
recipe: Recipe{
Name: "mygit.org/myorg/cool-recipe",
Dir: path.Join(cfg.GetAbraDir(), "/recipes/mygit_org_myorg_cool-recipe"),
GitURL: "https://mygit.org/myorg/cool-recipe.git",
SSHURL: "ssh://git@mygit.org/myorg/cool-recipe.git",
ComposePath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/compose.yml"),
ReadmePath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/README.md"),
SampleEnvPath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/.env.sample"),
AbraShPath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/abra.sh"),
},
},
{
name: "git@mygit.org:myorg/cool-recipe.git",
recipe: Recipe{
Name: "mygit.org/myorg/cool-recipe",
Dir: path.Join(cfg.GetAbraDir(), "/recipes/mygit_org_myorg_cool-recipe"),
GitURL: "https://mygit.org/myorg/cool-recipe.git",
SSHURL: "ssh://git@mygit.org/myorg/cool-recipe.git",
ComposePath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/compose.yml"),
ReadmePath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/README.md"),
SampleEnvPath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/.env.sample"),
AbraShPath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/abra.sh"),
},
},
{
name: "git@mygit.org:myorg/cool-recipe:1.2.4",
recipe: Recipe{
Name: "mygit.org/myorg/cool-recipe",
EnvVersion: "1.2.4",
EnvVersionRaw: "1.2.4",
Dir: path.Join(cfg.GetAbraDir(), "/recipes/mygit_org_myorg_cool-recipe"),
GitURL: "https://mygit.org/myorg/cool-recipe.git",
SSHURL: "ssh://git@mygit.org/myorg/cool-recipe.git",
ComposePath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/compose.yml"),
ReadmePath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/README.md"),
SampleEnvPath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/.env.sample"),
AbraShPath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/abra.sh"),
},
},
}
for _, tc := range testcases {
@@ -183,49 +101,6 @@ func TestGet(t *testing.T) {
}
}
func TestNormalizeRecipeName(t *testing.T) {
cases := []struct {
in, want string
}{
// catalog short names pass through
{"foo", "foo"},
{"foo:1.2.3", "foo:1.2.3"},
// bare host/path form
{"mygit.org/myorg/cool-recipe", "mygit.org/myorg/cool-recipe"},
{"mygit.org/myorg/cool-recipe.git", "mygit.org/myorg/cool-recipe"},
{"mygit.org/myorg/cool-recipe.git:1.2.3", "mygit.org/myorg/cool-recipe:1.2.3"},
{"mygit.org/myorg/cool-recipe/", "mygit.org/myorg/cool-recipe"},
// https://
{"https://mygit.org/myorg/cool-recipe", "mygit.org/myorg/cool-recipe"},
{"https://mygit.org/myorg/cool-recipe.git", "mygit.org/myorg/cool-recipe"},
{"https://mygit.org/myorg/cool-recipe.git:1.2.3", "mygit.org/myorg/cool-recipe:1.2.3"},
{"http://mygit.org/myorg/cool-recipe", "mygit.org/myorg/cool-recipe"},
// ssh://, with and without port
{"ssh://git@mygit.org/myorg/cool-recipe.git", "mygit.org/myorg/cool-recipe"},
{"ssh://git@mygit.org:2222/myorg/cool-recipe.git", "mygit.org/myorg/cool-recipe"},
// SCP-style git@host:path
{"git@mygit.org:myorg/cool-recipe", "mygit.org/myorg/cool-recipe"},
{"git@mygit.org:myorg/cool-recipe.git", "mygit.org/myorg/cool-recipe"},
{"git@mygit.org:myorg/cool-recipe:1.2.3", "mygit.org/myorg/cool-recipe:1.2.3"},
// whitespace
{" https://mygit.org/myorg/cool-recipe ", "mygit.org/myorg/cool-recipe"},
}
for _, tc := range cases {
t.Run(tc.in, func(t *testing.T) {
got := NormalizeRecipeName(tc.in)
if got != tc.want {
t.Errorf("NormalizeRecipeName(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
func TestGetVersionLabelLocalDoesNotUseTimeoutLabel(t *testing.T) {
test.Setup()
t.Cleanup(func() { test.Teardown() })
+2 -4
View File
@@ -20,7 +20,6 @@ import (
"coopcloud.tech/abra/pkg/envfile"
"coopcloud.tech/abra/pkg/i18n"
"coopcloud.tech/abra/pkg/log"
"coopcloud.tech/abra/pkg/upstream/stack"
loader "coopcloud.tech/abra/pkg/upstream/stack"
"github.com/decentral1se/passgen"
"github.com/docker/docker/api/types"
@@ -122,14 +121,13 @@ func ReadSecretsConfig(appEnvPath string, composeFiles []string, stackName strin
// Set the STACK_NAME to be able to generate the remote name correctly.
appEnv["STACK_NAME"] = stackName
opts := stack.Deploy{Composefiles: composeFiles}
composeConfig, err := loader.LoadComposefile(opts, appEnv)
composeConfig, err := loader.LoadCompose(loader.LoadConf{ComposeFiles: composeFiles, AppEnv: appEnv})
if err != nil {
return nil, err
}
// Read the compose files without injecting environment variables.
configWithoutEnv, err := loader.LoadComposefile(opts, map[string]string{}, loader.SkipInterpolation)
configWithoutEnv, err := loader.LoadCompose(loader.LoadConf{ComposeFiles: composeFiles})
if err != nil {
return nil, err
}
+1 -1
View File
@@ -3,7 +3,7 @@ version: "3.8"
services:
app:
image: nginx:1.31.3
image: nginx:1.31.2
secrets:
- test_pass_one
- test_pass_two
+11 -125
View File
@@ -5,16 +5,16 @@ import (
"log"
"os"
"path"
"path/filepath"
"runtime"
gitPkg "coopcloud.tech/abra/pkg/git"
"git.coopcloud.tech/toolshed/godotenv"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/plumbing"
"github.com/go-git/go-git/v5/plumbing/object"
)
var (
AppName = "test_app.example.com"
StackName = "test_app_example_com"
ServerName = "test_server"
RecipeName = "test_recipe"
@@ -62,13 +62,19 @@ func Setup() {
}
}
serverSrcDir := os.ExpandEnv("$PWD/../../tests/resources/test_server")
_, f, _, ok := runtime.Caller(0)
if !ok {
log.Fatal("Setup: unable to discover current working directory of file")
}
pwd := filepath.Dir(f)
serverSrcDir := filepath.Join(pwd, "/../../tests/resources/test_server")
serverDestDir := os.ExpandEnv("$ABRA_DIR/servers/test_server")
if err := os.CopyFS(serverDestDir, os.DirFS(serverSrcDir)); err != nil {
log.Fatal(err)
}
recipeSrcDir := os.ExpandEnv("$PWD/../../tests/resources/test_recipe")
recipeSrcDir := filepath.Join(pwd, "/../../tests/resources/test_recipe")
recipeDestDir := os.ExpandEnv("$ABRA_DIR/recipes/test_recipe")
if err := os.CopyFS(recipeDestDir, os.DirFS(recipeSrcDir)); err != nil {
log.Fatal(err)
@@ -79,126 +85,6 @@ func Setup() {
}
}
// UpstreamRecipeDir is the "remote" repo used by SetupWithUpstream, kept
// outside recipes/ so Teardown's blanket removal of $ABRA_DIR still cleans
// it up.
var UpstreamRecipeDir = os.ExpandEnv("$ABRA_DIR/_upstream/test_recipe")
// FeatureBranch is the non-default branch created by SetupWithUpstream.
const FeatureBranch = "feature"
// SetupWithUpstream is like Setup, but the recipe under $ABRA_DIR/recipes
// is a real clone of a separate "upstream" repo (UpstreamRecipeDir) rather
// than a git-inited copy of the fixture. The upstream repo additionally has
// a FeatureBranch with one extra commit not present on main, so tests can
// exercise resolving/fetching a non-default branch as a recipe :version.
func SetupWithUpstream() {
Teardown()
if err := os.Mkdir(os.ExpandEnv("$ABRA_DIR"), 0764); err != nil {
if !os.IsExist(err) {
log.Fatal(err)
}
}
if err := os.Mkdir(os.ExpandEnv("$ABRA_DIR/servers"), 0700); err != nil {
if !os.IsExist(err) {
log.Fatal(err)
}
}
if err := os.Mkdir(os.ExpandEnv("$ABRA_DIR/recipes"), 0764); err != nil {
if !os.IsExist(err) {
log.Fatal(err)
}
}
serverSrcDir := os.ExpandEnv("$PWD/../../tests/resources/test_server")
serverDestDir := os.ExpandEnv("$ABRA_DIR/servers/test_server")
if err := os.CopyFS(serverDestDir, os.DirFS(serverSrcDir)); err != nil {
log.Fatal(err)
}
if err := os.MkdirAll(path.Dir(UpstreamRecipeDir), 0764); err != nil {
log.Fatal(err)
}
recipeSrcDir := os.ExpandEnv("$PWD/../../tests/resources/test_recipe")
if err := os.CopyFS(UpstreamRecipeDir, os.DirFS(recipeSrcDir)); err != nil {
log.Fatal(err)
}
if err := os.WriteFile(path.Join(UpstreamRecipeDir, ".env.sample"), []byte("TYPE=test_recipe\n"), 0o644); err != nil {
log.Fatal(err)
}
if err := gitPkg.Init(UpstreamRecipeDir, true, "tester", "helo@coopcloud.tech"); err != nil {
log.Fatal(err)
}
if err := CreateBranchWithCommit(UpstreamRecipeDir, FeatureBranch, "feature.txt", "feature work"); err != nil {
log.Fatal(err)
}
recipeDestDir := os.ExpandEnv("$ABRA_DIR/recipes/test_recipe")
if err := gitPkg.Clone(recipeDestDir, UpstreamRecipeDir); err != nil {
log.Fatal(err)
}
}
// CreateBranchWithCommit checks out branchName in repoPath (creating it off
// the current HEAD if it doesn't exist yet, or extending it if it does),
// writes fileName with contents, commits it, then returns to the repo's
// default branch (leaving the new commit reachable only via the branch's
// ref, matching how a contributor would push a feature branch upstream
// without switching the repo's HEAD away from main).
func CreateBranchWithCommit(repoPath, branchName, fileName, contents string) error {
repo, err := git.PlainOpen(repoPath)
if err != nil {
return err
}
head, err := repo.Head()
if err != nil {
return err
}
worktree, err := repo.Worktree()
if err != nil {
return err
}
branchRef := plumbing.NewBranchReferenceName(branchName)
create := true
if _, err := repo.Reference(branchRef, true); err == nil {
create = false
}
checkoutOpts := &git.CheckoutOptions{Branch: branchRef, Create: create}
if create {
checkoutOpts.Hash = head.Hash()
}
if err := worktree.Checkout(checkoutOpts); err != nil {
return err
}
if err := os.WriteFile(path.Join(repoPath, fileName), []byte(contents), 0o644); err != nil {
return err
}
if _, err := worktree.Add(fileName); err != nil {
return err
}
if _, err := worktree.Commit(fileName, &git.CommitOptions{
Author: &object.Signature{Name: "tester", Email: "helo@coopcloud.tech"},
}); err != nil {
return err
}
return worktree.Checkout(&git.CheckoutOptions{Branch: head.Name()})
}
func AddEnv(envKey, envValue string) error {
filePath := os.ExpandEnv(fmt.Sprintf("$ABRA_DIR/servers/%s/%s.env", ServerName, AppName))
+13 -15
View File
@@ -4,7 +4,7 @@ import (
"io/ioutil"
"strings"
composetypes "github.com/docker/cli/cli/compose/types"
composeGoTypes "github.com/compose-spec/compose-go/v2/types"
networktypes "github.com/docker/docker/api/types/network"
"github.com/docker/docker/api/types/swarm"
)
@@ -48,19 +48,17 @@ func AddStackLabel(namespace Namespace, labels map[string]string) map[string]str
return labels
}
type networkMap map[string]composetypes.NetworkConfig
// Networks from the compose-file type to the engine API type
func Networks(namespace Namespace, networks networkMap, servicesNetworks map[string]struct{}) (map[string]networktypes.CreateOptions, []string) {
func Networks(namespace Namespace, networks map[string]composeGoTypes.NetworkConfig, servicesNetworks map[string]struct{}) (map[string]networktypes.CreateOptions, []string) {
if networks == nil {
networks = make(map[string]composetypes.NetworkConfig)
networks = make(map[string]composeGoTypes.NetworkConfig)
}
externalNetworks := []string{}
result := make(map[string]networktypes.CreateOptions)
for internalName := range servicesNetworks {
network := networks[internalName]
if network.External.External {
if network.External {
externalNetworks = append(externalNetworks, network.Name)
continue
}
@@ -98,19 +96,19 @@ func Networks(namespace Namespace, networks networkMap, servicesNetworks map[str
}
// Secrets converts secrets from the Compose type to the engine API type
func Secrets(namespace Namespace, secrets map[string]composetypes.SecretConfig) ([]swarm.SecretSpec, error) {
func Secrets(namespace Namespace, secrets map[string]composeGoTypes.SecretConfig) ([]swarm.SecretSpec, error) {
result := []swarm.SecretSpec{}
for name, secret := range secrets {
if secret.External.External {
if secret.External {
continue
}
var obj swarmFileObject
var err error
if secret.Driver != "" {
obj = driverObjectConfig(namespace, name, composetypes.FileObjectConfig(secret))
obj = driverObjectConfig(namespace, name, composeGoTypes.FileObjectConfig(secret))
} else {
obj, err = fileObjectConfig(namespace, name, composetypes.FileObjectConfig(secret))
obj, err = fileObjectConfig(namespace, name, composeGoTypes.FileObjectConfig(secret))
}
if err != nil {
return nil, err
@@ -133,14 +131,14 @@ func Secrets(namespace Namespace, secrets map[string]composetypes.SecretConfig)
}
// Configs converts config objects from the Compose type to the engine API type
func Configs(namespace Namespace, configs map[string]composetypes.ConfigObjConfig) ([]swarm.ConfigSpec, error) {
func Configs(namespace Namespace, configs map[string]composeGoTypes.ConfigObjConfig) ([]swarm.ConfigSpec, error) {
result := []swarm.ConfigSpec{}
for name, config := range configs {
if config.External.External {
if config.External {
continue
}
obj, err := fileObjectConfig(namespace, name, composetypes.FileObjectConfig(config))
obj, err := fileObjectConfig(namespace, name, composeGoTypes.FileObjectConfig(config))
if err != nil {
return nil, err
}
@@ -160,7 +158,7 @@ type swarmFileObject struct {
Data []byte
}
func driverObjectConfig(namespace Namespace, name string, obj composetypes.FileObjectConfig) swarmFileObject {
func driverObjectConfig(namespace Namespace, name string, obj composeGoTypes.FileObjectConfig) swarmFileObject {
if obj.Name != "" {
name = obj.Name
} else {
@@ -176,7 +174,7 @@ func driverObjectConfig(namespace Namespace, name string, obj composetypes.FileO
}
}
func fileObjectConfig(namespace Namespace, name string, obj composetypes.FileObjectConfig) (swarmFileObject, error) {
func fileObjectConfig(namespace Namespace, name string, obj composeGoTypes.FileObjectConfig) (swarmFileObject, error) {
data, err := ioutil.ReadFile(obj.File)
if err != nil {
return swarmFileObject{}, err
-170
View File
@@ -1,170 +0,0 @@
package convert // https://github.com/docker/cli/blob/master/cli/compose/convert/compose_test.go
import (
"testing"
composetypes "github.com/docker/cli/cli/compose/types"
"github.com/docker/docker/api/types/network"
"gotest.tools/v3/assert"
is "gotest.tools/v3/assert/cmp"
"gotest.tools/v3/fs"
)
func TestNamespaceScope(t *testing.T) {
scoped := Namespace{name: "foo"}.Scope("bar")
assert.Check(t, is.Equal("foo_bar", scoped))
}
func TestAddStackLabel(t *testing.T) {
labels := map[string]string{
"something": "labeled",
}
actual := AddStackLabel(Namespace{name: "foo"}, labels)
expected := map[string]string{
"something": "labeled",
LabelNamespace: "foo",
}
assert.Check(t, is.DeepEqual(expected, actual))
}
func TestNetworks(t *testing.T) {
namespace := Namespace{name: "foo"}
serviceNetworks := map[string]struct{}{
"normal": {},
"outside": {},
"default": {},
"attachablenet": {},
"named": {},
}
source := networkMap{
"normal": composetypes.NetworkConfig{
Driver: "overlay",
DriverOpts: map[string]string{
"opt": "value",
},
Ipam: composetypes.IPAMConfig{
Driver: "driver",
Config: []*composetypes.IPAMPool{
{
Subnet: "10.0.0.0",
},
},
},
Labels: map[string]string{
"something": "labeled",
},
},
"outside": composetypes.NetworkConfig{
External: composetypes.External{External: true},
Name: "special",
},
"attachablenet": composetypes.NetworkConfig{
Driver: "overlay",
Attachable: true,
},
"named": composetypes.NetworkConfig{
Name: "othername",
},
}
expected := map[string]network.CreateOptions{
"foo_default": {
Labels: map[string]string{
LabelNamespace: "foo",
},
},
"foo_normal": {
Driver: "overlay",
IPAM: &network.IPAM{
Driver: "driver",
Config: []network.IPAMConfig{
{
Subnet: "10.0.0.0",
},
},
},
Options: map[string]string{
"opt": "value",
},
Labels: map[string]string{
LabelNamespace: "foo",
"something": "labeled",
},
},
"foo_attachablenet": {
Driver: "overlay",
Attachable: true,
Labels: map[string]string{
LabelNamespace: "foo",
},
},
"othername": {
Labels: map[string]string{LabelNamespace: "foo"},
},
}
networks, externals := Networks(namespace, source, serviceNetworks)
assert.DeepEqual(t, expected, networks)
assert.DeepEqual(t, []string{"special"}, externals)
}
func TestSecrets(t *testing.T) {
namespace := Namespace{name: "foo"}
secretText := "this is the first secret"
secretFile := fs.NewFile(t, "convert-secrets", fs.WithContent(secretText))
defer secretFile.Remove()
source := map[string]composetypes.SecretConfig{
"one": {
File: secretFile.Path(),
Labels: map[string]string{"monster": "mash"},
},
"ext": {
External: composetypes.External{
External: true,
},
},
}
specs, err := Secrets(namespace, source)
assert.NilError(t, err)
assert.Assert(t, is.Len(specs, 1))
secret := specs[0]
assert.Check(t, is.Equal("foo_one", secret.Name))
assert.Check(t, is.DeepEqual(map[string]string{
"monster": "mash",
LabelNamespace: "foo",
}, secret.Labels))
assert.Check(t, is.DeepEqual([]byte(secretText), secret.Data))
}
func TestConfigs(t *testing.T) {
namespace := Namespace{name: "foo"}
configText := "this is the first config"
configFile := fs.NewFile(t, "convert-configs", fs.WithContent(configText))
defer configFile.Remove()
source := map[string]composetypes.ConfigObjConfig{
"one": {
File: configFile.Path(),
Labels: map[string]string{"monster": "mash"},
},
"ext": {
External: composetypes.External{
External: true,
},
},
}
specs, err := Configs(namespace, source)
assert.NilError(t, err)
assert.Assert(t, is.Len(specs, 1))
config := specs[0]
assert.Check(t, is.Equal("foo_one", config.Name))
assert.Check(t, is.DeepEqual(map[string]string{
"monster": "mash",
LabelNamespace: "foo",
}, config.Labels))
assert.Check(t, is.DeepEqual([]byte(configText), config.Data))
}
+148 -83
View File
@@ -5,11 +5,13 @@ import (
"fmt"
"os"
"sort"
"strconv"
"strings"
"time"
"unsafe"
"coopcloud.tech/abra/pkg/i18n"
composetypes "github.com/docker/cli/cli/compose/types"
composeGoTypes "github.com/compose-spec/compose-go/v2/types"
"github.com/docker/cli/opts"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
@@ -178,7 +180,7 @@ func ParseConfigs(client client.ConfigAPIClient, requestedConfigs []*swarmtypes.
// Services from compose-file types to engine API types
func Services(
namespace Namespace,
config *composetypes.Config,
config *composeGoTypes.Project,
client client.CommonAPIClient,
) (map[string]swarm.ServiceSpec, error) {
result := make(map[string]swarm.ServiceSpec)
@@ -211,18 +213,54 @@ func Services(
func Service(
apiVersion string,
namespace Namespace,
service composetypes.ServiceConfig,
networkConfigs map[string]composetypes.NetworkConfig,
volumes map[string]composetypes.VolumeConfig,
service composeGoTypes.ServiceConfig,
networkConfigs map[string]composeGoTypes.NetworkConfig,
volumes map[string]composeGoTypes.VolumeConfig,
secrets []*swarm.SecretReference,
configs []*swarm.ConfigReference,
) (swarm.ServiceSpec, error) {
name := namespace.Scope(service.Name)
endpoint := convertEndpointSpec(service.Deploy.EndpointMode, service.Ports)
mode, err := convertDeployMode(service.Deploy.Mode, service.Deploy.Replicas)
if err != nil {
return swarm.ServiceSpec{}, err
var endpoint *swarmtypes.EndpointSpec
var mode swarmtypes.ServiceMode
var resources *swarmtypes.ResourceRequirements
var restartPolicy *swarmtypes.RestartPolicy
var labels map[string]string
var updateConfig *swarmtypes.UpdateConfig
var rollbackConfig *swarmtypes.UpdateConfig
var placement *swarmtypes.Placement
if service.Deploy != nil {
convertedEndpoint, err := convertEndpointSpec(service.Deploy.EndpointMode, service.Ports)
if err != nil {
return swarm.ServiceSpec{}, err
}
endpoint = convertedEndpoint
convertedMode, err := convertDeployMode(service.Deploy.Mode, service.Deploy.Replicas)
if err != nil {
return swarm.ServiceSpec{}, err
}
mode = convertedMode
convertResources, err := convertResources(service.Deploy.Resources)
if err != nil {
return swarm.ServiceSpec{}, err
}
resources = convertResources
convertedRestartPolicy, err := convertRestartPolicy(
service.Restart, service.Deploy.RestartPolicy)
if err != nil {
return swarm.ServiceSpec{}, err
}
restartPolicy = convertedRestartPolicy
labels = AddStackLabel(namespace, service.Deploy.Labels)
updateConfig = convertUpdateConfig(service.Deploy.UpdateConfig)
rollbackConfig = convertUpdateConfig(service.Deploy.RollbackConfig)
placement = &swarm.Placement{
Constraints: service.Deploy.Placement.Constraints,
Preferences: getPlacementPreference(service.Deploy.Placement.Preferences),
MaxReplicas: service.Deploy.Placement.MaxReplicas,
}
} else {
labels = AddStackLabel(namespace, nil)
}
mounts, err := Volumes(service.Volumes, volumes, namespace)
@@ -230,17 +268,6 @@ func Service(
return swarm.ServiceSpec{}, err
}
resources, err := convertResources(service.Deploy.Resources)
if err != nil {
return swarm.ServiceSpec{}, err
}
restartPolicy, err := convertRestartPolicy(
service.Restart, service.Deploy.RestartPolicy)
if err != nil {
return swarm.ServiceSpec{}, err
}
healthcheck, err := convertHealthcheck(service.HealthCheck)
if err != nil {
return swarm.ServiceSpec{}, err
@@ -254,9 +281,16 @@ func Service(
dnsConfig := convertDNSConfig(service.DNS, service.DNSSearch)
var privileges swarm.Privileges
credSpec := service.CredentialSpec
if credSpec == nil {
credSpec = &composeGoTypes.CredentialSpecConfig{}
}
privileges.CredentialSpec, err = convertCredentialSpec(
namespace, service.CredentialSpec, configs,
namespace, *credSpec, configs,
)
if err != nil {
return swarm.ServiceSpec{}, err
}
@@ -271,10 +305,15 @@ func Service(
capAdd, capDrop := opts.EffectiveCapAddCapDrop(service.CapAdd, service.CapDrop)
var stopGracePtr time.Duration
if service.StopGracePeriod != nil {
stopGracePtr = time.Duration(*service.StopGracePeriod)
}
serviceSpec := swarm.ServiceSpec{
Annotations: swarm.Annotations{
Name: name,
Labels: AddStackLabel(namespace, service.Deploy.Labels),
Labels: labels,
},
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{
@@ -290,7 +329,7 @@ func Service(
Dir: service.WorkingDir,
User: service.User,
Mounts: mounts,
StopGracePeriod: composetypes.ConvertDurationPtr(service.StopGracePeriod),
StopGracePeriod: &stopGracePtr,
StopSignal: service.StopSignal,
TTY: service.Tty,
OpenStdin: service.StdinOpen,
@@ -308,16 +347,12 @@ func Service(
LogDriver: logDriver,
Resources: resources,
RestartPolicy: restartPolicy,
Placement: &swarm.Placement{
Constraints: service.Deploy.Placement.Constraints,
Preferences: getPlacementPreference(service.Deploy.Placement.Preferences),
MaxReplicas: service.Deploy.Placement.MaxReplicas,
},
Placement: placement,
},
EndpointSpec: endpoint,
Mode: mode,
UpdateConfig: convertUpdateConfig(service.Deploy.UpdateConfig),
RollbackConfig: convertUpdateConfig(service.Deploy.RollbackConfig),
UpdateConfig: updateConfig,
RollbackConfig: rollbackConfig,
}
// add an image label to serviceSpec
@@ -338,7 +373,7 @@ func Service(
return serviceSpec, nil
}
func getPlacementPreference(preferences []composetypes.PlacementPreferences) []swarm.PlacementPreference {
func getPlacementPreference(preferences []composeGoTypes.PlacementPreferences) []swarm.PlacementPreference {
result := []swarm.PlacementPreference{}
for _, preference := range preferences {
spreadDescriptor := preference.Spread
@@ -357,13 +392,13 @@ func sortStrings(strs []string) []string {
}
func convertServiceNetworks(
networks map[string]*composetypes.ServiceNetworkConfig,
networkConfigs networkMap,
networks map[string]*composeGoTypes.ServiceNetworkConfig,
networkConfigs map[string]composeGoTypes.NetworkConfig,
namespace Namespace,
name string,
) ([]swarm.NetworkAttachmentConfig, error) {
if len(networks) == 0 {
networks = map[string]*composetypes.ServiceNetworkConfig{
networks = map[string]*composeGoTypes.ServiceNetworkConfig{
defaultNetwork: {},
}
}
@@ -403,20 +438,20 @@ func convertServiceNetworks(
func convertServiceSecrets(
client client.SecretAPIClient,
namespace Namespace,
secrets []composetypes.ServiceSecretConfig,
secretSpecs map[string]composetypes.SecretConfig,
secrets []composeGoTypes.ServiceSecretConfig,
secretSpecs map[string]composeGoTypes.SecretConfig,
) ([]*swarm.SecretReference, error) {
refs := []*swarm.SecretReference{}
lookup := func(key string) (composetypes.FileObjectConfig, error) {
lookup := func(key string) (composeGoTypes.FileObjectConfig, error) {
secretSpec, exists := secretSpecs[key]
if !exists {
return composetypes.FileObjectConfig{}, errors.New(i18n.G("undefined secret %q", key))
return composeGoTypes.FileObjectConfig{}, errors.New(i18n.G("undefined secret %q", key))
}
return composetypes.FileObjectConfig(secretSpec), nil
return composeGoTypes.FileObjectConfig(secretSpec), nil
}
for _, secret := range secrets {
obj, err := convertFileObject(namespace, composetypes.FileReferenceConfig(secret), lookup)
obj, err := convertFileObject(namespace, composeGoTypes.FileReferenceConfig(secret), lookup)
if err != nil {
return nil, err
}
@@ -451,20 +486,20 @@ func convertServiceSecrets(
func convertServiceConfigObjs(
client client.ConfigAPIClient,
namespace Namespace,
service composetypes.ServiceConfig,
configSpecs map[string]composetypes.ConfigObjConfig,
service composeGoTypes.ServiceConfig,
configSpecs map[string]composeGoTypes.ConfigObjConfig,
) ([]*swarm.ConfigReference, error) {
refs := []*swarm.ConfigReference{}
lookup := func(key string) (composetypes.FileObjectConfig, error) {
lookup := func(key string) (composeGoTypes.FileObjectConfig, error) {
configSpec, exists := configSpecs[key]
if !exists {
return composetypes.FileObjectConfig{}, errors.New(i18n.G("undefined config %q", key))
return composeGoTypes.FileObjectConfig{}, errors.New(i18n.G("undefined config %q", key))
}
return composetypes.FileObjectConfig(configSpec), nil
return composeGoTypes.FileObjectConfig(configSpec), nil
}
for _, config := range service.Configs {
obj, err := convertFileObject(namespace, composetypes.FileReferenceConfig(config), lookup)
obj, err := convertFileObject(namespace, composeGoTypes.FileReferenceConfig(config), lookup)
if err != nil {
return nil, err
}
@@ -487,7 +522,7 @@ func convertServiceConfigObjs(
// if the credSpec uses a config, then we should grab the config name, and
// create a config reference for it. A File or Registry-type CredentialSpec
// does not need this operation.
if credSpec.Config != "" {
if credSpec != nil && credSpec.Config != "" {
// look up the config in the configSpecs.
obj, err := lookup(credSpec.Config)
if err != nil {
@@ -532,8 +567,8 @@ type swarmReferenceObject struct {
func convertFileObject(
namespace Namespace,
config composetypes.FileReferenceConfig,
lookup func(key string) (composetypes.FileObjectConfig, error),
config composeGoTypes.FileReferenceConfig,
lookup func(key string) (composeGoTypes.FileObjectConfig, error),
) (swarmReferenceObject, error) {
obj, err := lookup(config.Source)
if err != nil {
@@ -558,40 +593,37 @@ func convertFileObject(
if gid == "" {
gid = "0"
}
mode := config.Mode
if mode == nil {
mode = uint32Ptr(0444)
}
return swarmReferenceObject{
ref := swarmReferenceObject{
File: swarmReferenceTarget{
Name: target,
UID: uid,
GID: gid,
Mode: os.FileMode(*mode),
},
Name: source,
}, nil
}
}
func uint32Ptr(value uint32) *uint32 {
return &value
if config.Mode == nil {
defaultMode := 0444
ref.File.Mode = os.FileMode(defaultMode)
} else {
ref.File.Mode = os.FileMode(*config.Mode)
}
return ref, nil
}
// convertExtraHosts converts <host>:<ip> mappings to SwarmKit notation:
// "IP-address hostname(s)". The original order of mappings is preserved.
func convertExtraHosts(extraHosts composetypes.HostsList) []string {
func convertExtraHosts(extraHosts composeGoTypes.HostsList) []string {
hosts := []string{}
for _, hostIP := range extraHosts {
if v := strings.SplitN(hostIP, ":", 2); len(v) == 2 {
// Convert to SwarmKit notation: IP-address hostname(s)
hosts = append(hosts, fmt.Sprintf("%s %s", v[1], v[0]))
}
for hostName, hostIP := range extraHosts {
hosts = append(hosts, fmt.Sprintf("%s %s", hostIP, hostName))
}
return hosts
}
func convertHealthcheck(healthcheck *composetypes.HealthCheckConfig) (*container.HealthConfig, error) {
func convertHealthcheck(healthcheck *composeGoTypes.HealthCheckConfig) (*container.HealthConfig, error) {
if healthcheck == nil {
return nil, nil
}
@@ -629,7 +661,7 @@ func convertHealthcheck(healthcheck *composetypes.HealthCheckConfig) (*container
}, nil
}
func convertRestartPolicy(restart string, source *composetypes.RestartPolicy) (*swarm.RestartPolicy, error) {
func convertRestartPolicy(restart string, source *composeGoTypes.RestartPolicy) (*swarm.RestartPolicy, error) {
if source == nil {
policy, err := opts.ParseRestartPolicy(restart)
if err != nil {
@@ -653,15 +685,25 @@ func convertRestartPolicy(restart string, source *composetypes.RestartPolicy) (*
}
}
var windowPtr time.Duration
if source.Window != nil {
windowPtr = time.Duration(*source.Window)
}
var delayPtr time.Duration
if source.Delay != nil {
delayPtr = time.Duration(*source.Delay)
}
return &swarm.RestartPolicy{
Condition: swarm.RestartPolicyCondition(source.Condition),
Delay: composetypes.ConvertDurationPtr(source.Delay),
Delay: &delayPtr,
MaxAttempts: source.MaxAttempts,
Window: composetypes.ConvertDurationPtr(source.Window),
Window: &windowPtr,
}, nil
}
func convertUpdateConfig(source *composetypes.UpdateConfig) *swarm.UpdateConfig {
func convertUpdateConfig(source *composeGoTypes.UpdateConfig) *swarm.UpdateConfig {
if source == nil {
return nil
}
@@ -679,13 +721,13 @@ func convertUpdateConfig(source *composetypes.UpdateConfig) *swarm.UpdateConfig
}
}
func convertResources(source composetypes.Resources) (*swarm.ResourceRequirements, error) {
func convertResources(source composeGoTypes.Resources) (*swarm.ResourceRequirements, error) {
resources := &swarm.ResourceRequirements{}
var err error
if source.Limits != nil {
var cpus int64
if source.Limits.NanoCPUs != "" {
cpus, err = opts.ParseCPUs(source.Limits.NanoCPUs)
if source.Limits.NanoCPUs > 0 {
cpus, err = opts.ParseCPUs(fmt.Sprintf("%f", source.Limits.NanoCPUs))
if err != nil {
return nil, err
}
@@ -698,8 +740,8 @@ func convertResources(source composetypes.Resources) (*swarm.ResourceRequirement
}
if source.Reservations != nil {
var cpus int64
if source.Reservations.NanoCPUs != "" {
cpus, err = opts.ParseCPUs(source.Reservations.NanoCPUs)
if source.Reservations.NanoCPUs > 0 {
cpus, err = opts.ParseCPUs(fmt.Sprintf("%f", source.Reservations.NanoCPUs))
if err != nil {
return nil, err
}
@@ -728,13 +770,29 @@ func convertResources(source composetypes.Resources) (*swarm.ResourceRequirement
return resources, nil
}
func convertEndpointSpec(endpointMode string, source []composetypes.ServicePortConfig) *swarm.EndpointSpec {
func str2uint32(s string) (uint32, error) {
var u32 uint32
u64, err := strconv.ParseUint(s, 10, 32)
if err != nil {
return u32, err
}
return uint32(u64), nil
}
func convertEndpointSpec(endpointMode string, source []composeGoTypes.ServicePortConfig) (*swarm.EndpointSpec, error) {
portConfigs := []swarm.PortConfig{}
for _, port := range source {
published, err := str2uint32(port.Published)
if err != nil {
return &swarm.EndpointSpec{}, err
}
portConfig := swarm.PortConfig{
Protocol: swarm.PortConfigProtocol(port.Protocol),
TargetPort: port.Target,
PublishedPort: port.Published,
PublishedPort: published,
PublishMode: swarm.PortConfigPublishMode(port.Mode),
}
portConfigs = append(portConfigs, portConfig)
@@ -747,7 +805,7 @@ func convertEndpointSpec(endpointMode string, source []composetypes.ServicePortC
return &swarm.EndpointSpec{
Mode: swarm.ResolutionMode(strings.ToLower(endpointMode)),
Ports: portConfigs,
}
}, nil
}
func convertEnvironment(source map[string]*string) []string {
@@ -765,7 +823,7 @@ func convertEnvironment(source map[string]*string) []string {
return output
}
func convertDeployMode(mode string, replicas *uint64) (swarm.ServiceMode, error) {
func convertDeployMode(mode string, replicas *int) (swarm.ServiceMode, error) {
serviceMode := swarm.ServiceMode{}
switch mode {
@@ -775,7 +833,8 @@ func convertDeployMode(mode string, replicas *uint64) (swarm.ServiceMode, error)
}
serviceMode.Global = &swarm.GlobalService{}
case "replicated", "":
serviceMode.Replicated = &swarm.ReplicatedService{Replicas: replicas}
convReplicas := (*uint64)(unsafe.Pointer(replicas))
serviceMode.Replicated = &swarm.ReplicatedService{Replicas: convReplicas}
default:
return serviceMode, errors.New(i18n.G("unknown mode: %s", mode))
}
@@ -792,7 +851,7 @@ func convertDNSConfig(DNS []string, DNSSearch []string) *swarm.DNSConfig {
return nil
}
func convertCredentialSpec(namespace Namespace, spec composetypes.CredentialSpecConfig, refs []*swarm.ConfigReference) (*swarm.CredentialSpec, error) {
func convertCredentialSpec(namespace Namespace, spec composeGoTypes.CredentialSpecConfig, refs []*swarm.ConfigReference) (*swarm.CredentialSpec, error) {
var o []string
// Config was added in API v1.40
@@ -814,7 +873,13 @@ func convertCredentialSpec(namespace Namespace, spec composetypes.CredentialSpec
case l > 2:
return nil, errors.New(i18n.G("invalid credential spec: cannot specify both %s, and %s", strings.Join(o[:l-1], ", "), o[l-1]))
}
swarmCredSpec := swarm.CredentialSpec(spec)
swarmCredSpec := swarm.CredentialSpec{
Config: spec.Config,
File: spec.File,
Registry: spec.Registry,
}
// if we're using a swarm Config for the credential spec, over-write it
// here with the config ID
if swarmCredSpec.Config != "" {
@@ -836,7 +901,7 @@ func convertCredentialSpec(namespace Namespace, spec composetypes.CredentialSpec
return &swarmCredSpec, nil
}
func convertUlimits(origUlimits map[string]*composetypes.UlimitsConfig) []*units.Ulimit {
func convertUlimits(origUlimits map[string]*composeGoTypes.UlimitsConfig) []*units.Ulimit {
newUlimits := make(map[string]*units.Ulimit)
for name, u := range origUlimits {
if u.Single != 0 {
-678
View File
@@ -1,678 +0,0 @@
package convert // https://github.com/docker/cli/blob/master/cli/compose/convert/service_test.go
import (
"context"
"os"
"sort"
"strings"
"testing"
"time"
composetypes "github.com/docker/cli/cli/compose/types"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/swarm"
"github.com/docker/docker/client"
"github.com/pkg/errors"
"gotest.tools/v3/assert"
is "gotest.tools/v3/assert/cmp"
)
func TestConvertRestartPolicyFromNone(t *testing.T) {
policy, err := convertRestartPolicy("no", nil)
assert.NilError(t, err)
assert.Check(t, is.DeepEqual((*swarm.RestartPolicy)(nil), policy))
}
func TestConvertRestartPolicyFromUnknown(t *testing.T) {
_, err := convertRestartPolicy("unknown", nil)
assert.Error(t, err, "unknown restart policy: unknown")
}
func TestConvertRestartPolicyFromAlways(t *testing.T) {
policy, err := convertRestartPolicy("always", nil)
expected := &swarm.RestartPolicy{
Condition: swarm.RestartPolicyConditionAny,
}
assert.NilError(t, err)
assert.Check(t, is.DeepEqual(expected, policy))
}
func TestConvertRestartPolicyFromFailure(t *testing.T) {
policy, err := convertRestartPolicy("on-failure:4", nil)
attempts := uint64(4)
expected := &swarm.RestartPolicy{
Condition: swarm.RestartPolicyConditionOnFailure,
MaxAttempts: &attempts,
}
assert.NilError(t, err)
assert.Check(t, is.DeepEqual(expected, policy))
}
func strPtr(val string) *string {
return &val
}
func TestConvertEnvironment(t *testing.T) {
source := map[string]*string{
"foo": strPtr("bar"),
"key": strPtr("value"),
}
env := convertEnvironment(source)
sort.Strings(env)
assert.Check(t, is.DeepEqual([]string{"foo=bar", "key=value"}, env))
}
func TestConvertExtraHosts(t *testing.T) {
source := composetypes.HostsList{
"zulu:127.0.0.2",
"alpha:127.0.0.1",
"zulu:ff02::1",
}
assert.Check(t, is.DeepEqual([]string{"127.0.0.2 zulu", "127.0.0.1 alpha", "ff02::1 zulu"}, convertExtraHosts(source)))
}
func TestConvertResourcesFull(t *testing.T) {
source := composetypes.Resources{
Limits: &composetypes.ResourceLimit{
NanoCPUs: "0.003",
MemoryBytes: composetypes.UnitBytes(300000000),
},
Reservations: &composetypes.Resource{
NanoCPUs: "0.002",
MemoryBytes: composetypes.UnitBytes(200000000),
},
}
resources, err := convertResources(source)
assert.NilError(t, err)
expected := &swarm.ResourceRequirements{
Limits: &swarm.Limit{
NanoCPUs: 3000000,
MemoryBytes: 300000000,
},
Reservations: &swarm.Resources{
NanoCPUs: 2000000,
MemoryBytes: 200000000,
},
}
assert.Check(t, is.DeepEqual(expected, resources))
}
func TestConvertResourcesOnlyMemory(t *testing.T) {
source := composetypes.Resources{
Limits: &composetypes.ResourceLimit{
MemoryBytes: composetypes.UnitBytes(300000000),
},
Reservations: &composetypes.Resource{
MemoryBytes: composetypes.UnitBytes(200000000),
},
}
resources, err := convertResources(source)
assert.NilError(t, err)
expected := &swarm.ResourceRequirements{
Limits: &swarm.Limit{
MemoryBytes: 300000000,
},
Reservations: &swarm.Resources{
MemoryBytes: 200000000,
},
}
assert.Check(t, is.DeepEqual(expected, resources))
}
func TestConvertHealthcheck(t *testing.T) {
retries := uint64(10)
timeout := composetypes.Duration(30 * time.Second)
interval := composetypes.Duration(2 * time.Millisecond)
source := &composetypes.HealthCheckConfig{
Test: []string{"EXEC", "touch", "/foo"},
Timeout: &timeout,
Interval: &interval,
Retries: &retries,
}
expected := &container.HealthConfig{
Test: source.Test,
Timeout: time.Duration(timeout),
Interval: time.Duration(interval),
Retries: 10,
}
healthcheck, err := convertHealthcheck(source)
assert.NilError(t, err)
assert.Check(t, is.DeepEqual(expected, healthcheck))
}
func TestConvertHealthcheckDisable(t *testing.T) {
source := &composetypes.HealthCheckConfig{Disable: true}
expected := &container.HealthConfig{
Test: []string{"NONE"},
}
healthcheck, err := convertHealthcheck(source)
assert.NilError(t, err)
assert.Check(t, is.DeepEqual(expected, healthcheck))
}
func TestConvertHealthcheckDisableWithTest(t *testing.T) {
source := &composetypes.HealthCheckConfig{
Disable: true,
Test: []string{"EXEC", "touch"},
}
_, err := convertHealthcheck(source)
assert.Error(t, err, "test and disable can't be set at the same time")
}
func TestConvertEndpointSpec(t *testing.T) {
source := []composetypes.ServicePortConfig{
{
Protocol: "udp",
Target: 53,
Published: 1053,
Mode: "host",
},
{
Target: 8080,
Published: 80,
},
}
endpoint := convertEndpointSpec("vip", source)
expected := swarm.EndpointSpec{
Mode: swarm.ResolutionMode(strings.ToLower("vip")),
Ports: []swarm.PortConfig{
{
TargetPort: 8080,
PublishedPort: 80,
},
{
Protocol: "udp",
TargetPort: 53,
PublishedPort: 1053,
PublishMode: "host",
},
},
}
assert.Check(t, is.DeepEqual(expected, *endpoint))
}
func TestConvertServiceNetworksOnlyDefault(t *testing.T) {
networkConfigs := networkMap{}
configs, err := convertServiceNetworks(
nil, networkConfigs, NewNamespace("foo"), "service")
expected := []swarm.NetworkAttachmentConfig{
{
Target: "foo_default",
Aliases: []string{"service"},
},
}
assert.NilError(t, err)
assert.Check(t, is.DeepEqual(expected, configs))
}
func TestConvertServiceNetworks(t *testing.T) {
networkConfigs := networkMap{
"front": composetypes.NetworkConfig{
External: composetypes.External{External: true},
Name: "fronttier",
},
"back": composetypes.NetworkConfig{},
}
networks := map[string]*composetypes.ServiceNetworkConfig{
"front": {
Aliases: []string{"something"},
},
"back": {
Aliases: []string{"other"},
},
}
configs, err := convertServiceNetworks(
networks, networkConfigs, NewNamespace("foo"), "service")
expected := []swarm.NetworkAttachmentConfig{
{
Target: "foo_back",
Aliases: []string{"other", "service"},
},
{
Target: "fronttier",
Aliases: []string{"something", "service"},
},
}
assert.NilError(t, err)
assert.Check(t, is.DeepEqual(expected, configs))
}
func TestConvertServiceNetworksCustomDefault(t *testing.T) {
networkConfigs := networkMap{
"default": composetypes.NetworkConfig{
External: composetypes.External{External: true},
Name: "custom",
},
}
networks := map[string]*composetypes.ServiceNetworkConfig{}
configs, err := convertServiceNetworks(
networks, networkConfigs, NewNamespace("foo"), "service")
expected := []swarm.NetworkAttachmentConfig{
{
Target: "custom",
Aliases: []string{"service"},
},
}
assert.NilError(t, err)
assert.Check(t, is.DeepEqual(expected, configs))
}
func TestConvertDNSConfigEmpty(t *testing.T) {
dnsConfig := convertDNSConfig(nil, nil)
assert.Check(t, is.DeepEqual((*swarm.DNSConfig)(nil), dnsConfig))
}
var (
nameservers = []string{"8.8.8.8", "9.9.9.9"}
search = []string{"dc1.example.com", "dc2.example.com"}
)
func TestConvertDNSConfigAll(t *testing.T) {
dnsConfig := convertDNSConfig(nameservers, search)
assert.Check(t, is.DeepEqual(&swarm.DNSConfig{
Nameservers: nameservers,
Search: search,
}, dnsConfig))
}
func TestConvertDNSConfigNameservers(t *testing.T) {
dnsConfig := convertDNSConfig(nameservers, nil)
assert.Check(t, is.DeepEqual(&swarm.DNSConfig{
Nameservers: nameservers,
Search: nil,
}, dnsConfig))
}
func TestConvertDNSConfigSearch(t *testing.T) {
dnsConfig := convertDNSConfig(nil, search)
assert.Check(t, is.DeepEqual(&swarm.DNSConfig{
Nameservers: nil,
Search: search,
}, dnsConfig))
}
func TestConvertCredentialSpec(t *testing.T) {
tests := []struct {
name string
in composetypes.CredentialSpecConfig
out *swarm.CredentialSpec
configs []*swarm.ConfigReference
expectedErr string
}{
{
name: "empty",
},
{
name: "config-and-file",
in: composetypes.CredentialSpecConfig{Config: "0bt9dmxjvjiqermk6xrop3ekq", File: "somefile.json"},
expectedErr: `invalid credential spec: cannot specify both "Config" and "File"`,
},
{
name: "config-and-registry",
in: composetypes.CredentialSpecConfig{Config: "0bt9dmxjvjiqermk6xrop3ekq", Registry: "testing"},
expectedErr: `invalid credential spec: cannot specify both "Config" and "Registry"`,
},
{
name: "file-and-registry",
in: composetypes.CredentialSpecConfig{File: "somefile.json", Registry: "testing"},
expectedErr: `invalid credential spec: cannot specify both "File" and "Registry"`,
},
{
name: "config-and-file-and-registry",
in: composetypes.CredentialSpecConfig{Config: "0bt9dmxjvjiqermk6xrop3ekq", File: "somefile.json", Registry: "testing"},
expectedErr: `invalid credential spec: cannot specify both "Config", "File", and "Registry"`,
},
{
name: "missing-config-reference",
in: composetypes.CredentialSpecConfig{Config: "missing"},
expectedErr: "invalid credential spec: spec specifies config missing, but no such config can be found",
configs: []*swarm.ConfigReference{
{
ConfigName: "someName",
ConfigID: "missing",
},
},
},
{
name: "namespaced-config",
in: composetypes.CredentialSpecConfig{Config: "name"},
configs: []*swarm.ConfigReference{
{
ConfigName: "namespaced-config_name",
ConfigID: "someID",
},
},
out: &swarm.CredentialSpec{Config: "someID"},
},
{
name: "config",
in: composetypes.CredentialSpecConfig{Config: "someName"},
configs: []*swarm.ConfigReference{
{
ConfigName: "someOtherName",
ConfigID: "someOtherID",
}, {
ConfigName: "someName",
ConfigID: "someID",
},
},
out: &swarm.CredentialSpec{Config: "someID"},
},
{
name: "file",
in: composetypes.CredentialSpecConfig{File: "somefile.json"},
out: &swarm.CredentialSpec{File: "somefile.json"},
},
{
name: "registry",
in: composetypes.CredentialSpecConfig{Registry: "testing"},
out: &swarm.CredentialSpec{Registry: "testing"},
},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
namespace := NewNamespace(tc.name)
swarmSpec, err := convertCredentialSpec(namespace, tc.in, tc.configs)
if tc.expectedErr != "" {
assert.Error(t, err, tc.expectedErr)
} else {
assert.NilError(t, err)
}
assert.DeepEqual(t, swarmSpec, tc.out)
})
}
}
func TestConvertUpdateConfigOrder(t *testing.T) {
// test default behavior
updateConfig := convertUpdateConfig(&composetypes.UpdateConfig{})
assert.Check(t, is.Equal("", updateConfig.Order))
// test start-first
updateConfig = convertUpdateConfig(&composetypes.UpdateConfig{
Order: "start-first",
})
assert.Check(t, is.Equal(updateConfig.Order, "start-first"))
// test stop-first
updateConfig = convertUpdateConfig(&composetypes.UpdateConfig{
Order: "stop-first",
})
assert.Check(t, is.Equal(updateConfig.Order, "stop-first"))
}
func TestConvertFileObject(t *testing.T) {
namespace := NewNamespace("testing")
config := composetypes.FileReferenceConfig{
Source: "source",
Target: "target",
UID: "user",
GID: "group",
Mode: uint32Ptr(0644),
}
swarmRef, err := convertFileObject(namespace, config, lookupConfig)
assert.NilError(t, err)
expected := swarmReferenceObject{
Name: "testing_source",
File: swarmReferenceTarget{
Name: config.Target,
UID: config.UID,
GID: config.GID,
Mode: os.FileMode(0644),
},
}
assert.Check(t, is.DeepEqual(expected, swarmRef))
}
func lookupConfig(key string) (composetypes.FileObjectConfig, error) {
if key != "source" {
return composetypes.FileObjectConfig{}, errors.New("bad key")
}
return composetypes.FileObjectConfig{}, nil
}
func TestConvertFileObjectDefaults(t *testing.T) {
namespace := NewNamespace("testing")
config := composetypes.FileReferenceConfig{Source: "source"}
swarmRef, err := convertFileObject(namespace, config, lookupConfig)
assert.NilError(t, err)
expected := swarmReferenceObject{
Name: "testing_source",
File: swarmReferenceTarget{
Name: config.Source,
UID: "0",
GID: "0",
Mode: os.FileMode(0444),
},
}
assert.Check(t, is.DeepEqual(expected, swarmRef))
}
func TestServiceConvertsIsolation(t *testing.T) {
src := composetypes.ServiceConfig{
Isolation: "hyperv",
}
result, err := Service("1.35", Namespace{name: "foo"}, src, nil, nil, nil, nil)
assert.NilError(t, err)
assert.Check(t, is.Equal(container.IsolationHyperV, result.TaskTemplate.ContainerSpec.Isolation))
}
func TestConvertServiceSecrets(t *testing.T) {
namespace := Namespace{name: "foo"}
secrets := []composetypes.ServiceSecretConfig{
{Source: "foo_secret"},
{Source: "bar_secret"},
}
secretSpecs := map[string]composetypes.SecretConfig{
"foo_secret": {
Name: "foo_secret",
},
"bar_secret": {
Name: "bar_secret",
},
}
client := &fakeClient{
secretListFunc: func(opts types.SecretListOptions) ([]swarm.Secret, error) {
assert.Check(t, is.Contains(opts.Filters.Get("name"), "foo_secret"))
assert.Check(t, is.Contains(opts.Filters.Get("name"), "bar_secret"))
return []swarm.Secret{
{Spec: swarm.SecretSpec{Annotations: swarm.Annotations{Name: "foo_secret"}}},
{Spec: swarm.SecretSpec{Annotations: swarm.Annotations{Name: "bar_secret"}}},
}, nil
},
}
refs, err := convertServiceSecrets(client, namespace, secrets, secretSpecs)
assert.NilError(t, err)
expected := []*swarm.SecretReference{
{
SecretName: "bar_secret",
File: &swarm.SecretReferenceFileTarget{
Name: "bar_secret",
UID: "0",
GID: "0",
Mode: 0444,
},
},
{
SecretName: "foo_secret",
File: &swarm.SecretReferenceFileTarget{
Name: "foo_secret",
UID: "0",
GID: "0",
Mode: 0444,
},
},
}
assert.DeepEqual(t, expected, refs)
}
func TestConvertServiceConfigs(t *testing.T) {
namespace := Namespace{name: "foo"}
service := composetypes.ServiceConfig{
Configs: []composetypes.ServiceConfigObjConfig{
{Source: "foo_config"},
{Source: "bar_config"},
},
CredentialSpec: composetypes.CredentialSpecConfig{
Config: "baz_config",
},
}
configSpecs := map[string]composetypes.ConfigObjConfig{
"foo_config": {
Name: "foo_config",
},
"bar_config": {
Name: "bar_config",
},
"baz_config": {
Name: "baz_config",
},
}
client := &fakeClient{
configListFunc: func(opts types.ConfigListOptions) ([]swarm.Config, error) {
assert.Check(t, is.Contains(opts.Filters.Get("name"), "foo_config"))
assert.Check(t, is.Contains(opts.Filters.Get("name"), "bar_config"))
assert.Check(t, is.Contains(opts.Filters.Get("name"), "baz_config"))
return []swarm.Config{
{Spec: swarm.ConfigSpec{Annotations: swarm.Annotations{Name: "foo_config"}}},
{Spec: swarm.ConfigSpec{Annotations: swarm.Annotations{Name: "bar_config"}}},
{Spec: swarm.ConfigSpec{Annotations: swarm.Annotations{Name: "baz_config"}}},
}, nil
},
}
refs, err := convertServiceConfigObjs(client, namespace, service, configSpecs)
assert.NilError(t, err)
expected := []*swarm.ConfigReference{
{
ConfigName: "bar_config",
File: &swarm.ConfigReferenceFileTarget{
Name: "bar_config",
UID: "0",
GID: "0",
Mode: 0444,
},
},
{
ConfigName: "baz_config",
Runtime: &swarm.ConfigReferenceRuntimeTarget{},
},
{
ConfigName: "foo_config",
File: &swarm.ConfigReferenceFileTarget{
Name: "foo_config",
UID: "0",
GID: "0",
Mode: 0444,
},
},
}
assert.DeepEqual(t, expected, refs)
}
type fakeClient struct {
client.Client
secretListFunc func(types.SecretListOptions) ([]swarm.Secret, error)
configListFunc func(types.ConfigListOptions) ([]swarm.Config, error)
}
func (c *fakeClient) SecretList(ctx context.Context, options types.SecretListOptions) ([]swarm.Secret, error) {
if c.secretListFunc != nil {
return c.secretListFunc(options)
}
return []swarm.Secret{}, nil
}
func (c *fakeClient) ConfigList(ctx context.Context, options types.ConfigListOptions) ([]swarm.Config, error) {
if c.configListFunc != nil {
return c.configListFunc(options)
}
return []swarm.Config{}, nil
}
func TestConvertUpdateConfigParallelism(t *testing.T) {
parallel := uint64(4)
// test default behavior
updateConfig := convertUpdateConfig(&composetypes.UpdateConfig{})
assert.Check(t, is.Equal(uint64(1), updateConfig.Parallelism))
// Non default value
updateConfig = convertUpdateConfig(&composetypes.UpdateConfig{
Parallelism: &parallel,
})
assert.Check(t, is.Equal(parallel, updateConfig.Parallelism))
}
func TestConvertServiceCapAddAndCapDrop(t *testing.T) {
tests := []struct {
title string
in, out composetypes.ServiceConfig
}{
{
title: "default behavior",
},
{
title: "some values",
in: composetypes.ServiceConfig{
CapAdd: []string{"SYS_NICE", "CAP_NET_ADMIN"},
CapDrop: []string{"CHOWN", "CAP_NET_ADMIN", "DAC_OVERRIDE", "CAP_FSETID", "CAP_FOWNER"},
},
out: composetypes.ServiceConfig{
CapAdd: []string{"CAP_NET_ADMIN", "CAP_SYS_NICE"},
CapDrop: []string{"CAP_CHOWN", "CAP_DAC_OVERRIDE", "CAP_FOWNER", "CAP_FSETID"},
},
},
{
title: "adding ALL capabilities",
in: composetypes.ServiceConfig{
CapAdd: []string{"ALL", "CAP_NET_ADMIN"},
CapDrop: []string{"CHOWN", "CAP_NET_ADMIN", "DAC_OVERRIDE", "CAP_FSETID", "CAP_FOWNER"},
},
out: composetypes.ServiceConfig{
CapAdd: []string{"ALL"},
CapDrop: []string{"CAP_CHOWN", "CAP_DAC_OVERRIDE", "CAP_FOWNER", "CAP_FSETID", "CAP_NET_ADMIN"},
},
},
{
title: "dropping ALL capabilities",
in: composetypes.ServiceConfig{
CapAdd: []string{"CHOWN", "CAP_NET_ADMIN", "DAC_OVERRIDE", "CAP_FSETID", "CAP_FOWNER"},
CapDrop: []string{"ALL", "CAP_NET_ADMIN", "CAP_FOO"},
},
out: composetypes.ServiceConfig{
CapAdd: []string{"CAP_CHOWN", "CAP_DAC_OVERRIDE", "CAP_FOWNER", "CAP_FSETID", "CAP_NET_ADMIN"},
CapDrop: []string{"ALL"},
},
},
}
for _, tc := range tests {
tc := tc
t.Run(tc.title, func(t *testing.T) {
result, err := Service("1.41", Namespace{name: "foo"}, tc.in, nil, nil, nil, nil)
assert.NilError(t, err)
assert.Check(t, is.DeepEqual(result.TaskTemplate.ContainerSpec.CapabilityAdd, tc.out.CapAdd))
assert.Check(t, is.DeepEqual(result.TaskTemplate.ContainerSpec.CapabilityDrop, tc.out.CapDrop))
})
}
}
+11 -11
View File
@@ -2,15 +2,15 @@ package convert // https://github.com/docker/cli/blob/master/cli/compose/convert
import (
"coopcloud.tech/abra/pkg/i18n"
composetypes "github.com/docker/cli/cli/compose/types"
composeGoTypes "github.com/compose-spec/compose-go/v2/types"
"github.com/docker/docker/api/types/mount"
"github.com/pkg/errors"
)
type volumes map[string]composetypes.VolumeConfig
type volumes map[string]composeGoTypes.VolumeConfig
// Volumes from compose-file types to engine api types
func Volumes(serviceVolumes []composetypes.ServiceVolumeConfig, stackVolumes volumes, namespace Namespace) ([]mount.Mount, error) {
func Volumes(serviceVolumes []composeGoTypes.ServiceVolumeConfig, stackVolumes volumes, namespace Namespace) ([]mount.Mount, error) {
var mounts []mount.Mount
for _, volumeConfig := range serviceVolumes {
@@ -23,7 +23,7 @@ func Volumes(serviceVolumes []composetypes.ServiceVolumeConfig, stackVolumes vol
return mounts, nil
}
func createMountFromVolume(volume composetypes.ServiceVolumeConfig) mount.Mount {
func createMountFromVolume(volume composeGoTypes.ServiceVolumeConfig) mount.Mount {
return mount.Mount{
Type: mount.Type(volume.Type),
Target: volume.Target,
@@ -34,7 +34,7 @@ func createMountFromVolume(volume composetypes.ServiceVolumeConfig) mount.Mount
}
func handleVolumeToMount(
volume composetypes.ServiceVolumeConfig,
volume composeGoTypes.ServiceVolumeConfig,
stackVolumes volumes,
namespace Namespace,
) (mount.Mount, error) {
@@ -68,7 +68,7 @@ func handleVolumeToMount(
}
// External named volumes
if stackVolume.External.External {
if stackVolume.External {
return result, nil
}
@@ -83,7 +83,7 @@ func handleVolumeToMount(
return result, nil
}
func handleBindToMount(volume composetypes.ServiceVolumeConfig) (mount.Mount, error) {
func handleBindToMount(volume composeGoTypes.ServiceVolumeConfig) (mount.Mount, error) {
result := createMountFromVolume(volume)
if volume.Source == "" {
@@ -103,7 +103,7 @@ func handleBindToMount(volume composetypes.ServiceVolumeConfig) (mount.Mount, er
return result, nil
}
func handleTmpfsToMount(volume composetypes.ServiceVolumeConfig) (mount.Mount, error) {
func handleTmpfsToMount(volume composeGoTypes.ServiceVolumeConfig) (mount.Mount, error) {
result := createMountFromVolume(volume)
if volume.Source != "" {
@@ -117,13 +117,13 @@ func handleTmpfsToMount(volume composetypes.ServiceVolumeConfig) (mount.Mount, e
}
if volume.Tmpfs != nil {
result.TmpfsOptions = &mount.TmpfsOptions{
SizeBytes: volume.Tmpfs.Size,
SizeBytes: int64(volume.Tmpfs.Size),
}
}
return result, nil
}
func handleNpipeToMount(volume composetypes.ServiceVolumeConfig) (mount.Mount, error) {
func handleNpipeToMount(volume composeGoTypes.ServiceVolumeConfig) (mount.Mount, error) {
result := createMountFromVolume(volume)
if volume.Source == "" {
@@ -144,7 +144,7 @@ func handleNpipeToMount(volume composetypes.ServiceVolumeConfig) (mount.Mount, e
}
func convertVolumeToMount(
volume composetypes.ServiceVolumeConfig,
volume composeGoTypes.ServiceVolumeConfig,
stackVolumes volumes,
namespace Namespace,
) (mount.Mount, error) {
-361
View File
@@ -1,361 +0,0 @@
package convert // https://github.com/docker/cli/blob/master/cli/compose/convert/volume_test.go
import (
"testing"
composetypes "github.com/docker/cli/cli/compose/types"
"github.com/docker/docker/api/types/mount"
"gotest.tools/v3/assert"
is "gotest.tools/v3/assert/cmp"
)
func TestConvertVolumeToMountAnonymousVolume(t *testing.T) {
config := composetypes.ServiceVolumeConfig{
Type: "volume",
Target: "/foo/bar",
}
expected := mount.Mount{
Type: mount.TypeVolume,
Target: "/foo/bar",
}
mount, err := convertVolumeToMount(config, volumes{}, NewNamespace("foo"))
assert.NilError(t, err)
assert.Check(t, is.DeepEqual(expected, mount))
}
func TestConvertVolumeToMountAnonymousBind(t *testing.T) {
config := composetypes.ServiceVolumeConfig{
Type: "bind",
Target: "/foo/bar",
Bind: &composetypes.ServiceVolumeBind{
Propagation: "slave",
},
}
_, err := convertVolumeToMount(config, volumes{}, NewNamespace("foo"))
assert.Error(t, err, "invalid bind source, source cannot be empty")
}
func TestConvertVolumeToMountUnapprovedType(t *testing.T) {
config := composetypes.ServiceVolumeConfig{
Type: "foo",
Target: "/foo/bar",
}
_, err := convertVolumeToMount(config, volumes{}, NewNamespace("foo"))
assert.Error(t, err, "volume type must be volume, bind, tmpfs or npipe")
}
func TestConvertVolumeToMountConflictingOptionsBindInVolume(t *testing.T) {
namespace := NewNamespace("foo")
config := composetypes.ServiceVolumeConfig{
Type: "volume",
Source: "foo",
Target: "/target",
Bind: &composetypes.ServiceVolumeBind{
Propagation: "slave",
},
}
_, err := convertVolumeToMount(config, volumes{}, namespace)
assert.Error(t, err, "bind options are incompatible with type volume")
}
func TestConvertVolumeToMountConflictingOptionsTmpfsInVolume(t *testing.T) {
namespace := NewNamespace("foo")
config := composetypes.ServiceVolumeConfig{
Type: "volume",
Source: "foo",
Target: "/target",
Tmpfs: &composetypes.ServiceVolumeTmpfs{
Size: 1000,
},
}
_, err := convertVolumeToMount(config, volumes{}, namespace)
assert.Error(t, err, "tmpfs options are incompatible with type volume")
}
func TestConvertVolumeToMountConflictingOptionsVolumeInBind(t *testing.T) {
namespace := NewNamespace("foo")
config := composetypes.ServiceVolumeConfig{
Type: "bind",
Source: "/foo",
Target: "/target",
Volume: &composetypes.ServiceVolumeVolume{
NoCopy: true,
},
}
_, err := convertVolumeToMount(config, volumes{}, namespace)
assert.Error(t, err, "volume options are incompatible with type bind")
}
func TestConvertVolumeToMountConflictingOptionsTmpfsInBind(t *testing.T) {
namespace := NewNamespace("foo")
config := composetypes.ServiceVolumeConfig{
Type: "bind",
Source: "/foo",
Target: "/target",
Tmpfs: &composetypes.ServiceVolumeTmpfs{
Size: 1000,
},
}
_, err := convertVolumeToMount(config, volumes{}, namespace)
assert.Error(t, err, "tmpfs options are incompatible with type bind")
}
func TestConvertVolumeToMountConflictingOptionsBindInTmpfs(t *testing.T) {
namespace := NewNamespace("foo")
config := composetypes.ServiceVolumeConfig{
Type: "tmpfs",
Target: "/target",
Bind: &composetypes.ServiceVolumeBind{
Propagation: "slave",
},
}
_, err := convertVolumeToMount(config, volumes{}, namespace)
assert.Error(t, err, "bind options are incompatible with type tmpfs")
}
func TestConvertVolumeToMountConflictingOptionsVolumeInTmpfs(t *testing.T) {
namespace := NewNamespace("foo")
config := composetypes.ServiceVolumeConfig{
Type: "tmpfs",
Target: "/target",
Volume: &composetypes.ServiceVolumeVolume{
NoCopy: true,
},
}
_, err := convertVolumeToMount(config, volumes{}, namespace)
assert.Error(t, err, "volume options are incompatible with type tmpfs")
}
func TestConvertVolumeToMountNamedVolume(t *testing.T) {
stackVolumes := volumes{
"normal": composetypes.VolumeConfig{
Driver: "glusterfs",
DriverOpts: map[string]string{
"opt": "value",
},
Labels: map[string]string{
"something": "labeled",
},
},
}
namespace := NewNamespace("foo")
expected := mount.Mount{
Type: mount.TypeVolume,
Source: "foo_normal",
Target: "/foo",
ReadOnly: true,
VolumeOptions: &mount.VolumeOptions{
Labels: map[string]string{
LabelNamespace: "foo",
"something": "labeled",
},
DriverConfig: &mount.Driver{
Name: "glusterfs",
Options: map[string]string{
"opt": "value",
},
},
NoCopy: true,
},
}
config := composetypes.ServiceVolumeConfig{
Type: "volume",
Source: "normal",
Target: "/foo",
ReadOnly: true,
Volume: &composetypes.ServiceVolumeVolume{
NoCopy: true,
},
}
mount, err := convertVolumeToMount(config, stackVolumes, namespace)
assert.NilError(t, err)
assert.Check(t, is.DeepEqual(expected, mount))
}
func TestConvertVolumeToMountNamedVolumeWithNameCustomizd(t *testing.T) {
stackVolumes := volumes{
"normal": composetypes.VolumeConfig{
Name: "user_specified_name",
Driver: "vsphere",
DriverOpts: map[string]string{
"opt": "value",
},
Labels: map[string]string{
"something": "labeled",
},
},
}
namespace := NewNamespace("foo")
expected := mount.Mount{
Type: mount.TypeVolume,
Source: "user_specified_name",
Target: "/foo",
ReadOnly: true,
VolumeOptions: &mount.VolumeOptions{
Labels: map[string]string{
LabelNamespace: "foo",
"something": "labeled",
},
DriverConfig: &mount.Driver{
Name: "vsphere",
Options: map[string]string{
"opt": "value",
},
},
NoCopy: true,
},
}
config := composetypes.ServiceVolumeConfig{
Type: "volume",
Source: "normal",
Target: "/foo",
ReadOnly: true,
Volume: &composetypes.ServiceVolumeVolume{
NoCopy: true,
},
}
mount, err := convertVolumeToMount(config, stackVolumes, namespace)
assert.NilError(t, err)
assert.Check(t, is.DeepEqual(expected, mount))
}
func TestConvertVolumeToMountNamedVolumeExternal(t *testing.T) {
stackVolumes := volumes{
"outside": composetypes.VolumeConfig{
Name: "special",
External: composetypes.External{External: true},
},
}
namespace := NewNamespace("foo")
expected := mount.Mount{
Type: mount.TypeVolume,
Source: "special",
Target: "/foo",
VolumeOptions: &mount.VolumeOptions{NoCopy: false},
}
config := composetypes.ServiceVolumeConfig{
Type: "volume",
Source: "outside",
Target: "/foo",
}
mount, err := convertVolumeToMount(config, stackVolumes, namespace)
assert.NilError(t, err)
assert.Check(t, is.DeepEqual(expected, mount))
}
func TestConvertVolumeToMountNamedVolumeExternalNoCopy(t *testing.T) {
stackVolumes := volumes{
"outside": composetypes.VolumeConfig{
Name: "special",
External: composetypes.External{External: true},
},
}
namespace := NewNamespace("foo")
expected := mount.Mount{
Type: mount.TypeVolume,
Source: "special",
Target: "/foo",
VolumeOptions: &mount.VolumeOptions{
NoCopy: true,
},
}
config := composetypes.ServiceVolumeConfig{
Type: "volume",
Source: "outside",
Target: "/foo",
Volume: &composetypes.ServiceVolumeVolume{
NoCopy: true,
},
}
mount, err := convertVolumeToMount(config, stackVolumes, namespace)
assert.NilError(t, err)
assert.Check(t, is.DeepEqual(expected, mount))
}
func TestConvertVolumeToMountBind(t *testing.T) {
stackVolumes := volumes{}
namespace := NewNamespace("foo")
expected := mount.Mount{
Type: mount.TypeBind,
Source: "/bar",
Target: "/foo",
ReadOnly: true,
BindOptions: &mount.BindOptions{Propagation: mount.PropagationShared},
}
config := composetypes.ServiceVolumeConfig{
Type: "bind",
Source: "/bar",
Target: "/foo",
ReadOnly: true,
Bind: &composetypes.ServiceVolumeBind{Propagation: "shared"},
}
mount, err := convertVolumeToMount(config, stackVolumes, namespace)
assert.NilError(t, err)
assert.Check(t, is.DeepEqual(expected, mount))
}
func TestConvertVolumeToMountVolumeDoesNotExist(t *testing.T) {
namespace := NewNamespace("foo")
config := composetypes.ServiceVolumeConfig{
Type: "volume",
Source: "unknown",
Target: "/foo",
ReadOnly: true,
}
_, err := convertVolumeToMount(config, volumes{}, namespace)
assert.Error(t, err, "undefined volume \"unknown\"")
}
func TestConvertTmpfsToMountVolume(t *testing.T) {
config := composetypes.ServiceVolumeConfig{
Type: "tmpfs",
Target: "/foo/bar",
Tmpfs: &composetypes.ServiceVolumeTmpfs{
Size: 1000,
},
}
expected := mount.Mount{
Type: mount.TypeTmpfs,
Target: "/foo/bar",
TmpfsOptions: &mount.TmpfsOptions{SizeBytes: 1000},
}
mount, err := convertVolumeToMount(config, volumes{}, NewNamespace("foo"))
assert.NilError(t, err)
assert.Check(t, is.DeepEqual(expected, mount))
}
func TestConvertTmpfsToMountVolumeWithSource(t *testing.T) {
config := composetypes.ServiceVolumeConfig{
Type: "tmpfs",
Source: "/bar",
Target: "/foo/bar",
Tmpfs: &composetypes.ServiceVolumeTmpfs{
Size: 1000,
},
}
_, err := convertVolumeToMount(config, volumes{}, NewNamespace("foo"))
assert.Error(t, err, "invalid tmpfs source, source must be empty")
}
func TestConvertVolumeToMountAnonymousNpipe(t *testing.T) {
config := composetypes.ServiceVolumeConfig{
Type: "npipe",
Source: `\\.\pipe\foo`,
Target: `\\.\pipe\foo`,
}
expected := mount.Mount{
Type: mount.TypeNamedPipe,
Source: `\\.\pipe\foo`,
Target: `\\.\pipe\foo`,
}
mount, err := convertVolumeToMount(config, volumes{}, NewNamespace("foo"))
assert.NilError(t, err)
assert.Check(t, is.DeepEqual(expected, mount))
}
+42 -132
View File
@@ -1,148 +1,58 @@
package stack // https://github.com/docker/cli/blob/master/cli/command/stack/loader/loader.go
import (
"context"
"fmt"
"io/ioutil"
"path/filepath"
"sort"
"strings"
"io"
"coopcloud.tech/abra/pkg/i18n"
"coopcloud.tech/abra/pkg/log"
"github.com/docker/cli/cli/compose/loader"
"github.com/docker/cli/cli/compose/schema"
composetypes "github.com/docker/cli/cli/compose/types"
composeGoCli "github.com/compose-spec/compose-go/v2/cli"
composeGoTypes "github.com/compose-spec/compose-go/v2/types"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
)
// DontSkipValidation ensures validation is done for compose file loading
func DontSkipValidation(opts *loader.Options) {
opts.SkipValidation = false
type LoadConf struct {
ComposeFiles []string
AppEnv map[string]string
DisableConsistencyCheck bool
}
// SkipInterpolation skip interpolating environment variables.
func SkipInterpolation(opts *loader.Options) {
opts.SkipInterpolation = true
}
func LoadCompose(conf LoadConf) (*composeGoTypes.Project, error) {
// NOTE(d1): silence compose-go internal logger
logrus.SetOutput(io.Discard)
if len(conf.ComposeFiles) == 0 {
return nil, errors.New(i18n.G("LoadCompose: provide compose files"))
}
// LoadComposefile parse the composefile specified in the cli and returns its Config and version.
func LoadComposefile(opts Deploy, appEnv map[string]string, options ...func(*loader.Options)) (*composetypes.Config, error) {
configDetails, err := getConfigDetails(opts.Composefiles, appEnv)
hasEnvVariables := len(conf.AppEnv) > 0
newProjectOptions := []composeGoCli.ProjectOptionsFn{
composeGoCli.WithInterpolation(hasEnvVariables),
}
if hasEnvVariables {
var env []string
for k, v := range conf.AppEnv {
env = append(env, fmt.Sprintf("%s=%s", k, v))
}
newProjectOptions = append(newProjectOptions, composeGoCli.WithEnv(env))
// prefixes secrets and volumes with stack name
stackName, ok := conf.AppEnv["STACK_NAME"]
if ok && stackName != "" {
newProjectOptions = append(newProjectOptions, composeGoCli.WithName(stackName))
}
}
// this is needed for updating tags and labels, because each file is loaded one by one
// and the consistency check requires every loaded file to have an image with tag
if conf.DisableConsistencyCheck {
newProjectOptions = append(newProjectOptions, composeGoCli.WithConsistency(false))
}
projectOptions, err := composeGoCli.NewProjectOptions(conf.ComposeFiles, newProjectOptions...)
if err != nil {
return nil, err
}
if options == nil {
options = []func(*loader.Options){DontSkipValidation}
}
dicts := getDictsFrom(configDetails.ConfigFiles)
config, err := loader.Load(configDetails, options...)
if err != nil {
if fpe, ok := err.(*loader.ForbiddenPropertiesError); ok {
return nil, errors.New(i18n.G("compose file contains unsupported options: %s", propertyWarnings(fpe.Properties)))
}
return nil, err
}
recipeName, exists := appEnv["RECIPE"]
if !exists {
recipeName, _ = appEnv["TYPE"]
}
unsupportedProperties := loader.GetUnsupportedProperties(dicts...)
if len(unsupportedProperties) > 0 {
log.Warn(i18n.G("%s: ignoring unsupported options: %s", recipeName, strings.Join(unsupportedProperties, ", ")))
}
deprecatedProperties := loader.GetDeprecatedProperties(dicts...)
if len(deprecatedProperties) > 0 {
log.Warn(i18n.G("%s: ignoring deprecated options: %s", recipeName, propertyWarnings(deprecatedProperties)))
}
return config, nil
}
func getDictsFrom(configFiles []composetypes.ConfigFile) []map[string]interface{} {
dicts := []map[string]interface{}{}
for _, configFile := range configFiles {
dicts = append(dicts, configFile.Config)
}
return dicts
}
func propertyWarnings(properties map[string]string) string {
var msgs []string
for name, description := range properties {
msgs = append(msgs, fmt.Sprintf("%s: %s", name, description))
}
sort.Strings(msgs)
return strings.Join(msgs, "\n\n")
}
func getConfigDetails(composefiles []string, appEnv map[string]string) (composetypes.ConfigDetails, error) {
var details composetypes.ConfigDetails
absPath, err := filepath.Abs(composefiles[0])
if err != nil {
return details, err
}
details.WorkingDir = filepath.Dir(absPath)
details.ConfigFiles, err = loadConfigFiles(composefiles)
if err != nil {
return details, err
}
// Take the first file version (2 files can't have different version)
details.Version = schema.Version(details.ConfigFiles[0].Config)
details.Environment = appEnv
return details, err
}
func buildEnvironment(env []string) (map[string]string, error) {
result := make(map[string]string, len(env))
for _, s := range env {
// if value is empty, s is like "K=", not "K".
if !strings.Contains(s, "=") {
return result, errors.New(i18n.G("unexpected environment %q", s))
}
kv := strings.SplitN(s, "=", 2)
result[kv[0]] = kv[1]
}
return result, nil
}
func loadConfigFiles(filenames []string) ([]composetypes.ConfigFile, error) {
var configFiles []composetypes.ConfigFile
for _, filename := range filenames {
configFile, err := loadConfigFile(filename)
if err != nil {
return configFiles, err
}
configFiles = append(configFiles, *configFile)
}
return configFiles, nil
}
func loadConfigFile(filename string) (*composetypes.ConfigFile, error) {
var bytes []byte
var err error
bytes, err = ioutil.ReadFile(filename)
if err != nil {
return nil, err
}
config, err := loader.ParseYAML(bytes)
if err != nil {
return nil, fmt.Errorf("%s: %s", filename, err)
}
return &composetypes.ConfigFile{
Filename: filename,
Config: config,
}, nil
return projectOptions.LoadProject(context.Background())
}
+26
View File
@@ -0,0 +1,26 @@
package stack_test // https://github.com/docker/cli/blob/master/cli/command/stack/loader/loader.go
import (
"testing"
"coopcloud.tech/abra/pkg/app"
"coopcloud.tech/abra/pkg/test"
)
func TestSkipInterpolation(t *testing.T) {
test.Setup()
t.Cleanup(func() { test.Teardown() })
a, err := app.Get(test.AppName)
if err != nil {
t.Fatal(err)
}
_, err = a.Recipe.GetComposeConfig()
if err != nil {
t.Fatal(err)
}
// TODO: ensure compose has a port with no interpolated value
// TODO: ensure compose has port with interpolated value
}
+4 -4
View File
@@ -12,6 +12,7 @@ import (
stdlibErr "errors"
tea "github.com/charmbracelet/bubbletea"
composeGoTypes "github.com/compose-spec/compose-go/v2/types"
"coopcloud.tech/abra/pkg/config"
"coopcloud.tech/abra/pkg/i18n"
@@ -20,7 +21,6 @@ import (
"coopcloud.tech/abra/pkg/upstream/convert"
"github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/command/stack/formatter"
composetypes "github.com/docker/cli/cli/compose/types"
"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
@@ -197,7 +197,7 @@ func pruneServices(ctx context.Context, cl *dockerClient.Client, namespace conve
func RunDeploy(
cl *dockerClient.Client,
opts Deploy,
cfg *composetypes.Config,
cfg *composeGoTypes.Project,
appName string,
serverName string,
dontWait bool,
@@ -246,7 +246,7 @@ func deployCompose(
ctx context.Context,
cl *dockerClient.Client,
opts Deploy,
config *composetypes.Config,
config *composeGoTypes.Project,
appName string,
serverName string,
dontWait bool,
@@ -325,7 +325,7 @@ func deployCompose(
return nil
}
func getServicesDeclaredNetworks(serviceConfigs []composetypes.ServiceConfig) map[string]struct{} {
func getServicesDeclaredNetworks(serviceConfigs map[string]composeGoTypes.ServiceConfig) map[string]struct{} {
serviceNetworks := map[string]struct{}{}
for _, serviceConfig := range serviceConfigs {
if len(serviceConfig.Networks) == 0 {
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# This file generates local DNS aliases for the client VM for integration testing
echo "generating hosts file..."
hosts_file_path="./tests/resources/extra_hosts"
echo "removing old file..."
rm -f $hosts_file_path
# the server domain
server_ip="10.0.0.3"
domain="abra.local"
echo "$server_ip $domain" >> $hosts_file_path
# static subdomains used in integration tests
subdomains=("gitea" "zammad" "custom-html" "foo" "foobar")
for subdomain in "${subdomains[@]}"
do
echo "$server_ip $subdomain.$domain" >> $hosts_file_path
done
search_dir=./tests/integration
for entry in "$search_dir"/*.bats
do
file_name=$(basename "${entry}")
# converting file names to testing subdomains
subdomain=$(echo "$file_name" | tr . _)
echo "$server_ip $subdomain.$domain" >> $hosts_file_path
done
echo "writing extra hosts success"
+2 -1
View File
@@ -7,4 +7,5 @@
* Integration tests are in `./tests/integration`. Please see [these
docs](https://docs.coopcloud.tech/abra/hack/#integration-tests) for
instructions and tips on how to run them.
instructions and tips on how to run them. If you want to run it locally via nix,
read the [nix testing docs](../nix/hosts/README.md).
+9 -5
View File
@@ -598,8 +598,7 @@ teardown(){
run $ABRA app deploy "$TEST_APP_DOMAIN" --no-input
assert_success
run docker inspect --format='{{range .Config.Env}}{{println .}}{{end}}' \
$(docker ps -f name="$TEST_APP_DOMAIN_$TEST_SERVER" -q)
_inspect_env_test_app
assert_success
assert_output --partial "WITH_COMMENT=foo"
@@ -610,8 +609,7 @@ teardown(){
run $ABRA app deploy "$TEST_APP_DOMAIN" --no-input --force
assert_success
run docker inspect --format='{{range .Config.Env}}{{println .}}{{end}}' \
$(docker ps -f name="$TEST_APP_DOMAIN_$TEST_SERVER" -q)
_inspect_env_test_app
assert_success
refute_output --partial "WITH_COMMENT=foo"
assert_output --partial "WITH_COMMENT=bar"
@@ -629,8 +627,14 @@ teardown(){
run $ABRA app deploy "$TEST_APP_DOMAIN" --no-input
assert_success
run docker service inspect --format '{{ range .Endpoint.Ports }}{{ .Protocol }}={{ .PublishedPort }}{{ end }}' \
if _is_local;
then
run docker service inspect --format "{{ range .Endpoint.Ports }}{{ .Protocol }}={{ .PublishedPort }}{{ end }}" \
"${TEST_APP_DOMAIN//./_}_app"
else
run ssh "$TEST_SERVER" "docker service inspect --format '{{ range .Endpoint.Ports }}{{ .Protocol }}={{ .PublishedPort }}{{ end }}' ${TEST_APP_DOMAIN//./_}_app"
fi
assert_success
assert_output --partial "tcp=1312"
assert_output --partial "udp=1312"
@@ -133,8 +133,7 @@ teardown(){
run $ABRA app deploy "$TEST_APP_DOMAIN" "0.1.0+1.20.0" --no-input
assert_success
run docker inspect --format='{{range .Config.Env}}{{println .}}{{end}}' \
$(docker ps -f name="$TEST_APP_DOMAIN_$TEST_SERVER" -q)
_inspect_env_test_app
assert_success
assert_output --partial "$TEST_RECIIPE:0.1.0+1.20.0"
}
+11 -4
View File
@@ -140,15 +140,22 @@ teardown(){
_undeploy_app
sanitisedDomainName="${TEST_APP_DOMAIN//./_}"
run docker config create "${sanitisedDomainName}_test_conf_v99" "$ABRA_DIR/recipes/abra-test-recipe/abra.sh"
assert_success
remote_ssh_command=""
if _is_local;
then
run docker config create "${sanitisedDomainName}_test_conf_v99" "$ABRA_DIR/recipes/abra-test-recipe/abra.sh"
else
remote_ssh_command="ssh '$TEST_SERVER'"
run cat "$ABRA_DIR/recipes/abra-test-recipe/abra.sh" | ssh "$TEST_SERVER" docker config create "${sanitisedDomainName}_test_conf_v99 -"
assert_success
fi
assert bash -c "docker config ls | grep -q test_conf_v99"
assert bash -c "$remote_ssh_command docker config ls | grep -q test_conf_v99"
run $ABRA app rm "$TEST_APP_DOMAIN" --no-input
assert_success
refute bash -c "docker config ls | grep -q test_conf_v99"
refute bash -c "$remote_ssh_command docker config ls | grep -q test_conf_v99"
}
@test "remove .env file" {
@@ -54,8 +54,7 @@ teardown(){
--no-input
assert_success
run docker inspect --format='{{range .Config.Env}}{{println .}}{{end}}' \
$(docker ps -f name="$TEST_APP_DOMAIN_$TEST_SERVER" -q)
_inspect_env_test_app
assert_success
assert_output --partial "$TEST_RECIIPE:0.1.0+1.20.0"
}
@@ -53,8 +53,7 @@ teardown(){
run $ABRA app upgrade "$TEST_APP_DOMAIN" "0.2.0+1.21.0" --no-input
assert_success
run docker inspect --format='{{range .Config.Env}}{{println .}}{{end}}' \
$(docker ps -f name="$TEST_APP_DOMAIN_$TEST_SERVER" -q)
_inspect_env_test_app
assert_success
assert_output --partial "$TEST_RECIIPE:0.2.0+1.21.0"
assert_output --partial "$TEST_RECIPE:0.2.0+1.21.0"
}
+11 -2
View File
@@ -31,9 +31,18 @@ _ensure_ssh_agent() {
exit 1
fi
export SSH_AUTH_SOCK="$HOME/.ssh/ssh_auth_sock"
if [ ! -S ~/.ssh/ssh_auth_sock ]; then
if [[ ! -v SSH_AUTH_SOCK ]]; then
export SSH_AUTH_SOCK="$HOME/.ssh/ssh_auth_sock"
eval `ssh-agent`
ln -sf "$SSH_AUTH_SOCK" ~/.ssh/ssh_auth_sock
fi
}
_is_local() {
if [[ "$TEST_SERVER" == "default" ]];
then
return 0
else
return 1
fi
}
+30 -6
View File
@@ -1,13 +1,37 @@
#!/usr/bin/env bash
_ensure_swarm() {
if [ "$(docker info | grep Swarm | sed 's/Swarm: //g' | tr -d ' ')" == "inactive" ]; then
run docker swarm init --advertise-addr 127.0.0.1:2377
assert_success
if [ "$(docker info | grep Swarm | sed 's/Swarm: //g' | tr -d ' ')" == "inactive" ]; then
run docker swarm init --advertise-addr 127.0.0.1:2377
assert_success
fi
if ! $(docker network ls | grep -q 'proxy'); then
run docker network create -d overlay proxy
assert_success
if ! docker network ls | grep -q 'proxy'; then
run docker network create -d overlay proxy
assert_success
fi
if ! _is_local;
then
if [ "$(ssh "$TEST_SERVER" docker info | grep Swarm | sed 's/Swarm: //g' | tr -d ' ')" == "inactive" ]; then
run ssh "$TEST_SERVER" docker swarm init --advertise-addr 127.0.0.1:2377
assert_success
fi
if ! ssh "$TEST_SERVER" docker network ls | grep -q 'proxy'; then
run ssh "$TEST_SERVER" docker network create -d overlay proxy
assert_success
fi
fi
}
_inspect_env_test_app() {
if _is_local;
then
containerId="$(docker ps -f name="$TEST_APP_DOMAIN_$TEST_SERVER" -q)"
run docker inspect --format='{{range .Config.Env}}{{println .}}{{end}}' "$containerId"
else
containerId="$(ssh "$TEST_SERVER" docker ps -f name="$TEST_APP_DOMAIN" -q)"
run ssh "$TEST_SERVER" "docker inspect --format='{{range .Config.Env}}{{println .}}{{end}}' '$containerId'"
fi
}
+2 -1
View File
@@ -18,7 +18,8 @@ teardown(){
@test "recipe lint" {
run $ABRA recipe lint gitea
assert_success
assert_output --partial 'compose config has expected version'
# needs to be adjusted when upstream changes
assert_output --partial 'secret lfs_jwt_secret is longer than 12 characters'
}
@test "retrieve recipe if missing" {
-2
View File
@@ -38,8 +38,6 @@ teardown(){
run $ABRA recipe upgrade "custom-html" --no-input
assert_success
assert_output --partial 'can upgrade service: app'
assert_exists "$ABRA_DIR/recipes/custom-html"
}
+3 -3
View File
@@ -27,7 +27,7 @@ teardown(){
assert bash -c "docker context ls | grep -q $TEST_SERVER"
server_dir_perms=$(stat -c "%a" "$ABRA_DIR/servers/$TEST_SERVER")
assert_equal $server_dir_perms "700"
assert_equal "$server_dir_perms" "700"
}
@test "error if using name and --local together" {
@@ -43,8 +43,8 @@ teardown(){
assert bash -c "docker context ls | grep -q default"
assert_output --partial 'local server successfully added'
server_dir_perms=$(stat -c "%a" "$ABRA_DIR/servers/$TEST_SERVER")
assert_equal $server_dir_perms "700"
server_dir_perms=$(stat -c "%a" "$ABRA_DIR/servers/default")
assert_equal "$server_dir_perms" "700"
}
@test "create local server fails when no docker swarm" {
+57
View File
@@ -0,0 +1,57 @@
10.0.0.3 abra.local
10.0.0.3 gitea.abra.local
10.0.0.3 zammad.abra.local
10.0.0.3 custom-html.abra.local
10.0.0.3 foo.abra.local
10.0.0.3 foobar.abra.local
10.0.0.3 app_check_bats.abra.local
10.0.0.3 app_cmd_bats.abra.local
10.0.0.3 app_config_bats.abra.local
10.0.0.3 app_cp_bats.abra.local
10.0.0.3 app_deploy_bats.abra.local
10.0.0.3 app_deploy_env_version_bats.abra.local
10.0.0.3 app_deploy_overview_bats.abra.local
10.0.0.3 app_deploy_remote_recipes_bats.abra.local
10.0.0.3 app_env_bats.abra.local
10.0.0.3 app_env_version_bats.abra.local
10.0.0.3 app_labels_bats.abra.local
10.0.0.3 app_list_bats.abra.local
10.0.0.3 app_logs_bats.abra.local
10.0.0.3 app_move_bats.abra.local
10.0.0.3 app_new_bats.abra.local
10.0.0.3 app_ps_bats.abra.local
10.0.0.3 app_remove_bats.abra.local
10.0.0.3 app_restart_bats.abra.local
10.0.0.3 app_rollback_bats.abra.local
10.0.0.3 app_rollback_env_version_bats.abra.local
10.0.0.3 app_rollback_overview_bats.abra.local
10.0.0.3 app_run_bats.abra.local
10.0.0.3 app_secret_bats.abra.local
10.0.0.3 app_secret_env_version_bats.abra.local
10.0.0.3 app_services_bats.abra.local
10.0.0.3 app_undeploy_bats.abra.local
10.0.0.3 app_undeploy_env_version_bats.abra.local
10.0.0.3 app_undeploy_overview_bats.abra.local
10.0.0.3 app_upgrade_bats.abra.local
10.0.0.3 app_upgrade_env_version_bats.abra.local
10.0.0.3 app_upgrade_overview_bats.abra.local
10.0.0.3 app_volume_bats.abra.local
10.0.0.3 autocomplete_bats.abra.local
10.0.0.3 catalogue_bats.abra.local
10.0.0.3 dirs_bats.abra.local
10.0.0.3 install_bats.abra.local
10.0.0.3 recipe_diff_bats.abra.local
10.0.0.3 recipe_fetch_bats.abra.local
10.0.0.3 recipe_lint_bats.abra.local
10.0.0.3 recipe_list_bats.abra.local
10.0.0.3 recipe_new_bats.abra.local
10.0.0.3 recipe_release_bats.abra.local
10.0.0.3 recipe_reset_bats.abra.local
10.0.0.3 recipe_upgrade_bats.abra.local
10.0.0.3 recipe_version_bats.abra.local
10.0.0.3 server_add_bats.abra.local
10.0.0.3 server_list_bats.abra.local
10.0.0.3 server_prune_bats.abra.local
10.0.0.3 server_remove_bats.abra.local
10.0.0.3 upgrade_bats.abra.local
10.0.0.3 version_bats.abra.local
+7
View File
@@ -0,0 +1,7 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACBQ967UhxmFkq71WXvVRkEmtehCGsEnBVppAXxgtHHm7wAAAJhFGwTnRRsE
5wAAAAtzc2gtZWQyNTUxOQAAACBQ967UhxmFkq71WXvVRkEmtehCGsEnBVppAXxgtHHm7w
AAAEAnHEZcf2NeRQEcJC/aVgUWsdOz+vQgEG9ZY+3ErCeaKFD3rtSHGYWSrvVZe9VGQSa1
6EIawScFWmkBfGC0cebvAAAAFWFicmEgaW50ZWdyYXRpb24gdGVzdA==
-----END OPENSSH PRIVATE KEY-----
@@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFD3rtSHGYWSrvVZe9VGQSa16EIawScFWmkBfGC0cebv abra integration test
+1
View File
@@ -0,0 +1 @@
/path/to/this/repo
@@ -0,0 +1,7 @@
---
services:
app:
ports:
- target: 22
published: ${PORT}
+1 -2
View File
@@ -1,9 +1,8 @@
---
version: "3.8"
services:
app:
image: nginx:1.31.3
image: nginx:1.31.2
networks:
- proxy
deploy:
+191
View File
@@ -0,0 +1,191 @@
Apache License
Version 2.0, January 2004
https://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
Copyright 2020 The Compose Specification Authors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
https://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+2
View File
@@ -0,0 +1,2 @@
The Compose Specification
Copyright 2020 The Compose Specification Authors
+590
View File
@@ -0,0 +1,590 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package cli
import (
"context"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/sirupsen/logrus"
"go.yaml.in/yaml/v4"
"github.com/compose-spec/compose-go/v2/consts"
"github.com/compose-spec/compose-go/v2/dotenv"
"github.com/compose-spec/compose-go/v2/loader"
"github.com/compose-spec/compose-go/v2/types"
"github.com/compose-spec/compose-go/v2/utils"
)
// ProjectOptions provides common configuration for loading a project.
type ProjectOptions struct {
// Name is a valid Compose project name to be used or empty.
//
// If empty, the project loader will automatically infer a reasonable
// project name if possible.
Name string
// WorkingDir is a file path to use as the project directory or empty.
//
// If empty, the project loader will automatically infer a reasonable
// working directory if possible.
WorkingDir string
// ConfigPaths are file paths to one or more Compose files.
//
// These are applied in order by the loader following the override logic
// as described in the spec.
//
// The first entry is required and is the primary Compose file.
// For convenience, WithConfigFileEnv and WithDefaultConfigPath
// are provided to populate this in a predictable manner.
ConfigPaths []string
// Environment are additional environment variables to make available
// for interpolation.
//
// NOTE: For security, the loader does not automatically expose any
// process environment variables. For convenience, WithOsEnv can be
// used if appropriate.
Environment types.Mapping
// EnvFiles are file paths to ".env" files with additional environment
// variable data.
//
// These are loaded in-order, so it is possible to override variables or
// in subsequent files.
//
// This field is optional, but any file paths that are included here must
// exist or an error will be returned during load.
EnvFiles []string
loadOptions []func(*loader.Options)
// Callbacks to retrieve metadata information during parse defined before
// creating the project
Listeners []loader.Listener
// ResourceLoaders manages support for remote resources
ResourceLoaders []loader.ResourceLoader
}
type ProjectOptionsFn func(*ProjectOptions) error
// NewProjectOptions creates ProjectOptions
func NewProjectOptions(configs []string, opts ...ProjectOptionsFn) (*ProjectOptions, error) {
options := &ProjectOptions{
ConfigPaths: configs,
Environment: map[string]string{},
Listeners: []loader.Listener{},
}
for _, o := range opts {
err := o(options)
if err != nil {
return nil, err
}
}
return options, nil
}
// WithName defines ProjectOptions' name
func WithName(name string) ProjectOptionsFn {
return func(o *ProjectOptions) error {
// a project (once loaded) cannot have an empty name
// however, on the options object, the name is optional: if unset,
// a name will be inferred by the loader, so it's legal to set the
// name to an empty string here
if name != loader.NormalizeProjectName(name) {
return loader.InvalidProjectNameErr(name)
}
o.Name = name
return nil
}
}
// WithWorkingDirectory defines ProjectOptions' working directory
func WithWorkingDirectory(wd string) ProjectOptionsFn {
return func(o *ProjectOptions) error {
if wd == "" {
return nil
}
abs, err := filepath.Abs(wd)
if err != nil {
return err
}
o.WorkingDir = abs
return nil
}
}
// WithConfigFileEnv allow to set compose config file paths by COMPOSE_FILE environment variable
func WithConfigFileEnv(o *ProjectOptions) error {
if len(o.ConfigPaths) > 0 {
return nil
}
sep := o.Environment[consts.ComposePathSeparator]
if sep == "" {
sep = string(os.PathListSeparator)
}
f, ok := o.Environment[consts.ComposeFilePath]
if ok {
paths, err := absolutePaths(strings.Split(f, sep))
o.ConfigPaths = paths
return err
}
return nil
}
// WithDefaultConfigPath searches for default config files from working directory
func WithDefaultConfigPath(o *ProjectOptions) error {
if len(o.ConfigPaths) > 0 {
return nil
}
pwd, err := o.GetWorkingDir()
if err != nil {
return err
}
for {
candidates := findFiles(DefaultFileNames, pwd)
if len(candidates) > 0 {
winner := candidates[0]
if len(candidates) > 1 {
logrus.Warnf("Found multiple config files with supported names: %s", strings.Join(candidates, ", "))
logrus.Warnf("Using %s", winner)
}
o.ConfigPaths = append(o.ConfigPaths, winner)
overrides := findFiles(DefaultOverrideFileNames, pwd)
if len(overrides) > 0 {
if len(overrides) > 1 {
logrus.Warnf("Found multiple override files with supported names: %s", strings.Join(overrides, ", "))
logrus.Warnf("Using %s", overrides[0])
}
o.ConfigPaths = append(o.ConfigPaths, overrides[0])
}
return nil
}
parent := filepath.Dir(pwd)
if parent == pwd {
// no config file found, but that's not a blocker if caller only needs project name
return nil
}
pwd = parent
}
}
// WithEnv defines a key=value set of variables used for compose file interpolation
func WithEnv(env []string) ProjectOptionsFn {
return func(o *ProjectOptions) error {
for k, v := range utils.GetAsEqualsMap(env) {
o.Environment[k] = v
}
return nil
}
}
// WithDiscardEnvFile sets discards the `env_file` section after resolving to
// the `environment` section
func WithDiscardEnvFile(o *ProjectOptions) error {
o.loadOptions = append(o.loadOptions, loader.WithDiscardEnvFiles)
return nil
}
// WithLoadOptions provides a hook to control how compose files are loaded
func WithLoadOptions(loadOptions ...func(*loader.Options)) ProjectOptionsFn {
return func(o *ProjectOptions) error {
o.loadOptions = append(o.loadOptions, loadOptions...)
return nil
}
}
// WithDefaultProfiles uses the provided profiles (if any), and falls back to
// profiles specified via the COMPOSE_PROFILES environment variable otherwise.
func WithDefaultProfiles(profiles ...string) ProjectOptionsFn {
return func(o *ProjectOptions) error {
if len(profiles) == 0 {
for _, s := range strings.Split(o.Environment[consts.ComposeProfiles], ",") {
profiles = append(profiles, strings.TrimSpace(s))
}
}
o.loadOptions = append(o.loadOptions, loader.WithProfiles(profiles))
return nil
}
}
// WithProfiles sets profiles to be activated
func WithProfiles(profiles []string) ProjectOptionsFn {
return func(o *ProjectOptions) error {
o.loadOptions = append(o.loadOptions, loader.WithProfiles(profiles))
return nil
}
}
// WithOsEnv imports environment variables from OS
func WithOsEnv(o *ProjectOptions) error {
for k, v := range utils.GetAsEqualsMap(os.Environ()) {
if _, set := o.Environment[k]; set {
continue
}
o.Environment[k] = v
}
return nil
}
// WithEnvFile sets an alternate env file.
//
// Deprecated: use WithEnvFiles instead.
func WithEnvFile(file string) ProjectOptionsFn {
var files []string
if file != "" {
files = []string{file}
}
return WithEnvFiles(files...)
}
// WithEnvFiles set env file(s) to be loaded to set project environment.
// defaults to local .env file if no explicit file is selected, until COMPOSE_DISABLE_ENV_FILE is set
func WithEnvFiles(file ...string) ProjectOptionsFn {
return func(o *ProjectOptions) error {
if len(file) > 0 {
o.EnvFiles = file
return nil
}
if v, ok := os.LookupEnv(consts.ComposeDisableDefaultEnvFile); ok {
b, err := strconv.ParseBool(v)
if err != nil {
return err
}
if b {
return nil
}
}
wd, err := o.GetWorkingDir()
if err != nil {
return err
}
defaultDotEnv := filepath.Join(wd, ".env")
s, err := os.Stat(defaultDotEnv)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
if !s.IsDir() {
o.EnvFiles = []string{defaultDotEnv}
}
return nil
}
}
// WithDotEnv imports environment variables from .env file
func WithDotEnv(o *ProjectOptions) error {
envMap, err := dotenv.GetEnvFromFile(o.Environment, o.EnvFiles)
if err != nil {
return err
}
o.Environment.Merge(envMap)
return nil
}
// WithInterpolation set ProjectOptions to enable/skip interpolation
func WithInterpolation(interpolation bool) ProjectOptionsFn {
return func(o *ProjectOptions) error {
o.loadOptions = append(o.loadOptions, func(options *loader.Options) {
options.SkipInterpolation = !interpolation
})
return nil
}
}
// WithNormalization set ProjectOptions to enable/skip normalization
func WithNormalization(normalization bool) ProjectOptionsFn {
return func(o *ProjectOptions) error {
o.loadOptions = append(o.loadOptions, func(options *loader.Options) {
options.SkipNormalization = !normalization
})
return nil
}
}
// WithConsistency set ProjectOptions to enable/skip consistency
func WithConsistency(consistency bool) ProjectOptionsFn {
return func(o *ProjectOptions) error {
o.loadOptions = append(o.loadOptions, func(options *loader.Options) {
options.SkipConsistencyCheck = !consistency
})
return nil
}
}
// WithResolvedPaths set ProjectOptions to enable paths resolution
func WithResolvedPaths(resolve bool) ProjectOptionsFn {
return func(o *ProjectOptions) error {
o.loadOptions = append(o.loadOptions, func(options *loader.Options) {
options.ResolvePaths = resolve
})
return nil
}
}
// WithResourceLoader register support for ResourceLoader to manage remote resources
func WithResourceLoader(r loader.ResourceLoader) ProjectOptionsFn {
return func(o *ProjectOptions) error {
o.ResourceLoaders = append(o.ResourceLoaders, r)
o.loadOptions = append(o.loadOptions, func(options *loader.Options) {
options.ResourceLoaders = o.ResourceLoaders
})
return nil
}
}
// WithExtension register a know extension `x-*` with the go struct type to decode into
func WithExtension(name string, typ any) ProjectOptionsFn {
return func(o *ProjectOptions) error {
o.loadOptions = append(o.loadOptions, func(options *loader.Options) {
if options.KnownExtensions == nil {
options.KnownExtensions = map[string]any{}
}
options.KnownExtensions[name] = typ
})
return nil
}
}
// Append listener to event
func (o *ProjectOptions) WithListeners(listeners ...loader.Listener) {
o.Listeners = append(o.Listeners, listeners...)
}
// WithoutEnvironmentResolution disable environment resolution
func WithoutEnvironmentResolution(o *ProjectOptions) error {
o.loadOptions = append(o.loadOptions, func(options *loader.Options) {
options.SkipResolveEnvironment = true
})
return nil
}
// DefaultFileNames defines the Compose file names for auto-discovery (in order of preference)
var DefaultFileNames = []string{"compose.yaml", "compose.yml", "docker-compose.yml", "docker-compose.yaml"}
// DefaultOverrideFileNames defines the Compose override file names for auto-discovery (in order of preference)
var DefaultOverrideFileNames = []string{"compose.override.yml", "compose.override.yaml", "docker-compose.override.yml", "docker-compose.override.yaml"}
func (o *ProjectOptions) GetWorkingDir() (string, error) {
if o.WorkingDir != "" {
return filepath.Abs(o.WorkingDir)
}
PATH:
for _, path := range o.ConfigPaths {
if path != "-" {
for _, l := range o.ResourceLoaders {
if l.Accept(path) {
break PATH
}
}
absPath, err := filepath.Abs(path)
if err != nil {
return "", err
}
return filepath.Dir(absPath), nil
}
}
return os.Getwd()
}
// ReadConfigFiles reads ConfigFiles and populates the content field
func (o *ProjectOptions) ReadConfigFiles(ctx context.Context, workingDir string, options *ProjectOptions) (*types.ConfigDetails, error) {
config, err := loader.LoadConfigFiles(ctx, options.ConfigPaths, workingDir, options.loadOptions...)
if err != nil {
return nil, err
}
configs := make([][]byte, len(config.ConfigFiles))
for i, c := range config.ConfigFiles {
var err error
var b []byte
if c.IsStdin() {
b, err = io.ReadAll(os.Stdin)
if err != nil {
return nil, err
}
} else {
f, err := filepath.Abs(c.Filename)
if err != nil {
return nil, err
}
b, err = os.ReadFile(f)
if err != nil {
return nil, err
}
}
configs[i] = b
}
for i, c := range configs {
config.ConfigFiles[i].Content = c
}
return config, nil
}
// LoadProject loads compose file according to options and bind to types.Project go structs
func (o *ProjectOptions) LoadProject(ctx context.Context) (*types.Project, error) {
config, err := o.prepare(ctx)
if err != nil {
return nil, err
}
project, err := loader.LoadWithContext(ctx, types.ConfigDetails{
ConfigFiles: config.ConfigFiles,
WorkingDir: config.WorkingDir,
Environment: o.Environment,
}, o.loadOptions...)
if err != nil {
return nil, err
}
for _, config := range config.ConfigFiles {
project.ComposeFiles = append(project.ComposeFiles, config.Filename)
}
return project, nil
}
// LoadModel loads compose file according to options and returns a raw (yaml tree) model
func (o *ProjectOptions) LoadModel(ctx context.Context) (map[string]any, error) {
configDetails, err := o.prepare(ctx)
if err != nil {
return nil, err
}
return loader.LoadModelWithContext(ctx, *configDetails, o.loadOptions...)
}
// prepare converts ProjectOptions into loader's types.ConfigDetails and configures default load options
func (o *ProjectOptions) prepare(ctx context.Context) (*types.ConfigDetails, error) {
defaultDir, err := o.GetWorkingDir()
if err != nil {
return &types.ConfigDetails{}, err
}
configDetails, err := o.ReadConfigFiles(ctx, defaultDir, o)
if err != nil {
return configDetails, err
}
isNamed := false
if o.Name == "" {
type named struct {
Name string `yaml:"name,omitempty"`
}
// if any of the compose file is named, this is equivalent to user passing --project-name
for _, cfg := range configDetails.ConfigFiles {
var n named
err = yaml.Unmarshal(cfg.Content, &n)
if err != nil {
return nil, err
}
if n.Name != "" {
isNamed = true
break
}
}
}
o.loadOptions = append(o.loadOptions,
withNamePrecedenceLoad(defaultDir, isNamed, o),
withConvertWindowsPaths(o),
withListeners(o))
return configDetails, nil
}
// ProjectFromOptions load a compose project based on command line options
// Deprecated: use ProjectOptions.LoadProject or ProjectOptions.LoadModel
func ProjectFromOptions(ctx context.Context, options *ProjectOptions) (*types.Project, error) {
return options.LoadProject(ctx)
}
func withNamePrecedenceLoad(absWorkingDir string, namedInYaml bool, options *ProjectOptions) func(*loader.Options) {
return func(opts *loader.Options) {
if options.Name != "" {
opts.SetProjectName(options.Name, true)
} else if nameFromEnv, ok := options.Environment[consts.ComposeProjectName]; ok && nameFromEnv != "" {
opts.SetProjectName(nameFromEnv, true)
} else if !namedInYaml {
dirname := filepath.Base(absWorkingDir)
symlink, err := filepath.EvalSymlinks(absWorkingDir)
if err == nil && filepath.Base(symlink) != dirname {
logrus.Warnf("project has been loaded without an explicit name from a symlink. Using name %q", dirname)
}
opts.SetProjectName(
loader.NormalizeProjectName(dirname),
false,
)
}
}
}
func withConvertWindowsPaths(options *ProjectOptions) func(*loader.Options) {
return func(o *loader.Options) {
if o.ResolvePaths {
o.ConvertWindowsPaths = utils.StringToBool(options.Environment["COMPOSE_CONVERT_WINDOWS_PATHS"])
}
}
}
// save listeners from ProjectOptions (compose) to loader.Options
func withListeners(options *ProjectOptions) func(*loader.Options) {
return func(opts *loader.Options) {
opts.Listeners = append(opts.Listeners, options.Listeners...)
}
}
func findFiles(names []string, pwd string) []string {
candidates := []string{}
for _, n := range names {
f := filepath.Join(pwd, n)
if _, err := os.Stat(f); err == nil {
candidates = append(candidates, f)
}
}
return candidates
}
func absolutePaths(p []string) ([]string, error) {
var paths []string
for _, f := range p {
if f == "-" {
paths = append(paths, f)
continue
}
abs, err := filepath.Abs(f)
if err != nil {
return nil, err
}
f = abs
if _, err := os.Stat(f); err != nil {
return nil, err
}
paths = append(paths, f)
}
return paths, nil
}
+29
View File
@@ -0,0 +1,29 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package consts
const (
ComposeProjectName = "COMPOSE_PROJECT_NAME"
ComposePathSeparator = "COMPOSE_PATH_SEPARATOR"
ComposeFilePath = "COMPOSE_FILE"
ComposeDisableDefaultEnvFile = "COMPOSE_DISABLE_ENV_FILE"
ComposeProfiles = "COMPOSE_PROFILES"
)
const Extensions = "#extensions" // Using # prefix, we prevent risk to conflict with an actual yaml key
type ComposeFileKey struct{}
+22
View File
@@ -0,0 +1,22 @@
Copyright (c) 2013 John Barton
MIT License
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+73
View File
@@ -0,0 +1,73 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package dotenv
import (
"bytes"
"fmt"
"os"
"path/filepath"
)
func GetEnvFromFile(currentEnv map[string]string, filenames []string) (map[string]string, error) {
envMap := make(map[string]string)
for _, dotEnvFile := range filenames {
abs, err := filepath.Abs(dotEnvFile)
if err != nil {
return envMap, err
}
dotEnvFile = abs
s, err := os.Stat(dotEnvFile)
if os.IsNotExist(err) {
return envMap, fmt.Errorf("couldn't find env file: %s", dotEnvFile)
}
if err != nil {
return envMap, err
}
if s.IsDir() {
if len(filenames) == 0 {
return envMap, nil
}
return envMap, fmt.Errorf("%s is a directory", dotEnvFile)
}
b, err := os.ReadFile(dotEnvFile)
if os.IsNotExist(err) {
return nil, fmt.Errorf("couldn't read env file: %s", dotEnvFile)
}
if err != nil {
return envMap, err
}
err = parseWithLookup(bytes.NewReader(b), envMap, func(k string) (string, bool) {
v, ok := currentEnv[k]
if ok {
return v, true
}
v, ok = envMap[k]
return v, ok
})
if err != nil {
return envMap, fmt.Errorf("failed to read %s: %w", dotEnvFile, err)
}
}
return envMap, nil
}
+51
View File
@@ -0,0 +1,51 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package dotenv
import (
"fmt"
"io"
)
const DotEnv = ".env"
var formats = map[string]Parser{
DotEnv: func(r io.Reader, filename string, vars map[string]string, lookup func(key string) (string, bool)) error {
err := parseWithLookup(r, vars, lookup)
if err != nil {
return fmt.Errorf("failed to read %s: %w", filename, err)
}
return nil
},
}
type Parser func(r io.Reader, filename string, vars map[string]string, lookup func(key string) (string, bool)) error
func RegisterFormat(format string, p Parser) {
formats[format] = p
}
func ParseWithFormat(r io.Reader, filename string, vars map[string]string, resolve LookupFn, format string) error {
if format == "" {
format = DotEnv
}
fn, ok := formats[format]
if !ok {
return fmt.Errorf("unsupported env_file format %q", format)
}
return fn(r, filename, vars, resolve)
}
+182
View File
@@ -0,0 +1,182 @@
// Package dotenv is a go port of the ruby dotenv library (https://github.com/bkeepers/dotenv)
//
// Examples/readme can be found on the github page at https://github.com/joho/godotenv
//
// The TL;DR is that you make a .env file that looks something like
//
// SOME_ENV_VAR=somevalue
//
// and then in your go code you can call
//
// godotenv.Load()
//
// and all the env vars declared in .env will be available through os.Getenv("SOME_ENV_VAR")
package dotenv
import (
"bytes"
"io"
"os"
"regexp"
"strings"
"github.com/compose-spec/compose-go/v2/template"
)
var utf8BOM = []byte("\uFEFF")
var startsWithDigitRegex = regexp.MustCompile(`^\s*\d.*`) // Keys starting with numbers are ignored
// LookupFn represents a lookup function to resolve variables from
type LookupFn func(string) (string, bool)
var noLookupFn = func(_ string) (string, bool) {
return "", false
}
// Parse reads an env file from io.Reader, returning a map of keys and values.
func Parse(r io.Reader) (map[string]string, error) {
return ParseWithLookup(r, nil)
}
// ParseWithLookup reads an env file from io.Reader, returning a map of keys and values.
func ParseWithLookup(r io.Reader, lookupFn LookupFn) (map[string]string, error) {
vars := map[string]string{}
err := parseWithLookup(r, vars, lookupFn)
return vars, err
}
// ParseWithLookup reads an env file from io.Reader, returning a map of keys and values.
func parseWithLookup(r io.Reader, vars map[string]string, lookupFn LookupFn) error {
data, err := io.ReadAll(r)
if err != nil {
return err
}
// seek past the UTF-8 BOM if it exists (particularly on Windows, some
// editors tend to add it, and it'll cause parsing to fail)
data = bytes.TrimPrefix(data, utf8BOM)
return newParser().parse(string(data), vars, lookupFn)
}
// Load will read your env file(s) and load them into ENV for this process.
//
// Call this function as close as possible to the start of your program (ideally in main).
//
// If you call Load without any args it will default to loading .env in the current path.
//
// You can otherwise tell it which files to load (there can be more than one) like:
//
// godotenv.Load("fileone", "filetwo")
//
// It's important to note that it WILL NOT OVERRIDE an env variable that already exists - consider the .env file to set dev vars or sensible defaults
func Load(filenames ...string) error {
return load(false, filenames...)
}
func load(overload bool, filenames ...string) error {
filenames = filenamesOrDefault(filenames)
for _, filename := range filenames {
err := loadFile(filename, overload)
if err != nil {
return err
}
}
return nil
}
// ReadWithLookup gets all env vars from the files and/or lookup function and return values as
// a map rather than automatically writing values into env
func ReadWithLookup(lookupFn LookupFn, filenames ...string) (map[string]string, error) {
filenames = filenamesOrDefault(filenames)
envMap := make(map[string]string)
for _, filename := range filenames {
individualEnvMap, individualErr := ReadFile(filename, lookupFn)
if individualErr != nil {
return envMap, individualErr
}
for key, value := range individualEnvMap {
if startsWithDigitRegex.MatchString(key) {
continue
}
envMap[key] = value
}
}
return envMap, nil
}
// Read all env (with same file loading semantics as Load) but return values as
// a map rather than automatically writing values into env
func Read(filenames ...string) (map[string]string, error) {
return ReadWithLookup(nil, filenames...)
}
// UnmarshalBytesWithLookup parses env file from byte slice of chars, returning a map of keys and values.
func UnmarshalBytesWithLookup(src []byte, lookupFn LookupFn) (map[string]string, error) {
return UnmarshalWithLookup(string(src), lookupFn)
}
// UnmarshalWithLookup parses env file from string, returning a map of keys and values.
func UnmarshalWithLookup(src string, lookupFn LookupFn) (map[string]string, error) {
out := make(map[string]string)
err := newParser().parse(src, out, lookupFn)
return out, err
}
func filenamesOrDefault(filenames []string) []string {
if len(filenames) == 0 {
return []string{".env"}
}
return filenames
}
func loadFile(filename string, overload bool) error {
envMap, err := ReadFile(filename, nil)
if err != nil {
return err
}
currentEnv := map[string]bool{}
rawEnv := os.Environ()
for _, rawEnvLine := range rawEnv {
key := strings.Split(rawEnvLine, "=")[0]
currentEnv[key] = true
}
for key, value := range envMap {
if !currentEnv[key] || overload {
_ = os.Setenv(key, value)
}
}
return nil
}
func ReadFile(filename string, lookupFn LookupFn) (map[string]string, error) {
file, err := os.Open(filename)
if err != nil {
return nil, err
}
defer file.Close()
return ParseWithLookup(file, lookupFn)
}
func expandVariables(value string, envMap map[string]string, lookupFn LookupFn) (string, error) {
retVal, err := template.Substitute(value, func(k string) (string, bool) {
if v, ok := lookupFn(k); ok {
return v, true
}
v, ok := envMap[k]
return v, ok
})
if err != nil {
return value, err
}
return retVal, nil
}
+286
View File
@@ -0,0 +1,286 @@
package dotenv
import (
"errors"
"fmt"
"regexp"
"strconv"
"strings"
"unicode"
)
const (
charComment = '#'
prefixSingleQuote = '\''
prefixDoubleQuote = '"'
)
var (
escapeSeqRegex = regexp.MustCompile(`(\\(?:[abcfnrtv$"\\]|0\d{0,3}))`)
exportRegex = regexp.MustCompile(`^export\s+`)
)
type parser struct {
line int
}
func newParser() *parser {
return &parser{
line: 1,
}
}
func (p *parser) parse(src string, out map[string]string, lookupFn LookupFn) error {
cutset := src
if lookupFn == nil {
lookupFn = noLookupFn
}
for {
cutset = p.getStatementStart(cutset)
if cutset == "" {
// reached end of file
break
}
key, left, inherited, err := p.locateKeyName(cutset)
if err != nil {
return err
}
if strings.Contains(key, " ") {
return fmt.Errorf("line %d: key cannot contain a space", p.line)
}
if inherited {
value, ok := lookupFn(key)
if ok {
out[key] = value
}
cutset = left
continue
}
value, left, err := p.extractVarValue(left, out, lookupFn)
if err != nil {
return err
}
out[key] = value
cutset = left
}
return nil
}
// getStatementPosition returns position of statement begin.
//
// It skips any comment line or non-whitespace character.
func (p *parser) getStatementStart(src string) string {
pos := p.indexOfNonSpaceChar(src)
if pos == -1 {
return ""
}
src = src[pos:]
if src[0] != charComment {
return src
}
// skip comment section
pos = strings.IndexFunc(src, isCharFunc('\n'))
if pos == -1 {
return ""
}
return p.getStatementStart(src[pos:])
}
// locateKeyName locates and parses key name and returns rest of slice
func (p *parser) locateKeyName(src string) (string, string, bool, error) {
var key string
var inherited bool
// trim "export" and space at beginning
if exportRegex.MatchString(src) {
// we use a `strings.trim` to preserve the pointer to the same underlying memory.
// a regexp replace would copy the string.
src = strings.TrimLeftFunc(strings.TrimPrefix(src, "export"), isSpace)
}
// locate key name end and validate it in single loop
offset := 0
loop:
for i, rune := range src {
if isSpace(rune) {
continue
}
switch rune {
case '=', ':', '\n':
// library also supports yaml-style value declaration
key = src[0:i]
offset = i + 1
inherited = rune == '\n'
break loop
case '_', '.', '-', '[', ']':
default:
// variable name should match [A-Za-z0-9_.-]
if unicode.IsLetter(rune) || unicode.IsNumber(rune) {
continue
}
return "", "", inherited, fmt.Errorf(
`line %d: unexpected character %q in variable name %q`,
p.line, string(rune), strings.Split(src, "\n")[0])
}
}
if src == "" {
return "", "", inherited, errors.New("zero length string")
}
if inherited && strings.IndexByte(key, ' ') == -1 {
p.line++
}
// trim whitespace
key = strings.TrimRightFunc(key, unicode.IsSpace)
cutset := strings.TrimLeftFunc(src[offset:], isSpace)
return key, cutset, inherited, nil
}
// extractVarValue extracts variable value and returns rest of slice
func (p *parser) extractVarValue(src string, envMap map[string]string, lookupFn LookupFn) (string, string, error) {
quote, isQuoted := hasQuotePrefix(src)
if !isQuoted {
// unquoted value - read until new line
value, rest, _ := strings.Cut(src, "\n")
p.line++
// Remove inline comments on unquoted lines
value, _, _ = strings.Cut(value, " #")
value = strings.TrimRightFunc(value, unicode.IsSpace)
retVal, err := expandVariables(value, envMap, lookupFn)
return retVal, rest, err
}
previousCharIsEscape := false
// lookup quoted string terminator
var chars []byte
for i := 1; i < len(src); i++ {
char := src[i]
if char == '\n' {
p.line++
}
if char != quote {
if !previousCharIsEscape && char == '\\' {
previousCharIsEscape = true
continue
}
if previousCharIsEscape {
previousCharIsEscape = false
chars = append(chars, '\\')
}
chars = append(chars, char)
continue
}
// skip escaped quote symbol (\" or \', depends on quote)
if previousCharIsEscape {
previousCharIsEscape = false
chars = append(chars, char)
continue
}
// trim quotes
value := string(chars)
if quote == prefixDoubleQuote {
// expand standard shell escape sequences & then interpolate
// variables on the result
retVal, err := expandVariables(expandEscapes(value), envMap, lookupFn)
if err != nil {
return "", "", err
}
value = retVal
}
return value, src[i+1:], nil
}
// return formatted error if quoted string is not terminated
valEndIndex := strings.IndexFunc(src, isCharFunc('\n'))
if valEndIndex == -1 {
valEndIndex = len(src)
}
return "", "", fmt.Errorf("line %d: unterminated quoted value %s", p.line, src[:valEndIndex])
}
func expandEscapes(str string) string {
out := escapeSeqRegex.ReplaceAllStringFunc(str, func(match string) string {
if match == `\$` {
// `\$` is not a Go escape sequence, the expansion parser uses
// the special `$$` syntax
// both `FOO=\$bar` and `FOO=$$bar` are valid in an env file and
// will result in FOO w/ literal value of "$bar" (no interpolation)
return "$$"
}
if strings.HasPrefix(match, `\0`) {
// octal escape sequences in Go are not prefixed with `\0`, so
// rewrite the prefix, e.g. `\0123` -> `\123` -> literal value "S"
match = strings.Replace(match, `\0`, `\`, 1)
}
// use Go to unquote (unescape) the literal
// see https://go.dev/ref/spec#Rune_literals
//
// NOTE: Go supports ADDITIONAL escapes like `\x` & `\u` & `\U`!
// These are NOT supported, which is why we use a regex to find
// only matches we support and then use `UnquoteChar` instead of a
// `Unquote` on the entire value
v, _, _, err := strconv.UnquoteChar(match, '"')
if err != nil {
return match
}
return string(v)
})
return out
}
func (p *parser) indexOfNonSpaceChar(src string) int {
return strings.IndexFunc(src, func(r rune) bool {
if r == '\n' {
p.line++
}
return !unicode.IsSpace(r)
})
}
// hasQuotePrefix reports whether charset starts with single or double quote and returns quote character
func hasQuotePrefix(src string) (byte, bool) {
if src == "" {
return 0, false
}
switch quote := src[0]; quote {
case prefixDoubleQuote, prefixSingleQuote:
return quote, true // isQuoted
default:
return 0, false
}
}
func isCharFunc(char rune) func(rune) bool {
return func(v rune) bool {
return v == char
}
}
// isSpace reports whether the rune is a space character but not line break character
//
// this differs from unicode.IsSpace, which also applies line break as space
func isSpace(r rune) bool {
switch r {
case '\t', '\v', '\f', '\r', ' ', 0x85, 0xA0:
return true
}
return false
}
+56
View File
@@ -0,0 +1,56 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package errdefs
import "errors"
var (
// ErrNotFound is returned when an object is not found
ErrNotFound = errors.New("not found")
// ErrInvalid is returned when a compose project is invalid
ErrInvalid = errors.New("invalid compose project")
// ErrUnsupported is returned when a compose project uses an unsupported attribute
ErrUnsupported = errors.New("unsupported attribute")
// ErrIncompatible is returned when a compose project uses an incompatible attribute
ErrIncompatible = errors.New("incompatible attribute")
// ErrDisabled is returned when a resource was found in model but is disabled
ErrDisabled = errors.New("disabled")
)
// IsNotFoundError returns true if the unwrapped error is ErrNotFound
func IsNotFoundError(err error) bool {
return errors.Is(err, ErrNotFound)
}
// IsInvalidError returns true if the unwrapped error is ErrInvalid
func IsInvalidError(err error) bool {
return errors.Is(err, ErrInvalid)
}
// IsUnsupportedError returns true if the unwrapped error is ErrUnsupported
func IsUnsupportedError(err error) bool {
return errors.Is(err, ErrUnsupported)
}
// IsUnsupportedError returns true if the unwrapped error is ErrIncompatible
func IsIncompatibleError(err error) bool {
return errors.Is(err, ErrIncompatible)
}
+199
View File
@@ -0,0 +1,199 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package format
import (
"errors"
"fmt"
"strings"
"unicode"
"unicode/utf8"
"github.com/compose-spec/compose-go/v2/types"
)
const endOfSpec = rune(0)
// ParseVolume parses a volume spec without any knowledge of the target platform
func ParseVolume(spec string) (types.ServiceVolumeConfig, error) {
volume := types.ServiceVolumeConfig{}
switch len(spec) {
case 0:
return volume, errors.New("invalid empty volume spec")
case 1, 2:
volume.Target = spec
volume.Type = types.VolumeTypeVolume
return volume, nil
}
var buffer []rune
var inVarSubstitution int // Track nesting depth of ${...}
for i, char := range spec + string(endOfSpec) {
// Check if we're entering a variable substitution
if char == '$' && i+1 < len(spec) && rune(spec[i+1]) == '{' {
inVarSubstitution++
buffer = append(buffer, char)
continue
}
// Check if we're exiting a variable substitution
if char == '}' && inVarSubstitution > 0 {
inVarSubstitution--
buffer = append(buffer, char)
continue
}
switch {
case isWindowsDrive(buffer, char):
buffer = append(buffer, char)
case (char == ':' || char == endOfSpec) && inVarSubstitution == 0:
if err := populateFieldFromBuffer(char, buffer, &volume); err != nil {
populateType(&volume)
return volume, fmt.Errorf("invalid spec: %s: %w", spec, err)
}
buffer = nil
default:
buffer = append(buffer, char)
}
}
populateType(&volume)
return volume, nil
}
func isWindowsDrive(buffer []rune, char rune) bool {
return char == ':' && len(buffer) == 1 && unicode.IsLetter(buffer[0])
}
func populateFieldFromBuffer(char rune, buffer []rune, volume *types.ServiceVolumeConfig) error {
strBuffer := string(buffer)
switch {
case len(buffer) == 0:
return errors.New("empty section between colons")
// Anonymous volume
case volume.Source == "" && char == endOfSpec:
volume.Target = strBuffer
return nil
case volume.Source == "":
volume.Source = strBuffer
return nil
case volume.Target == "":
volume.Target = strBuffer
return nil
case char == ':':
return errors.New("too many colons")
}
for _, option := range strings.Split(strBuffer, ",") {
switch option {
case "ro":
volume.ReadOnly = true
case "rw":
volume.ReadOnly = false
case "nocopy":
volume.Volume = &types.ServiceVolumeVolume{NoCopy: true}
default:
if isBindOption(option) {
setBindOption(volume, option)
}
// ignore unknown options FIXME why not report an error here?
}
}
return nil
}
var Propagations = []string{
types.PropagationRPrivate,
types.PropagationPrivate,
types.PropagationRShared,
types.PropagationShared,
types.PropagationRSlave,
types.PropagationSlave,
}
type setBindOptionFunc func(bind *types.ServiceVolumeBind, option string)
var bindOptions = map[string]setBindOptionFunc{
types.PropagationRPrivate: setBindPropagation,
types.PropagationPrivate: setBindPropagation,
types.PropagationRShared: setBindPropagation,
types.PropagationShared: setBindPropagation,
types.PropagationRSlave: setBindPropagation,
types.PropagationSlave: setBindPropagation,
types.SELinuxShared: setBindSELinux,
types.SELinuxPrivate: setBindSELinux,
}
func setBindPropagation(bind *types.ServiceVolumeBind, option string) {
bind.Propagation = option
}
func setBindSELinux(bind *types.ServiceVolumeBind, option string) {
bind.SELinux = option
}
func isBindOption(option string) bool {
_, ok := bindOptions[option]
return ok
}
func setBindOption(volume *types.ServiceVolumeConfig, option string) {
if volume.Bind == nil {
volume.Bind = &types.ServiceVolumeBind{}
}
bindOptions[option](volume.Bind, option)
}
func populateType(volume *types.ServiceVolumeConfig) {
if isFilePath(volume.Source) {
volume.Type = types.VolumeTypeBind
if volume.Bind == nil {
volume.Bind = &types.ServiceVolumeBind{}
}
// For backward compatibility with docker-compose legacy, using short notation involves
// bind will create missing host path
volume.Bind.CreateHostPath = true
} else {
volume.Type = types.VolumeTypeVolume
if volume.Volume == nil {
volume.Volume = &types.ServiceVolumeVolume{}
}
}
}
func isFilePath(source string) bool {
if source == "" {
return false
}
switch source[0] {
case '.', '/', '~':
return true
}
// windows named pipes
if strings.HasPrefix(source, `\\`) {
return true
}
first, nextIndex := utf8.DecodeRuneInString(source)
if len(source) <= nextIndex {
return false
}
return isWindowsDrive([]rune{first}, rune(source[nextIndex]))
}
+63
View File
@@ -0,0 +1,63 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package graph
import (
"fmt"
"slices"
"strings"
"github.com/compose-spec/compose-go/v2/types"
"github.com/compose-spec/compose-go/v2/utils"
)
// CheckCycle analyze project's depends_on relation and report an error on cycle detection
func CheckCycle(project *types.Project) error {
g, err := newGraph(project)
if err != nil {
return err
}
return g.checkCycle()
}
func (g *graph[T]) checkCycle() error {
// iterate on vertices in a name-order to render a predicable error message
// this is required by tests and enforce command reproducibility by user, which otherwise could be confusing
names := utils.MapKeys(g.vertices)
for _, name := range names {
err := searchCycle([]string{name}, g.vertices[name])
if err != nil {
return err
}
}
return nil
}
func searchCycle[T any](path []string, v *vertex[T]) error {
names := utils.MapKeys(v.children)
for _, name := range names {
if i := slices.Index(path, name); i >= 0 {
return fmt.Errorf("dependency cycle detected: %s -> %s", strings.Join(path[i:], " -> "), name)
}
ch := v.children[name]
err := searchCycle(append(path, name), ch)
if err != nil {
return err
}
}
return nil
}
+75
View File
@@ -0,0 +1,75 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package graph
// graph represents project as service dependencies
type graph[T any] struct {
vertices map[string]*vertex[T]
}
// vertex represents a service in the dependencies structure
type vertex[T any] struct {
key string
service *T
children map[string]*vertex[T]
parents map[string]*vertex[T]
}
func (g *graph[T]) addVertex(name string, service T) {
g.vertices[name] = &vertex[T]{
key: name,
service: &service,
parents: map[string]*vertex[T]{},
children: map[string]*vertex[T]{},
}
}
func (g *graph[T]) addEdge(src, dest string) {
g.vertices[src].children[dest] = g.vertices[dest]
g.vertices[dest].parents[src] = g.vertices[src]
}
func (g *graph[T]) roots() []*vertex[T] {
var res []*vertex[T]
for _, v := range g.vertices {
if len(v.parents) == 0 {
res = append(res, v)
}
}
return res
}
func (g *graph[T]) leaves() []*vertex[T] {
var res []*vertex[T]
for _, v := range g.vertices {
if len(v.children) == 0 {
res = append(res, v)
}
}
return res
}
// descendents return all descendents for a vertex, might contain duplicates
func (v *vertex[T]) descendents() []string {
var vx []string
for _, n := range v.children {
vx = append(vx, n.key)
vx = append(vx, n.descendents()...)
}
return vx
}
+80
View File
@@ -0,0 +1,80 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package graph
import (
"context"
"fmt"
"github.com/compose-spec/compose-go/v2/types"
)
// InDependencyOrder walk the service graph an invoke VisitorFn in respect to dependency order
func InDependencyOrder(ctx context.Context, project *types.Project, fn VisitorFn[types.ServiceConfig], options ...func(*Options)) error {
_, err := CollectInDependencyOrder[any](ctx, project, func(ctx context.Context, s string, config types.ServiceConfig) (any, error) {
return nil, fn(ctx, s, config)
}, options...)
return err
}
// CollectInDependencyOrder walk the service graph an invoke CollectorFn in respect to dependency order, then return result for each call
func CollectInDependencyOrder[T any](ctx context.Context, project *types.Project, fn CollectorFn[types.ServiceConfig, T], options ...func(*Options)) (map[string]T, error) {
graph, err := newGraph(project)
if err != nil {
return nil, err
}
t := newTraversal(fn)
for _, option := range options {
option(t.Options)
}
err = walk(ctx, graph, t)
return t.results, err
}
// newGraph creates a service graph from project
func newGraph(project *types.Project) (*graph[types.ServiceConfig], error) {
g := &graph[types.ServiceConfig]{
vertices: map[string]*vertex[types.ServiceConfig]{},
}
for name, s := range project.Services {
g.addVertex(name, s)
}
for name, s := range project.Services {
src := g.vertices[name]
for dep, condition := range s.DependsOn {
dest, ok := g.vertices[dep]
if !ok {
if condition.Required {
if ds, exists := project.DisabledServices[dep]; exists {
return nil, fmt.Errorf("service %q is required by %q but is disabled. Can be enabled by profiles %s", dep, name, ds.Profiles)
}
return nil, fmt.Errorf("service %q depends on unknown service %q", name, dep)
}
delete(s.DependsOn, name)
project.Services[name] = s
continue
}
src.children[dep] = dest
dest.parents[name] = src
}
}
err := g.checkCycle()
return g, err
}
+211
View File
@@ -0,0 +1,211 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package graph
import (
"context"
"slices"
"sync"
"golang.org/x/sync/errgroup"
)
// CollectorFn executes on each graph vertex based on visit order and return associated value
type CollectorFn[S any, T any] func(context.Context, string, S) (T, error)
// VisitorFn executes on each graph nodes based on visit order
type VisitorFn[S any] func(context.Context, string, S) error
type traversal[S any, T any] struct {
*Options
visitor CollectorFn[S, T]
mu sync.Mutex
status map[string]int
results map[string]T
}
type Options struct {
// inverse reverse the traversal direction
inverse bool
// maxConcurrency limit the concurrent execution of visitorFn while walking the graph
maxConcurrency int
// after marks a set of node as starting points walking the graph
after []string
}
const (
vertexEntered = iota
vertexVisited
)
func newTraversal[S, T any](fn CollectorFn[S, T]) *traversal[S, T] {
return &traversal[S, T]{
Options: &Options{},
status: map[string]int{},
results: map[string]T{},
visitor: fn,
}
}
// WithMaxConcurrency configure traversal to limit concurrency walking graph nodes
func WithMaxConcurrency(concurrency int) func(*Options) {
return func(o *Options) {
o.maxConcurrency = concurrency
}
}
// InReverseOrder configure traversal to walk the graph in reverse dependency order
func InReverseOrder(o *Options) {
o.inverse = true
}
// WithRootNodesAndDown creates a graphTraversal to start from selected nodes
func WithRootNodesAndDown(nodes []string) func(*Options) {
return func(o *Options) {
o.after = nodes
}
}
func walk[S, T any](ctx context.Context, g *graph[S], t *traversal[S, T]) error {
expect := len(g.vertices)
if expect == 0 {
return nil
}
// nodeCh need to allow n=expect writers while reader goroutine could have returned after ctx.Done
nodeCh := make(chan *vertex[S], expect)
defer close(nodeCh)
eg, ctx := errgroup.WithContext(ctx)
if t.maxConcurrency > 0 {
eg.SetLimit(t.maxConcurrency + 1)
}
eg.Go(func() error {
for {
select {
case <-ctx.Done():
return nil
case node := <-nodeCh:
expect--
if expect == 0 {
return nil
}
for _, adj := range t.adjacentNodes(node) {
t.visit(ctx, eg, adj, nodeCh)
}
}
}
})
// select nodes to start walking the graph based on traversal.direction
for _, node := range t.extremityNodes(g) {
t.visit(ctx, eg, node, nodeCh)
}
return eg.Wait()
}
func (t *traversal[S, T]) visit(ctx context.Context, eg *errgroup.Group, node *vertex[S], nodeCh chan *vertex[S]) {
if !t.ready(node) {
// don't visit this service yet as dependencies haven't been visited
return
}
if !t.enter(node) {
// another worker already acquired this node
return
}
eg.Go(func() error {
var (
err error
result T
)
if !t.skip(node) {
result, err = t.visitor(ctx, node.key, *node.service)
}
t.done(node, result)
nodeCh <- node
return err
})
}
func (t *traversal[S, T]) extremityNodes(g *graph[S]) []*vertex[S] {
if t.inverse {
return g.roots()
}
return g.leaves()
}
func (t *traversal[S, T]) adjacentNodes(v *vertex[S]) map[string]*vertex[S] {
if t.inverse {
return v.children
}
return v.parents
}
func (t *traversal[S, T]) ready(v *vertex[S]) bool {
t.mu.Lock()
defer t.mu.Unlock()
depends := v.children
if t.inverse {
depends = v.parents
}
for name := range depends {
if t.status[name] != vertexVisited {
return false
}
}
return true
}
func (t *traversal[S, T]) enter(v *vertex[S]) bool {
t.mu.Lock()
defer t.mu.Unlock()
if _, ok := t.status[v.key]; ok {
return false
}
t.status[v.key] = vertexEntered
return true
}
func (t *traversal[S, T]) done(v *vertex[S], result T) {
t.mu.Lock()
defer t.mu.Unlock()
t.status[v.key] = vertexVisited
t.results[v.key] = result
}
func (t *traversal[S, T]) skip(node *vertex[S]) bool {
if len(t.after) == 0 {
return false
}
if slices.Contains(t.after, node.key) {
return false
}
// is none of our starting node is a descendent, skip visit
ancestors := node.descendents()
for _, name := range t.after {
if slices.Contains(ancestors, name) {
return false
}
}
return true
}
@@ -0,0 +1,137 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package interpolation
import (
"errors"
"fmt"
"os"
"github.com/compose-spec/compose-go/v2/template"
"github.com/compose-spec/compose-go/v2/tree"
)
// Options supported by Interpolate
type Options struct {
// LookupValue from a key
LookupValue LookupValue
// TypeCastMapping maps key paths to functions to cast to a type
TypeCastMapping map[tree.Path]Cast
// Substitution function to use
Substitute func(string, template.Mapping) (string, error)
}
// LookupValue is a function which maps from variable names to values.
// Returns the value as a string and a bool indicating whether
// the value is present, to distinguish between an empty string
// and the absence of a value.
type LookupValue func(key string) (string, bool)
// Cast a value to a new type, or return an error if the value can't be cast
type Cast func(value string) (interface{}, error)
// Interpolate replaces variables in a string with the values from a mapping
func Interpolate(config map[string]interface{}, opts Options) (map[string]interface{}, error) {
if opts.LookupValue == nil {
opts.LookupValue = os.LookupEnv
}
if opts.TypeCastMapping == nil {
opts.TypeCastMapping = make(map[tree.Path]Cast)
}
if opts.Substitute == nil {
opts.Substitute = template.Substitute
}
out := map[string]interface{}{}
for key, value := range config {
interpolatedValue, err := recursiveInterpolate(value, tree.NewPath(key), opts)
if err != nil {
return out, err
}
out[key] = interpolatedValue
}
return out, nil
}
func recursiveInterpolate(value interface{}, path tree.Path, opts Options) (interface{}, error) {
switch value := value.(type) {
case string:
newValue, err := opts.Substitute(value, template.Mapping(opts.LookupValue))
if err != nil {
return value, newPathError(path, err)
}
caster, ok := opts.getCasterForPath(path)
if !ok {
return newValue, nil
}
casted, err := caster(newValue)
if err != nil {
return casted, newPathError(path, fmt.Errorf("failed to cast to expected type: %w", err))
}
return casted, nil
case map[string]interface{}:
out := map[string]interface{}{}
for key, elem := range value {
interpolatedElem, err := recursiveInterpolate(elem, path.Next(key), opts)
if err != nil {
return nil, err
}
out[key] = interpolatedElem
}
return out, nil
case []interface{}:
out := make([]interface{}, len(value))
for i, elem := range value {
interpolatedElem, err := recursiveInterpolate(elem, path.Next(tree.PathMatchList), opts)
if err != nil {
return nil, err
}
out[i] = interpolatedElem
}
return out, nil
default:
return value, nil
}
}
func newPathError(path tree.Path, err error) error {
var ite *template.InvalidTemplateError
switch {
case err == nil:
return nil
case errors.As(err, &ite):
return fmt.Errorf(
"invalid interpolation format for %s.\nYou may need to escape any $ with another $.\n%s",
path, ite.Template)
default:
return fmt.Errorf("error while interpolating %s: %w", path, err)
}
}
func (o Options) getCasterForPath(path tree.Path) (Cast, bool) {
for pattern, caster := range o.TypeCastMapping {
if path.Matches(pattern) {
return caster, true
}
}
return nil, false
}
+110
View File
@@ -0,0 +1,110 @@
/*
Copyright 2020 The Compose Specification Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package loader
import (
"fmt"
"github.com/compose-spec/compose-go/v2/types"
)
// ResolveEnvironment update the environment variables for the format {- VAR} (without interpolation)
func ResolveEnvironment(dict map[string]any, environment types.Mapping) {
resolveServicesEnvironment(dict, environment)
resolveSecretsEnvironment(dict, environment)
resolveConfigsEnvironment(dict, environment)
}
func resolveServicesEnvironment(dict map[string]any, environment types.Mapping) {
services, ok := dict["services"].(map[string]any)
if !ok {
return
}
for service, cfg := range services {
serviceConfig, ok := cfg.(map[string]any)
if !ok {
continue
}
serviceEnv, ok := serviceConfig["environment"].([]any)
if !ok {
continue
}
envs := []any{}
for _, env := range serviceEnv {
varEnv, ok := env.(string)
if !ok {
continue
}
if found, ok := environment[varEnv]; ok {
envs = append(envs, fmt.Sprintf("%s=%s", varEnv, found))
} else {
// either does not exist or it was already resolved in interpolation
envs = append(envs, varEnv)
}
}
serviceConfig["environment"] = envs
services[service] = serviceConfig
}
dict["services"] = services
}
func resolveSecretsEnvironment(dict map[string]any, environment types.Mapping) {
secrets, ok := dict["secrets"].(map[string]any)
if !ok {
return
}
for name, cfg := range secrets {
secret, ok := cfg.(map[string]any)
if !ok {
continue
}
env, ok := secret["environment"].(string)
if !ok {
continue
}
if found, ok := environment[env]; ok {
secret[types.SecretConfigXValue] = found
}
secrets[name] = secret
}
dict["secrets"] = secrets
}
func resolveConfigsEnvironment(dict map[string]any, environment types.Mapping) {
configs, ok := dict["configs"].(map[string]any)
if !ok {
return
}
for name, cfg := range configs {
config, ok := cfg.(map[string]any)
if !ok {
continue
}
env, ok := config["environment"].(string)
if !ok {
continue
}
if found, ok := environment[env]; ok {
config["content"] = found
}
configs[name] = config
}
dict["configs"] = configs
}
+10
View File
@@ -0,0 +1,10 @@
# passed through
FOO=foo_from_env_file
ENV.WITH.DOT=ok
ENV_WITH_UNDERSCORE=ok
# overridden in example2.env
BAR=bar_from_env_file
# overridden in full-example.yml
BAZ=baz_from_env_file
+10
View File
@@ -0,0 +1,10 @@
# passed through
FOO=foo_from_label_file
LABEL.WITH.DOT=ok
LABEL_WITH_UNDERSCORE=ok
# overridden in example2.label
BAR=bar_from_label_file
# overridden in full-example.yml
BAZ=baz_from_label_file
+4
View File
@@ -0,0 +1,4 @@
BAR=bar_from_env_file_2
# overridden in configDetails.Environment
QUX=quz_from_env_file_2
+4
View File
@@ -0,0 +1,4 @@
BAR=bar_from_label_file_2
# overridden in configDetails.Labels
QUX=quz_from_label_file_2

Some files were not shown because too many files have changed in this diff Show More