1
0
Fork 0
mirror of https://github.com/portainer/portainer.git synced 2025-07-24 07:49:41 +02:00

feat(libstack): remove the docker-compose binary BE-10801 (#111)

Co-authored-by: andres-portainer <andres-portainer@users.noreply.github.com>
Co-authored-by: oscarzhou <oscar.zhou@portainer.io>
This commit is contained in:
andres-portainer 2024-11-11 19:05:56 -03:00 committed by GitHub
parent 55aa0c0c5d
commit a7127bc74f
34 changed files with 913 additions and 761 deletions

View file

@ -1,11 +1,8 @@
package compose
import (
"github.com/portainer/portainer/pkg/libstack"
"github.com/portainer/portainer/pkg/libstack/compose/internal/composeplugin"
)
type ComposeDeployer struct{}
// NewComposeDeployer will try to create a wrapper for docker-compose plugin
func NewComposeDeployer(binaryPath, configPath string) (libstack.Deployer, error) {
return composeplugin.NewPluginWrapper(binaryPath, configPath)
// NewComposeDeployer creates a new compose deployer
func NewComposeDeployer() *ComposeDeployer {
return &ComposeDeployer{}
}

View file

@ -21,7 +21,7 @@ func checkPrerequisites(t *testing.T) {
func Test_UpAndDown(t *testing.T) {
checkPrerequisites(t)
deployer, _ := compose.NewComposeDeployer("", "")
deployer := compose.NewComposeDeployer()
const composeFileContent = `
version: "3.9"
@ -69,7 +69,7 @@ func Test_UpAndDown(t *testing.T) {
t.Fatal("container should exist")
}
err = deployer.Remove(ctx, projectName, []string{filePathOriginal, filePathOverride}, libstack.Options{})
err = deployer.Remove(ctx, projectName, []string{filePathOriginal, filePathOverride}, libstack.RemoveOptions{})
if err != nil {
t.Fatal(err)
}
@ -81,14 +81,11 @@ func Test_UpAndDown(t *testing.T) {
func createFile(dir, fileName, content string) (string, error) {
filePath := filepath.Join(dir, fileName)
f, err := os.Create(filePath)
if err != nil {
if err := os.WriteFile(filePath, []byte(content), 0644); err != nil {
return "", err
}
f.WriteString(content)
f.Close()
return filePath, nil
}

View file

@ -0,0 +1,241 @@
package compose
import (
"context"
"fmt"
"maps"
"os"
"path/filepath"
"slices"
"strings"
"github.com/portainer/portainer/pkg/libstack"
"github.com/compose-spec/compose-go/v2/dotenv"
"github.com/compose-spec/compose-go/v2/loader"
"github.com/compose-spec/compose-go/v2/types"
"github.com/docker/cli/cli/command"
"github.com/docker/cli/cli/flags"
"github.com/docker/compose/v2/pkg/api"
"github.com/docker/compose/v2/pkg/compose"
"github.com/rs/zerolog/log"
)
func withCli(
ctx context.Context,
options libstack.Options,
cliFn func(context.Context, *command.DockerCli) error,
) error {
ctx = context.Background()
cli, err := command.NewDockerCli()
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}
}
tempDir, err := os.MkdirTemp("", "docker-config")
if err != nil {
return fmt.Errorf("unable to create a temporary directory for the Docker config: %w", err)
}
defer os.RemoveAll(tempDir)
opts.ConfigDir = tempDir
if err := cli.Initialize(opts); err != nil {
return fmt.Errorf("unable to initialize the Docker client: %w", err)
}
defer cli.Client().Close()
for _, r := range options.Registries {
creds := cli.ConfigFile().GetCredentialsStore(r.ServerAddress)
if err := creds.Store(r); err != nil {
return fmt.Errorf("unable to store the Docker credentials: %w", err)
}
}
return cliFn(ctx, cli)
}
func withComposeService(
ctx context.Context,
filePaths []string,
options libstack.Options,
composeFn func(api.Service, *types.Project) error,
) error {
return withCli(ctx, options, func(ctx context.Context, cli *command.DockerCli) error {
composeService := compose.NewComposeService(cli)
configDetails := types.ConfigDetails{WorkingDir: options.WorkingDir}
for _, p := range filePaths {
configDetails.ConfigFiles = append(configDetails.ConfigFiles, types.ConfigFile{Filename: p})
}
envFile := make(map[string]string)
if options.EnvFilePath != "" {
env, err := dotenv.GetEnvFromFile(make(map[string]string), []string{options.EnvFilePath})
if err != nil {
return fmt.Errorf("unable to get the environment from the env file: %w", err)
}
maps.Copy(envFile, env)
configDetails.Environment = env
}
if len(configDetails.ConfigFiles) == 0 {
return composeFn(composeService, nil)
}
project, err := loader.LoadWithContext(ctx, configDetails,
func(o *loader.Options) {
o.SkipResolveEnvironment = true
o.ResolvePaths = !slices.Contains(options.ConfigOptions, "--no-path-resolution")
if options.ProjectName != "" {
o.SetProjectName(options.ProjectName, true)
}
},
)
if err != nil {
return fmt.Errorf("failed to load the compose file: %w", err)
}
if options.EnvFilePath != "" {
// 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 = filepath.Join(project.WorkingDir, envFile.Path)
}
}
}
if p, err := project.WithServicesEnvironmentResolved(true); err == nil {
project = p
} else {
return fmt.Errorf("failed to resolve services environment: %w", err)
}
}
return composeFn(composeService, project)
})
}
// Deploy creates and starts containers
func (c *ComposeDeployer) Deploy(ctx context.Context, filePaths []string, options libstack.DeployOptions) error {
return withComposeService(ctx, filePaths, options.Options, func(composeService api.Service, project *types.Project) error {
addServiceLabels(project)
var opts api.UpOptions
if options.ForceRecreate {
opts.Create.Recreate = api.RecreateForce
}
opts.Create.RemoveOrphans = options.RemoveOrphans
opts.Start.CascadeStop = options.AbortOnContainerExit
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
})
}
func (c *ComposeDeployer) Run(ctx context.Context, filePaths []string, serviceName string, options libstack.RunOptions) error {
return withComposeService(ctx, filePaths, options.Options, func(composeService api.Service, project *types.Project) error {
addServiceLabels(project)
opts := api.RunOptions{
AutoRemove: options.Remove,
Command: options.Args,
Detach: options.Detached,
}
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 := withCli(ctx, options.Options, 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 := withComposeService(ctx, filePaths, options, func(composeService api.Service, 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 withComposeService(ctx, filePaths, options, func(composeService api.Service, project *types.Project) error {
return nil
})
}
func (c *ComposeDeployer) Config(ctx context.Context, filePaths []string, options libstack.Options) ([]byte, error) {
var payload []byte
if err := withComposeService(ctx, filePaths, options, func(composeService api.Service, 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 addServiceLabels(project *types.Project) {
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: "/",
api.ConfigFilesLabel: strings.Join(project.ComposeFiles, ","),
api.OneoffLabel: "False",
}
project.Services[i] = s
}
}

View file

@ -1,4 +1,4 @@
package composeplugin
package compose
import (
"context"
@ -6,129 +6,80 @@ import (
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
"testing"
"github.com/portainer/portainer/pkg/libstack"
"github.com/stretchr/testify/require"
)
func checkPrerequisites(t *testing.T) {
// if _, err := os.Stat("docker-compose"); errors.Is(err, os.ErrNotExist) {
// t.Fatal("docker-compose binary not found, please run download.sh and re-run this test suite")
// }
}
func setup(t *testing.T) libstack.Deployer {
w, err := NewPluginWrapper("", "")
if err != nil {
t.Fatal(err)
}
return w
}
func Test_NewCommand_SingleFilePath(t *testing.T) {
checkPrerequisites(t)
cmd := newCommand([]string{"up", "-d"}, []string{"docker-compose.yml"})
expected := []string{"-f", "docker-compose.yml"}
if !reflect.DeepEqual(cmd.globalArgs, expected) {
t.Errorf("wrong output args, want: %v, got: %v", expected, cmd.globalArgs)
}
}
func Test_NewCommand_MultiFilePaths(t *testing.T) {
checkPrerequisites(t)
cmd := newCommand([]string{"up", "-d"}, []string{"docker-compose.yml", "docker-compose-override.yml"})
expected := []string{"-f", "docker-compose.yml", "-f", "docker-compose-override.yml"}
if !reflect.DeepEqual(cmd.globalArgs, expected) {
t.Errorf("wrong output args, want: %v, got: %v", expected, cmd.globalArgs)
}
}
func Test_NewCommand_MultiFilePaths_WithSpaces(t *testing.T) {
checkPrerequisites(t)
cmd := newCommand([]string{"up", "-d"}, []string{" docker-compose.yml", "docker-compose-override.yml "})
expected := []string{"-f", "docker-compose.yml", "-f", "docker-compose-override.yml"}
if !reflect.DeepEqual(cmd.globalArgs, expected) {
t.Errorf("wrong output args, want: %v, got: %v", expected, cmd.globalArgs)
}
}
func Test_UpAndDown(t *testing.T) {
checkPrerequisites(t)
const projectName = "composetest"
const composeFileContent = `version: "3.9"
services:
busybox:
image: "alpine:3.7"
container_name: "plugintest_container_one"`
container_name: "composetest_container_one"`
const overrideComposeFileContent = `version: "3.9"
services:
busybox:
image: "alpine:latest"
container_name: "plugintest_container_two"`
container_name: "composetest_container_two"`
const composeContainerName = "plugintest_container_two"
composeContainerName := projectName + "_container_two"
w := setup(t)
w := NewComposeDeployer()
dir := t.TempDir()
filePathOriginal, err := createFile(dir, "docker-compose.yml", composeFileContent)
if err != nil {
t.Fatal(err)
}
require.NoError(t, err)
filePathOverride, err := createFile(dir, "docker-compose-override.yml", overrideComposeFileContent)
if err != nil {
t.Fatal(err)
}
require.NoError(t, err)
projectName := "plugintest"
filePaths := []string{filePathOriginal, filePathOverride}
ctx := context.Background()
err = w.Deploy(ctx, []string{filePathOriginal, filePathOverride}, libstack.DeployOptions{
err = w.Validate(ctx, filePaths, libstack.Options{ProjectName: projectName})
require.NoError(t, err)
err = w.Pull(ctx, filePaths, libstack.Options{ProjectName: projectName})
require.NoError(t, err)
require.False(t, containerExists(composeContainerName))
err = w.Deploy(ctx, filePaths, libstack.DeployOptions{
Options: libstack.Options{
ProjectName: projectName,
},
})
if err != nil {
t.Fatal(err)
}
require.NoError(t, err)
if !containerExists(composeContainerName) {
t.Fatal("container should exist")
}
require.True(t, containerExists(composeContainerName))
err = w.Remove(ctx, projectName, []string{filePathOriginal, filePathOverride}, libstack.Options{})
if err != nil {
t.Fatal(err)
}
waitResult := <-w.WaitForStatus(ctx, projectName, libstack.StatusCompleted, "")
if containerExists(composeContainerName) {
t.Fatal("container should be removed")
}
require.Empty(t, waitResult.ErrorMsg)
require.Equal(t, libstack.StatusCompleted, waitResult.Status)
err = w.Remove(ctx, projectName, filePaths, libstack.RemoveOptions{})
require.NoError(t, err)
require.False(t, containerExists(composeContainerName))
}
func createFile(dir, fileName, content string) (string, error) {
filePath := filepath.Join(dir, fileName)
f, err := os.Create(filePath)
if err != nil {
if err := os.WriteFile(filePath, []byte(content), 0644); err != nil {
return "", err
}
_, err = f.WriteString(content)
if err != nil {
return "", err
}
f.Close()
return filePath, nil
}
@ -143,9 +94,27 @@ func containerExists(containerName string) bool {
return strings.Contains(string(out), containerName)
}
func Test_Config(t *testing.T) {
checkPrerequisites(t)
func Test_Validate(t *testing.T) {
invalidComposeFileContent := `invalid-file-content`
w := NewComposeDeployer()
dir := t.TempDir()
filePathOriginal, err := createFile(dir, "docker-compose.yml", invalidComposeFileContent)
require.NoError(t, err)
filePaths := []string{filePathOriginal}
projectName := "plugintest"
ctx := context.Background()
err = w.Validate(ctx, filePaths, libstack.Options{ProjectName: projectName})
require.Error(t, err)
}
func Test_Config(t *testing.T) {
ctx := context.Background()
dir := t.TempDir()
projectName := "configtest"
@ -340,32 +309,24 @@ networks:
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
composeFilePath, err := createFile(dir, "docker-compose.yml", tc.composeFileContent)
if err != nil {
t.Fatal(err)
}
require.NoError(t, err)
envFilePath := ""
if tc.envFileContent != "" {
envFilePath, err = createFile(dir, "stack.env", tc.envFileContent)
if err != nil {
t.Fatal(err)
}
require.NoError(t, err)
}
w := setup(t)
w := NewComposeDeployer()
actual, err := w.Config(ctx, []string{composeFilePath}, libstack.Options{
WorkingDir: dir,
ProjectName: projectName,
EnvFilePath: envFilePath,
ConfigOptions: []string{"--no-path-resolution"},
})
if err != nil {
t.Fatalf("failed to get config: %s. Error: %s", string(actual), err)
}
require.NoError(t, err)
if string(actual) != tc.expectFileContent {
t.Fatalf("unexpected config output: %s(len=%d), expect: %s(len=%d)", actual, len(actual), tc.expectFileContent, len(tc.expectFileContent))
}
require.Equal(t, tc.expectFileContent, string(actual))
})
}
}

View file

@ -1,8 +0,0 @@
package errors
import "errors"
var (
// ErrBinaryNotFound is returned when docker-compose binary is not found
ErrBinaryNotFound = errors.New("docker-compose binary not found")
)

View file

@ -1,261 +0,0 @@
package composeplugin
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"strings"
"github.com/pkg/errors"
"github.com/portainer/portainer/pkg/libstack"
"github.com/portainer/portainer/pkg/libstack/compose/internal/utils"
"github.com/rs/zerolog/log"
)
var (
MissingDockerComposePluginErr = errors.New("docker-compose plugin is missing from config path")
)
// PluginWrapper provide a type for managing docker compose commands
type PluginWrapper struct {
binaryPath string
configPath string
}
// NewPluginWrapper initializes a new ComposeWrapper service with local docker-compose binary.
func NewPluginWrapper(binaryPath, configPath string) (libstack.Deployer, error) {
if !utils.IsBinaryPresent(utils.ProgramPath(binaryPath, "docker-compose")) {
return nil, MissingDockerComposePluginErr
}
return &PluginWrapper{binaryPath: binaryPath, configPath: configPath}, nil
}
// Up create and start containers
func (wrapper *PluginWrapper) Deploy(ctx context.Context, filePaths []string, options libstack.DeployOptions) error {
output, err := wrapper.command(newUpCommand(filePaths, upOptions{
forceRecreate: options.ForceRecreate,
abortOnContainerExit: options.AbortOnContainerExit,
}), options.Options)
if len(output) != 0 {
if err != nil {
return err
}
log.Info().Msg("Stack deployment successful")
log.Debug().
Str("output", string(output)).
Msg("docker compose")
}
return err
}
// Down stop and remove containers
func (wrapper *PluginWrapper) Remove(ctx context.Context, projectName string, filePaths []string, options libstack.Options) error {
output, err := wrapper.command(newDownCommand(projectName), options)
if len(output) != 0 {
if err != nil {
return err
}
log.Info().Msg("Stack removal successful")
log.Debug().
Str("output", string(output)).
Msg("docker compose")
}
return err
}
// Pull images
func (wrapper *PluginWrapper) Pull(ctx context.Context, filePaths []string, options libstack.Options) error {
output, err := wrapper.command(newPullCommand(filePaths), options)
if len(output) != 0 {
if err != nil {
return err
}
log.Info().Msg("Stack pull successful")
log.Debug().
Str("output", string(output)).
Msg("docker compose")
}
return err
}
// Validate stack file
func (wrapper *PluginWrapper) Validate(ctx context.Context, filePaths []string, options libstack.Options) error {
output, err := wrapper.command(newValidateCommand(filePaths), options)
if len(output) != 0 {
if err != nil {
return err
}
log.Info().Msg("Valid stack format")
log.Debug().
Str("output", string(output)).
Msg("docker compose")
}
return err
}
func (wrapper *PluginWrapper) Config(ctx context.Context, filePaths []string, options libstack.Options) ([]byte, error) {
configArgs := append([]string{"config"}, options.ConfigOptions...)
return wrapper.command(newCommand(configArgs, filePaths), options)
}
// Command execute a docker-compose command
func (wrapper *PluginWrapper) command(command composeCommand, options libstack.Options) ([]byte, error) {
program := utils.ProgramPath(wrapper.binaryPath, "docker-compose")
if options.ProjectName != "" {
command.WithProjectName(options.ProjectName)
}
if options.EnvFilePath != "" {
command.WithEnvFilePath(options.EnvFilePath)
}
if options.Host != "" {
command.WithHost(options.Host)
}
if options.ProjectDir != "" {
command.WithProjectDirectory(options.ProjectDir)
}
var stderr bytes.Buffer
args := []string{}
args = append(args, command.ToArgs()...)
cmd := exec.Command(program, args...)
if options.WorkingDir != "" {
// Specify an non-exist working directory will cause the failure
// of the "docker-compose down" command even if the project name
// is correct.
cmd.Dir = options.WorkingDir
}
if wrapper.configPath != "" || len(options.Env) > 0 {
cmd.Env = os.Environ()
}
if wrapper.configPath != "" {
cmd.Env = append(cmd.Env, "DOCKER_CONFIG="+wrapper.configPath)
}
cmd.Env = append(cmd.Env, options.Env...)
executedCommand := cmd.String()
log.Debug().
Str("command", executedCommand).
Interface("env", cmd.Env).
Msg("execute command")
cmd.Stderr = &stderr
output, err := cmd.Output()
if err != nil {
errOutput := stderr.String()
log.Warn().
Str("output", string(output)).
Str("error_output", errOutput).
Err(err).
Msg("docker compose command failed")
if errOutput != "" {
return nil, errors.New(errOutput)
}
return nil, fmt.Errorf("docker compose command failed: %w", err)
}
return output, nil
}
type composeCommand struct {
globalArgs []string // docker-compose global arguments: --host host -f file.yaml
subCommandAndArgs []string // docker-compose subcommand: up, down folllowed by subcommand arguments
}
func newCommand(command []string, filePaths []string) composeCommand {
args := []string{}
for _, path := range filePaths {
args = append(args, "-f")
args = append(args, strings.TrimSpace(path))
}
return composeCommand{
globalArgs: args,
subCommandAndArgs: command,
}
}
type upOptions struct {
forceRecreate bool
abortOnContainerExit bool
}
func newUpCommand(filePaths []string, options upOptions) composeCommand {
args := []string{"up"}
if options.abortOnContainerExit {
args = append(args, "--abort-on-container-exit")
} else { // detach by default, not working with --abort-on-container-exit
args = append(args, "-d")
}
if options.forceRecreate {
args = append(args, "--force-recreate")
}
return newCommand(args, filePaths)
}
func newDownCommand(projectName string) composeCommand {
cmd := newCommand([]string{"down", "--remove-orphans"}, nil)
cmd.WithProjectName(projectName)
return cmd
}
func newPullCommand(filePaths []string) composeCommand {
return newCommand([]string{"pull"}, filePaths)
}
func newValidateCommand(filePaths []string) composeCommand {
return newCommand([]string{"config", "--quiet"}, filePaths)
}
func (command *composeCommand) WithHost(host string) {
// prepend compatibility flags such as this one as they must appear before the
// regular global args otherwise docker-compose will throw an error
command.globalArgs = append([]string{"--host", host}, command.globalArgs...)
}
func (command *composeCommand) WithProjectName(projectName string) {
command.globalArgs = append(command.globalArgs, "--project-name", projectName)
}
func (command *composeCommand) WithEnvFilePath(envFilePath string) {
command.globalArgs = append(command.globalArgs, "--env-file", envFilePath)
}
func (command *composeCommand) WithProjectDirectory(projectDir string) {
command.globalArgs = append(command.globalArgs, "--project-directory", projectDir)
}
func (command *composeCommand) ToArgs() []string {
return append(command.globalArgs, command.subCommandAndArgs...)
}

View file

@ -1,53 +0,0 @@
package composeplugin
import (
"context"
"github.com/portainer/portainer/pkg/libstack"
"github.com/rs/zerolog/log"
)
func (wrapper *PluginWrapper) Run(ctx context.Context, filePaths []string, serviceName string, options libstack.RunOptions) error {
output, err := wrapper.command(newRunCommand(filePaths, serviceName, runOptions{
remove: options.Remove,
args: options.Args,
detached: options.Detached,
}), options.Options)
if len(output) != 0 {
if err != nil {
return err
}
log.Info().Msg("Stack run successful")
log.Debug().
Str("output", string(output)).
Msg("docker compose")
}
return err
}
type runOptions struct {
remove bool
args []string
detached bool
}
func newRunCommand(filePaths []string, serviceName string, options runOptions) composeCommand {
args := []string{"run"}
if options.remove {
args = append(args, "--rm")
}
if options.detached {
args = append(args, "-d")
}
args = append(args, serviceName)
args = append(args, options.args...)
return newCommand(args, filePaths)
}

View file

@ -1,60 +0,0 @@
package utils
import (
"os"
"os/exec"
"path"
"runtime"
"github.com/pkg/errors"
)
func osProgram(program string) string {
if runtime.GOOS == "windows" {
program += ".exe"
}
return program
}
func ProgramPath(rootPath, program string) string {
return path.Join(rootPath, osProgram(program))
}
// IsBinaryPresent check if docker compose binary is present
func IsBinaryPresent(program string) bool {
_, err := exec.LookPath(program)
return err == nil
}
// Copy copies sourcePath to destinationPath
func Copy(sourcePath, destinationPath string) error {
si, err := os.Stat(sourcePath)
if err != nil {
return errors.WithMessage(err, "file check failed")
}
input, err := os.ReadFile(sourcePath)
if err != nil {
return errors.WithMessage(err, "failed reading file")
}
err = os.WriteFile(destinationPath, input, si.Mode())
if err != nil {
return errors.WithMessage(err, "failed writing file")
}
return nil
}
// Move sourcePath to destinationPath
func Move(sourcePath, destinationPath string) error {
if err := Copy(sourcePath, destinationPath); err != nil {
return err
}
if err := os.Remove(sourcePath); err != nil {
return err
}
return nil
}

View file

@ -1,33 +0,0 @@
package utils
import (
"testing"
)
func TestIsBinaryPresent(t *testing.T) {
type testCase struct {
Name string
Binary string
Expected bool
}
testCases := []testCase{
{
Name: "not existing",
Binary: "qwgq-er-gerw",
Expected: false,
},
{
Name: "docker-compose exists",
Binary: "docker-compose",
Expected: true,
},
}
for _, tc := range testCases {
got := IsBinaryPresent(tc.Binary)
if got != tc.Expected {
t.Errorf("Error in test %s got = %v, and Expected = %v.", tc.Name, got, tc.Expected)
}
}
}

View file

@ -1,17 +1,15 @@
package composeplugin
package compose
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"time"
"github.com/portainer/portainer/pkg/libstack"
"github.com/compose-spec/compose-go/v2/types"
"github.com/docker/compose/v2/pkg/api"
"github.com/rs/zerolog/log"
"github.com/segmentio/encoding/json"
)
type publisher struct {
@ -113,14 +111,11 @@ func aggregateStatuses(services []service) (libstack.Status, string) {
}
func (wrapper *PluginWrapper) WaitForStatus(ctx context.Context, name string, status libstack.Status, _ string) <-chan libstack.WaitResult {
func (c *ComposeDeployer) WaitForStatus(ctx context.Context, name string, status libstack.Status, _ string) <-chan libstack.WaitResult {
waitResultCh := make(chan libstack.WaitResult)
waitResult := libstack.WaitResult{
Status: status,
}
waitResult := libstack.WaitResult{Status: status}
go func() {
OUTER:
for {
select {
case <-ctx.Done():
@ -131,49 +126,23 @@ func (wrapper *PluginWrapper) WaitForStatus(ctx context.Context, name string, st
time.Sleep(1 * time.Second)
output, err := wrapper.command(newCommand([]string{"ps", "-a", "--format", "json"}, nil), libstack.Options{
ProjectName: name,
})
if len(output) == 0 {
log.Debug().
Str("project_name", name).
Msg("no output from docker compose ps")
var containerSummaries []api.ContainerSummary
if status == libstack.StatusRemoved {
waitResultCh <- waitResult
return
}
if err := withComposeService(ctx, nil, libstack.Options{ProjectName: name}, func(composeService api.Service, project *types.Project) error {
var err error
containerSummaries, err = composeService.Ps(ctx, name, api.PsOptions{All: true})
continue
}
if err != nil {
return err
}); err != nil {
log.Debug().
Str("project_name", name).
Err(err).
Msg("error from docker compose ps")
continue
}
var services []service
dec := json.NewDecoder(bytes.NewReader(output))
for {
var svc service
err := dec.Decode(&svc)
if errors.Is(err, io.EOF) {
break
}
if err != nil {
log.Debug().
Str("project_name", name).
Err(err).
Msg("failed to parse docker compose output")
continue OUTER
}
services = append(services, svc)
}
services := serviceListFromContainerSummary(containerSummaries)
if len(services) == 0 && status == libstack.StatusRemoved {
waitResultCh <- waitResult
@ -203,9 +172,42 @@ func (wrapper *PluginWrapper) WaitForStatus(ctx context.Context, name string, st
Str("required_status", string(status)).
Str("status", string(aggregateStatus)).
Msg("waiting for status")
}
}()
return waitResultCh
}
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
}

View file

@ -1,4 +1,4 @@
package composeplugin
package compose
import (
"context"
@ -49,7 +49,7 @@ func TestComposeProjectStatus(t *testing.T) {
},
}
w := setup(t)
w := NewComposeDeployer()
ctx := context.Background()
for _, testCase := range testCases {
@ -79,7 +79,7 @@ func TestComposeProjectStatus(t *testing.T) {
t.Fatalf("[test: %s] Expected status message but got empty", testCase.TestName)
}
err = w.Remove(ctx, projectName, nil, libstack.Options{})
err = w.Remove(ctx, projectName, nil, libstack.RemoveOptions{})
if err != nil {
t.Fatalf("[test: %s] Failed to remove compose project: %v", testCase.TestName, err)
}