mirror of
https://github.com/portainer/portainer.git
synced 2025-07-19 13:29:41 +02:00
refactor(libstack): move library to portainer [EE-5474] (#9120)
This commit is contained in:
parent
11571fd6ea
commit
8c16fbb8aa
15 changed files with 691 additions and 0 deletions
237
pkg/libstack/compose/internal/composeplugin/composeplugin.go
Normal file
237
pkg/libstack/compose/internal/composeplugin/composeplugin.go
Normal file
|
@ -0,0 +1,237 @@
|
|||
package composeplugin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"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, filePaths), 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
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
var stderr bytes.Buffer
|
||||
|
||||
args := []string{}
|
||||
args = append(args, command.ToArgs()...)
|
||||
|
||||
cmd := exec.Command(program, args...)
|
||||
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...)
|
||||
|
||||
log.Debug().
|
||||
Str("command", program).
|
||||
Strs("args", args).
|
||||
Interface("env", cmd.Env).
|
||||
Msg("run 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")
|
||||
|
||||
return nil, errors.New(errOutput)
|
||||
}
|
||||
|
||||
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, filePaths []string) composeCommand {
|
||||
cmd := newCommand([]string{"down", "--remove-orphans"}, filePaths)
|
||||
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) ToArgs() []string {
|
||||
return append(command.globalArgs, command.subCommandAndArgs...)
|
||||
}
|
|
@ -0,0 +1,136 @@
|
|||
package composeplugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/portainer/portainer/pkg/libstack"
|
||||
)
|
||||
|
||||
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 composeFileContent = `version: "3.9"
|
||||
services:
|
||||
busybox:
|
||||
image: "alpine:3.7"
|
||||
container_name: "test_container_one"`
|
||||
|
||||
const overrideComposeFileContent = `version: "3.9"
|
||||
services:
|
||||
busybox:
|
||||
image: "alpine:latest"
|
||||
container_name: "test_container_two"`
|
||||
|
||||
const composeContainerName = "test_container_two"
|
||||
|
||||
w := setup(t)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
err = w.Deploy(ctx, []string{filePathOriginal, filePathOverride}, libstack.DeployOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !containerExists(composeContainerName) {
|
||||
t.Fatal("container should exist")
|
||||
}
|
||||
|
||||
err = w.Remove(ctx, "", []string{filePathOriginal, filePathOverride}, libstack.Options{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if containerExists(composeContainerName) {
|
||||
t.Fatal("container should be removed")
|
||||
}
|
||||
}
|
||||
|
||||
func createFile(dir, fileName, content string) (string, error) {
|
||||
filePath := filepath.Join(dir, fileName)
|
||||
f, err := os.Create(filePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
f.WriteString(content)
|
||||
f.Close()
|
||||
|
||||
return filePath, nil
|
||||
}
|
||||
|
||||
func containerExists(containerName string) bool {
|
||||
cmd := exec.Command("docker", "ps", "-a", "-f", fmt.Sprintf("name=%s", containerName))
|
||||
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
log.Fatalf("failed to list containers: %s", err)
|
||||
}
|
||||
|
||||
return strings.Contains(string(out), containerName)
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
version: "3.9"
|
||||
services:
|
||||
redis:
|
||||
image: "redis:alpine"
|
Loading…
Add table
Add a link
Reference in a new issue