Compare commits
44
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82a5dc35cc | ||
|
|
16a789fd64 | ||
|
|
2733b20d42 | ||
|
|
d01c5e7828 | ||
|
|
8eba47a3e1 | ||
|
|
527813c924 | ||
|
|
38f7720bd6 | ||
|
|
8c72890a63 | ||
|
|
0e5b42da17 | ||
|
|
4c72dcb49c | ||
|
|
a28cc7afce | ||
|
|
21dccc895d | ||
|
|
bb3c7576f8 | ||
|
|
02cbf7c014 | ||
|
|
a8c8df8604 | ||
|
|
5766a25144 | ||
|
|
1ccbb31b70 | ||
|
|
5e0dac6508 | ||
|
|
38d13eae73 | ||
|
|
b5e22790be | ||
|
|
beee7bd3f2 | ||
|
|
0c0e16914a | ||
|
|
9bd8f4962a | ||
|
|
4e42d4fb9e | ||
|
|
b13b10be9a | ||
|
|
8137e56d1e | ||
|
|
f3943d25d1 | ||
|
|
9859ff46f5 | ||
|
|
3109a0da82 | ||
|
|
b1146a707e | ||
|
|
6d13d14d75 | ||
|
|
427ec22cab | ||
|
|
1111b69f12 | ||
|
|
1541e6aa6a | ||
|
|
14ee80582a | ||
|
|
e5c65b8fa0 | ||
|
|
e1b10f6020 | ||
|
|
8e4ed7b689
|
||
|
|
a44fde2df2
|
||
|
|
e623f55852 | ||
|
|
11e6f28a60 | ||
|
|
acec067d76 | ||
|
|
f50a57af7a | ||
|
|
94624bb16d
|
+11
-6
@@ -8,12 +8,17 @@ steps:
|
||||
- make check
|
||||
|
||||
- name: xgettext-go
|
||||
image: git.coopcloud.tech/toolshed/drone-xgettext-go:latest
|
||||
settings:
|
||||
keyword: i18n.G
|
||||
keyword_ctx: i18n.GC
|
||||
out: pkg/i18n/locales/abra.pot
|
||||
comments_tag: translators
|
||||
image: golang:1.26
|
||||
environment:
|
||||
GOPRIVATE: coopcloud.tech
|
||||
commands:
|
||||
- go run git.coopcloud.tech/toolshed/xgettext-go@latest
|
||||
-o pkg/i18n/locales/abra.pot
|
||||
--keyword=i18n.G
|
||||
--keyword-ctx=i18n.GC
|
||||
--sort-output
|
||||
--add-comments-tag="translators"
|
||||
$(find . -name "*.go" -not -path "*vendor*" | sort)
|
||||
depends_on:
|
||||
- make check
|
||||
when:
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ WORKDIR /app
|
||||
|
||||
RUN CGO_ENABLED=0 make build
|
||||
|
||||
FROM alpine:3.23
|
||||
FROM alpine:3.24
|
||||
|
||||
RUN apk add --no-cache \
|
||||
ca-certificates \
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
ABRA := ./cmd/abra
|
||||
XGETTEXT := ./bin/xgettext-go
|
||||
XGETTEXT := go run git.coopcloud.tech/toolshed/xgettext-go@latest
|
||||
COMMIT := $(shell git rev-list -1 HEAD)
|
||||
GOPATH := $(shell go env GOPATH)
|
||||
GOVERSION := 1.26
|
||||
@@ -62,8 +62,8 @@ update-po:
|
||||
done
|
||||
|
||||
.PHONY: update-pot
|
||||
update-pot: $(XGETTEXT)
|
||||
@${XGETTEXT} \
|
||||
update-pot:
|
||||
@$(XGETTEXT) \
|
||||
-o pkg/i18n/locales/$(DOMAIN).pot \
|
||||
--keyword=i18n.G \
|
||||
--keyword-ctx=i18n.GC \
|
||||
@@ -71,11 +71,6 @@ update-pot: $(XGETTEXT)
|
||||
--add-comments-tag="translators" \
|
||||
$$(find . -name "*.go" -not -path "*vendor*" | sort)
|
||||
|
||||
${XGETTEXT}:
|
||||
@mkdir -p ./bin && \
|
||||
wget -O ./bin/xgettext-go https://git.coopcloud.tech/toolshed/xgettext-go/raw/branch/main/xgettext-go && \
|
||||
chmod +x ./bin/xgettext-go
|
||||
|
||||
.PHONY: update-pot-po-metadata
|
||||
update-pot-po-metadata:
|
||||
@sed -i "s/charset=CHARSET/charset=UTF-8/g" pkg/i18n/locales/*.po pkg/i18n/locales/*.pot
|
||||
@@ -89,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
|
||||
|
||||
+9
-6
@@ -151,13 +151,16 @@ checkout as-is. Recipe commit hashes are also supported as values for
|
||||
|
||||
stackName := app.StackName()
|
||||
deployOpts := stack.Deploy{
|
||||
Composefiles: composeFiles,
|
||||
Namespace: stackName,
|
||||
Prune: false,
|
||||
ResolveImage: stack.ResolveImageAlways,
|
||||
Detach: false,
|
||||
Composefiles: composeFiles,
|
||||
Namespace: stackName,
|
||||
Prune: false,
|
||||
ResolveImage: stack.ResolveImageAlways,
|
||||
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)
|
||||
}
|
||||
|
||||
+3
-3
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
+5
-1
@@ -112,7 +112,11 @@ var AppNewCommand = &cobra.Command{
|
||||
}
|
||||
}
|
||||
|
||||
if len(recipeVersions) > 0 {
|
||||
if recipeVersion != "" {
|
||||
if _, err := recipe.EnsureVersion(recipeVersion); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
} else if len(recipeVersions) > 0 {
|
||||
latest := recipeVersions[len(recipeVersions)-1]
|
||||
for tag := range latest {
|
||||
recipeVersion = tag
|
||||
|
||||
+7
-12
@@ -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))
|
||||
|
||||
|
||||
+8
-6
@@ -166,14 +166,16 @@ beforehand. See "abra app backup" for more.`),
|
||||
|
||||
stackName := app.StackName()
|
||||
deployOpts := stack.Deploy{
|
||||
Composefiles: composeFiles,
|
||||
Namespace: stackName,
|
||||
Prune: false,
|
||||
ResolveImage: stack.ResolveImageAlways,
|
||||
Detach: false,
|
||||
Composefiles: composeFiles,
|
||||
Namespace: stackName,
|
||||
Prune: false,
|
||||
ResolveImage: stack.ResolveImageAlways,
|
||||
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)
|
||||
}
|
||||
|
||||
+2
-2
@@ -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)
|
||||
}
|
||||
|
||||
+8
-6
@@ -178,14 +178,16 @@ beforehand. See "abra app backup" for more.`),
|
||||
|
||||
stackName := app.StackName()
|
||||
deployOpts := stack.Deploy{
|
||||
Composefiles: composeFiles,
|
||||
Namespace: stackName,
|
||||
Prune: false,
|
||||
ResolveImage: stack.ResolveImageAlways,
|
||||
Detach: false,
|
||||
Composefiles: composeFiles,
|
||||
Namespace: stackName,
|
||||
Prune: false,
|
||||
ResolveImage: stack.ResolveImageAlways,
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -70,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
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Generated
+38
@@ -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,
|
||||
|
||||
@@ -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
|
||||
];
|
||||
};
|
||||
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ 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
|
||||
@@ -19,8 +20,9 @@ require (
|
||||
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.0
|
||||
golang.org/x/term v0.43.0
|
||||
github.com/schollz/progressbar/v3 v3.19.1
|
||||
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
|
||||
)
|
||||
@@ -77,9 +79,10 @@ require (
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
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,9 +128,11 @@ 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
|
||||
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.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
|
||||
@@ -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.45.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
|
||||
|
||||
@@ -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=
|
||||
@@ -623,8 +627,8 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
|
||||
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
|
||||
@@ -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,9 +812,11 @@ 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.0 h1:Ea18xuIRQXLAUidVDox3AbwfUhD0/1IvohyTutOIFoc=
|
||||
github.com/schollz/progressbar/v3 v3.19.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec=
|
||||
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=
|
||||
github.com/sclevine/spec v1.2.0/go.mod h1:W4J29eT/Kzv7/b9IWLB055Z+qvVC9vt0Arko24q7p+U=
|
||||
github.com/seccomp/libseccomp-golang v0.9.1/go.mod h1:GbW5+tmTXfcxTToHLXlScSlAvWlF4P2Ca7zGrPiEpWo=
|
||||
github.com/seccomp/libseccomp-golang v0.9.2-0.20210429002308-3879420cc921/go.mod h1:JA8cRccbGaA1s33RQf7Y1+q9gHmZX1yB/z9WDN1C6fg=
|
||||
@@ -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=
|
||||
@@ -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,14 +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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.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.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||
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=
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
];
|
||||
}
|
||||
@@ -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";
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{ ... }:
|
||||
{
|
||||
virtualisation.docker = {
|
||||
enable = true;
|
||||
liveRestore = false;
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
];
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
];
|
||||
};
|
||||
}
|
||||
+15
-10
@@ -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
|
||||
}
|
||||
@@ -333,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
|
||||
}
|
||||
@@ -490,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
|
||||
}
|
||||
@@ -504,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 {
|
||||
|
||||
+7
-7
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
+235
-273
File diff suppressed because it is too large
Load Diff
Binary file not shown.
+789
-518
File diff suppressed because it is too large
Load Diff
+13
-29
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -416,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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ version: "3.8"
|
||||
|
||||
services:
|
||||
app:
|
||||
image: nginx:1.31.1
|
||||
image: nginx:1.31.2
|
||||
secrets:
|
||||
- test_pass_one
|
||||
- test_pass_two
|
||||
|
||||
+11
-2
@@ -5,6 +5,8 @@ import (
|
||||
"log"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
gitPkg "coopcloud.tech/abra/pkg/git"
|
||||
"git.coopcloud.tech/toolshed/godotenv"
|
||||
@@ -12,6 +14,7 @@ import (
|
||||
|
||||
var (
|
||||
AppName = "test_app.example.com"
|
||||
StackName = "test_app_example_com"
|
||||
ServerName = "test_server"
|
||||
RecipeName = "test_recipe"
|
||||
|
||||
@@ -59,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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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: ¶llel,
|
||||
})
|
||||
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))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Executable
+30
@@ -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
@@ -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).
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -61,6 +61,16 @@ teardown(){
|
||||
run grep -q "TYPE=$TEST_RECIPE:0.3.0+1.21.0" \
|
||||
"$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
|
||||
assert_success
|
||||
|
||||
# 0.3.0-only
|
||||
run grep -q "NETWORK_WITH_COMMENT=BAZ" \
|
||||
"$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
|
||||
assert_success
|
||||
|
||||
# latest-only
|
||||
run grep -q "TIMEOUT=120" \
|
||||
"$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
|
||||
assert_failure
|
||||
}
|
||||
|
||||
@test "ensure recipe is up-to-date" {
|
||||
@@ -100,6 +110,16 @@ teardown(){
|
||||
run grep -q "TYPE=$TEST_RECIPE:${tagHash}" \
|
||||
"$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
|
||||
assert_success
|
||||
|
||||
# 0.3.0-only
|
||||
run grep -q "NETWORK_WITH_COMMENT=BAZ" \
|
||||
"$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
|
||||
assert_success
|
||||
|
||||
# latest-only
|
||||
run grep -q "TIMEOUT=120" \
|
||||
"$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
|
||||
assert_failure
|
||||
}
|
||||
|
||||
@test "does not overwrite existing env files" {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1 @@
|
||||
/path/to/this/repo
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
|
||||
services:
|
||||
app:
|
||||
ports:
|
||||
- target: 22
|
||||
published: ${PORT}
|
||||
@@ -1,9 +1,8 @@
|
||||
---
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
app:
|
||||
image: nginx:1.31.1
|
||||
image: nginx:1.31.2
|
||||
networks:
|
||||
- proxy
|
||||
deploy:
|
||||
|
||||
+191
@@ -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
@@ -0,0 +1,2 @@
|
||||
The Compose Specification
|
||||
Copyright 2020 The Compose Specification Authors
|
||||
+590
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
+137
@@ -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
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,4 @@
|
||||
BAR=bar_from_env_file_2
|
||||
|
||||
# overridden in configDetails.Environment
|
||||
QUX=quz_from_env_file_2
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
BAR=bar_from_label_file_2
|
||||
|
||||
# overridden in configDetails.Labels
|
||||
QUX=quz_from_label_file_2
|
||||
+221
@@ -0,0 +1,221 @@
|
||||
/*
|
||||
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 (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/compose-spec/compose-go/v2/consts"
|
||||
"github.com/compose-spec/compose-go/v2/override"
|
||||
"github.com/compose-spec/compose-go/v2/paths"
|
||||
"github.com/compose-spec/compose-go/v2/types"
|
||||
)
|
||||
|
||||
func ApplyExtends(ctx context.Context, dict map[string]any, opts *Options, tracker *cycleTracker, post PostProcessor) error {
|
||||
a, ok := dict["services"]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
services, ok := a.(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("services must be a mapping")
|
||||
}
|
||||
for name := range services {
|
||||
merged, err := applyServiceExtends(ctx, name, services, opts, tracker, post)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
services[name] = merged
|
||||
}
|
||||
dict["services"] = services
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyServiceExtends(ctx context.Context, name string, services map[string]any, opts *Options, tracker *cycleTracker, post PostProcessor) (any, error) {
|
||||
s := services[name]
|
||||
if s == nil {
|
||||
return nil, nil
|
||||
}
|
||||
service, ok := s.(map[string]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("services.%s must be a mapping", name)
|
||||
}
|
||||
extends, ok := service["extends"]
|
||||
if !ok {
|
||||
return s, nil
|
||||
}
|
||||
filename := ctx.Value(consts.ComposeFileKey{}).(string)
|
||||
var (
|
||||
err error
|
||||
ref string
|
||||
file any
|
||||
)
|
||||
switch v := extends.(type) {
|
||||
case map[string]any:
|
||||
ref, ok = v["service"].(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("extends.%s.service is required", name)
|
||||
}
|
||||
file = v["file"]
|
||||
opts.ProcessEvent("extends", v)
|
||||
case string:
|
||||
ref = v
|
||||
opts.ProcessEvent("extends", map[string]any{"service": ref})
|
||||
}
|
||||
|
||||
var (
|
||||
base any
|
||||
processor = post
|
||||
)
|
||||
|
||||
if file != nil {
|
||||
refFilename := file.(string)
|
||||
services, processor, err = getExtendsBaseFromFile(ctx, name, ref, filename, refFilename, opts, tracker)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
filename = refFilename
|
||||
} else {
|
||||
_, ok := services[ref]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("cannot extend service %q in %s: service %q not found", name, filename, ref)
|
||||
}
|
||||
}
|
||||
|
||||
tracker, err = tracker.Add(filename, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// recursively apply `extends`
|
||||
base, err = applyServiceExtends(ctx, ref, services, opts, tracker, processor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if base == nil {
|
||||
return service, nil
|
||||
}
|
||||
source := deepClone(base).(map[string]any)
|
||||
|
||||
err = post.Apply(map[string]any{
|
||||
"services": map[string]any{
|
||||
name: source,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
merged, err := override.ExtendService(source, service)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
delete(merged, "extends")
|
||||
services[name] = merged
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
func getExtendsBaseFromFile(
|
||||
ctx context.Context,
|
||||
name, ref string,
|
||||
path, refPath string,
|
||||
opts *Options,
|
||||
ct *cycleTracker,
|
||||
) (map[string]any, PostProcessor, error) {
|
||||
for _, loader := range opts.ResourceLoaders {
|
||||
if !loader.Accept(refPath) {
|
||||
continue
|
||||
}
|
||||
local, err := loader.Load(ctx, refPath)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
localdir := filepath.Dir(local)
|
||||
relworkingdir := loader.Dir(refPath)
|
||||
|
||||
extendsOpts := opts.clone()
|
||||
// replace localResourceLoader with a new flavour, using extended file base path
|
||||
extendsOpts.ResourceLoaders = append(opts.RemoteResourceLoaders(), localResourceLoader{
|
||||
WorkingDir: localdir,
|
||||
})
|
||||
extendsOpts.ResolvePaths = false // we do relative path resolution after file has been loaded
|
||||
extendsOpts.SkipNormalization = true
|
||||
extendsOpts.SkipConsistencyCheck = true
|
||||
extendsOpts.SkipInclude = true
|
||||
extendsOpts.SkipExtends = true // we manage extends recursively based on raw service definition
|
||||
extendsOpts.SkipValidation = true // we validate the merge result
|
||||
extendsOpts.SkipDefaultValues = true
|
||||
source, processor, err := loadYamlFile(ctx, types.ConfigFile{Filename: local},
|
||||
extendsOpts, relworkingdir, nil, ct, map[string]any{}, nil)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
m, ok := source["services"]
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("cannot extend service %q in %s: no services section", name, local)
|
||||
}
|
||||
services, ok := m.(map[string]any)
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("cannot extend service %q in %s: services must be a mapping", name, local)
|
||||
}
|
||||
_, ok = services[ref]
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf(
|
||||
"cannot extend service %q in %s: service %q not found in %s",
|
||||
name,
|
||||
path,
|
||||
ref,
|
||||
refPath,
|
||||
)
|
||||
}
|
||||
|
||||
var remotes []paths.RemoteResource
|
||||
for _, loader := range opts.RemoteResourceLoaders() {
|
||||
remotes = append(remotes, loader.Accept)
|
||||
}
|
||||
err = paths.ResolveRelativePaths(source, relworkingdir, remotes)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return services, processor, nil
|
||||
}
|
||||
return nil, nil, fmt.Errorf("cannot read %s", refPath)
|
||||
}
|
||||
|
||||
func deepClone(value any) any {
|
||||
switch v := value.(type) {
|
||||
case []any:
|
||||
cp := make([]any, len(v))
|
||||
for i, e := range v {
|
||||
cp[i] = deepClone(e)
|
||||
}
|
||||
return cp
|
||||
case map[string]any:
|
||||
cp := make(map[string]any, len(v))
|
||||
for k, e := range v {
|
||||
cp[k] = deepClone(e)
|
||||
}
|
||||
return cp
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
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
|
||||
|
||||
// fixEmptyNotNull is a workaround for https://github.com/xeipuuv/gojsonschema/issues/141
|
||||
// as go-yaml `[]` will load as a `[]any(nil)`, which is not the same as an empty array
|
||||
func fixEmptyNotNull(value any) interface{} {
|
||||
switch v := value.(type) {
|
||||
case []any:
|
||||
if v == nil {
|
||||
return []any{}
|
||||
}
|
||||
for i, e := range v {
|
||||
v[i] = fixEmptyNotNull(e)
|
||||
}
|
||||
case map[string]any:
|
||||
for k, e := range v {
|
||||
v[k] = fixEmptyNotNull(e)
|
||||
}
|
||||
}
|
||||
return value
|
||||
}
|
||||
+461
@@ -0,0 +1,461 @@
|
||||
name: full_example_project_name
|
||||
services:
|
||||
|
||||
bar:
|
||||
build:
|
||||
dockerfile_inline: |
|
||||
FROM alpine
|
||||
RUN echo "hello" > /world.txt
|
||||
|
||||
foo:
|
||||
annotations:
|
||||
- com.example.foo=bar
|
||||
build:
|
||||
context: ./dir
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
foo: bar
|
||||
ssh:
|
||||
- default
|
||||
target: foo
|
||||
network: foo
|
||||
cache_from:
|
||||
- foo
|
||||
- bar
|
||||
labels: [FOO=BAR]
|
||||
additional_contexts:
|
||||
foo: ./bar
|
||||
secrets:
|
||||
- source: secret1
|
||||
target: /run/secrets/secret1
|
||||
- source: secret2
|
||||
target: my_secret
|
||||
uid: '103'
|
||||
gid: '103'
|
||||
mode: 0440
|
||||
tags:
|
||||
- foo:v1.0.0
|
||||
- docker.io/username/foo:my-other-tag
|
||||
- ${COMPOSE_PROJECT_NAME}:1.0.0
|
||||
platforms:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
|
||||
|
||||
cap_add:
|
||||
- ALL
|
||||
|
||||
cap_drop:
|
||||
- NET_ADMIN
|
||||
- SYS_ADMIN
|
||||
|
||||
cgroup_parent: m-executor-abcd
|
||||
|
||||
# String or list
|
||||
command: bundle exec thin -p 3000
|
||||
# command: ["bundle", "exec", "thin", "-p", "3000"]
|
||||
|
||||
configs:
|
||||
- config1
|
||||
- source: config2
|
||||
target: /my_config
|
||||
uid: '103'
|
||||
gid: '103'
|
||||
mode: 0440
|
||||
|
||||
container_name: my-web-container
|
||||
|
||||
depends_on:
|
||||
- db
|
||||
- redis
|
||||
|
||||
deploy:
|
||||
mode: replicated
|
||||
replicas: 6
|
||||
labels: [FOO=BAR]
|
||||
rollback_config:
|
||||
parallelism: 3
|
||||
delay: 10s
|
||||
failure_action: continue
|
||||
monitor: 60s
|
||||
max_failure_ratio: 0.3
|
||||
order: start-first
|
||||
update_config:
|
||||
parallelism: 3
|
||||
delay: 10s
|
||||
failure_action: continue
|
||||
monitor: 60s
|
||||
max_failure_ratio: 0.3
|
||||
order: start-first
|
||||
resources:
|
||||
limits:
|
||||
cpus: '0.001'
|
||||
memory: 50M
|
||||
reservations:
|
||||
cpus: '0.0001'
|
||||
memory: 20M
|
||||
generic_resources:
|
||||
- discrete_resource_spec:
|
||||
kind: 'gpu'
|
||||
value: 2
|
||||
- discrete_resource_spec:
|
||||
kind: 'ssd'
|
||||
value: 1
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
delay: 5s
|
||||
max_attempts: 3
|
||||
window: 120s
|
||||
placement:
|
||||
constraints: [node=foo]
|
||||
max_replicas_per_node: 5
|
||||
preferences:
|
||||
- spread: node.labels.az
|
||||
endpoint_mode: dnsrr
|
||||
|
||||
device_cgroup_rules:
|
||||
- "c 1:3 mr"
|
||||
- "a 7:* rmw"
|
||||
|
||||
devices:
|
||||
- source: /dev/ttyUSB0
|
||||
target: /dev/ttyUSB0
|
||||
permissions: rwm
|
||||
|
||||
# String or list
|
||||
# dns: 8.8.8.8
|
||||
dns:
|
||||
- 8.8.8.8
|
||||
- 9.9.9.9
|
||||
|
||||
# String or list
|
||||
# dns_search: example.com
|
||||
dns_search:
|
||||
- dc1.example.com
|
||||
- dc2.example.com
|
||||
|
||||
domainname: foo.com
|
||||
|
||||
# String or list
|
||||
# entrypoint: /code/entrypoint.sh -p 3000
|
||||
entrypoint: ["/code/entrypoint.sh", "-p", "3000"]
|
||||
|
||||
# String or list
|
||||
# env_file: .env
|
||||
env_file:
|
||||
- ./example1.env
|
||||
- path: ./example2.env
|
||||
required: false
|
||||
|
||||
# Mapping or list
|
||||
# Mapping values can be strings, numbers or null
|
||||
# Booleans are not allowed - must be quoted
|
||||
environment:
|
||||
BAZ: baz_from_service_def
|
||||
QUX:
|
||||
# environment:
|
||||
# - RACK_ENV=development
|
||||
# - SHOW=true
|
||||
# - SESSION_SECRET
|
||||
|
||||
# Items can be strings or numbers
|
||||
expose:
|
||||
- "3000"
|
||||
- 8000
|
||||
|
||||
external_links:
|
||||
- redis_1
|
||||
- project_db_1:mysql
|
||||
- project_db_1:postgresql
|
||||
|
||||
# Mapping or list
|
||||
# Mapping values must be strings
|
||||
# extra_hosts:
|
||||
# somehost: "162.242.195.82"
|
||||
# otherhost: "50.31.209.229"
|
||||
extra_hosts:
|
||||
- "otherhost:50.31.209.229"
|
||||
- "somehost:162.242.195.82"
|
||||
|
||||
hostname: foo
|
||||
|
||||
healthcheck:
|
||||
test: echo "hello world"
|
||||
interval: 10s
|
||||
timeout: 1s
|
||||
retries: 5
|
||||
start_period: 15s
|
||||
start_interval: 5s
|
||||
|
||||
# Any valid image reference - repo, tag, id, sha
|
||||
image: redis
|
||||
# image: ubuntu:14.04
|
||||
# image: tutum/influxdb
|
||||
# image: example-registry.com:4000/postgresql
|
||||
# image: a4bc65fd
|
||||
# image: busybox@sha256:38a203e1986cf79639cfb9b2e1d6e773de84002feea2d4eb006b52004ee8502d
|
||||
|
||||
ipc: host
|
||||
|
||||
uts: host
|
||||
|
||||
# Mapping or list
|
||||
# Mapping values can be strings, numbers or null
|
||||
labels:
|
||||
com.example.description: "Accounting webapp"
|
||||
com.example.number: 42
|
||||
com.example.empty-label:
|
||||
# labels:
|
||||
# - "com.example.description=Accounting webapp"
|
||||
# - "com.example.number=42"
|
||||
# - "com.example.empty-label"
|
||||
|
||||
label_file:
|
||||
- ./example1.label
|
||||
- ./example2.label
|
||||
|
||||
links:
|
||||
- db
|
||||
- db:database
|
||||
- redis
|
||||
|
||||
logging:
|
||||
driver: syslog
|
||||
options:
|
||||
syslog-address: "tcp://192.168.0.42:123"
|
||||
|
||||
mac_address: 02:42:ac:11:65:43
|
||||
|
||||
# network_mode: "bridge"
|
||||
# network_mode: "host"
|
||||
# network_mode: "none"
|
||||
# Use the network mode of an arbitrary container from another service
|
||||
# network_mode: "service:db"
|
||||
# Use the network mode of another container, specified by name or id
|
||||
# network_mode: "container:some-container"
|
||||
network_mode: "container:0cfeab0f748b9a743dc3da582046357c6ef497631c1a016d28d2bf9b4f899f7b"
|
||||
|
||||
networks:
|
||||
some-network:
|
||||
aliases:
|
||||
- alias1
|
||||
- alias3
|
||||
other-network:
|
||||
ipv4_address: 172.16.238.10
|
||||
ipv6_address: 2001:3984:3989::10
|
||||
mac_address: 02:42:72:98:65:08
|
||||
other-other-network:
|
||||
|
||||
pid: "host"
|
||||
|
||||
ports:
|
||||
- 3000
|
||||
- "3001-3005"
|
||||
- "8000:8000"
|
||||
- "9090-9091:8080-8081"
|
||||
- "49100:22"
|
||||
- "127.0.0.1:8001:8001"
|
||||
- "127.0.0.1:5000-5010:5000-5010"
|
||||
|
||||
privileged: true
|
||||
|
||||
read_only: true
|
||||
|
||||
restart: always
|
||||
|
||||
secrets:
|
||||
- source: secret1
|
||||
target: /run/secrets/secret1
|
||||
- source: secret2
|
||||
target: my_secret
|
||||
uid: '103'
|
||||
gid: '103'
|
||||
mode: 0440
|
||||
|
||||
security_opt:
|
||||
- label=level:s0:c100,c200
|
||||
- label=type:svirt_apache_t
|
||||
|
||||
stdin_open: true
|
||||
|
||||
stop_grace_period: 20s
|
||||
|
||||
stop_signal: SIGUSR1
|
||||
storage_opt:
|
||||
size: "20G"
|
||||
sysctls:
|
||||
net.core.somaxconn: 1024
|
||||
net.ipv4.tcp_syncookies: 0
|
||||
|
||||
# String or list
|
||||
# tmpfs: /run
|
||||
tmpfs:
|
||||
- /run
|
||||
- /tmp
|
||||
|
||||
tty: true
|
||||
|
||||
ulimits:
|
||||
# Single number or mapping with soft + hard limits
|
||||
nproc: 65535
|
||||
nofile:
|
||||
soft: 20000
|
||||
hard: 40000
|
||||
|
||||
user: someone
|
||||
|
||||
volumes:
|
||||
# Just specify a path and let the Engine create a volume
|
||||
- /var/lib/anonymous
|
||||
# Specify an absolute path mapping
|
||||
- /opt/data:/var/lib/data
|
||||
# Path on the host, relative to the Compose file
|
||||
- .:/code
|
||||
- ./static:/var/www/html
|
||||
# User-relative path
|
||||
- ~/configs:/etc/configs:ro
|
||||
# Named volume
|
||||
- datavolume:/var/lib/volume
|
||||
- type: bind
|
||||
source: ./opt
|
||||
target: /opt/cached
|
||||
consistency: cached
|
||||
- type: tmpfs
|
||||
target: /opt/tmpfs
|
||||
tmpfs:
|
||||
size: 10000
|
||||
|
||||
working_dir: /code
|
||||
x-bar: baz
|
||||
x-foo: bar
|
||||
|
||||
networks:
|
||||
# Entries can be null, which specifies simply that a network
|
||||
# called "{project name}_some-network" should be created and
|
||||
# use the default driver
|
||||
some-network:
|
||||
|
||||
other-network:
|
||||
driver: overlay
|
||||
|
||||
driver_opts:
|
||||
# Values can be strings or numbers
|
||||
foo: "bar"
|
||||
baz: 1
|
||||
|
||||
ipam:
|
||||
driver: overlay
|
||||
# driver_opts:
|
||||
# # Values can be strings or numbers
|
||||
# com.docker.network.enable_ipv6: "true"
|
||||
# com.docker.network.numeric_value: 1
|
||||
config:
|
||||
- subnet: 172.28.0.0/16
|
||||
ip_range: 172.28.5.0/24
|
||||
gateway: 172.28.5.254
|
||||
aux_addresses:
|
||||
host1: 172.28.1.5
|
||||
host2: 172.28.1.6
|
||||
host3: 172.28.1.7
|
||||
- subnet: 2001:3984:3989::/64
|
||||
gateway: 2001:3984:3989::1
|
||||
|
||||
labels:
|
||||
foo: bar
|
||||
|
||||
external-network:
|
||||
# Specifies that a pre-existing network called "external-network"
|
||||
# can be referred to within this file as "external-network"
|
||||
external: true
|
||||
|
||||
other-external-network:
|
||||
# Specifies that a pre-existing network called "my-cool-network"
|
||||
# can be referred to within this file as "other-external-network"
|
||||
external:
|
||||
name: my-cool-network
|
||||
x-bar: baz
|
||||
x-foo: bar
|
||||
|
||||
volumes:
|
||||
# Entries can be null, which specifies simply that a volume
|
||||
# called "{project name}_some-volume" should be created and
|
||||
# use the default driver
|
||||
some-volume:
|
||||
|
||||
other-volume:
|
||||
driver: flocker
|
||||
|
||||
driver_opts:
|
||||
# Values can be strings or numbers
|
||||
foo: "bar"
|
||||
baz: 1
|
||||
labels:
|
||||
foo: bar
|
||||
|
||||
another-volume:
|
||||
name: "user_specified_name"
|
||||
driver: vsphere
|
||||
|
||||
driver_opts:
|
||||
# Values can be strings or numbers
|
||||
foo: "bar"
|
||||
baz: 1
|
||||
|
||||
external-volume:
|
||||
# Specifies that a pre-existing volume called "external-volume"
|
||||
# can be referred to within this file as "external-volume"
|
||||
external: true
|
||||
|
||||
other-external-volume:
|
||||
# Specifies that a pre-existing volume called "my-cool-volume"
|
||||
# can be referred to within this file as "other-external-volume"
|
||||
# This example uses the deprecated "volume.external.name" (replaced by "volume.name")
|
||||
external:
|
||||
name: my-cool-volume
|
||||
|
||||
external-volume3:
|
||||
# Specifies that a pre-existing volume called "this-is-volume3"
|
||||
# can be referred to within this file as "external-volume3"
|
||||
name: this-is-volume3
|
||||
external: true
|
||||
x-bar: baz
|
||||
x-foo: bar
|
||||
|
||||
configs:
|
||||
config1:
|
||||
file: ./config_data
|
||||
labels:
|
||||
foo: bar
|
||||
config2:
|
||||
external:
|
||||
name: my_config
|
||||
config3:
|
||||
external: true
|
||||
config4:
|
||||
name: foo
|
||||
file: ~/config_data
|
||||
x-bar: baz
|
||||
x-foo: bar
|
||||
|
||||
secrets:
|
||||
secret1:
|
||||
file: ./secret_data
|
||||
labels:
|
||||
foo: bar
|
||||
secret2:
|
||||
external:
|
||||
name: my_secret
|
||||
secret3:
|
||||
external: true
|
||||
secret4:
|
||||
name: bar
|
||||
environment: BAR
|
||||
x-bar: baz
|
||||
x-foo: bar
|
||||
secret5:
|
||||
file: /abs/secret_data
|
||||
x-bar: baz
|
||||
x-foo: bar
|
||||
x-nested:
|
||||
bar: baz
|
||||
foo: bar
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
/*
|
||||
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 (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/compose-spec/compose-go/v2/dotenv"
|
||||
interp "github.com/compose-spec/compose-go/v2/interpolation"
|
||||
"github.com/compose-spec/compose-go/v2/override"
|
||||
"github.com/compose-spec/compose-go/v2/tree"
|
||||
"github.com/compose-spec/compose-go/v2/types"
|
||||
)
|
||||
|
||||
// loadIncludeConfig parse the required config from raw yaml
|
||||
func loadIncludeConfig(source any) ([]types.IncludeConfig, error) {
|
||||
if source == nil {
|
||||
return nil, nil
|
||||
}
|
||||
configs, ok := source.([]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("`include` must be a list, got %s", source)
|
||||
}
|
||||
for i, config := range configs {
|
||||
if v, ok := config.(string); ok {
|
||||
configs[i] = map[string]any{
|
||||
"path": v,
|
||||
}
|
||||
}
|
||||
}
|
||||
var requires []types.IncludeConfig
|
||||
err := Transform(source, &requires)
|
||||
return requires, err
|
||||
}
|
||||
|
||||
func ApplyInclude(ctx context.Context, workingDir string, environment types.Mapping, model map[string]any, options *Options, included []string, processor PostProcessor) error {
|
||||
includeConfig, err := loadIncludeConfig(model["include"])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, r := range includeConfig {
|
||||
for _, listener := range options.Listeners {
|
||||
listener("include", map[string]any{
|
||||
"path": r.Path,
|
||||
"workingdir": workingDir,
|
||||
})
|
||||
}
|
||||
|
||||
var relworkingdir string
|
||||
for i, p := range r.Path {
|
||||
for _, loader := range options.ResourceLoaders {
|
||||
if !loader.Accept(p) {
|
||||
continue
|
||||
}
|
||||
path, err := loader.Load(ctx, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p = path
|
||||
|
||||
if i == 0 { // This is the "main" file, used to define project-directory. Others are overrides
|
||||
|
||||
switch {
|
||||
case r.ProjectDirectory == "":
|
||||
relworkingdir = loader.Dir(path)
|
||||
r.ProjectDirectory = filepath.Dir(path)
|
||||
case !filepath.IsAbs(r.ProjectDirectory):
|
||||
relworkingdir = loader.Dir(r.ProjectDirectory)
|
||||
r.ProjectDirectory = filepath.Join(workingDir, r.ProjectDirectory)
|
||||
|
||||
default:
|
||||
relworkingdir = r.ProjectDirectory
|
||||
|
||||
}
|
||||
for _, f := range included {
|
||||
if f == path {
|
||||
included = append(included, path)
|
||||
return fmt.Errorf("include cycle detected:\n%s\n include %s", included[0], strings.Join(included[1:], "\n include "))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
r.Path[i] = p
|
||||
}
|
||||
|
||||
loadOptions := options.clone()
|
||||
loadOptions.ResolvePaths = true
|
||||
loadOptions.SkipNormalization = true
|
||||
loadOptions.SkipConsistencyCheck = true
|
||||
loadOptions.ResourceLoaders = append(loadOptions.RemoteResourceLoaders(), localResourceLoader{
|
||||
WorkingDir: r.ProjectDirectory,
|
||||
})
|
||||
|
||||
if len(r.EnvFile) == 0 {
|
||||
f := filepath.Join(r.ProjectDirectory, ".env")
|
||||
if s, err := os.Stat(f); err == nil && !s.IsDir() {
|
||||
r.EnvFile = types.StringList{f}
|
||||
}
|
||||
} else {
|
||||
envFile := []string{}
|
||||
for _, f := range r.EnvFile {
|
||||
if f == "/dev/null" {
|
||||
continue
|
||||
}
|
||||
if !filepath.IsAbs(f) {
|
||||
f = filepath.Join(workingDir, f)
|
||||
s, err := os.Stat(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if s.IsDir() {
|
||||
return fmt.Errorf("%s is not a file", f)
|
||||
}
|
||||
}
|
||||
envFile = append(envFile, f)
|
||||
}
|
||||
r.EnvFile = envFile
|
||||
}
|
||||
|
||||
envFromFile, err := dotenv.GetEnvFromFile(environment, r.EnvFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
config := types.ConfigDetails{
|
||||
WorkingDir: relworkingdir,
|
||||
ConfigFiles: types.ToConfigFiles(r.Path),
|
||||
Environment: environment.Clone().Merge(envFromFile),
|
||||
}
|
||||
loadOptions.Interpolate = &interp.Options{
|
||||
Substitute: options.Interpolate.Substitute,
|
||||
LookupValue: config.LookupEnv,
|
||||
TypeCastMapping: options.Interpolate.TypeCastMapping,
|
||||
}
|
||||
imported, err := loadYamlModel(ctx, config, loadOptions, &cycleTracker{}, included)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = importResources(imported, model, processor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
delete(model, "include")
|
||||
return nil
|
||||
}
|
||||
|
||||
// importResources import into model all resources defined by imported, and report error on conflict
|
||||
func importResources(source map[string]any, target map[string]any, processor PostProcessor) error {
|
||||
if err := importResource(source, target, "services", processor); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := importResource(source, target, "volumes", processor); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := importResource(source, target, "networks", processor); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := importResource(source, target, "secrets", processor); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := importResource(source, target, "configs", processor); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := importResource(source, target, "models", processor); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func importResource(source map[string]any, target map[string]any, key string, processor PostProcessor) error {
|
||||
from := source[key]
|
||||
if from != nil {
|
||||
var to map[string]any
|
||||
if v, ok := target[key]; ok {
|
||||
to = v.(map[string]any)
|
||||
} else {
|
||||
to = map[string]any{}
|
||||
}
|
||||
for name, a := range from.(map[string]any) {
|
||||
conflict, ok := to[name]
|
||||
if !ok {
|
||||
to[name] = a
|
||||
continue
|
||||
}
|
||||
err := processor.Apply(map[string]any{
|
||||
key: map[string]any{
|
||||
name: a,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
merged, err := override.MergeYaml(a, conflict, tree.NewPath(key, name))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
to[name] = merged
|
||||
}
|
||||
target[key] = to
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
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"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
interp "github.com/compose-spec/compose-go/v2/interpolation"
|
||||
"github.com/compose-spec/compose-go/v2/tree"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
var interpolateTypeCastMapping = map[tree.Path]interp.Cast{
|
||||
servicePath("cpu_count"): toInt64,
|
||||
servicePath("cpu_percent"): toFloat,
|
||||
servicePath("cpu_period"): toInt64,
|
||||
servicePath("cpu_quota"): toInt64,
|
||||
servicePath("cpu_rt_period"): toInt64,
|
||||
servicePath("cpu_rt_runtime"): toInt64,
|
||||
servicePath("cpus"): toFloat32,
|
||||
servicePath("cpu_shares"): toInt64,
|
||||
servicePath("init"): toBoolean,
|
||||
servicePath("depends_on", tree.PathMatchAll, "required"): toBoolean,
|
||||
servicePath("depends_on", tree.PathMatchAll, "restart"): toBoolean,
|
||||
servicePath("deploy", "replicas"): toInt,
|
||||
servicePath("deploy", "update_config", "parallelism"): toInt,
|
||||
servicePath("deploy", "update_config", "max_failure_ratio"): toFloat,
|
||||
servicePath("deploy", "rollback_config", "parallelism"): toInt,
|
||||
servicePath("deploy", "rollback_config", "max_failure_ratio"): toFloat,
|
||||
servicePath("deploy", "restart_policy", "max_attempts"): toInt,
|
||||
servicePath("deploy", "placement", "max_replicas_per_node"): toInt,
|
||||
servicePath("healthcheck", "retries"): toInt,
|
||||
servicePath("healthcheck", "disable"): toBoolean,
|
||||
servicePath("oom_kill_disable"): toBoolean,
|
||||
servicePath("oom_score_adj"): toInt64,
|
||||
servicePath("pids_limit"): toInt64,
|
||||
servicePath("ports", tree.PathMatchList, "target"): toInt,
|
||||
servicePath("privileged"): toBoolean,
|
||||
servicePath("read_only"): toBoolean,
|
||||
servicePath("scale"): toInt,
|
||||
servicePath("stdin_open"): toBoolean,
|
||||
servicePath("tty"): toBoolean,
|
||||
servicePath("ulimits", tree.PathMatchAll): toInt,
|
||||
servicePath("ulimits", tree.PathMatchAll, "hard"): toInt,
|
||||
servicePath("ulimits", tree.PathMatchAll, "soft"): toInt,
|
||||
servicePath("volumes", tree.PathMatchList, "read_only"): toBoolean,
|
||||
servicePath("volumes", tree.PathMatchList, "volume", "nocopy"): toBoolean,
|
||||
iPath("networks", tree.PathMatchAll, "external"): toBoolean,
|
||||
iPath("networks", tree.PathMatchAll, "internal"): toBoolean,
|
||||
iPath("networks", tree.PathMatchAll, "attachable"): toBoolean,
|
||||
iPath("networks", tree.PathMatchAll, "enable_ipv4"): toBoolean,
|
||||
iPath("networks", tree.PathMatchAll, "enable_ipv6"): toBoolean,
|
||||
iPath("volumes", tree.PathMatchAll, "external"): toBoolean,
|
||||
iPath("secrets", tree.PathMatchAll, "external"): toBoolean,
|
||||
iPath("configs", tree.PathMatchAll, "external"): toBoolean,
|
||||
}
|
||||
|
||||
func iPath(parts ...string) tree.Path {
|
||||
return tree.NewPath(parts...)
|
||||
}
|
||||
|
||||
func servicePath(parts ...string) tree.Path {
|
||||
return iPath(append([]string{"services", tree.PathMatchAll}, parts...)...)
|
||||
}
|
||||
|
||||
func toInt(value string) (interface{}, error) {
|
||||
return strconv.Atoi(value)
|
||||
}
|
||||
|
||||
func toInt64(value string) (interface{}, error) {
|
||||
return strconv.ParseInt(value, 10, 64)
|
||||
}
|
||||
|
||||
func toFloat(value string) (interface{}, error) {
|
||||
return strconv.ParseFloat(value, 64)
|
||||
}
|
||||
|
||||
func toFloat32(value string) (interface{}, error) {
|
||||
f, err := strconv.ParseFloat(value, 32)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return float32(f), nil
|
||||
}
|
||||
|
||||
// should match http://yaml.org/type/bool.html
|
||||
func toBoolean(value string) (interface{}, error) {
|
||||
switch strings.ToLower(value) {
|
||||
case "true":
|
||||
return true, nil
|
||||
case "false":
|
||||
return false, nil
|
||||
case "y", "yes", "on":
|
||||
logrus.Warnf("%q for boolean is not supported by YAML 1.2, please use `true`", value)
|
||||
return true, nil
|
||||
case "n", "no", "off":
|
||||
logrus.Warnf("%q for boolean is not supported by YAML 1.2, please use `false`", value)
|
||||
return false, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid boolean: %s", value)
|
||||
}
|
||||
}
|
||||
+899
@@ -0,0 +1,899 @@
|
||||
/*
|
||||
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 (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/compose-spec/compose-go/v2/consts"
|
||||
"github.com/compose-spec/compose-go/v2/errdefs"
|
||||
interp "github.com/compose-spec/compose-go/v2/interpolation"
|
||||
"github.com/compose-spec/compose-go/v2/override"
|
||||
"github.com/compose-spec/compose-go/v2/paths"
|
||||
"github.com/compose-spec/compose-go/v2/schema"
|
||||
"github.com/compose-spec/compose-go/v2/template"
|
||||
"github.com/compose-spec/compose-go/v2/transform"
|
||||
"github.com/compose-spec/compose-go/v2/tree"
|
||||
"github.com/compose-spec/compose-go/v2/types"
|
||||
"github.com/compose-spec/compose-go/v2/validation"
|
||||
"github.com/go-viper/mapstructure/v2"
|
||||
"github.com/sirupsen/logrus"
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
// Options supported by Load
|
||||
type Options struct {
|
||||
// Skip schema validation
|
||||
SkipValidation bool
|
||||
// Skip interpolation
|
||||
SkipInterpolation bool
|
||||
// Skip normalization
|
||||
SkipNormalization bool
|
||||
// Resolve path
|
||||
ResolvePaths bool
|
||||
// Convert Windows path
|
||||
ConvertWindowsPaths bool
|
||||
// Skip consistency check
|
||||
SkipConsistencyCheck bool
|
||||
// Skip extends
|
||||
SkipExtends bool
|
||||
// SkipInclude will ignore `include` and only load model from file(s) set by ConfigDetails
|
||||
SkipInclude bool
|
||||
// SkipResolveEnvironment will ignore computing `environment` for services
|
||||
SkipResolveEnvironment bool
|
||||
// SkipDefaultValues will ignore missing required attributes
|
||||
SkipDefaultValues bool
|
||||
// Interpolation options
|
||||
Interpolate *interp.Options
|
||||
// Discard 'env_file' entries after resolving to 'environment' section
|
||||
discardEnvFiles bool
|
||||
// Set project projectName
|
||||
projectName string
|
||||
// Indicates when the projectName was imperatively set or guessed from path
|
||||
projectNameImperativelySet bool
|
||||
// Profiles set profiles to enable
|
||||
Profiles []string
|
||||
// ResourceLoaders manages support for remote resources
|
||||
ResourceLoaders []ResourceLoader
|
||||
// KnownExtensions manages x-* attribute we know and the corresponding go structs
|
||||
KnownExtensions map[string]any
|
||||
// Metada for telemetry
|
||||
Listeners []Listener
|
||||
}
|
||||
|
||||
var versionWarning []string
|
||||
|
||||
func (o *Options) warnObsoleteVersion(file string) {
|
||||
if !slices.Contains(versionWarning, file) {
|
||||
logrus.Warning(fmt.Sprintf("%s: the attribute `version` is obsolete, it will be ignored, please remove it to avoid potential confusion", file))
|
||||
}
|
||||
versionWarning = append(versionWarning, file)
|
||||
}
|
||||
|
||||
type Listener = func(event string, metadata map[string]any)
|
||||
|
||||
// Invoke all listeners for an event
|
||||
func (o *Options) ProcessEvent(event string, metadata map[string]any) {
|
||||
for _, l := range o.Listeners {
|
||||
l(event, metadata)
|
||||
}
|
||||
}
|
||||
|
||||
// ResourceLoader is a plugable remote resource resolver
|
||||
type ResourceLoader interface {
|
||||
// Accept returns `true` is the resource reference matches ResourceLoader supported protocol(s)
|
||||
Accept(path string) bool
|
||||
// Load returns the path to a local copy of remote resource identified by `path`.
|
||||
Load(ctx context.Context, path string) (string, error)
|
||||
// Dir computes path to resource"s parent folder, made relative if possible
|
||||
Dir(path string) string
|
||||
}
|
||||
|
||||
// RemoteResourceLoaders excludes localResourceLoader from ResourceLoaders
|
||||
func (o Options) RemoteResourceLoaders() []ResourceLoader {
|
||||
var loaders []ResourceLoader
|
||||
for i, loader := range o.ResourceLoaders {
|
||||
if _, ok := loader.(localResourceLoader); ok {
|
||||
if i != len(o.ResourceLoaders)-1 {
|
||||
logrus.Warning("misconfiguration of ResourceLoaders: localResourceLoader should be last")
|
||||
}
|
||||
continue
|
||||
}
|
||||
loaders = append(loaders, loader)
|
||||
}
|
||||
return loaders
|
||||
}
|
||||
|
||||
type localResourceLoader struct {
|
||||
WorkingDir string
|
||||
}
|
||||
|
||||
func (l localResourceLoader) abs(p string) string {
|
||||
if filepath.IsAbs(p) {
|
||||
return p
|
||||
}
|
||||
return filepath.Join(l.WorkingDir, p)
|
||||
}
|
||||
|
||||
func (l localResourceLoader) Accept(_ string) bool {
|
||||
// LocalResourceLoader is the last loader tested so it always should accept the config and try to get the content.
|
||||
return true
|
||||
}
|
||||
|
||||
func (l localResourceLoader) Load(_ context.Context, p string) (string, error) {
|
||||
return l.abs(p), nil
|
||||
}
|
||||
|
||||
func (l localResourceLoader) Dir(originalPath string) string {
|
||||
path := l.abs(originalPath)
|
||||
if !l.isDir(path) {
|
||||
path = l.abs(filepath.Dir(originalPath))
|
||||
}
|
||||
rel, err := filepath.Rel(l.WorkingDir, path)
|
||||
if err != nil {
|
||||
return path
|
||||
}
|
||||
return rel
|
||||
}
|
||||
|
||||
func (l localResourceLoader) isDir(path string) bool {
|
||||
fileInfo, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return fileInfo.IsDir()
|
||||
}
|
||||
|
||||
func (o *Options) clone() *Options {
|
||||
return &Options{
|
||||
SkipValidation: o.SkipValidation,
|
||||
SkipInterpolation: o.SkipInterpolation,
|
||||
SkipNormalization: o.SkipNormalization,
|
||||
ResolvePaths: o.ResolvePaths,
|
||||
ConvertWindowsPaths: o.ConvertWindowsPaths,
|
||||
SkipConsistencyCheck: o.SkipConsistencyCheck,
|
||||
SkipExtends: o.SkipExtends,
|
||||
SkipInclude: o.SkipInclude,
|
||||
Interpolate: o.Interpolate,
|
||||
discardEnvFiles: o.discardEnvFiles,
|
||||
projectName: o.projectName,
|
||||
projectNameImperativelySet: o.projectNameImperativelySet,
|
||||
Profiles: o.Profiles,
|
||||
ResourceLoaders: o.ResourceLoaders,
|
||||
KnownExtensions: o.KnownExtensions,
|
||||
Listeners: o.Listeners,
|
||||
}
|
||||
}
|
||||
|
||||
func (o *Options) SetProjectName(name string, imperativelySet bool) {
|
||||
o.projectName = name
|
||||
o.projectNameImperativelySet = imperativelySet
|
||||
}
|
||||
|
||||
func (o Options) GetProjectName() (string, bool) {
|
||||
return o.projectName, o.projectNameImperativelySet
|
||||
}
|
||||
|
||||
// serviceRef identifies a reference to a service. It's used to detect cyclic
|
||||
// references in "extends".
|
||||
type serviceRef struct {
|
||||
filename string
|
||||
service string
|
||||
}
|
||||
|
||||
type cycleTracker struct {
|
||||
loaded []serviceRef
|
||||
}
|
||||
|
||||
func (ct *cycleTracker) Add(filename, service string) (*cycleTracker, error) {
|
||||
toAdd := serviceRef{filename: filename, service: service}
|
||||
for _, loaded := range ct.loaded {
|
||||
if toAdd == loaded {
|
||||
// Create an error message of the form:
|
||||
// Circular reference:
|
||||
// service-a in docker-compose.yml
|
||||
// extends service-b in docker-compose.yml
|
||||
// extends service-a in docker-compose.yml
|
||||
errLines := []string{
|
||||
"Circular reference:",
|
||||
fmt.Sprintf(" %s in %s", ct.loaded[0].service, ct.loaded[0].filename),
|
||||
}
|
||||
for _, service := range append(ct.loaded[1:], toAdd) {
|
||||
errLines = append(errLines, fmt.Sprintf(" extends %s in %s", service.service, service.filename))
|
||||
}
|
||||
|
||||
return nil, errors.New(strings.Join(errLines, "\n"))
|
||||
}
|
||||
}
|
||||
|
||||
var branch []serviceRef
|
||||
branch = append(branch, ct.loaded...)
|
||||
branch = append(branch, toAdd)
|
||||
return &cycleTracker{
|
||||
loaded: branch,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// WithDiscardEnvFiles sets the Options to discard the `env_file` section after resolving to
|
||||
// the `environment` section
|
||||
func WithDiscardEnvFiles(opts *Options) {
|
||||
opts.discardEnvFiles = true
|
||||
}
|
||||
|
||||
// WithSkipValidation sets the Options to skip validation when loading sections
|
||||
func WithSkipValidation(opts *Options) {
|
||||
opts.SkipValidation = true
|
||||
}
|
||||
|
||||
// WithProfiles sets profiles to be activated
|
||||
func WithProfiles(profiles []string) func(*Options) {
|
||||
return func(opts *Options) {
|
||||
opts.Profiles = profiles
|
||||
}
|
||||
}
|
||||
|
||||
// PostProcessor is used to tweak compose model based on metadata extracted during yaml Unmarshal phase
|
||||
// that hardly can be implemented using go-yaml and mapstructure
|
||||
type PostProcessor interface {
|
||||
// Apply changes to compose model based on recorder metadata
|
||||
Apply(interface{}) error
|
||||
}
|
||||
|
||||
type NoopPostProcessor struct{}
|
||||
|
||||
func (NoopPostProcessor) Apply(interface{}) error { return nil }
|
||||
|
||||
// LoadConfigFiles ingests config files with ResourceLoader and returns config details with paths to local copies
|
||||
func LoadConfigFiles(ctx context.Context, configFiles []string, workingDir string, options ...func(*Options)) (*types.ConfigDetails, error) {
|
||||
if len(configFiles) < 1 {
|
||||
return &types.ConfigDetails{}, fmt.Errorf("no configuration file provided: %w", errdefs.ErrNotFound)
|
||||
}
|
||||
|
||||
opts := &Options{}
|
||||
config := &types.ConfigDetails{
|
||||
ConfigFiles: make([]types.ConfigFile, len(configFiles)),
|
||||
}
|
||||
|
||||
for _, op := range options {
|
||||
op(opts)
|
||||
}
|
||||
opts.ResourceLoaders = append(opts.ResourceLoaders, localResourceLoader{})
|
||||
|
||||
for i, p := range configFiles {
|
||||
if p == "-" {
|
||||
config.ConfigFiles[i] = types.ConfigFile{
|
||||
Filename: p,
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
for _, loader := range opts.ResourceLoaders {
|
||||
_, isLocalResourceLoader := loader.(localResourceLoader)
|
||||
if !loader.Accept(p) {
|
||||
continue
|
||||
}
|
||||
local, err := loader.Load(ctx, p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if config.WorkingDir == "" && !isLocalResourceLoader {
|
||||
config.WorkingDir = filepath.Dir(local)
|
||||
}
|
||||
abs, err := filepath.Abs(local)
|
||||
if err != nil {
|
||||
abs = local
|
||||
}
|
||||
config.ConfigFiles[i] = types.ConfigFile{
|
||||
Filename: abs,
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if config.WorkingDir == "" {
|
||||
config.WorkingDir = workingDir
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// LoadWithContext reads a ConfigDetails and returns a fully loaded configuration as a compose-go Project
|
||||
func LoadWithContext(ctx context.Context, configDetails types.ConfigDetails, options ...func(*Options)) (*types.Project, error) {
|
||||
opts := ToOptions(&configDetails, options)
|
||||
dict, err := loadModelWithContext(ctx, &configDetails, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ModelToProject(dict, opts, configDetails)
|
||||
}
|
||||
|
||||
// LoadModelWithContext reads a ConfigDetails and returns a fully loaded configuration as a yaml dictionary
|
||||
func LoadModelWithContext(ctx context.Context, configDetails types.ConfigDetails, options ...func(*Options)) (map[string]any, error) {
|
||||
opts := ToOptions(&configDetails, options)
|
||||
return loadModelWithContext(ctx, &configDetails, opts)
|
||||
}
|
||||
|
||||
// LoadModelWithContext reads a ConfigDetails and returns a fully loaded configuration as a yaml dictionary
|
||||
func loadModelWithContext(ctx context.Context, configDetails *types.ConfigDetails, opts *Options) (map[string]any, error) {
|
||||
if len(configDetails.ConfigFiles) < 1 {
|
||||
return nil, errors.New("no compose file specified")
|
||||
}
|
||||
|
||||
err := projectName(configDetails, opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return load(ctx, *configDetails, opts, nil)
|
||||
}
|
||||
|
||||
func ToOptions(configDetails *types.ConfigDetails, options []func(*Options)) *Options {
|
||||
opts := &Options{
|
||||
Interpolate: &interp.Options{
|
||||
Substitute: template.Substitute,
|
||||
LookupValue: configDetails.LookupEnv,
|
||||
TypeCastMapping: interpolateTypeCastMapping,
|
||||
},
|
||||
ResolvePaths: true,
|
||||
}
|
||||
|
||||
for _, op := range options {
|
||||
op(opts)
|
||||
}
|
||||
opts.ResourceLoaders = append(opts.ResourceLoaders, localResourceLoader{configDetails.WorkingDir})
|
||||
return opts
|
||||
}
|
||||
|
||||
func loadYamlModel(ctx context.Context, config types.ConfigDetails, opts *Options, ct *cycleTracker, included []string) (map[string]interface{}, error) {
|
||||
var (
|
||||
dict = map[string]interface{}{}
|
||||
err error
|
||||
)
|
||||
workingDir, environment := config.WorkingDir, config.Environment
|
||||
|
||||
for _, file := range config.ConfigFiles {
|
||||
dict, _, err = loadYamlFile(ctx, file, opts, workingDir, environment, ct, dict, included)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if !opts.SkipDefaultValues {
|
||||
dict, err = transform.SetDefaultValues(dict)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if !opts.SkipValidation {
|
||||
if err := validation.Validate(dict); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if opts.ResolvePaths {
|
||||
var remotes []paths.RemoteResource
|
||||
for _, loader := range opts.RemoteResourceLoaders() {
|
||||
remotes = append(remotes, loader.Accept)
|
||||
}
|
||||
err = paths.ResolveRelativePaths(dict, config.WorkingDir, remotes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
ResolveEnvironment(dict, config.Environment)
|
||||
|
||||
return dict, nil
|
||||
}
|
||||
|
||||
func loadYamlFile(ctx context.Context,
|
||||
file types.ConfigFile,
|
||||
opts *Options,
|
||||
workingDir string,
|
||||
environment types.Mapping,
|
||||
ct *cycleTracker,
|
||||
dict map[string]interface{},
|
||||
included []string,
|
||||
) (map[string]interface{}, PostProcessor, error) {
|
||||
ctx = context.WithValue(ctx, consts.ComposeFileKey{}, file.Filename)
|
||||
if file.Content == nil && file.Config == nil {
|
||||
content, err := os.ReadFile(file.Filename)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
file.Content = content
|
||||
}
|
||||
|
||||
processRawYaml := func(raw interface{}, processor PostProcessor) error {
|
||||
converted, err := convertToStringKeysRecursive(raw, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cfg, ok := converted.(map[string]interface{})
|
||||
if !ok {
|
||||
return errors.New("top-level object must be a mapping")
|
||||
}
|
||||
|
||||
if opts.Interpolate != nil && !opts.SkipInterpolation {
|
||||
cfg, err = interp.Interpolate(cfg, *opts.Interpolate)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
fixEmptyNotNull(cfg)
|
||||
|
||||
if !opts.SkipExtends {
|
||||
err = ApplyExtends(ctx, cfg, opts, ct, processor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := processor.Apply(dict); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !opts.SkipInclude {
|
||||
included = append(included, file.Filename)
|
||||
err = ApplyInclude(ctx, workingDir, environment, cfg, opts, included, processor)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
dict, err = override.Merge(dict, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dict, err = override.EnforceUnicity(dict)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !opts.SkipValidation {
|
||||
if err := schema.Validate(dict); err != nil {
|
||||
return fmt.Errorf("validating %s: %w", file.Filename, err)
|
||||
}
|
||||
if _, ok := dict["version"]; ok {
|
||||
opts.warnObsoleteVersion(file.Filename)
|
||||
delete(dict, "version")
|
||||
}
|
||||
}
|
||||
|
||||
dict, err = transform.Canonical(dict, opts.SkipInterpolation)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dict = OmitEmpty(dict)
|
||||
|
||||
// Canonical transformation can reveal duplicates, typically as ports can be a range and conflict with an override
|
||||
dict, err = override.EnforceUnicity(dict)
|
||||
return err
|
||||
}
|
||||
|
||||
var processor PostProcessor
|
||||
if file.Config == nil {
|
||||
r := bytes.NewReader(file.Content)
|
||||
decoder := yaml.NewDecoder(r)
|
||||
for {
|
||||
var raw interface{}
|
||||
reset := &ResetProcessor{target: &raw}
|
||||
err := decoder.Decode(reset)
|
||||
if err != nil && errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to parse %s: %w", file.Filename, err)
|
||||
}
|
||||
processor = reset
|
||||
if err := processRawYaml(raw, processor); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if err := processRawYaml(file.Config, NoopPostProcessor{}); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
}
|
||||
return dict, processor, nil
|
||||
}
|
||||
|
||||
func load(ctx context.Context, configDetails types.ConfigDetails, opts *Options, loaded []string) (map[string]interface{}, error) {
|
||||
mainFile := configDetails.ConfigFiles[0].Filename
|
||||
for _, f := range loaded {
|
||||
if f == mainFile {
|
||||
loaded = append(loaded, mainFile)
|
||||
return nil, fmt.Errorf("include cycle detected:\n%s\n include %s", loaded[0], strings.Join(loaded[1:], "\n include "))
|
||||
}
|
||||
}
|
||||
|
||||
dict, err := loadYamlModel(ctx, configDetails, opts, &cycleTracker{}, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if len(dict) == 0 {
|
||||
return nil, errors.New("empty compose file")
|
||||
}
|
||||
|
||||
if !opts.SkipValidation && opts.projectName == "" {
|
||||
return nil, errors.New("project name must not be empty")
|
||||
}
|
||||
|
||||
if !opts.SkipNormalization {
|
||||
dict["name"] = opts.projectName
|
||||
dict, err = Normalize(dict, configDetails.Environment)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return dict, nil
|
||||
}
|
||||
|
||||
// ModelToProject binds a canonical yaml dict into compose-go structs
|
||||
func ModelToProject(dict map[string]interface{}, opts *Options, configDetails types.ConfigDetails) (*types.Project, error) {
|
||||
project := &types.Project{
|
||||
Name: opts.projectName,
|
||||
WorkingDir: configDetails.WorkingDir,
|
||||
Environment: configDetails.Environment,
|
||||
}
|
||||
delete(dict, "name") // project name set by yaml must be identified by caller as opts.projectName
|
||||
|
||||
var err error
|
||||
dict, err = processExtensions(dict, tree.NewPath(), opts.KnownExtensions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = Transform(dict, project)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if opts.ConvertWindowsPaths {
|
||||
for i, service := range project.Services {
|
||||
for j, volume := range service.Volumes {
|
||||
service.Volumes[j] = convertVolumePath(volume)
|
||||
}
|
||||
project.Services[i] = service
|
||||
}
|
||||
}
|
||||
|
||||
if project, err = project.WithProfiles(opts.Profiles); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !opts.SkipConsistencyCheck {
|
||||
err := checkConsistency(project)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if !opts.SkipResolveEnvironment {
|
||||
project, err = project.WithServicesEnvironmentResolved(opts.discardEnvFiles)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
project, err = project.WithServicesLabelsResolved(opts.discardEnvFiles)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return project, nil
|
||||
}
|
||||
|
||||
func InvalidProjectNameErr(v string) error {
|
||||
return fmt.Errorf(
|
||||
"invalid project name %q: must consist only of lowercase alphanumeric characters, hyphens, and underscores as well as start with a letter or number",
|
||||
v,
|
||||
)
|
||||
}
|
||||
|
||||
// projectName determines the canonical name to use for the project considering
|
||||
// the loader Options as well as `name` fields in Compose YAML fields (which
|
||||
// also support interpolation).
|
||||
func projectName(details *types.ConfigDetails, opts *Options) error {
|
||||
defer func() {
|
||||
if details.Environment == nil {
|
||||
details.Environment = map[string]string{}
|
||||
}
|
||||
details.Environment[consts.ComposeProjectName] = opts.projectName
|
||||
}()
|
||||
|
||||
if opts.projectNameImperativelySet {
|
||||
if NormalizeProjectName(opts.projectName) != opts.projectName {
|
||||
return InvalidProjectNameErr(opts.projectName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type named struct {
|
||||
Name string `yaml:"name"`
|
||||
}
|
||||
|
||||
// if user did NOT provide a name explicitly, then see if one is defined
|
||||
// in any of the config files
|
||||
var pjNameFromConfigFile string
|
||||
for _, configFile := range details.ConfigFiles {
|
||||
content := configFile.Content
|
||||
if content == nil {
|
||||
// This can be hit when Filename is set but Content is not. One
|
||||
// example is when using ToConfigFiles().
|
||||
d, err := os.ReadFile(configFile.Filename)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read file %q: %w", configFile.Filename, err)
|
||||
}
|
||||
content = d
|
||||
configFile.Content = d
|
||||
}
|
||||
var n named
|
||||
r := bytes.NewReader(content)
|
||||
decoder := yaml.NewDecoder(r)
|
||||
for {
|
||||
err := decoder.Decode(&n)
|
||||
if err != nil && errors.Is(err, io.EOF) {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
// HACK: the way that loading is currently structured, this is
|
||||
// a duplicative parse just for the `name`. if it fails, we
|
||||
// give up but don't return the error, knowing that it'll get
|
||||
// caught downstream for us
|
||||
break
|
||||
}
|
||||
if n.Name != "" {
|
||||
pjNameFromConfigFile = n.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
if !opts.SkipInterpolation {
|
||||
interpolated, err := interp.Interpolate(
|
||||
map[string]interface{}{"name": pjNameFromConfigFile},
|
||||
*opts.Interpolate,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pjNameFromConfigFile = interpolated["name"].(string)
|
||||
}
|
||||
|
||||
if !opts.SkipNormalization {
|
||||
pjNameFromConfigFile = NormalizeProjectName(pjNameFromConfigFile)
|
||||
}
|
||||
if pjNameFromConfigFile != "" {
|
||||
opts.projectName = pjNameFromConfigFile
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func NormalizeProjectName(s string) string {
|
||||
r := regexp.MustCompile("[a-z0-9_-]")
|
||||
s = strings.ToLower(s)
|
||||
s = strings.Join(r.FindAllString(s, -1), "")
|
||||
return strings.TrimLeft(s, "_-")
|
||||
}
|
||||
|
||||
var userDefinedKeys = []tree.Path{
|
||||
"services",
|
||||
"services.*.depends_on",
|
||||
"volumes",
|
||||
"networks",
|
||||
"secrets",
|
||||
"configs",
|
||||
}
|
||||
|
||||
func processExtensions(dict map[string]any, p tree.Path, extensions map[string]any) (map[string]interface{}, error) {
|
||||
extras := map[string]any{}
|
||||
var err error
|
||||
for key, value := range dict {
|
||||
skip := false
|
||||
for _, uk := range userDefinedKeys {
|
||||
if p.Matches(uk) {
|
||||
skip = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !skip && strings.HasPrefix(key, "x-") {
|
||||
extras[key] = value
|
||||
delete(dict, key)
|
||||
continue
|
||||
}
|
||||
switch v := value.(type) {
|
||||
case map[string]interface{}:
|
||||
dict[key], err = processExtensions(v, p.Next(key), extensions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case []interface{}:
|
||||
for i, e := range v {
|
||||
if m, ok := e.(map[string]interface{}); ok {
|
||||
v[i], err = processExtensions(m, p.Next(strconv.Itoa(i)), extensions)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for name, val := range extras {
|
||||
if typ, ok := extensions[name]; ok {
|
||||
target := reflect.New(reflect.TypeOf(typ)).Elem().Interface()
|
||||
err = Transform(val, &target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
extras[name] = target
|
||||
}
|
||||
}
|
||||
if len(extras) > 0 {
|
||||
dict[consts.Extensions] = extras
|
||||
}
|
||||
return dict, nil
|
||||
}
|
||||
|
||||
// Transform converts the source into the target struct with compose types transformer
|
||||
// and the specified transformers if any.
|
||||
func Transform(source interface{}, target interface{}) error {
|
||||
data := mapstructure.Metadata{}
|
||||
config := &mapstructure.DecoderConfig{
|
||||
DecodeHook: mapstructure.ComposeDecodeHookFunc(
|
||||
nameServices,
|
||||
decoderHook,
|
||||
cast,
|
||||
secretConfigDecoderHook,
|
||||
),
|
||||
Result: target,
|
||||
TagName: "yaml",
|
||||
Metadata: &data,
|
||||
}
|
||||
decoder, err := mapstructure.NewDecoder(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return decoder.Decode(source)
|
||||
}
|
||||
|
||||
// nameServices create implicit `name` key for convenience accessing service
|
||||
func nameServices(from reflect.Value, to reflect.Value) (interface{}, error) {
|
||||
if to.Type() == reflect.TypeOf(types.Services{}) {
|
||||
nameK := reflect.ValueOf("name")
|
||||
iter := from.MapRange()
|
||||
for iter.Next() {
|
||||
name := iter.Key()
|
||||
elem := iter.Value()
|
||||
elem.Elem().SetMapIndex(nameK, name)
|
||||
}
|
||||
}
|
||||
return from.Interface(), nil
|
||||
}
|
||||
|
||||
func secretConfigDecoderHook(from, to reflect.Type, data interface{}) (interface{}, error) {
|
||||
// Check if the input is a map and we're decoding into a SecretConfig
|
||||
if from.Kind() == reflect.Map && to == reflect.TypeOf(types.SecretConfig{}) {
|
||||
if v, ok := data.(map[string]interface{}); ok {
|
||||
if ext, ok := v[consts.Extensions].(map[string]interface{}); ok {
|
||||
if val, ok := ext[types.SecretConfigXValue].(string); ok {
|
||||
// Return a map with the Content field populated
|
||||
v["Content"] = val
|
||||
delete(ext, types.SecretConfigXValue)
|
||||
|
||||
if len(ext) == 0 {
|
||||
delete(v, consts.Extensions)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return the original data so the rest is handled by default mapstructure logic
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// keys need to be converted to strings for jsonschema
|
||||
func convertToStringKeysRecursive(value interface{}, keyPrefix string) (interface{}, error) {
|
||||
if mapping, ok := value.(map[string]interface{}); ok {
|
||||
for key, entry := range mapping {
|
||||
var newKeyPrefix string
|
||||
if keyPrefix == "" {
|
||||
newKeyPrefix = key
|
||||
} else {
|
||||
newKeyPrefix = fmt.Sprintf("%s.%s", keyPrefix, key)
|
||||
}
|
||||
convertedEntry, err := convertToStringKeysRecursive(entry, newKeyPrefix)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mapping[key] = convertedEntry
|
||||
}
|
||||
return mapping, nil
|
||||
}
|
||||
if mapping, ok := value.(map[interface{}]interface{}); ok {
|
||||
dict := make(map[string]interface{})
|
||||
for key, entry := range mapping {
|
||||
str, ok := key.(string)
|
||||
if !ok {
|
||||
return nil, formatInvalidKeyError(keyPrefix, key)
|
||||
}
|
||||
var newKeyPrefix string
|
||||
if keyPrefix == "" {
|
||||
newKeyPrefix = str
|
||||
} else {
|
||||
newKeyPrefix = fmt.Sprintf("%s.%s", keyPrefix, str)
|
||||
}
|
||||
convertedEntry, err := convertToStringKeysRecursive(entry, newKeyPrefix)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dict[str] = convertedEntry
|
||||
}
|
||||
return dict, nil
|
||||
}
|
||||
if list, ok := value.([]interface{}); ok {
|
||||
var convertedList []interface{}
|
||||
for index, entry := range list {
|
||||
newKeyPrefix := fmt.Sprintf("%s[%d]", keyPrefix, index)
|
||||
convertedEntry, err := convertToStringKeysRecursive(entry, newKeyPrefix)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
convertedList = append(convertedList, convertedEntry)
|
||||
}
|
||||
return convertedList, nil
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func formatInvalidKeyError(keyPrefix string, key interface{}) error {
|
||||
var location string
|
||||
if keyPrefix == "" {
|
||||
location = "at top level"
|
||||
} else {
|
||||
location = fmt.Sprintf("in %s", keyPrefix)
|
||||
}
|
||||
return fmt.Errorf("non-string key %s: %#v", location, key)
|
||||
}
|
||||
|
||||
// Windows path, c:\\my\\path\\shiny, need to be changed to be compatible with
|
||||
// the Engine. Volume path are expected to be linux style /c/my/path/shiny/
|
||||
func convertVolumePath(volume types.ServiceVolumeConfig) types.ServiceVolumeConfig {
|
||||
volumeName := strings.ToLower(filepath.VolumeName(volume.Source))
|
||||
if len(volumeName) != 2 {
|
||||
return volume
|
||||
}
|
||||
|
||||
convertedSource := fmt.Sprintf("/%c%s", volumeName[0], volume.Source[len(volumeName):])
|
||||
convertedSource = strings.ReplaceAll(convertedSource, "\\", "/")
|
||||
|
||||
volume.Source = convertedSource
|
||||
return volume
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
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 (
|
||||
"reflect"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// comparable to yaml.Unmarshaler, decoder allow a type to define it's own custom logic to convert value
|
||||
// see https://github.com/mitchellh/mapstructure/pull/294
|
||||
type decoder interface {
|
||||
DecodeMapstructure(interface{}) error
|
||||
}
|
||||
|
||||
// see https://github.com/mitchellh/mapstructure/issues/115#issuecomment-735287466
|
||||
// adapted to support types derived from built-in types, as DecodeMapstructure would not be able to mutate internal
|
||||
// value, so need to invoke DecodeMapstructure defined by pointer to type
|
||||
func decoderHook(from reflect.Value, to reflect.Value) (interface{}, error) {
|
||||
// If the destination implements the decoder interface
|
||||
u, ok := to.Interface().(decoder)
|
||||
if !ok {
|
||||
// for non-struct types we need to invoke func (*type) DecodeMapstructure()
|
||||
if to.CanAddr() {
|
||||
pto := to.Addr()
|
||||
u, ok = pto.Interface().(decoder)
|
||||
}
|
||||
if !ok {
|
||||
return from.Interface(), nil
|
||||
}
|
||||
}
|
||||
// If it is nil and a pointer, create and assign the target value first
|
||||
if to.Type().Kind() == reflect.Ptr && to.IsNil() {
|
||||
to.Set(reflect.New(to.Type().Elem()))
|
||||
u = to.Interface().(decoder)
|
||||
}
|
||||
// Call the custom DecodeMapstructure method
|
||||
if err := u.DecodeMapstructure(from.Interface()); err != nil {
|
||||
return to.Interface(), err
|
||||
}
|
||||
return to.Interface(), nil
|
||||
}
|
||||
|
||||
func cast(from reflect.Value, to reflect.Value) (interface{}, error) {
|
||||
switch from.Type().Kind() {
|
||||
case reflect.String:
|
||||
switch to.Kind() {
|
||||
case reflect.Bool:
|
||||
return toBoolean(from.String())
|
||||
case reflect.Int:
|
||||
return toInt(from.String())
|
||||
case reflect.Int64:
|
||||
return toInt64(from.String())
|
||||
case reflect.Float32:
|
||||
return toFloat32(from.String())
|
||||
case reflect.Float64:
|
||||
return toFloat(from.String())
|
||||
}
|
||||
case reflect.Int:
|
||||
if to.Kind() == reflect.String {
|
||||
return strconv.FormatInt(from.Int(), 10), nil
|
||||
}
|
||||
}
|
||||
return from.Interface(), nil
|
||||
}
|
||||
+266
@@ -0,0 +1,266 @@
|
||||
/*
|
||||
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"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/compose-spec/compose-go/v2/types"
|
||||
)
|
||||
|
||||
// Normalize compose project by moving deprecated attributes to their canonical position and injecting implicit defaults
|
||||
func Normalize(dict map[string]any, env types.Mapping) (map[string]any, error) {
|
||||
normalizeNetworks(dict)
|
||||
|
||||
if d, ok := dict["services"]; ok {
|
||||
services := d.(map[string]any)
|
||||
for name, s := range services {
|
||||
service := s.(map[string]any)
|
||||
|
||||
if service["pull_policy"] == types.PullPolicyIfNotPresent {
|
||||
service["pull_policy"] = types.PullPolicyMissing
|
||||
}
|
||||
|
||||
fn := func(s string) (string, bool) {
|
||||
v, ok := env[s]
|
||||
return v, ok
|
||||
}
|
||||
|
||||
if b, ok := service["build"]; ok {
|
||||
build := b.(map[string]any)
|
||||
if build["context"] == nil {
|
||||
build["context"] = "."
|
||||
}
|
||||
if build["dockerfile"] == nil && build["dockerfile_inline"] == nil {
|
||||
build["dockerfile"] = "Dockerfile"
|
||||
}
|
||||
|
||||
if a, ok := build["args"]; ok {
|
||||
build["args"], _ = resolve(a, fn, false)
|
||||
}
|
||||
|
||||
service["build"] = build
|
||||
}
|
||||
|
||||
if e, ok := service["environment"]; ok {
|
||||
service["environment"], _ = resolve(e, fn, true)
|
||||
}
|
||||
|
||||
var dependsOn map[string]any
|
||||
if d, ok := service["depends_on"]; ok {
|
||||
dependsOn = d.(map[string]any)
|
||||
} else {
|
||||
dependsOn = map[string]any{}
|
||||
}
|
||||
if l, ok := service["links"]; ok {
|
||||
links := l.([]any)
|
||||
for _, e := range links {
|
||||
link := e.(string)
|
||||
parts := strings.Split(link, ":")
|
||||
if len(parts) == 2 {
|
||||
link = parts[0]
|
||||
}
|
||||
if _, ok := dependsOn[link]; !ok {
|
||||
dependsOn[link] = map[string]any{
|
||||
"condition": types.ServiceConditionStarted,
|
||||
"restart": true,
|
||||
"required": true,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, namespace := range []string{"network_mode", "ipc", "pid", "uts", "cgroup"} {
|
||||
if n, ok := service[namespace]; ok {
|
||||
ref := n.(string)
|
||||
if strings.HasPrefix(ref, types.ServicePrefix) {
|
||||
shared := ref[len(types.ServicePrefix):]
|
||||
if _, ok := dependsOn[shared]; !ok {
|
||||
dependsOn[shared] = map[string]any{
|
||||
"condition": types.ServiceConditionStarted,
|
||||
"restart": true,
|
||||
"required": true,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if v, ok := service["volumes"]; ok {
|
||||
volumes := v.([]any)
|
||||
for i, volume := range volumes {
|
||||
vol := volume.(map[string]any)
|
||||
target := vol["target"].(string)
|
||||
vol["target"] = path.Clean(target)
|
||||
volumes[i] = vol
|
||||
}
|
||||
service["volumes"] = volumes
|
||||
}
|
||||
|
||||
if n, ok := service["volumes_from"]; ok {
|
||||
volumesFrom := n.([]any)
|
||||
for _, v := range volumesFrom {
|
||||
vol := v.(string)
|
||||
if !strings.HasPrefix(vol, types.ContainerPrefix) {
|
||||
spec := strings.Split(vol, ":")
|
||||
if _, ok := dependsOn[spec[0]]; !ok {
|
||||
dependsOn[spec[0]] = map[string]any{
|
||||
"condition": types.ServiceConditionStarted,
|
||||
"restart": false,
|
||||
"required": true,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(dependsOn) > 0 {
|
||||
service["depends_on"] = dependsOn
|
||||
}
|
||||
services[name] = service
|
||||
}
|
||||
|
||||
dict["services"] = services
|
||||
}
|
||||
setNameFromKey(dict)
|
||||
|
||||
return dict, nil
|
||||
}
|
||||
|
||||
func normalizeNetworks(dict map[string]any) {
|
||||
var networks map[string]any
|
||||
if n, ok := dict["networks"]; ok {
|
||||
networks = n.(map[string]any)
|
||||
} else {
|
||||
networks = map[string]any{}
|
||||
}
|
||||
|
||||
// implicit `default` network must be introduced only if actually used by some service
|
||||
usesDefaultNetwork := false
|
||||
|
||||
if s, ok := dict["services"]; ok {
|
||||
services := s.(map[string]any)
|
||||
for name, se := range services {
|
||||
service := se.(map[string]any)
|
||||
if _, ok := service["provider"]; ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := service["network_mode"]; ok {
|
||||
continue
|
||||
}
|
||||
if n, ok := service["networks"]; !ok {
|
||||
// If none explicitly declared, service is connected to default network
|
||||
service["networks"] = map[string]any{"default": nil}
|
||||
usesDefaultNetwork = true
|
||||
} else {
|
||||
net := n.(map[string]any)
|
||||
if len(net) == 0 {
|
||||
// networks section declared but empty (corner case)
|
||||
service["networks"] = map[string]any{"default": nil}
|
||||
usesDefaultNetwork = true
|
||||
} else if _, ok := net["default"]; ok {
|
||||
usesDefaultNetwork = true
|
||||
}
|
||||
}
|
||||
services[name] = service
|
||||
}
|
||||
dict["services"] = services
|
||||
}
|
||||
|
||||
if _, ok := networks["default"]; !ok && usesDefaultNetwork {
|
||||
// If not declared explicitly, Compose model involves an implicit "default" network
|
||||
networks["default"] = nil
|
||||
}
|
||||
|
||||
if len(networks) > 0 {
|
||||
dict["networks"] = networks
|
||||
}
|
||||
}
|
||||
|
||||
func resolve(a any, fn func(s string) (string, bool), keepEmpty bool) (any, bool) {
|
||||
switch v := a.(type) {
|
||||
case []any:
|
||||
var resolved []any
|
||||
for _, val := range v {
|
||||
if r, ok := resolve(val, fn, keepEmpty); ok {
|
||||
resolved = append(resolved, r)
|
||||
}
|
||||
}
|
||||
return resolved, true
|
||||
case map[string]any:
|
||||
resolved := map[string]any{}
|
||||
for key, val := range v {
|
||||
if val != nil {
|
||||
resolved[key] = val
|
||||
continue
|
||||
}
|
||||
if s, ok := fn(key); ok {
|
||||
resolved[key] = s
|
||||
} else if keepEmpty {
|
||||
resolved[key] = nil
|
||||
}
|
||||
}
|
||||
return resolved, true
|
||||
case string:
|
||||
if !strings.Contains(v, "=") {
|
||||
if val, ok := fn(v); ok {
|
||||
return fmt.Sprintf("%s=%s", v, val), true
|
||||
}
|
||||
if keepEmpty {
|
||||
return v, true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
return v, true
|
||||
default:
|
||||
return v, false
|
||||
}
|
||||
}
|
||||
|
||||
// Resources with no explicit name are actually named by their key in map
|
||||
func setNameFromKey(dict map[string]any) {
|
||||
for _, r := range []string{"networks", "volumes", "configs", "secrets"} {
|
||||
a, ok := dict[r]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
toplevel := a.(map[string]any)
|
||||
for key, r := range toplevel {
|
||||
var resource map[string]any
|
||||
if r != nil {
|
||||
resource = r.(map[string]any)
|
||||
} else {
|
||||
resource = map[string]any{}
|
||||
}
|
||||
if resource["name"] == nil {
|
||||
if x, ok := resource["external"]; ok && isTrue(x) {
|
||||
resource["name"] = key
|
||||
} else {
|
||||
resource["name"] = fmt.Sprintf("%s_%s", dict["name"], key)
|
||||
}
|
||||
}
|
||||
toplevel[key] = resource
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func isTrue(x any) bool {
|
||||
parseBool, _ := strconv.ParseBool(fmt.Sprint(x))
|
||||
return parseBool
|
||||
}
|
||||
+75
@@ -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 loader
|
||||
|
||||
import "github.com/compose-spec/compose-go/v2/tree"
|
||||
|
||||
var omitempty = []tree.Path{
|
||||
"services.*.dns",
|
||||
}
|
||||
|
||||
// OmitEmpty removes empty attributes which are irrelevant when unset
|
||||
func OmitEmpty(yaml map[string]any) map[string]any {
|
||||
cleaned := omitEmpty(yaml, tree.NewPath())
|
||||
return cleaned.(map[string]any)
|
||||
}
|
||||
|
||||
func omitEmpty(data any, p tree.Path) any {
|
||||
switch v := data.(type) {
|
||||
case map[string]any:
|
||||
for k, e := range v {
|
||||
if isEmpty(e) && mustOmit(p) {
|
||||
delete(v, k)
|
||||
continue
|
||||
}
|
||||
|
||||
v[k] = omitEmpty(e, p.Next(k))
|
||||
}
|
||||
return v
|
||||
case []any:
|
||||
var c []any
|
||||
for _, e := range v {
|
||||
if isEmpty(e) && mustOmit(p) {
|
||||
continue
|
||||
}
|
||||
|
||||
c = append(c, omitEmpty(e, p.Next("[]")))
|
||||
}
|
||||
return c
|
||||
default:
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
func mustOmit(p tree.Path) bool {
|
||||
for _, pattern := range omitempty {
|
||||
if p.Matches(pattern) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isEmpty(e any) bool {
|
||||
if e == nil {
|
||||
return true
|
||||
}
|
||||
if v, ok := e.(string); ok && v == "" {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user