chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:08:39 +08:00
commit a0df89c693
5252 changed files with 523444 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
Copyright 2021 Portainer.io
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.
+3
View File
@@ -0,0 +1,3 @@
# LibStack
LibStack is a library that provides an abstraction to run stacks. Currently it supports Docker Compose, but we plan to support other formats in the future.
+149
View File
@@ -0,0 +1,149 @@
package compose
import (
"crypto/sha256"
"encoding/hex"
"io"
"os"
"path/filepath"
"sort"
"github.com/compose-spec/compose-go/v2/types"
"github.com/rs/zerolog/log"
)
const BindMountHashLabelKey = "io.portainer.bind-mount-hash"
func addBindMountHashLabel(name string, s types.ServiceConfig) (types.ServiceConfig, error) {
hashes := []string{}
for _, volume := range s.Volumes {
// Calculate hash for bind mounts only for now
if volume.Type != "bind" {
continue
}
// Calculate hash for volume.Source, volume.Source can be a file or dir
// and volume.Source is already an absolute path so we can hash it directly
hash, err := pathHash(volume.Source)
if err != nil {
// If we fail to calculate the hash for this bind mount, skip it and continue
log.Debug().Err(err).
Str("bind_mount_source", volume.Source).
Str("service", name).
Msg("failed to calculate hash for bind mount, skipping this bind mount from hash label calculation")
continue
}
if hash != "" {
hashes = append(hashes, hash)
}
}
if len(hashes) == 0 {
return s, nil
}
// Sort hashes to ensure deterministic output
sort.Strings(hashes)
// Final hash of the combined hashes
finalH := sha256.New()
for _, h := range hashes {
finalH.Write([]byte(h))
}
value := hex.EncodeToString(finalH.Sum(nil))
if s.Labels == nil {
s.Labels = make(map[string]string)
}
s.Labels[BindMountHashLabelKey] = value
log.Debug().Str("service", name).
Str("label_key", BindMountHashLabelKey).
Str("bind_mount_hash", value).
Msg("Calculated bind mount hash for service")
return s, nil
}
// pathHash calculates a SHA-256 hash for a file or a directory.
func pathHash(path string) (string, error) {
hash := sha256.New()
info, err := os.Stat(path)
if err != nil {
return "", err
}
if !info.IsDir() {
// It's a single file
return hashFile(path)
}
// It's a directory: we must collect and sort all files for determinism
var files []string
if err := filepath.Walk(path, func(p string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() {
files = append(files, p)
}
return nil
}); err != nil {
return "", err
}
sort.Strings(files)
for _, f := range files {
// Include the relative path in the hash so that renames and moves within
// the directory change the hash even when file contents stay the same.
relPath, err := filepath.Rel(path, f)
if err != nil {
return "", err
}
if _, err := hash.Write([]byte(relPath)); err != nil {
return "", err
}
// Stream the file content into the same hasher
if err := copyFileToHash(hash, f); err != nil {
return "", err
}
}
return hex.EncodeToString(hash.Sum(nil)), nil
}
func hashFile(path string) (string, error) {
h := sha256.New()
if err := copyFileToHash(h, path); err != nil {
return "", err
}
return hex.EncodeToString(h.Sum(nil)), nil
}
// copyFileToHash opens the file at path, streams its content into w, and closes it.
// If the copy fails, the close error is logged but the copy error is returned.
// If the copy succeeds but close fails, the close error is returned.
func copyFileToHash(w io.Writer, path string) (err error) {
f, err := os.Open(path)
if err != nil {
return err
}
defer func() {
if cerr := f.Close(); cerr != nil {
log.Debug().Err(cerr).
Str("filename", path).
Msg("error closing file after hash")
if err == nil {
err = cerr
}
}
}()
_, err = io.Copy(w, f)
return err
}
@@ -0,0 +1,129 @@
package compose
import (
"os"
"testing"
"github.com/portainer/portainer/api/filesystem"
"github.com/compose-spec/compose-go/v2/types"
"github.com/stretchr/testify/require"
)
func TestPathHash_File(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filesystem.JoinPaths(dir, "file.txt")
require.NoError(t, os.WriteFile(path, []byte("hello"), 0644))
h1, err := pathHash(path)
require.NoError(t, err)
require.NotEmpty(t, h1)
// Same content, same hash
h2, err := pathHash(path)
require.NoError(t, err)
require.Equal(t, h1, h2)
// Different content, different hash
require.NoError(t, os.WriteFile(path, []byte("world"), 0644))
h3, err := pathHash(path)
require.NoError(t, err)
require.NotEqual(t, h1, h3)
}
func TestPathHash_Directory(t *testing.T) {
t.Parallel()
dir := t.TempDir()
require.NoError(t, os.WriteFile(filesystem.JoinPaths(dir, "a.txt"), []byte("aaa"), 0644))
require.NoError(t, os.WriteFile(filesystem.JoinPaths(dir, "b.txt"), []byte("bbb"), 0644))
h1, err := pathHash(dir)
require.NoError(t, err)
// Same directory -> same hash
h2, err := pathHash(dir)
require.NoError(t, err)
require.Equal(t, h1, h2)
// Rename a file -> different hash (relative path is part of the hash)
require.NoError(t, os.Rename(filesystem.JoinPaths(dir, "a.txt"), filesystem.JoinPaths(dir, "c.txt")))
h3, err := pathHash(dir)
require.NoError(t, err)
require.NotEqual(t, h1, h3, "renaming a file should change the directory hash")
// Restore and change content -> different hash
require.NoError(t, os.Rename(filesystem.JoinPaths(dir, "c.txt"), filesystem.JoinPaths(dir, "a.txt")))
require.NoError(t, os.WriteFile(filesystem.JoinPaths(dir, "a.txt"), []byte("modified"), 0644))
h4, err := pathHash(dir)
require.NoError(t, err)
require.NotEqual(t, h1, h4, "changing file content should change the directory hash")
}
func TestAddBindMountHashLabel(t *testing.T) {
t.Parallel()
dir := t.TempDir()
webDir := filesystem.JoinPaths(dir, "web")
require.NoError(t, os.MkdirAll(webDir, 0755))
require.NoError(t, os.WriteFile(filesystem.JoinPaths(webDir, "nginx.conf"), []byte("server {}"), 0644))
t.Run("no bind mounts", func(t *testing.T) {
svc := types.ServiceConfig{Name: "web"}
result, err := addBindMountHashLabel("web", svc)
require.NoError(t, err)
require.Empty(t, result.Labels[BindMountHashLabelKey])
})
t.Run("non-bind volume is skipped", func(t *testing.T) {
svc := types.ServiceConfig{
Name: "web",
Volumes: []types.ServiceVolumeConfig{{Type: "volume", Source: "myvolume"}},
}
result, err := addBindMountHashLabel("web", svc)
require.NoError(t, err)
require.Empty(t, result.Labels[BindMountHashLabelKey])
})
t.Run("missing path silently skips label", func(t *testing.T) {
svc := types.ServiceConfig{
Name: "web",
Volumes: []types.ServiceVolumeConfig{{Type: "bind", Source: "/nonexistent/path"}},
}
result, err := addBindMountHashLabel("web", svc)
require.NoError(t, err)
require.Empty(t, result.Labels[BindMountHashLabelKey])
})
t.Run("valid bind mount with directory source sets label", func(t *testing.T) {
svc := types.ServiceConfig{
Name: "web",
Volumes: []types.ServiceVolumeConfig{{Type: "bind", Source: webDir}},
}
result, err := addBindMountHashLabel("web", svc)
require.NoError(t, err)
require.NotEmpty(t, result.Labels[BindMountHashLabelKey])
})
t.Run("valid bind mount with file source sets label", func(t *testing.T) {
svc := types.ServiceConfig{
Name: "web",
Volumes: []types.ServiceVolumeConfig{{Type: "bind", Source: filesystem.JoinPaths(webDir, "nginx.conf")}},
}
result, err := addBindMountHashLabel("web", svc)
require.NoError(t, err)
require.NotEmpty(t, result.Labels[BindMountHashLabelKey])
})
t.Run("label is deterministic", func(t *testing.T) {
svc := types.ServiceConfig{
Name: "web",
Volumes: []types.ServiceVolumeConfig{{Type: "bind", Source: webDir}},
}
r1, err := addBindMountHashLabel("web", svc)
require.NoError(t, err)
r2, err := addBindMountHashLabel("web", svc)
require.NoError(t, err)
require.Equal(t, r1.Labels[BindMountHashLabelKey], r2.Labels[BindMountHashLabelKey])
})
}
+18
View File
@@ -0,0 +1,18 @@
package compose
import (
"github.com/docker/cli/cli/command"
"github.com/docker/compose/v2/pkg/api"
"github.com/docker/compose/v2/pkg/compose"
)
type ComposeDeployer struct {
createComposeServiceFn func(command.Cli, ...compose.Option) api.Compose
}
// NewComposeDeployer creates a new compose deployer
func NewComposeDeployer() *ComposeDeployer {
return &ComposeDeployer{
createComposeServiceFn: compose.NewComposeService,
}
}
+100
View File
@@ -0,0 +1,100 @@
package compose_test
import (
"log"
"os"
"os/exec"
"strings"
"testing"
"github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/pkg/libstack"
"github.com/portainer/portainer/pkg/libstack/compose"
"github.com/portainer/portainer/pkg/testhelpers"
)
func checkPrerequisites(t *testing.T) {
testhelpers.IntegrationTest(t)
}
func Test_UpAndDown(t *testing.T) {
t.Parallel()
checkPrerequisites(t)
deployer := compose.NewComposeDeployer()
const composeFileContent = `
version: "3.9"
services:
busybox:
image: "alpine:3.7"
container_name: "binarytest_container_one"
`
const overrideComposeFileContent = `
version: "3.9"
services:
busybox:
image: "alpine:latest"
container_name: "binarytest_container_two"
`
const composeContainerName = "binarytest_container_two"
dir := t.TempDir()
filePathOriginal, err := createFile(dir, "docker-compose.yml", composeFileContent)
if err != nil {
t.Fatal(err)
}
filePathOverride, err := createFile(dir, "docker-compose-override.yml", overrideComposeFileContent)
if err != nil {
t.Fatal(err)
}
projectName := "binarytest"
err = deployer.Deploy(t.Context(), []string{filePathOriginal, filePathOverride}, libstack.DeployOptions{
Options: libstack.Options{
ProjectName: projectName,
},
})
if err != nil {
t.Fatal(err)
}
if !containerExists(composeContainerName) {
t.Fatal("container should exist")
}
err = deployer.Remove(t.Context(), projectName, []string{filePathOriginal, filePathOverride}, libstack.RemoveOptions{})
if err != nil {
t.Fatal(err)
}
if containerExists(composeContainerName) {
t.Fatal("container should be removed")
}
}
func createFile(dir, fileName, content string) (string, error) {
filePath := filesystem.JoinPaths(dir, fileName)
if err := os.WriteFile(filePath, []byte(content), 0644); err != nil {
return "", err
}
return filePath, nil
}
func containerExists(containerName string) bool {
cmd := exec.Command("docker", "ps", "-a", "-f", "name="+containerName)
out, err := cmd.Output()
if err != nil {
log.Fatalf("failed to list containers: %s", err)
}
return strings.Contains(string(out), containerName)
}
+359
View File
@@ -0,0 +1,359 @@
package compose
import (
"context"
"errors"
"fmt"
"maps"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/pkg/libstack"
"github.com/compose-spec/compose-go/v2/cli"
"github.com/compose-spec/compose-go/v2/types"
"github.com/docker/cli/cli/command"
cmdcompose "github.com/docker/compose/v2/cmd/compose"
"github.com/docker/compose/v2/pkg/api"
"github.com/docker/compose/v2/pkg/compose"
"github.com/docker/compose/v2/pkg/utils"
"github.com/rs/zerolog/log"
"github.com/sirupsen/logrus"
)
const PortainerEdgeStackLabel = "io.portainer.edge_stack_id"
func init() {
logrus.SetOutput(LogrusToZerologWriter{})
logrus.SetFormatter(&logrus.TextFormatter{
DisableTimestamp: true,
})
}
func (c *ComposeDeployer) withComposeService(
ctx context.Context,
filePaths []string,
options libstack.Options,
composeFn func(api.Compose, *types.Project) error,
) error {
return libstack.WithCli(ctx,
libstack.DockerCliOptions{Host: options.Host, Registries: options.Registries},
func(ctx context.Context, cli *command.DockerCli) error {
composeService := c.createComposeServiceFn(cli)
if len(filePaths) == 0 {
return composeFn(composeService, nil)
}
project, err := createProject(ctx, filePaths, options)
if err != nil {
return fmt.Errorf("failed to create compose project: %w", err)
}
parallel := 0
if v, ok := project.Environment[cmdcompose.ComposeParallelLimit]; ok {
i, err := strconv.Atoi(v)
if err != nil {
return fmt.Errorf("%s must be an integer (found: %q)", cmdcompose.ComposeParallelLimit, v)
}
parallel = i
}
if parallel > 0 {
composeService.MaxConcurrency(parallel)
}
return composeFn(composeService, project)
})
}
// Deploy creates and starts containers
func (c *ComposeDeployer) Deploy(ctx context.Context, filePaths []string, options libstack.DeployOptions) error {
return c.withComposeService(ctx, filePaths, options.Options, func(composeService api.Compose, project *types.Project) error {
addServiceLabels(project, false, options.EdgeStackID)
project = project.WithoutUnnecessaryResources()
opts := api.UpOptions{
Start: api.StartOptions{
Project: project,
},
}
if options.ForceRecreate {
opts.Create.Recreate = api.RecreateForce
}
opts.Create.RemoveOrphans = options.RemoveOrphans
if removeOrphans, ok := project.Environment[cmdcompose.ComposeRemoveOrphans]; ok {
opts.Create.RemoveOrphans = utils.StringToBool(removeOrphans)
}
if ignoreOrphans, ok := project.Environment[cmdcompose.ComposeIgnoreOrphans]; ok {
opts.Create.IgnoreOrphans = utils.StringToBool(ignoreOrphans)
}
if options.AbortOnContainerExit {
opts.Start.OnExit = api.CascadeStop
}
if err := composeService.Build(ctx, project, api.BuildOptions{}); err != nil {
return fmt.Errorf("compose build operation failed: %w", err)
}
if err := composeService.Up(ctx, project, opts); err != nil {
return fmt.Errorf("compose up operation failed: %w", err)
}
log.Info().Msg("Stack deployment successful")
return nil
})
}
// Run runs the given service just once, without considering dependencies
func (c *ComposeDeployer) Run(ctx context.Context, filePaths []string, serviceName string, options libstack.RunOptions) error {
return c.withComposeService(ctx, filePaths, options.Options, func(composeService api.Compose, project *types.Project) error {
addServiceLabels(project, true, 0)
for name, service := range project.Services {
if name == serviceName {
project.DisabledServices[serviceName] = service
}
}
project.Services = make(types.Services)
if err := composeService.Create(ctx, project, api.CreateOptions{RemoveOrphans: true}); err != nil {
return fmt.Errorf("compose create operation failed: %w", err)
}
maps.Copy(project.Services, project.DisabledServices)
project.DisabledServices = make(types.Services)
opts := api.RunOptions{
AutoRemove: options.Remove,
Command: options.Args,
Detach: options.Detached,
Service: serviceName,
}
if _, err := composeService.RunOneOffContainer(ctx, project, opts); err != nil {
return fmt.Errorf("compose run operation failed: %w", err)
}
log.Info().Msg("Stack run successful")
return nil
})
}
// Remove stops and removes containers
func (c *ComposeDeployer) Remove(ctx context.Context, projectName string, filePaths []string, options libstack.RemoveOptions) error {
if err := libstack.WithCli(ctx,
libstack.DockerCliOptions{Host: options.Host, Registries: options.Registries},
func(ctx context.Context, cli *command.DockerCli) error {
composeService := compose.NewComposeService(cli)
return composeService.Down(ctx, projectName, api.DownOptions{RemoveOrphans: true, Volumes: options.Volumes})
}); err != nil {
return fmt.Errorf("compose down operation failed: %w", err)
}
log.Info().Msg("Stack removal successful")
return nil
}
// Pull pulls images
func (c *ComposeDeployer) Pull(ctx context.Context, filePaths []string, options libstack.Options) error {
if err := c.withComposeService(ctx, filePaths, options, func(composeService api.Compose, project *types.Project) error {
return composeService.Pull(ctx, project, api.PullOptions{})
}); err != nil {
return fmt.Errorf("compose pull operation failed: %w", err)
}
log.Info().Msg("Stack pull successful")
return nil
}
// Validate validates stack file
func (c *ComposeDeployer) Validate(ctx context.Context, filePaths []string, options libstack.Options) error {
return c.withComposeService(ctx, filePaths, options, func(composeService api.Compose, project *types.Project) error {
return nil
})
}
// Config returns the compose file with the paths resolved
func (c *ComposeDeployer) Config(ctx context.Context, filePaths []string, options libstack.Options) ([]byte, error) {
var payload []byte
if err := c.withComposeService(ctx, filePaths, options, func(composeService api.Compose, project *types.Project) error {
var err error
payload, err = project.MarshalYAML()
if err != nil {
return fmt.Errorf("unable to marshal as YAML: %w", err)
}
return nil
}); err != nil {
return nil, fmt.Errorf("compose config operation failed: %w", err)
}
return payload, nil
}
func (c *ComposeDeployer) GetExistingEdgeStacks(ctx context.Context) ([]libstack.EdgeStack, error) {
m := make(map[int]libstack.EdgeStack)
if err := c.withComposeService(ctx, nil, libstack.Options{}, func(composeService api.Compose, project *types.Project) error {
stacks, err := composeService.List(ctx, api.ListOptions{
All: true,
})
if err != nil {
return err
}
for _, s := range stacks {
summary, err := composeService.Ps(ctx, s.Name, api.PsOptions{All: true})
if err != nil {
return err
}
for _, cs := range summary {
if sid, ok := cs.Labels[PortainerEdgeStackLabel]; ok {
id, err := strconv.Atoi(sid)
if err != nil {
return err
}
if cs.Labels[api.ProjectLabel] == "" {
return errors.New("invalid project label")
}
m[id] = libstack.EdgeStack{
ID: id,
Name: cs.Labels[api.ProjectLabel],
ExitCode: cs.ExitCode,
}
}
}
}
return nil
}); err != nil {
return nil, err
}
return slices.Collect(maps.Values(m)), nil
}
func addServiceLabels(project *types.Project, oneOff bool, edgeStackID portainer.EdgeStackID) {
oneOffLabel := "False"
if oneOff {
oneOffLabel = "True"
}
for i, s := range project.Services {
s.CustomLabels = map[string]string{
api.ProjectLabel: project.Name,
api.ServiceLabel: s.Name,
api.VersionLabel: api.ComposeVersion,
api.WorkingDirLabel: project.WorkingDir,
api.ConfigFilesLabel: strings.Join(project.ComposeFiles, ","),
api.OneoffLabel: oneOffLabel,
}
if edgeStackID > 0 {
s.CustomLabels.Add(PortainerEdgeStackLabel, strconv.Itoa(int(edgeStackID)))
}
project.Services[i] = s
}
}
func createProject(ctx context.Context, configFilepaths []string, options libstack.Options) (*types.Project, error) {
var workingDir string
if len(configFilepaths) > 0 {
workingDir = filepath.Dir(configFilepaths[0])
}
if options.ProjectDir != "" {
// When relative paths are used in the compose file, the project directory is used as the base path
workingDir = options.ProjectDir
}
var envFiles []string
if options.EnvFilePath != "" {
envFiles = append(envFiles, options.EnvFilePath)
}
var composeEnvVars []string
for _, ev := range os.Environ() {
if strings.HasPrefix(ev, "COMPOSE_") {
composeEnvVars = append(composeEnvVars, ev)
}
}
projectOptions, err := cli.NewProjectOptions(configFilepaths,
cli.WithWorkingDirectory(workingDir),
cli.WithName(options.ProjectName),
cli.WithoutEnvironmentResolution,
cli.WithResolvedPaths(!slices.Contains(options.ConfigOptions, "--no-path-resolution")),
cli.WithEnv(libstack.PortainerEnvVars()),
cli.WithEnv(composeEnvVars),
cli.WithEnv(options.Env),
cli.WithEnvFiles(envFiles...),
func(o *cli.ProjectOptions) error {
if len(o.EnvFiles) > 0 {
return nil
}
if fs, ok := o.Environment[cmdcompose.ComposeEnvFiles]; ok {
o.EnvFiles = strings.Split(fs, ",")
}
return nil
},
cli.WithDotEnv,
cli.WithDefaultProfiles(),
cli.WithConfigFileEnv,
)
if err != nil {
return nil, fmt.Errorf("failed to load the compose file options : %w", err)
}
project, err := projectOptions.LoadProject(ctx)
if err != nil {
return nil, fmt.Errorf("failed to load the compose file : %w", err)
}
// Work around compose path handling
for i, service := range project.Services {
for j, envFile := range service.EnvFiles {
if !filepath.IsAbs(envFile.Path) {
project.Services[i].EnvFiles[j].Path = filesystem.JoinPaths(workingDir, envFile.Path)
}
}
}
// Set the services environment variables
if p, err := project.WithServicesEnvironmentResolved(true); err == nil {
project = p
} else {
return nil, fmt.Errorf("failed to resolve services environment: %w", err)
}
if options.BindMountHashEnabled {
// Set per-service label for bind mount hashes under each service
if project, err = project.WithServicesTransform(addBindMountHashLabel); err != nil {
log.Warn().
Err(err).
Msg("Failed to set bind mount hash labels, proceeding without them. Stack updates may not be detected when bind-mounted files change")
}
}
return project, nil
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,107 @@
package compose
import (
"errors"
"os"
"testing"
"github.com/compose-spec/compose-go/v2/types"
"github.com/google/go-cmp/cmp"
"github.com/portainer/portainer/pkg/libstack"
)
func Test_createProject_win(t *testing.T) {
t.Parallel()
dir := t.TempDir()
projectName := "create-project-test"
defer func() {
err := os.RemoveAll(dir)
if err != nil {
t.Fatalf("Failed to remove temp dir: %v", err)
}
}()
testcases := []struct {
name string
createFilesFn func() []string
configFilepaths []string
options libstack.Options
expectedProject *types.Project
}{
{
name: "Convert windows paths",
createFilesFn: func() []string {
var filepaths []string
filepaths = append(filepaths, createFile(t, dir, "docker-compose.yml", `services:
nginx:
container_name: nginx
image: nginx:latest
volumes:
- "C:\\Users\\Joey\\Desktop\\backend:/var/www/html"`))
return filepaths
},
configFilepaths: []string{dir + "/docker-compose.yml"},
options: libstack.Options{
WorkingDir: dir,
ProjectName: projectName,
Env: []string{"COMPOSE_CONVERT_WINDOWS_PATHS=true"},
},
expectedProject: &types.Project{
Name: projectName,
WorkingDir: dir,
Services: types.Services{
"nginx": {
Name: "nginx",
ContainerName: "nginx",
Environment: types.MappingWithEquals{},
Image: "nginx:latest",
Networks: map[string]*types.ServiceNetworkConfig{"default": nil},
Volumes: []types.ServiceVolumeConfig{
{
Type: "bind",
Source: "/c/Users/Joey/Desktop/backend",
Target: "/var/www/html",
ReadOnly: false,
Bind: &types.ServiceVolumeBind{CreateHostPath: true},
},
},
},
},
Networks: types.Networks{"default": {Name: "create-project-test_default"}},
ComposeFiles: []string{
dir + "/docker-compose.yml",
},
Environment: types.Mapping{"COMPOSE_PROJECT_NAME": "create-project-test", "COMPOSE_CONVERT_WINDOWS_PATHS": "true"},
DisabledServices: types.Services{},
Profiles: []string{""},
},
},
}
for _, tc := range testcases {
t.Run(tc.name, func(t *testing.T) {
createdFiles := tc.createFilesFn()
defer func() {
var errs error
for _, f := range createdFiles {
errs = errors.Join(errs, os.Remove(f))
}
if errs != nil {
t.Fatalf("Failed to remove config files: %v", errs)
}
}()
gotProject, err := createProject(t.Context(), tc.configFilepaths, tc.options)
if err != nil {
t.Fatalf("Failed to create new project: %v", err)
}
if diff := cmp.Diff(gotProject, tc.expectedProject); diff != "" {
t.Fatalf("Projects are different:\n%s", diff)
}
})
}
}
@@ -0,0 +1,4 @@
version: '3.9'
services:
redis:
image: 'redis:alpine'
+58
View File
@@ -0,0 +1,58 @@
package compose
import (
"strings"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
// LogrusToZerologWriter is a custom logrus writer that writes logrus logs to zerolog.
// logrus is the logging library used by Docker Compose.
type LogrusToZerologWriter struct{}
func (ltzw LogrusToZerologWriter) Write(p []byte) (n int, err error) {
logMessage := string(p)
logMessage = strings.TrimSuffix(logMessage, "\n")
// Parse the log level and message from the logrus log.
// This assumes logrus's default text format.
var level, message string
if strings.HasPrefix(logMessage, "time=") {
// Example logrus log: `time="2023-10-01T12:34:56Z" level=info msg="This is a log message" key=value`
parts := strings.SplitN(logMessage, " ", 4)
if len(parts) >= 3 {
level = strings.TrimPrefix(parts[1], "level=")
message = strings.TrimPrefix(parts[2], "msg=")
message = strings.Trim(message, `"`)
}
} else {
// Fallback for simpler log formats.
level = "info"
message = logMessage
}
// Map logrus levels to zerolog levels.
var zlogLevel zerolog.Level
switch level {
case "debug":
zlogLevel = zerolog.DebugLevel
case "info":
zlogLevel = zerolog.InfoLevel
case "warn", "warning":
zlogLevel = zerolog.WarnLevel
case "error":
zlogLevel = zerolog.ErrorLevel
case "fatal":
zlogLevel = zerolog.FatalLevel
case "panic":
zlogLevel = zerolog.PanicLevel
default:
zlogLevel = zerolog.InfoLevel
}
// Log the message using zerolog.
log.WithLevel(zlogLevel).Msg(message)
return len(p), nil
}
+248
View File
@@ -0,0 +1,248 @@
package compose
import (
"bytes"
"context"
"fmt"
"io"
"time"
"github.com/portainer/portainer/api/logs"
"github.com/portainer/portainer/pkg/libstack"
"github.com/compose-spec/compose-go/v2/types"
"github.com/docker/cli/cli/command"
"github.com/docker/compose/v2/pkg/api"
"github.com/docker/docker/api/types/container"
"github.com/pkg/errors"
"github.com/rs/zerolog/log"
)
type publisher struct {
URL string
TargetPort int
PublishedPort int
Protocol string
}
type service struct {
ID string
Name string
Image string
Command string
Project string
Service string
Created int64
State string
Status string
Health string
ExitCode int
Publishers []publisher
}
// docker container state can be one of "created", "running", "paused", "restarting", "removing", "exited", or "dead"
func getServiceStatus(ctx context.Context, service service) (libstack.Status, string) {
log.Debug().
Str("service", service.Name).
Str("state", service.State).
Int("exitCode", service.ExitCode).
Msg("getServiceStatus")
switch service.State {
case "created", "restarting", "paused":
return libstack.StatusStarting, ""
case "running":
return libstack.StatusRunning, ""
case "removing":
return libstack.StatusRemoving, ""
case "exited":
if service.ExitCode == 0 {
return libstack.StatusCompleted, ""
}
errorMessage, err := getContainerLogsTail(ctx, service)
if err != nil {
log.Error().
Err(err).
Str("service", service.Name).
Msg("failed to get logs from container")
errorMessage = fmt.Sprintf("service %s exited with code %d", service.Name, service.ExitCode)
}
return libstack.StatusError, errorMessage
case "dead":
if service.ExitCode == 0 {
return libstack.StatusRemoved, ""
}
errorMessage, err := getContainerLogsTail(ctx, service)
if err != nil {
log.Error().
Err(err).
Str("service", service.Name).
Msg("failed to get logs from container")
errorMessage = fmt.Sprintf("service %s exited with code %d", service.Name, service.ExitCode)
}
return libstack.StatusError, errorMessage
default:
return libstack.StatusUnknown, ""
}
}
func getContainerLogsTail(ctx context.Context, service service) (string, error) {
var combinedOutput bytes.Buffer
if err := libstack.WithCli(ctx, libstack.DockerCliOptions{}, func(ctx context.Context, cli *command.DockerCli) error {
out, err := cli.Client().ContainerLogs(ctx, service.Name, container.LogsOptions{
ShowStdout: true,
ShowStderr: true,
Timestamps: true,
Follow: false,
Tail: "20",
})
if err != nil {
return errors.Wrap(err, "unable to get logs from container")
}
defer logs.CloseAndLogErr(out)
_, err = io.Copy(&combinedOutput, out)
if err != nil {
return errors.Wrap(err, "unable to read container logs")
}
return nil
}); err != nil {
return "", errors.Wrap(err, "unable to get logs from container")
}
return combinedOutput.String(), nil
}
func aggregateStatuses(ctx context.Context, services []service) (libstack.Status, string) {
servicesCount := len(services)
if servicesCount == 0 {
log.Debug().
Msg("no services found")
return libstack.StatusRemoved, ""
}
statusCounts := make(map[libstack.Status]int)
errorMessage := ""
for _, service := range services {
status, serviceError := getServiceStatus(ctx, service)
if serviceError != "" {
errorMessage = serviceError
}
statusCounts[status]++
}
log.Debug().
Interface("statusCounts", statusCounts).
Str("errorMessage", errorMessage).
Msg("check_status")
if errorMessage != "" {
return libstack.StatusError, errorMessage
}
return libstack.AggregateStatusCounts(statusCounts, servicesCount), ""
}
func (c *ComposeDeployer) WaitForStatus(ctx context.Context, name string, status libstack.Status) libstack.WaitResult {
waitResult := libstack.WaitResult{Status: status}
for {
if ctx.Err() != nil {
waitResult.ErrorMsg = "failed to wait for status: " + ctx.Err().Error()
return waitResult
}
time.Sleep(1 * time.Second)
var containerSummaries []api.ContainerSummary
if err := c.withComposeService(ctx, nil, libstack.Options{ProjectName: name}, func(composeService api.Compose, project *types.Project) error {
var err error
psCtx, cancelFunc := context.WithTimeout(context.Background(), time.Minute)
defer cancelFunc()
containerSummaries, err = composeService.Ps(psCtx, name, api.PsOptions{All: true})
return err
}); err != nil {
log.Debug().
Str("project_name", name).
Err(err).
Msg("error from docker compose ps")
continue
}
services := serviceListFromContainerSummary(containerSummaries)
if len(services) == 0 && status == libstack.StatusRemoved {
return waitResult
}
aggregateStatus, errorMessage := aggregateStatuses(ctx, services)
if aggregateStatus == status {
return waitResult
}
if status == libstack.StatusRunning && aggregateStatus == libstack.StatusCompleted {
waitResult.Status = libstack.StatusCompleted
return waitResult
}
if errorMessage != "" {
waitResult.ErrorMsg = errorMessage
return waitResult
}
log.Debug().
Str("project_name", name).
Str("required_status", string(status)).
Str("status", string(aggregateStatus)).
Msg("waiting for status")
}
}
func serviceListFromContainerSummary(containerSummaries []api.ContainerSummary) []service {
var services []service
for _, cs := range containerSummaries {
var publishers []publisher
for _, p := range cs.Publishers {
publishers = append(publishers, publisher{
URL: p.URL,
TargetPort: p.TargetPort,
PublishedPort: p.PublishedPort,
Protocol: p.Protocol,
})
}
services = append(services, service{
ID: cs.ID,
Name: cs.Name,
Image: cs.Image,
Command: cs.Command,
Project: cs.Project,
Service: cs.Service,
Created: cs.Created,
State: cs.State,
Status: cs.Status,
Health: cs.Health,
ExitCode: cs.ExitCode,
Publishers: publishers,
})
}
return services
}
@@ -0,0 +1,115 @@
package compose
import (
"context"
"os"
"testing"
"time"
"github.com/portainer/portainer/pkg/libstack"
)
/*
1. starting = docker compose file that runs several services, one of them should be with status starting
2. running = docker compose file that runs successfully and returns status running
3. removing = run docker compose config, remove the stack, and return removing status
4. failed = run a valid docker compose file, but one of the services should fail to start (so "docker compose up" should run successfully, but one of the services should do something like `CMD ["exit", "1"]
5. removed = remove a compose stack and return status removed
*/
func ensureIntegrationTest(t *testing.T) {
if _, ok := os.LookupEnv("INTEGRATION_TEST"); !ok {
t.Skip("skip an integration test")
}
}
func TestComposeProjectStatus(t *testing.T) {
t.Parallel()
ensureIntegrationTest(t)
testCases := []struct {
TestName string
ComposeFile string
ExpectedStatus libstack.Status
ExpectedStatusMessage bool
}{
{
TestName: "running",
ComposeFile: "status_test_files/running.yml",
ExpectedStatus: libstack.StatusRunning,
},
{
TestName: "failed",
ComposeFile: "status_test_files/failed.yml",
ExpectedStatus: libstack.StatusError,
ExpectedStatusMessage: true,
},
}
w := NewComposeDeployer()
for _, testCase := range testCases {
t.Run(testCase.TestName, func(t *testing.T) {
projectName := testCase.TestName
err := w.Deploy(t.Context(), []string{testCase.ComposeFile}, libstack.DeployOptions{
Options: libstack.Options{
ProjectName: projectName,
},
})
if err != nil {
t.Fatalf("[test: %s] Failed to deploy compose file: %v", testCase.TestName, err)
}
time.Sleep(5 * time.Second)
status, statusMessage, err := waitForStatus(w, t.Context(), projectName, libstack.StatusRunning)
if err != nil {
t.Fatalf("[test: %s] Failed to get compose project status: %v", testCase.TestName, err)
}
if status != testCase.ExpectedStatus {
t.Fatalf("[test: %s] Expected status: %s, got: %s", testCase.TestName, testCase.ExpectedStatus, status)
}
if testCase.ExpectedStatusMessage && statusMessage == "" {
t.Fatalf("[test: %s] Expected status message but got empty", testCase.TestName)
}
err = w.Remove(t.Context(), projectName, nil, libstack.RemoveOptions{})
if err != nil {
t.Fatalf("[test: %s] Failed to remove compose project: %v", testCase.TestName, err)
}
time.Sleep(20 * time.Second)
status, statusMessage, err = waitForStatus(w, t.Context(), projectName, libstack.StatusRemoved)
if err != nil {
t.Fatalf("[test: %s] Failed to get compose project status: %v", testCase.TestName, err)
}
if status != libstack.StatusRemoved {
t.Fatalf("[test: %s] Expected stack to be removed, got %s", testCase.TestName, status)
}
if statusMessage != "" {
t.Fatalf("[test: %s] Expected empty status message: %s, got: %s", "", testCase.TestName, statusMessage)
}
})
}
}
func waitForStatus(deployer libstack.Deployer, ctx context.Context, stackName string, requiredStatus libstack.Status) (libstack.Status, string, error) {
ctx, cancel := context.WithTimeout(ctx, 1*time.Minute)
defer cancel()
result := deployer.WaitForStatus(ctx, stackName, requiredStatus)
if result.ErrorMsg == "" {
return result.Status, "", nil
}
return libstack.StatusError, result.ErrorMsg, nil
}
@@ -0,0 +1,7 @@
version: '3'
services:
web:
image: nginx:latest
failing-service:
image: busybox
command: ['false']
@@ -0,0 +1,4 @@
version: '3'
services:
web:
image: nginx:latest
+100
View File
@@ -0,0 +1,100 @@
package libstack
import (
"context"
"fmt"
"maps"
"sync"
"github.com/portainer/portainer/api/logs"
"github.com/docker/cli/cli/command"
configtypes "github.com/docker/cli/cli/config/types"
"github.com/docker/cli/cli/flags"
"github.com/docker/docker/registry"
"github.com/rs/zerolog/log"
)
// DockerCliOptions holds the settings required to initialise a DockerCli.
type DockerCliOptions struct {
Host string
Registries []configtypes.AuthConfig
// Headers are injected as custom HTTP headers on every request the client makes.
Headers map[string]string
}
// mu serialises calls to cli.Initialize across all deployer types (compose and
// swarm) to prevent concurrent initialisation of the Docker client config.
var mu sync.Mutex
// WithCli creates and initialises a DockerCli, injects registry credentials,
// and calls cliFn with the ready client. The client is closed after cliFn returns.
func WithCli(
ctx context.Context, //nolint:staticcheck
options DockerCliOptions,
cliFn func(context.Context, *command.DockerCli) error,
) error {
ctx = context.Background() //nolint:staticcheck
cli, err := command.NewDockerCli(command.WithCombinedStreams(log.Logger))
if err != nil {
return fmt.Errorf("unable to create a Docker client: %w", err)
}
opts := flags.NewClientOptions()
if options.Host != "" {
opts.Hosts = []string{options.Host}
}
mu.Lock()
if err := cli.Initialize(opts); err != nil {
mu.Unlock()
return fmt.Errorf("unable to initialize the Docker client: %w", err)
}
mu.Unlock()
// Inject custom headers before the first Client() call, which is where the
// Docker client is lazily created and reads ConfigFile().HTTPHeaders.
if len(options.Headers) > 0 {
cfg := cli.ConfigFile()
if cfg.HTTPHeaders == nil {
cfg.HTTPHeaders = make(map[string]string)
}
maps.Copy(cfg.HTTPHeaders, options.Headers)
}
defer logs.CloseAndLogErr(cli.Client())
for _, r := range options.Registries {
if r.ServerAddress == "" || r.ServerAddress == registry.DefaultNamespace {
r.ServerAddress = registry.IndexServer
}
cli.ConfigFile().AuthConfigs[r.ServerAddress] = r
}
// Docker resolves credentials in the following priority:
// 1. credHelpers per-registry credential helpers
// 2. credsStore global credential store used for all registries
// 3. auths inline credentials defined in config.json
//
// Many Docker Desktop users (Windows/macOS) have a global credsStore configured
// by default (e.g. "desktop.exe" on Windows or "osxkeychain" on macOS). These
// global stores often do not include credentials for the custom registries
// defined in Portainer stacks, leading to authentication failures.
//
// To avoid this, when inline credentials are provided for one or more registries,
// we intentionally clear the global credsStore. This ensures Docker uses the
// credentials configured in Portainer instead of falling back to an empty global
// store.
//
// If no inline credentials are configured in Portainer, we keep the credsStore
// so Docker can still use it as a fallback.
// credHelpers are not affected as they are external services managed by the user.
// @ref: https://linear.app/portainer/issue/BE-12237
if len(options.Registries) > 0 {
cli.ConfigFile().CredentialsStore = ""
}
return cliFn(ctx, cli)
}
+38
View File
@@ -0,0 +1,38 @@
package libstack
import (
"context"
"testing"
"github.com/docker/cli/cli/command"
"github.com/stretchr/testify/require"
)
func TestWithCli_InjectsHeaders(t *testing.T) {
const headerName = "X-PortainerAgent-ManagerOperation"
err := WithCli(
t.Context(),
DockerCliOptions{
Host: "tcp://127.0.0.1:1",
Headers: map[string]string{headerName: "1"},
},
func(_ context.Context, cli *command.DockerCli) error {
require.Equal(t, "1", cli.ConfigFile().HTTPHeaders[headerName])
return nil
},
)
require.NoError(t, err)
}
func TestWithCli_NoHeaders(t *testing.T) {
err := WithCli(
context.Background(),
DockerCliOptions{Host: "tcp://127.0.0.1:1"},
func(_ context.Context, cli *command.DockerCli) error {
require.Empty(t, cli.ConfigFile().HTTPHeaders)
return nil
},
)
require.NoError(t, err)
}
+138
View File
@@ -0,0 +1,138 @@
package libstack
import (
"context"
"os"
"strings"
portainer "github.com/portainer/portainer/api"
configtypes "github.com/docker/cli/cli/config/types"
)
const PortainerEnvVarsPrefix = "PORTAINER_"
// PortainerEnvVars returns all environment variables from os.Environ() that
// start with the PORTAINER_ prefix.
func PortainerEnvVars() []string {
var vars []string
for _, e := range os.Environ() {
if strings.HasPrefix(e, PortainerEnvVarsPrefix) {
vars = append(vars, e)
}
}
return vars
}
// AggregateStatusCounts derives a single stack Status from a map of per-status
// counts and the total number of services. The priority order matches the
// behaviour of both the compose and swarm deployers.
func AggregateStatusCounts(statusCounts map[Status]int, total int) Status {
switch {
case statusCounts[StatusError] > 0:
return StatusError
case statusCounts[StatusStarting] > 0:
return StatusStarting
case statusCounts[StatusRemoving] > 0:
return StatusRemoving
case statusCounts[StatusCompleted] == total:
return StatusCompleted
case statusCounts[StatusRunning]+statusCounts[StatusCompleted] == total:
return StatusRunning
case statusCounts[StatusStopped] == total:
return StatusStopped
case statusCounts[StatusRemoved] == total:
return StatusRemoved
default:
return StatusUnknown
}
}
type Deployer interface {
Deploy(ctx context.Context, filePaths []string, options DeployOptions) error
// Remove stops and removes containers
//
// projectName or filePaths are required
// if projectName is supplied filePaths will be ignored
Remove(ctx context.Context, projectName string, filePaths []string, options RemoveOptions) error
Pull(ctx context.Context, filePaths []string, options Options) error
Run(ctx context.Context, filePaths []string, serviceName string, options RunOptions) error
Validate(ctx context.Context, filePaths []string, options Options) error
WaitForStatus(ctx context.Context, name string, status Status) WaitResult
Config(ctx context.Context, filePaths []string, options Options) ([]byte, error)
GetExistingEdgeStacks(ctx context.Context) ([]EdgeStack, error)
}
type Status string
const (
StatusUnknown Status = "unknown"
StatusStarting Status = "starting"
StatusRunning Status = "running"
StatusStopped Status = "stopped"
StatusError Status = "error"
StatusRemoving Status = "removing"
StatusRemoved Status = "removed"
StatusCompleted Status = "completed"
)
type WaitResult struct {
Status Status
ErrorMsg string
}
type Options struct {
// WorkingDir is the working directory for the command execution
WorkingDir string
Host string
ProjectName string
// EnvFilePath is the path to a .env file
EnvFilePath string
// Env is a list of environment variables to pass to the command, example: "FOO=bar"
Env []string
// ProjectDir is the working directory for containers created by docker compose file.
// By default, it is an empty string, which means it corresponds to the path of the compose file itself.
// This is particularly helpful when mounting a relative path.
ProjectDir string
// ConfigOptions is a list of options to pass to the docker-compose config command
ConfigOptions []string
Registries []configtypes.AuthConfig
// BindMountHashEnabled controls whether bind mount hash labels are set for services.
// This option is primarily used by libstack internals and advanced callers that need
// to manage bind mount hash behavior explicitly. This is not an option that users can set.
BindMountHashEnabled bool
}
type DeployOptions struct {
Options
ForceRecreate bool
// AbortOnContainerExit will stop the deployment if a container exits.
// This is useful when running a onetime task.
//
// When this is set, docker compose will output its logs to stdout
AbortOnContainerExit bool
RemoveOrphans bool
EdgeStackID portainer.EdgeStackID
}
type RunOptions struct {
Options
// Automatically remove the container when it exits
Remove bool
// A list of arguments to pass to the container
Args []string
// Run the container in detached mode
Detached bool
}
type RemoveOptions struct {
Options
Volumes bool
}
type EdgeStack struct {
ID int
Name string
ExitCode int
}
+933
View File
@@ -0,0 +1,933 @@
package swarm
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/pkg/libstack"
"github.com/containerd/errdefs"
"github.com/distribution/reference"
"github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/compose/convert"
composeloader "github.com/docker/cli/cli/compose/loader"
"github.com/docker/cli/cli/compose/schema"
composetypes "github.com/docker/cli/cli/compose/types"
configtypes "github.com/docker/cli/cli/config/types"
"github.com/docker/docker/api/types/container"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/api/types/network"
registrytypes "github.com/docker/docker/api/types/registry"
"github.com/docker/docker/api/types/swarm"
"github.com/docker/docker/client"
dockerregistry "github.com/docker/docker/registry"
"github.com/rs/zerolog/log"
)
// managerOperationHeader forces the Portainer agent to route requests to a swarm
// manager node. Swarm-scoped operations (e.g. network create/remove) fail on worker
// nodes without it. This constant is originally defined in the agent, see package/agent/README.md.
const managerOperationHeader = "X-PortainerAgent-ManagerOperation"
// cliOptions builds the Docker CLI options for swarm operations, always setting the
// manager-operation header so requests proxied through an agent target a manager node.
func cliOptions(host string, registries []configtypes.AuthConfig) libstack.DockerCliOptions {
return libstack.DockerCliOptions{
Host: host,
Registries: registries,
Headers: map[string]string{managerOperationHeader: "1"},
}
}
// Options holds connection and credential settings for swarm operations.
type Options struct {
ProjectName string
Host string
Env []string
Registries []configtypes.AuthConfig
}
// DeployOptions extends Options with deployment-specific settings.
type DeployOptions struct {
Options
RemoveOrphans bool
// PullImage controls how image digests are resolved on deploy:
// true - query the registry (ResolveImageAlways)
// false - never contact the registry; reuse the existing digest (ResolveImageNever)
PullImage bool
// ForceRecreate increments ForceUpdate on every existing service so that
// Docker re-schedules all tasks even when nothing in the spec has changed.
ForceRecreate bool
}
// RemoveOptions extends Options with removal settings.
type RemoveOptions struct {
Options
}
// Deployer is the interface for in-process Docker Swarm stack management.
type Deployer interface {
Deploy(ctx context.Context, filePaths []string, options DeployOptions) error
Remove(ctx context.Context, projectName string, options RemoveOptions) error
Validate(ctx context.Context, filePaths []string, options Options) error
WaitForStatus(ctx context.Context, projectName string, options Options, status libstack.Status) libstack.WaitResult
}
// SwarmDeployer implements Deployer using the Docker API in-process.
type SwarmDeployer struct{}
// NewSwarmDeployer creates a new SwarmDeployer.
func NewSwarmDeployer() *SwarmDeployer { return &SwarmDeployer{} }
// Deploy creates or updates a Docker Swarm stack from the given compose files.
func (d *SwarmDeployer) Deploy(ctx context.Context, filePaths []string, options DeployOptions) error {
// WithCli replaces the context with Background internally, so we capture the
// caller's context here to preserve cancellation.
callerCtx := ctx
return libstack.WithCli(
ctx,
cliOptions(options.Host, options.Registries),
func(_ context.Context, dockerCLI *command.DockerCli) error {
return deployStack(callerCtx, dockerCLI, filePaths, options)
})
}
// Validate loads and parses the compose file(s), returning an error if they are invalid.
func (d *SwarmDeployer) Validate(_ context.Context, filePaths []string, options Options) error {
_, err := getConfig(filePaths, options.Env)
return err
}
// Remove deletes all resources belonging to a Swarm stack and waits for tasks to terminate.
func (d *SwarmDeployer) Remove(ctx context.Context, projectName string, options RemoveOptions) error {
// WithCli replaces the context with Background internally, so we capture the
// caller's context here to preserve cancellation.
callerCtx := ctx
return libstack.WithCli(
ctx,
cliOptions(options.Host, options.Registries),
func(_ context.Context, dockerCLI *command.DockerCli) error {
apiClient := dockerCLI.Client()
services, err := getStackServices(callerCtx, apiClient, projectName)
if err != nil {
return err
}
secrets, err := getStackSecrets(callerCtx, apiClient, projectName)
if err != nil {
return err
}
configs, err := getStackConfigs(callerCtx, apiClient, projectName)
if err != nil {
return err
}
networks, err := getStackNetworks(callerCtx, apiClient, projectName)
if err != nil {
return err
}
if len(services)+len(secrets)+len(configs)+len(networks) == 0 {
log.Info().Str("stack", projectName).Msg("nothing found in stack")
return nil
}
var errs []error
if err := removeServices(callerCtx, apiClient, services); err != nil {
errs = append(errs, err)
}
if err := removeSecrets(callerCtx, apiClient, secrets); err != nil {
errs = append(errs, err)
}
if err := removeConfigs(callerCtx, apiClient, configs); err != nil {
errs = append(errs, err)
}
if err := removeNetworks(callerCtx, apiClient, networks); err != nil {
errs = append(errs, err)
}
if len(errs) > 0 {
return errors.Join(errs...)
}
// Wait for all tasks to reach a terminal state before returning, mirroring
// the behaviour of `docker stack rm --detach=false`.
return waitOnTasks(callerCtx, apiClient, projectName)
})
}
// deployStack is the core stack deployment logic.
// It reimplements `docker stack deploy` in-process using the docker/cli compose loader and
// convert packages. Reference: https://github.com/docker/cli/blob/v28.5.2/cli/command/stack/swarm/deploy_composefile.go
func deployStack(ctx context.Context, dockerCLI *command.DockerCli, filePaths []string, options DeployOptions) error {
info, err := dockerCLI.Client().Info(ctx)
if err != nil {
return fmt.Errorf("failed to get docker info: %w", err)
}
if !info.Swarm.ControlAvailable {
return errors.New(`this node is not a swarm manager. Use "docker swarm init" or "docker swarm join" to connect this node to swarm and try again`)
}
config, err := getConfig(filePaths, options.Env)
if err != nil {
return fmt.Errorf("failed to load compose file: %w", err)
}
namespace := convert.NewNamespace(options.ProjectName)
// Prune orphan services before deploying to avoid name conflicts during rolling updates.
if options.RemoveOrphans {
incoming := make(map[string]struct{}, len(config.Services))
for _, svc := range config.Services {
incoming[svc.Name] = struct{}{}
}
err := pruneServices(ctx, dockerCLI.Client(), namespace, incoming)
if err != nil {
return err
}
}
serviceNetworks := getServicesDeclaredNetworks(config.Services)
networks, externalNetworks := convert.Networks(namespace, config.Networks, serviceNetworks)
if err := validateExternalNetworks(ctx, dockerCLI.Client(), externalNetworks); err != nil {
return err
}
if err := createNetworks(ctx, dockerCLI.Client(), namespace, networks); err != nil {
return err
}
secrets, err := convert.Secrets(namespace, config.Secrets)
if err != nil {
return err
}
if err := createSecrets(ctx, dockerCLI.Client(), secrets); err != nil {
return err
}
configs, err := convert.Configs(namespace, config.Configs)
if err != nil {
return err
}
if err := createConfigs(ctx, dockerCLI.Client(), configs); err != nil {
return err
}
services, err := convert.Services(ctx, namespace, config, dockerCLI.Client())
if err != nil {
return err
}
return deployServices(
ctx,
dockerCLI.Client(),
options.Registries,
services,
namespace,
options.PullImage,
options.ForceRecreate,
)
}
func getStackFilter(namespace string) filters.Args {
f := filters.NewArgs()
f.Add("label", convert.LabelNamespace+"="+namespace)
return f
}
func getStackServices(ctx context.Context, apiClient client.APIClient, namespace string) ([]swarm.Service, error) {
return apiClient.ServiceList(ctx, swarm.ServiceListOptions{Filters: getStackFilter(namespace)})
}
func getStackNetworks(ctx context.Context, apiClient client.APIClient, namespace string) ([]network.Summary, error) {
return apiClient.NetworkList(ctx, network.ListOptions{Filters: getStackFilter(namespace)})
}
func getStackSecrets(ctx context.Context, apiClient client.APIClient, namespace string) ([]swarm.Secret, error) {
return apiClient.SecretList(ctx, swarm.SecretListOptions{Filters: getStackFilter(namespace)})
}
func getStackConfigs(ctx context.Context, apiClient client.APIClient, namespace string) ([]swarm.Config, error) {
return apiClient.ConfigList(ctx, swarm.ConfigListOptions{Filters: getStackFilter(namespace)})
}
func getStackTasks(ctx context.Context, apiClient client.APIClient, namespace string) ([]swarm.Task, error) {
return apiClient.TaskList(ctx, swarm.TaskListOptions{Filters: getStackFilter(namespace)})
}
func getServicesDeclaredNetworks(services []composetypes.ServiceConfig) map[string]struct{} {
serviceNetworks := make(map[string]struct{})
for _, svc := range services {
if len(svc.Networks) == 0 {
serviceNetworks["default"] = struct{}{}
continue
}
for nw := range svc.Networks {
serviceNetworks[nw] = struct{}{}
}
}
return serviceNetworks
}
func validateExternalNetworks(ctx context.Context, apiClient client.NetworkAPIClient, externalNetworks []string) error {
for _, name := range externalNetworks {
if !container.NetworkMode(name).IsUserDefined() {
// Networks that are not user defined always exist on all nodes as
// local-scoped networks, so there's no need to inspect them.
continue
}
nw, err := apiClient.NetworkInspect(ctx, name, network.InspectOptions{})
switch {
case errdefs.IsNotFound(err):
return fmt.Errorf("network %q is declared as external, but could not be found. You need to create a swarm-scoped network before the stack is deployed", name)
case err != nil:
return err
case nw.Scope != "swarm":
return fmt.Errorf("network %q is declared as external, but it is not in the right scope: %q instead of \"swarm\"", name, nw.Scope)
}
}
return nil
}
func createNetworks(
ctx context.Context,
apiClient client.APIClient,
namespace convert.Namespace,
networks map[string]network.CreateOptions,
) error {
existingNetworks, err := getStackNetworks(ctx, apiClient, namespace.Name())
if err != nil {
return err
}
existingNetworkMap := make(map[string]network.Summary, len(existingNetworks))
for _, nw := range existingNetworks {
existingNetworkMap[nw.Name] = nw
}
for name, createOpts := range networks {
if _, exists := existingNetworkMap[name]; exists {
continue
}
if createOpts.Driver == "" {
createOpts.Driver = "overlay"
}
log.Info().Str("network", name).Msg("creating network")
if _, err := apiClient.NetworkCreate(ctx, name, createOpts); err != nil {
return fmt.Errorf("failed to create network %s: %w", name, err)
}
}
return nil
}
func createSecrets(ctx context.Context, apiClient client.APIClient, secrets []swarm.SecretSpec) error {
for _, secretSpec := range secrets {
existing, _, err := apiClient.SecretInspectWithRaw(ctx, secretSpec.Name)
switch {
case err == nil:
if err := apiClient.SecretUpdate(ctx, existing.ID, existing.Version, secretSpec); err != nil {
return fmt.Errorf("failed to update secret %s: %w", secretSpec.Name, err)
}
case errdefs.IsNotFound(err):
log.Info().Str("secret", secretSpec.Name).Msg("creating secret")
if _, err := apiClient.SecretCreate(ctx, secretSpec); err != nil {
return fmt.Errorf("failed to create secret %s: %w", secretSpec.Name, err)
}
default:
return err
}
}
return nil
}
func createConfigs(ctx context.Context, apiClient client.APIClient, configs []swarm.ConfigSpec) error {
for _, configSpec := range configs {
existing, _, err := apiClient.ConfigInspectWithRaw(ctx, configSpec.Name)
switch {
case err == nil:
if err := apiClient.ConfigUpdate(ctx, existing.ID, existing.Version, configSpec); err != nil {
return fmt.Errorf("failed to update config %s: %w", configSpec.Name, err)
}
case errdefs.IsNotFound(err):
log.Info().Str("config", configSpec.Name).Msg("creating config")
if _, err := apiClient.ConfigCreate(ctx, configSpec); err != nil {
return fmt.Errorf("failed to create config %s: %w", configSpec.Name, err)
}
default:
return err
}
}
return nil
}
// encodeRegistryAuth finds the registry credentials for the given image and returns
// the base64-encoded auth string expected by the Docker service API.
// Returns an empty string (no error) when no matching credentials are found.
func encodeRegistryAuth(image string, registries []configtypes.AuthConfig) (string, error) {
named, err := reference.ParseNormalizedNamed(image)
if err != nil {
return "", fmt.Errorf("failed to parse image reference %q: %w", image, err)
}
domain := reference.Domain(named)
if domain == "docker.io" {
domain = dockerregistry.IndexServer
}
for _, r := range registries {
if r.ServerAddress == domain {
encoded, err := registrytypes.EncodeAuthConfig(registrytypes.AuthConfig{
Username: r.Username,
Password: r.Password,
ServerAddress: r.ServerAddress,
Auth: r.Auth,
IdentityToken: r.IdentityToken,
RegistryToken: r.RegistryToken,
})
if err != nil {
return "", fmt.Errorf("failed to encode auth for registry %s: %w", domain, err)
}
return encoded, nil
}
}
return "", nil
}
func deployServices(
ctx context.Context,
apiClient client.APIClient,
registries []configtypes.AuthConfig,
services map[string]swarm.ServiceSpec,
namespace convert.Namespace,
pullImage bool,
forceRecreate bool,
) error {
existingServices, err := getStackServices(ctx, apiClient, namespace.Name())
if err != nil {
return err
}
existingServiceMap := make(map[string]swarm.Service, len(existingServices))
for _, svc := range existingServices {
existingServiceMap[svc.Spec.Name] = svc
}
for internalName, serviceSpec := range services {
name := namespace.Scope(internalName)
image := serviceSpec.TaskTemplate.ContainerSpec.Image
encodedAuth, err := encodeRegistryAuth(image, registries)
if err != nil {
return fmt.Errorf("failed to encode registry auth for image %s: %w", image, err)
}
if existing, exists := existingServiceMap[name]; exists {
log.Info().Str("service", name).Str("id", existing.ID).Msg("updating service")
updateOpts := swarm.ServiceUpdateOptions{EncodedRegistryAuth: encodedAuth}
if pullImage {
// pullImage=true → ResolveImageAlways: always query the registry during
// updates so redeploys can repull images even when the tag is unchanged.
updateOpts.QueryRegistry = true
} else {
// pullImage=false → ResolveImageNever: always reuse the existing digest.
if image == existing.Spec.Labels[convert.LabelImage] {
serviceSpec.TaskTemplate.ContainerSpec.Image = existing.Spec.TaskTemplate.ContainerSpec.Image
}
}
if forceRecreate {
serviceSpec.TaskTemplate.ForceUpdate = existing.Spec.TaskTemplate.ForceUpdate + 1
} else {
// Preserve ForceUpdate so that tasks are not re-deployed if nothing changed.
serviceSpec.TaskTemplate.ForceUpdate = existing.Spec.TaskTemplate.ForceUpdate
}
response, err := apiClient.ServiceUpdate(ctx, existing.ID, existing.Version, serviceSpec, updateOpts)
if err != nil {
return fmt.Errorf("failed to update service %s: %w", name, err)
}
for _, warning := range response.Warnings {
log.Warn().Str("service", name).Msg(warning)
}
} else {
log.Info().Str("service", name).Msg("creating service")
createOpts := swarm.ServiceCreateOptions{EncodedRegistryAuth: encodedAuth}
if pullImage {
createOpts.QueryRegistry = true
}
if _, err := apiClient.ServiceCreate(ctx, serviceSpec, createOpts); err != nil {
return fmt.Errorf("failed to create service %s: %w", name, err)
}
}
}
return nil
}
// pruneServices removes services that are present in the existing stack but absent from
// the incoming config. Must be called before deploying the new config to avoid name conflicts.
func pruneServices(
ctx context.Context,
apiClient client.APIClient,
namespace convert.Namespace,
incoming map[string]struct{},
) error {
existingServices, err := getStackServices(ctx, apiClient, namespace.Name())
if err != nil {
return fmt.Errorf("failed to list services for pruning: %w", err)
}
toRemove := make([]swarm.Service, 0, len(existingServices))
for _, svc := range existingServices {
if _, exists := incoming[namespace.Descope(svc.Spec.Name)]; !exists {
toRemove = append(toRemove, svc)
}
}
err = removeServices(ctx, apiClient, toRemove)
if err != nil {
return fmt.Errorf("failed to prune orphan services: %w", err)
}
return nil
}
func removeServices(ctx context.Context, apiClient client.APIClient, services []swarm.Service) error {
sort.Slice(services, func(i, j int) bool {
return services[i].Spec.Name < services[j].Spec.Name
})
var errs []error
for _, svc := range services {
log.Info().Str("service", svc.Spec.Name).Msg("removing service")
if err := apiClient.ServiceRemove(ctx, svc.ID); err != nil {
errs = append(errs, fmt.Errorf("failed to remove service %s: %w", svc.Spec.Name, err))
}
}
return errors.Join(errs...)
}
func removeNetworks(ctx context.Context, apiClient client.APIClient, networks []network.Summary) error {
var errs []error
for _, nw := range networks {
log.Info().Str("network", nw.Name).Msg("removing network")
if err := apiClient.NetworkRemove(ctx, nw.ID); err != nil {
errs = append(errs, fmt.Errorf("failed to remove network %s: %w", nw.Name, err))
}
}
return errors.Join(errs...)
}
func removeSecrets(ctx context.Context, apiClient client.APIClient, secrets []swarm.Secret) error {
var errs []error
for _, secret := range secrets {
log.Info().Str("secret", secret.Spec.Name).Msg("removing secret")
if err := apiClient.SecretRemove(ctx, secret.ID); err != nil {
errs = append(errs, fmt.Errorf("failed to remove secret %s: %w", secret.Spec.Name, err))
}
}
return errors.Join(errs...)
}
func removeConfigs(ctx context.Context, apiClient client.APIClient, configs []swarm.Config) error {
var errs []error
for _, cfg := range configs {
log.Info().Str("config", cfg.Spec.Name).Msg("removing config")
if err := apiClient.ConfigRemove(ctx, cfg.ID); err != nil {
errs = append(errs, fmt.Errorf("failed to remove config %s: %w", cfg.Spec.Name, err))
}
}
return errors.Join(errs...)
}
// taskStateOrdinal mirrors docker/cli's unexported numberedStates map (cli/command/stack/swarm/remove.go).
// The Docker SDK does not export terminal-state checking utilities, so we duplicate it here.
var taskStateOrdinal = map[swarm.TaskState]int{
swarm.TaskStateNew: 1,
swarm.TaskStateAllocated: 2,
swarm.TaskStatePending: 3,
swarm.TaskStateAssigned: 4,
swarm.TaskStateAccepted: 5,
swarm.TaskStatePreparing: 6,
swarm.TaskStateReady: 7,
swarm.TaskStateStarting: 8,
swarm.TaskStateRunning: 9,
swarm.TaskStateComplete: 10,
swarm.TaskStateShutdown: 11,
swarm.TaskStateFailed: 12,
swarm.TaskStateRejected: 13,
}
func isTerminalState(state swarm.TaskState) bool {
return taskStateOrdinal[state] > taskStateOrdinal[swarm.TaskStateRunning]
}
func getConfig(filePaths []string, env []string) (*composetypes.Config, error) {
// Load and parse the compose file(s).
configDetails, err := getConfigDetails(filePaths, env)
if err != nil {
return nil, fmt.Errorf("failed to load compose file: %w", err)
}
// Collect raw config dicts for unsupported/deprecated property checks.
dicts := make([]map[string]any, 0, len(configDetails.ConfigFiles))
for _, cf := range configDetails.ConfigFiles {
dicts = append(dicts, cf.Config)
}
config, err := composeloader.Load(configDetails)
if err != nil {
if fpe, ok := errors.AsType[*composeloader.ForbiddenPropertiesError](err); ok {
return nil, fmt.Errorf("compose file contains unsupported options: %v", fpe.Properties)
}
return nil, fmt.Errorf("failed to parse compose file: %w", err)
}
if unsupported := composeloader.GetUnsupportedProperties(dicts...); len(unsupported) > 0 {
log.Warn().Strs("properties", unsupported).Msg("ignoring unsupported compose properties")
}
if deprecated := composeloader.GetDeprecatedProperties(dicts...); len(deprecated) > 0 {
log.Warn().Interface("properties", deprecated).Msg("ignoring deprecated compose properties")
}
for _, svc := range config.Services {
if svc.Image == "" {
return nil, fmt.Errorf("invalid image reference for service %s: no image specified", svc.Name)
}
if _, err := reference.ParseAnyReference(svc.Image); err != nil {
return nil, fmt.Errorf("invalid image reference for service %s: %w", svc.Name, err)
}
}
return config, nil
}
func getConfigDetails(filePaths []string, env []string) (composetypes.ConfigDetails, error) {
var details composetypes.ConfigDetails
if len(filePaths) == 0 {
return details, errors.New("at least one compose file must be specified")
}
// Resolve relative references against the compose file's own directory, matching
// Docker Compose semantics (the default project directory is the directory of the
// first compose file) and the compose (non-swarm) libstack deployer.
details.WorkingDir = filepath.Dir(filePaths[0])
details.ConfigFiles = make([]composetypes.ConfigFile, 0, len(filePaths))
for _, fp := range filePaths {
bytes, err := os.ReadFile(fp)
if err != nil {
return details, err
}
config, err := composeloader.ParseYAML(bytes)
if err != nil {
return details, err
}
resolveEnvFilePaths(config, filepath.Dir(fp))
details.ConfigFiles = append(details.ConfigFiles, composetypes.ConfigFile{
Filename: fp,
Config: config,
})
}
// Take the first file version (2 files can't have different version)
details.Version = schema.Version(details.ConfigFiles[0].Config)
details.Environment = make(map[string]string)
addEnvVarFn := func(e string) {
k, v, _ := strings.Cut(e, "=")
if k != "" {
details.Environment[k] = v
}
}
for _, e := range libstack.PortainerEnvVars() {
addEnvVarFn(e)
}
for _, e := range env {
addEnvVarFn(e)
}
return details, nil
}
func resolveEnvFilePaths(rawConfig map[string]any, workingDir string) {
services, ok := rawConfig["services"].(map[string]any)
if !ok {
return
}
for _, svcAny := range services {
svc, ok := svcAny.(map[string]any)
if !ok {
continue
}
envFileAny, ok := svc["env_file"]
if !ok {
continue
}
switch ef := envFileAny.(type) {
case string:
if !filepath.IsAbs(ef) {
svc["env_file"] = filesystem.JoinPaths(workingDir, ef)
}
case []any:
for i, v := range ef {
if s, ok := v.(string); ok && !filepath.IsAbs(s) {
ef[i] = filesystem.JoinPaths(workingDir, s)
}
}
}
}
}
// WaitForStatus blocks until all services in the stack reach the requested status,
// or the context is cancelled. It polls the Docker API every second.
func (d *SwarmDeployer) WaitForStatus(
ctx context.Context,
projectName string,
options Options,
status libstack.Status,
) libstack.WaitResult {
waitResult := libstack.WaitResult{Status: status}
// WithCli replaces the context with Background internally, so we capture the
// caller's context here to preserve cancellation.
callerCtx := ctx
err := libstack.WithCli(
ctx,
cliOptions(options.Host, options.Registries),
func(_ context.Context, dockerCLI *command.DockerCli) error {
apiClient := dockerCLI.Client()
for {
if callerCtx.Err() != nil {
waitResult.ErrorMsg = "failed to wait for status: " + callerCtx.Err().Error()
return nil
}
time.Sleep(time.Second)
services, err := getStackServices(callerCtx, apiClient, projectName)
if err != nil {
log.Warn().Str("project_name", projectName).Err(err).Msg("failed to list stack services")
continue
}
if len(services) == 0 && status == libstack.StatusRemoved {
return nil
}
var serviceStatuses []libstack.Status
for _, svc := range services {
svcStatus, errorMessage, err := getServiceStatus(callerCtx, apiClient, svc)
if err != nil {
log.Warn().
Str("project_name", projectName).
Str("service_name", svc.Spec.Name).
Err(err).
Msg("failed to get service status")
continue
}
if errorMessage != "" {
// An error message means a service failed, so the result must reflect that.
waitResult.Status = libstack.StatusError
waitResult.ErrorMsg = errorMessage
return nil
}
serviceStatuses = append(serviceStatuses, svcStatus)
}
if aggregateStatus(serviceStatuses) == status {
return nil
}
log.Debug().
Str("project_name", projectName).
Str("required_status", string(status)).
Str("status", string(aggregateStatus(serviceStatuses))).
Msg("waiting for status")
}
})
if err != nil && waitResult.ErrorMsg == "" {
waitResult.Status = libstack.StatusError
waitResult.ErrorMsg = err.Error()
}
return waitResult
}
func aggregateStatus(statuses []libstack.Status) libstack.Status {
if len(statuses) == 0 {
return libstack.StatusRemoved
}
statusCounts := make(map[libstack.Status]int)
for _, status := range statuses {
statusCounts[status]++
}
log.Debug().Interface("statusCounts", statusCounts).Msg("check_status")
return libstack.AggregateStatusCounts(statusCounts, len(statuses))
}
func getServiceStatus(
ctx context.Context,
apiClient client.APIClient,
svc swarm.Service,
) (libstack.Status, string, error) {
tasks, err := apiClient.TaskList(ctx, swarm.TaskListOptions{
Filters: filters.NewArgs(filters.KeyValuePair{Key: "service", Value: svc.ID}),
})
if err != nil {
return "", "", fmt.Errorf("failed to list tasks for service %s: %w", svc.Spec.Name, err)
}
expectedRunningTaskCount := 0
if svc.Spec.Mode.Replicated != nil {
expectedRunningTaskCount = int(*svc.Spec.Mode.Replicated.Replicas)
}
if svc.Spec.Mode.Global != nil {
nodes, err := apiClient.NodeList(ctx, swarm.NodeListOptions{})
if err != nil {
return "", "", fmt.Errorf("failed to list nodes: %w", err)
}
expectedRunningTaskCount = len(nodes)
}
if expectedRunningTaskCount != 0 {
runningTaskCount := 0
for _, task := range tasks {
if task.Status.State == swarm.TaskStateRunning {
runningTaskCount++
}
}
if runningTaskCount == expectedRunningTaskCount {
return libstack.StatusRunning, "", nil
}
}
for _, task := range tasks {
switch task.Status.State {
case swarm.TaskStateRunning:
return libstack.StatusRunning, "", nil
case swarm.TaskStatePending, swarm.TaskStateStarting:
return libstack.StatusStarting, "", nil
case swarm.TaskStateRemove:
return libstack.StatusRemoving, "", nil
case swarm.TaskStateFailed, swarm.TaskStateRejected:
// Rejected means the task could never start (e.g. missing image, no disk space).
return libstack.StatusError, task.Status.Err, nil
default:
return libstack.StatusUnknown, "", nil
}
}
return libstack.StatusUnknown, "", nil
}
// waitOnTasks polls until all tasks belonging to the namespace reach a terminal state.
func waitOnTasks(ctx context.Context, apiClient client.APIClient, namespace string) error {
for {
if ctx.Err() != nil {
return ctx.Err()
}
tasks, err := getStackTasks(ctx, apiClient, namespace)
if err != nil {
return fmt.Errorf("failed to get tasks: %w", err)
}
if len(tasks) == 0 {
return nil
}
allTerminal := true
for _, task := range tasks {
if !isTerminalState(task.Status.State) {
allTerminal = false
break
}
}
if allTerminal {
return nil
}
time.Sleep(time.Second)
}
}
@@ -0,0 +1,229 @@
package swarm
import (
"context"
"os"
"testing"
"time"
"github.com/docker/docker/api/types/filters"
"github.com/docker/docker/api/types/swarm"
"github.com/docker/docker/client"
"github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/pkg/libstack"
"github.com/stretchr/testify/require"
)
// ensureSwarmMode ensures the Docker daemon is a swarm manager for the duration
// of the test. If the daemon is inactive, it initialises a single-node swarm and
// registers a cleanup to leave it afterwards. If the daemon is already a manager
// it does nothing. If the daemon is a worker it fails the test immediately.
func ensureSwarmMode(t *testing.T) *client.Client {
t.Helper()
apiClient, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, apiClient.Close()) })
info, err := apiClient.Info(t.Context())
require.NoError(t, err)
switch info.Swarm.LocalNodeState {
case swarm.LocalNodeStateInactive:
_, err = apiClient.SwarmInit(t.Context(), swarm.InitRequest{
ListenAddr: "0.0.0.0:2377",
AdvertiseAddr: "127.0.0.1",
})
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, apiClient.SwarmLeave(context.Background(), true)) })
case swarm.LocalNodeStateActive:
if !info.Swarm.ControlAvailable {
t.Fatal("docker daemon is a swarm worker, not a manager: cannot run swarm stack tests")
}
// already a manager - don't tear down, don't disrupt
default:
t.Fatalf("unexpected swarm node state: %s", info.Swarm.LocalNodeState)
}
return apiClient
}
// serviceExists reports whether a service named <stackName>_<serviceName> exists.
func serviceExists(t *testing.T, apiClient client.APIClient, stackName, serviceName string) bool {
fullName := stackName + "_" + serviceName
services, err := apiClient.ServiceList(t.Context(), swarm.ServiceListOptions{
Filters: filters.NewArgs(filters.KeyValuePair{Key: "name", Value: fullName}),
})
require.NoError(t, err)
for _, svc := range services {
if svc.Spec.Name == fullName {
return true
}
}
return false
}
func createComposeFile(t *testing.T, dir, name, content string) string {
t.Helper()
path := filesystem.JoinPaths(dir, name)
require.NoError(t, os.WriteFile(path, []byte(content), 0o644))
return path
}
func TestSwarmValidate(t *testing.T) {
ensureIntegrationTest(t)
deployer := NewSwarmDeployer()
dir := t.TempDir()
testCases := []struct {
name string
composeFile string
expectedError string
}{
{
name: "valid compose file",
composeFile: `version: '3'
services:
web:
image: nginx:latest`,
expectedError: "",
},
{
name: "invalid YAML returns error",
composeFile: "not valid yaml content",
expectedError: "failed to load compose file: top-level object must be a mapping",
},
{
name: "missing image returns error",
composeFile: `version: '3'
services:
web:
command: echo hello`,
expectedError: "invalid image reference for service web: no image specified",
},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
path := createComposeFile(t, dir, "docker-compose.yml", testCase.composeFile)
err := deployer.Validate(t.Context(), []string{path}, Options{})
var gotError string
if err != nil {
gotError = err.Error()
}
if gotError != "" && testCase.expectedError == "" {
t.Fatalf("expected no error but got: %v", err)
}
require.Contains(t, gotError, testCase.expectedError)
})
}
}
func TestSwarmDeployWithRemoveOrphans(t *testing.T) {
ensureIntegrationTest(t)
apiClient := ensureSwarmMode(t)
const projectName = "swarm_orphan_test"
const twoServiceContent = `version: '3'
services:
service-1:
image: alpine:latest
command: ["sh", "-c", "while true; do sleep 3600; done"]
service-2:
image: alpine:latest
command: ["sh", "-c", "while true; do sleep 3600; done"]`
const oneServiceContent = `version: '3'
services:
service-2:
image: alpine:latest
command: ["sh", "-c", "while true; do sleep 3600; done"]`
deployer := NewSwarmDeployer()
dir := t.TempDir()
twoServicePath := createComposeFile(t, dir, "two-services.yml", twoServiceContent)
oneServicePath := createComposeFile(t, dir, "one-service.yml", oneServiceContent)
err := deployer.Deploy(
t.Context(),
[]string{twoServicePath},
DeployOptions{Options: Options{ProjectName: projectName}},
)
require.NoError(t, err)
t.Cleanup(func() {
err := deployer.Remove(context.Background(), projectName, RemoveOptions{})
require.NoError(t, err)
})
ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
t.Cleanup(func() { cancel() })
result := deployer.WaitForStatus(ctx, projectName, Options{}, libstack.StatusRunning)
require.Empty(t, result.ErrorMsg)
require.Equal(t, libstack.StatusRunning, result.Status)
require.True(t, serviceExists(t, apiClient, projectName, "service-1"))
require.True(t, serviceExists(t, apiClient, projectName, "service-2"))
err = deployer.Deploy(ctx, []string{oneServicePath}, DeployOptions{
Options: Options{ProjectName: projectName},
RemoveOrphans: true,
})
require.NoError(t, err)
result = deployer.WaitForStatus(ctx, projectName, Options{}, libstack.StatusRunning)
require.Empty(t, result.ErrorMsg)
require.Equal(t, libstack.StatusRunning, result.Status)
require.False(t, serviceExists(t, apiClient, projectName, "service-1"))
require.True(t, serviceExists(t, apiClient, projectName, "service-2"))
}
func TestSwarmDeployWithEnvVars(t *testing.T) {
ensureIntegrationTest(t)
ensureSwarmMode(t)
const projectName = "swarm_envvar_test"
const composeContent = `version: '3'
services:
web:
image: alpine:${TAG}
command: ["sh", "-c", "while true; do sleep 3600; done"]`
deployer := NewSwarmDeployer()
dir := t.TempDir()
path := createComposeFile(t, dir, "envvar.yml", composeContent)
err := deployer.Deploy(t.Context(), []string{path}, DeployOptions{
Options: Options{
ProjectName: projectName,
Env: []string{"TAG=latest"},
},
})
require.NoError(t, err)
t.Cleanup(func() {
err := deployer.Remove(context.Background(), projectName, RemoveOptions{})
require.NoError(t, err)
})
ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
t.Cleanup(func() { cancel() })
result := deployer.WaitForStatus(ctx, projectName, Options{}, libstack.StatusRunning)
require.Empty(t, result.ErrorMsg)
require.Equal(t, libstack.StatusRunning, result.Status)
}
+112
View File
@@ -0,0 +1,112 @@
package swarm
import (
"context"
"fmt"
"os"
"testing"
"time"
"github.com/portainer/portainer/api/filesystem"
libstack "github.com/portainer/portainer/pkg/libstack"
"github.com/stretchr/testify/require"
)
func ensureIntegrationTest(t *testing.T) {
t.Helper()
if _, ok := os.LookupEnv("INTEGRATION_TEST"); !ok {
t.Skip("skip an integration test")
}
}
func TestSwarmProjectStatus(t *testing.T) {
ensureIntegrationTest(t)
testCases := []struct {
TestName string
FileContent string
ExpectedStatus libstack.Status
ExpectedStatusMessage string
}{
{
TestName: "running",
FileContent: `version: '3'
services:
web:
image: nginx:latest`,
ExpectedStatus: libstack.StatusRunning,
},
{
TestName: "failed",
FileContent: `version: '3'
services:
failing:
image: alpine:latest
command: ["sh", "-c", "exit 1"]`,
ExpectedStatus: libstack.StatusError,
ExpectedStatusMessage: "task: non-zero exit (1)",
},
}
ensureSwarmMode(t)
deployer := NewSwarmDeployer()
dir := t.TempDir()
for _, testCase := range testCases {
t.Run(testCase.TestName, func(t *testing.T) {
projectName := testCase.TestName
composeFileName := fmt.Sprintf("docker-compose-%s.yml", projectName)
composeFilePath := filesystem.JoinPaths(dir, composeFileName)
f, err := os.Create(composeFilePath)
require.NoError(t, err, "failed to create compose file")
_, err = f.WriteString(testCase.FileContent)
require.NoError(t, err, "failed to write compose file")
err = deployer.Deploy(
t.Context(),
[]string{composeFilePath},
DeployOptions{Options: Options{ProjectName: projectName}},
)
require.NoError(t, err, "failed to deploy stack")
t.Cleanup(func() {
err := deployer.Remove(context.Background(), projectName, RemoveOptions{})
require.NoError(t, err, "failed to remove stack")
result := waitForSwarmStatus(t, deployer, projectName, libstack.StatusRemoved)
require.Equal(
t,
libstack.StatusRemoved,
result.Status,
"expected stack to be removed, got %s (err: %s)",
result.Status,
result.ErrorMsg,
)
})
result := waitForSwarmStatus(t, deployer, projectName, testCase.ExpectedStatus)
require.Equal(t, testCase.ExpectedStatus, result.Status, "unexpected status. Error message: %v", result.ErrorMsg)
require.Equal(t, testCase.ExpectedStatusMessage, result.ErrorMsg)
})
}
}
func waitForSwarmStatus(
t *testing.T,
deployer *SwarmDeployer,
projectName string,
status libstack.Status,
) libstack.WaitResult {
t.Helper()
ctx, cancel := context.WithTimeout(t.Context(), time.Minute)
defer cancel()
return deployer.WaitForStatus(ctx, projectName, Options{}, status)
}
+699
View File
@@ -0,0 +1,699 @@
package swarm
import (
"context"
"os"
"path/filepath"
"slices"
"testing"
"github.com/docker/cli/cli/compose/convert"
composetypes "github.com/docker/cli/cli/compose/types"
configtypes "github.com/docker/cli/cli/config/types"
"github.com/docker/docker/api/types/swarm"
"github.com/docker/docker/client"
dockerregistry "github.com/docker/docker/registry"
"github.com/portainer/portainer/api/filesystem"
"github.com/portainer/portainer/pkg/libstack"
"github.com/stretchr/testify/require"
)
func Test_aggregateStatus(t *testing.T) {
t.Parallel()
tests := []struct {
name string
statuses []libstack.Status
expectedStatus libstack.Status
}{
{
name: "empty returns removed",
statuses: []libstack.Status{},
expectedStatus: libstack.StatusRemoved,
},
{
name: "all running",
statuses: []libstack.Status{libstack.StatusRunning, libstack.StatusRunning},
expectedStatus: libstack.StatusRunning,
},
{
name: "all completed",
statuses: []libstack.Status{libstack.StatusCompleted, libstack.StatusCompleted},
expectedStatus: libstack.StatusCompleted,
},
{
name: "mix of running and completed",
statuses: []libstack.Status{libstack.StatusRunning, libstack.StatusCompleted},
expectedStatus: libstack.StatusRunning,
},
{
name: "error takes priority",
statuses: []libstack.Status{libstack.StatusRunning, libstack.StatusError},
expectedStatus: libstack.StatusError,
},
{
name: "starting takes priority over running",
statuses: []libstack.Status{libstack.StatusRunning, libstack.StatusStarting},
expectedStatus: libstack.StatusStarting,
},
{
name: "removing",
statuses: []libstack.Status{libstack.StatusRemoving, libstack.StatusRunning},
expectedStatus: libstack.StatusRemoving,
},
{
name: "all stopped",
statuses: []libstack.Status{libstack.StatusStopped, libstack.StatusStopped},
expectedStatus: libstack.StatusStopped,
},
{
name: "all removed",
statuses: []libstack.Status{libstack.StatusRemoved, libstack.StatusRemoved},
expectedStatus: libstack.StatusRemoved,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.expectedStatus, aggregateStatus(tt.statuses))
})
}
}
func Test_cliOptions(t *testing.T) {
t.Parallel()
registries := []configtypes.AuthConfig{
{ServerAddress: "registry.example.com", Username: "user", Password: "pass"},
{ServerAddress: dockerregistry.IndexServer, Username: "other"},
}
tests := []struct {
name string
host string
registries []configtypes.AuthConfig
expected libstack.DockerCliOptions
}{
{
name: "sets manager-operation header and passes through host and registries",
host: "tcp://127.0.0.1:2377",
registries: registries,
expected: libstack.DockerCliOptions{
Host: "tcp://127.0.0.1:2377",
Registries: registries,
Headers: map[string]string{managerOperationHeader: "1"},
},
},
{
name: "empty host and nil registries still set the header",
expected: libstack.DockerCliOptions{
Headers: map[string]string{managerOperationHeader: "1"},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
require.Equal(t, tt.expected, cliOptions(tt.host, tt.registries))
})
}
}
func Test_isTerminalState(t *testing.T) {
t.Parallel()
tests := []struct {
state swarm.TaskState
terminal bool
}{
{swarm.TaskStateNew, false},
{swarm.TaskStateAllocated, false},
{swarm.TaskStatePending, false},
{swarm.TaskStateAssigned, false},
{swarm.TaskStateAccepted, false},
{swarm.TaskStatePreparing, false},
{swarm.TaskStateReady, false},
{swarm.TaskStateStarting, false},
{swarm.TaskStateRunning, false},
{swarm.TaskStateComplete, true},
{swarm.TaskStateShutdown, true},
{swarm.TaskStateFailed, true},
{swarm.TaskStateRejected, true},
}
for _, tt := range tests {
t.Run(string(tt.state), func(t *testing.T) {
require.Equal(t, tt.terminal, isTerminalState(tt.state))
})
}
}
func Test_getServicesDeclaredNetworks(t *testing.T) {
t.Parallel()
tests := []struct {
name string
services []composetypes.ServiceConfig
expectedNetworks map[string]struct{}
}{
{
name: "service with no networks gets default",
services: []composetypes.ServiceConfig{
{Name: "web", Networks: nil},
},
expectedNetworks: map[string]struct{}{"default": {}},
},
{
name: "service with explicit network",
services: []composetypes.ServiceConfig{
{Name: "web", Networks: map[string]*composetypes.ServiceNetworkConfig{"mynet": nil}},
},
expectedNetworks: map[string]struct{}{"mynet": {}},
},
{
name: "mix: one with networks, one without",
services: []composetypes.ServiceConfig{
{Name: "web", Networks: map[string]*composetypes.ServiceNetworkConfig{"mynet": nil}},
{Name: "worker", Networks: nil},
},
expectedNetworks: map[string]struct{}{"mynet": {}, "default": {}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := getServicesDeclaredNetworks(tt.services)
require.Equal(t, tt.expectedNetworks, got)
})
}
}
func Test_encodeRegistryAuth(t *testing.T) {
t.Parallel()
dockerIORegistry := configtypes.AuthConfig{
ServerAddress: dockerregistry.IndexServer,
Username: "user",
Password: "pass",
}
customRegistry := configtypes.AuthConfig{
ServerAddress: "registry.example.com",
Username: "user",
Password: "pass",
}
tests := []struct {
name string
image string
registries []configtypes.AuthConfig
expectedErr string
expectedAuth string
}{
{
name: "docker.io image with matching credentials",
image: "nginx:latest",
registries: []configtypes.AuthConfig{dockerIORegistry},
expectedAuth: "eyJ1c2VybmFtZSI6InVzZXIiLCJwYXNzd29yZCI6InBhc3MiLCJzZXJ2ZXJhZGRyZXNzIjoiaHR0cHM6Ly9pbmRleC5kb2NrZXIuaW8vdjEvIn0=",
},
{
name: "docker.io image with no matching credentials",
image: "nginx:latest",
registries: []configtypes.AuthConfig{},
},
{
name: "custom registry with matching credentials",
image: "registry.example.com/myimage:latest",
registries: []configtypes.AuthConfig{customRegistry},
expectedAuth: "eyJ1c2VybmFtZSI6InVzZXIiLCJwYXNzd29yZCI6InBhc3MiLCJzZXJ2ZXJhZGRyZXNzIjoicmVnaXN0cnkuZXhhbXBsZS5jb20ifQ==",
},
{
name: "custom registry image with unrelated credentials",
image: "registry.example.com/myimage:latest",
registries: []configtypes.AuthConfig{dockerIORegistry},
},
{
name: "invalid image reference returns error",
image: "@@invalid@@",
expectedErr: "failed to parse image reference \"@@invalid@@\": invalid reference format",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := encodeRegistryAuth(tt.image, tt.registries)
if err != nil {
if tt.expectedErr == "" {
t.Fatalf("expected no error but got: %v", err)
}
require.Contains(t, err.Error(), tt.expectedErr)
} else {
require.Equal(t, tt.expectedAuth, got)
}
})
}
}
func Test_getConfig(t *testing.T) {
dir := t.TempDir()
writeFile := func(name, content string) string {
path := filesystem.JoinPaths(dir, name)
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
require.NoError(t, os.WriteFile(path, []byte(content), 0o644))
return path
}
getStrPointer := func(s string) *string { return &s }
tests := []struct {
name string
composeFiles map[string]string
files map[string]string
env []string
osEnv map[string]string
expectedCfg *composetypes.Config
expectedErr string
}{
{
name: "valid compose file",
composeFiles: map[string]string{
"valid.yml": `version: '3'
services:
web:
image: nginx:latest`,
},
expectedCfg: &composetypes.Config{
Filename: dir + "/valid.yml",
Version: "3.13",
Services: composetypes.Services{
composetypes.ServiceConfig{
Name: "web",
Environment: composetypes.MappingWithEquals{},
Image: "nginx:latest",
},
},
Networks: map[string]composetypes.NetworkConfig{},
Volumes: map[string]composetypes.VolumeConfig{},
Secrets: map[string]composetypes.SecretConfig{},
Configs: map[string]composetypes.ConfigObjConfig{},
},
},
{
name: "invalid YAML returns error",
composeFiles: map[string]string{
"invalid.yml": `not: valid: yaml: content`,
},
expectedErr: "failed to load compose file: yaml: mapping values are not allowed in this context",
},
{
name: "no file paths returns error",
expectedErr: "failed to load compose file: at least one compose file must be specified",
},
{
name: "service missing image returns error",
composeFiles: map[string]string{
"noimage.yml": `version: '3'
services:
web:
command: echo hello`,
},
expectedErr: "invalid image reference for service web: no image specified",
},
{
name: "two compose files are merged",
composeFiles: map[string]string{
"base.yml": `version: '3'
services:
web:
image: nginx:latest`,
"override.yml": `version: '3'
services:
worker:
image: alpine:latest`,
},
expectedCfg: &composetypes.Config{
Filename: dir + "/base.yml",
Version: "3.13",
Services: composetypes.Services{
composetypes.ServiceConfig{
Name: "web",
Environment: composetypes.MappingWithEquals{},
Image: "nginx:latest",
},
composetypes.ServiceConfig{
Name: "worker",
Environment: composetypes.MappingWithEquals{},
Image: "alpine:latest",
},
},
Networks: map[string]composetypes.NetworkConfig{},
Volumes: map[string]composetypes.VolumeConfig{},
Secrets: map[string]composetypes.SecretConfig{},
Configs: map[string]composetypes.ConfigObjConfig{},
},
},
{
name: "env var in image resolved from options env",
composeFiles: map[string]string{
"envvar.yml": `version: '3'
services:
web:
image: nginx:${TAG}`,
},
env: []string{"TAG=1.25"},
expectedCfg: &composetypes.Config{
Filename: dir + "/envvar.yml",
Version: "3.13",
Services: composetypes.Services{
composetypes.ServiceConfig{
Name: "web",
Environment: composetypes.MappingWithEquals{},
Image: "nginx:1.25",
},
},
Networks: map[string]composetypes.NetworkConfig{},
Volumes: map[string]composetypes.VolumeConfig{},
Secrets: map[string]composetypes.SecretConfig{},
Configs: map[string]composetypes.ConfigObjConfig{},
},
},
{
name: "PORTAINER_ prefixed env var from os.Environ is resolved",
composeFiles: map[string]string{
"portainerenv.yml": `version: '3'
services:
web:
image: nginx:${PORTAINER_TAG}`,
},
osEnv: map[string]string{
libstack.PortainerEnvVarsPrefix + "TAG": "1.25",
},
expectedCfg: &composetypes.Config{
Filename: dir + "/portainerenv.yml",
Version: "3.13",
Services: composetypes.Services{
composetypes.ServiceConfig{
Name: "web",
Environment: composetypes.MappingWithEquals{},
Image: "nginx:1.25",
},
},
Networks: map[string]composetypes.NetworkConfig{},
Volumes: map[string]composetypes.VolumeConfig{},
Secrets: map[string]composetypes.SecretConfig{},
Configs: map[string]composetypes.ConfigObjConfig{},
},
},
{
name: "env_file with relative path is resolved against the compose file directory",
composeFiles: map[string]string{
"docker-compose.yaml": `services:
configtest:
image: nginx:latest
env_file:
- stack.env`,
},
files: map[string]string{
"stack.env": "A=junk",
},
expectedCfg: &composetypes.Config{
Filename: dir + "/docker-compose.yaml",
Version: "3.13",
Services: composetypes.Services{
composetypes.ServiceConfig{
Name: "configtest",
Environment: composetypes.MappingWithEquals{
"A": getStrPointer("junk"),
},
Image: "nginx:latest",
EnvFile: []string{dir + "/stack.env"},
},
},
Networks: map[string]composetypes.NetworkConfig{},
Volumes: map[string]composetypes.VolumeConfig{},
Secrets: map[string]composetypes.SecretConfig{},
Configs: map[string]composetypes.ConfigObjConfig{},
},
},
{
name: "absolute path env_filed",
composeFiles: map[string]string{
"docker-compose.yaml": `services:
configtest:
image: nginx:latest
env_file:
- ` + dir + "/stack.env",
},
files: map[string]string{
"stack.env": "A=junk",
},
expectedCfg: &composetypes.Config{
Filename: dir + "/docker-compose.yaml",
Version: "3.13",
Services: composetypes.Services{
composetypes.ServiceConfig{
Name: "configtest",
Environment: composetypes.MappingWithEquals{
"A": getStrPointer("junk"),
},
Image: "nginx:latest",
EnvFile: []string{dir + "/stack.env"},
},
},
Networks: map[string]composetypes.NetworkConfig{},
Volumes: map[string]composetypes.VolumeConfig{},
Secrets: map[string]composetypes.SecretConfig{},
Configs: map[string]composetypes.ConfigObjConfig{},
},
},
{
// Regression test for BE-13157: a Git stack whose compose file lives in a
// sub-directory must resolve a sibling env_file relative to that sub-directory,
// not the project root.
name: "env_file with relative path is resolved within the compose file sub-directory",
composeFiles: map[string]string{
"sub/docker-compose.yaml": `services:
configtest:
image: nginx:latest
env_file:
- stack.env`,
},
files: map[string]string{
"sub/stack.env": "A=junk",
"stack.env": "A=not-this-one",
},
expectedCfg: &composetypes.Config{
Filename: dir + "/sub/docker-compose.yaml",
Version: "3.13",
Services: composetypes.Services{
composetypes.ServiceConfig{
Name: "configtest",
Environment: composetypes.MappingWithEquals{
"A": getStrPointer("junk"),
},
Image: "nginx:latest",
EnvFile: []string{dir + "/sub/stack.env"},
},
},
Networks: map[string]composetypes.NetworkConfig{},
Volumes: map[string]composetypes.VolumeConfig{},
Secrets: map[string]composetypes.SecretConfig{},
Configs: map[string]composetypes.ConfigObjConfig{},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
filePaths := make([]string, 0, len(tt.composeFiles))
for filename, content := range tt.composeFiles {
filePaths = append(filePaths, writeFile(filename, content))
}
slices.Sort(filePaths)
for filename, content := range tt.files {
writeFile(filename, content)
}
for k, v := range tt.osEnv {
t.Setenv(k, v)
}
cfg, err := getConfig(filePaths, tt.env)
if err != nil {
if tt.expectedErr == "" {
t.Fatalf("expected no error but got: %v", err)
}
require.Contains(t, err.Error(), tt.expectedErr)
} else {
require.Equal(t, tt.expectedCfg, cfg)
}
})
}
}
type mockAPIClient struct {
client.APIClient
serviceListFn func(context.Context, swarm.ServiceListOptions) ([]swarm.Service, error)
taskListFn func(context.Context, swarm.TaskListOptions) ([]swarm.Task, error)
serviceUpdateFn func(
context.Context,
string,
swarm.Version,
swarm.ServiceSpec,
swarm.ServiceUpdateOptions,
) (swarm.ServiceUpdateResponse, error)
}
func (m *mockAPIClient) ServiceList(ctx context.Context, opts swarm.ServiceListOptions) ([]swarm.Service, error) {
if m.serviceListFn == nil {
return nil, nil
}
return m.serviceListFn(ctx, opts)
}
func (m *mockAPIClient) TaskList(ctx context.Context, opts swarm.TaskListOptions) ([]swarm.Task, error) {
if m.taskListFn == nil {
return nil, nil
}
return m.taskListFn(ctx, opts)
}
func (m *mockAPIClient) ServiceUpdate(
ctx context.Context,
id string,
ver swarm.Version,
spec swarm.ServiceSpec,
opts swarm.ServiceUpdateOptions,
) (swarm.ServiceUpdateResponse, error) {
if m.serviceUpdateFn == nil {
return swarm.ServiceUpdateResponse{}, nil
}
return m.serviceUpdateFn(ctx, id, ver, spec, opts)
}
func Test_deployServices_forceRecreate(t *testing.T) {
t.Parallel()
const initialForceUpdate = uint64(3)
tests := []struct {
name string
forceRecreate bool
expectedForceUpdate uint64
}{
{"true increments ForceUpdate", true, initialForceUpdate + 1},
{"false preserves ForceUpdate", false, initialForceUpdate},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
existingSvc := swarm.Service{
ID: "svc-id-1",
Meta: swarm.Meta{Version: swarm.Version{Index: 10}},
Spec: swarm.ServiceSpec{
Annotations: swarm.Annotations{Name: "mystack_web"},
TaskTemplate: swarm.TaskSpec{
ForceUpdate: initialForceUpdate,
ContainerSpec: &swarm.ContainerSpec{Image: "nginx:latest"},
},
},
}
var capturedForceUpdate uint64
mock := &mockAPIClient{
serviceListFn: func(_ context.Context, _ swarm.ServiceListOptions) ([]swarm.Service, error) {
return []swarm.Service{existingSvc}, nil
},
serviceUpdateFn: func(
_ context.Context,
_ string,
_ swarm.Version,
spec swarm.ServiceSpec,
_ swarm.ServiceUpdateOptions,
) (swarm.ServiceUpdateResponse, error) {
capturedForceUpdate = spec.TaskTemplate.ForceUpdate
return swarm.ServiceUpdateResponse{}, nil
},
}
services := map[string]swarm.ServiceSpec{
"web": {
TaskTemplate: swarm.TaskSpec{
ContainerSpec: &swarm.ContainerSpec{Image: "nginx:latest"},
},
},
}
namespace := convert.NewNamespace("mystack")
err := deployServices(context.Background(), mock, nil, services, namespace, false, tt.forceRecreate)
require.NoError(t, err)
require.Equal(t, tt.expectedForceUpdate, capturedForceUpdate)
})
}
}
func Test_getServiceStatus(t *testing.T) {
t.Parallel()
tests := []struct {
name string
task swarm.Task
expectedStatus libstack.Status
expectedErrMsg string
}{
{
name: "running task reports running",
task: swarm.Task{Status: swarm.TaskStatus{State: swarm.TaskStateRunning}},
expectedStatus: libstack.StatusRunning,
},
{
name: "pending task reports starting",
task: swarm.Task{Status: swarm.TaskStatus{State: swarm.TaskStatePending}},
expectedStatus: libstack.StatusStarting,
},
{
name: "failed task reports error with message",
task: swarm.Task{Status: swarm.TaskStatus{State: swarm.TaskStateFailed, Err: "task: non-zero exit (1)"}},
expectedStatus: libstack.StatusError,
expectedErrMsg: "task: non-zero exit (1)",
},
{
// Regression case for #13213: a rejected task must report an error, not "unknown".
name: "rejected task reports error with message",
task: swarm.Task{Status: swarm.TaskStatus{
State: swarm.TaskStateRejected,
Err: "No such image: this-image-definitely-does-not-exist-xyz:latest",
}},
expectedStatus: libstack.StatusError,
expectedErrMsg: "No such image: this-image-definitely-does-not-exist-xyz:latest",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
mock := &mockAPIClient{
taskListFn: func(context.Context, swarm.TaskListOptions) ([]swarm.Task, error) {
return []swarm.Task{tt.task}, nil
},
}
svc := swarm.Service{ID: "svc-id-1", Spec: swarm.ServiceSpec{Annotations: swarm.Annotations{Name: "mystack_web"}}}
status, errMsg, err := getServiceStatus(context.Background(), mock, svc)
require.NoError(t, err)
require.Equal(t, tt.expectedStatus, status)
require.Equal(t, tt.expectedErrMsg, errMsg)
})
}
}