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:
parent
55aa0c0c5d
commit
a7127bc74f
34 changed files with 913 additions and 761 deletions
|
@ -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{}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
241
pkg/libstack/compose/composeplugin.go
Normal file
241
pkg/libstack/compose/composeplugin.go
Normal 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
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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")
|
||||
)
|
|
@ -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...)
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue