wisski-cloud-distillery/internal/dis/component/stack.go
2023-03-13 13:19:32 +01:00

302 lines
9 KiB
Go

// Package stack implements a docker compose stack
package component
import (
"context"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"github.com/FAU-CDI/wisski-distillery/pkg/compose"
"github.com/FAU-CDI/wisski-distillery/pkg/execx"
"github.com/FAU-CDI/wisski-distillery/pkg/fsx"
"github.com/FAU-CDI/wisski-distillery/pkg/unpack"
"github.com/pkg/errors"
"github.com/tkw1536/goprogram/stream"
)
// Stack represents a 'docker compose' stack living in a specific directory
//
// NOTE(twiesing): In the current implementation this requires a 'docker' executable on the system.
// This executable must be capable of the 'docker compose' command.
// In the future the idea is to replace this with a native docker compose client.
type Stack struct {
Dir string // Directory this Stack is located in
DockerExecutable string // Path to the native docker executable to use
}
var errStackKill = errors.New("Stack.Kill: Kill returned non-zero exit code")
func (ds Stack) Kill(ctx context.Context, progress io.Writer, service string, signal os.Signal) error {
code := ds.compose(ctx, stream.NonInteractive(progress), "kill", service, "-s", signal.String())()
if code != 0 {
return errStackKill
}
return nil
}
var errStackUpdatePull = errors.New("Stack.Update: Pull returned non-zero exit code")
var errStackUpdateBuild = errors.New("Stack.Update: Build returned non-zero exit code")
// Update pulls, builds, and then optionally starts this stack.
// This does not have a direct 'docker compose' shell equivalent.
//
// See also Up.
func (ds Stack) Update(ctx context.Context, progress io.Writer, start bool) error {
if code := ds.compose(ctx, stream.NonInteractive(progress), "pull")(); code != 0 {
return errStackUpdatePull
}
if code := ds.compose(ctx, stream.NonInteractive(progress), "build", "--pull")(); code != 0 {
return errStackUpdateBuild
}
if start {
return ds.Up(ctx, progress)
}
return nil
}
var errStackUp = errors.New("Stack.Up: Up returned non-zero exit code")
// Up creates and starts the containers in this Stack.
// It is equivalent to 'docker compose up --force-recreate --remove-orphans --detach' on the shell.
func (ds Stack) Up(ctx context.Context, progress io.Writer) error {
if code := ds.compose(ctx, stream.NonInteractive(progress), "up", "--force-recreate", "--remove-orphans", "--detach")(); code != 0 {
return errStackUp
}
return nil
}
// Exec executes an executable in the provided running service.
// It is equivalent to 'docker compose exec $service $executable $args...'.
//
// It returns the exit code of the process.
func (ds Stack) Exec(ctx context.Context, io stream.IOStream, service, executable string, args ...string) func() int {
compose := []string{"exec"}
if io.StdinIsATerminal() {
compose = append(compose, "-ti")
}
compose = append(compose, service)
compose = append(compose, executable)
compose = append(compose, args...)
return ds.compose(ctx, io, compose...)
}
// Run runs a command in a running container with the given executable.
// It is equivalent to 'docker compose run [--rm] $service $executable $args...'.
//
// It returns the exit code of the process.
func (ds Stack) Run(ctx context.Context, io stream.IOStream, autoRemove bool, service, command string, args ...string) (int, error) {
compose := []string{"run"}
if autoRemove {
compose = append(compose, "--rm")
}
if !io.StdinIsATerminal() {
compose = append(compose, "-T")
}
compose = append(compose, service, command)
compose = append(compose, args...)
code := ds.compose(ctx, io, compose...)()
return code, nil
}
var errStackRestart = errors.New("Stack.Restart: Restart returned non-zero exit code")
// Restart restarts all containers in this Stack.
// It is equivalent to 'docker compose restart' on the shell.
func (ds Stack) Restart(ctx context.Context, progress io.Writer) error {
code := ds.compose(ctx, stream.NonInteractive(progress), "restart")()
if code != 0 {
return errStackRestart
}
return nil
}
var errStackDown = errors.New("Stack.Down: Down returned non-zero exit code")
// Down stops and removes all containers in this Stack.
// It is equivalent to 'docker compose down -v' on the shell.
func (ds Stack) Down(ctx context.Context, progress io.Writer) error {
code := ds.compose(ctx, stream.NonInteractive(progress), "down", "-v")()
if code != 0 {
return errStackDown
}
return nil
}
// compose executes a 'docker compose' command on this stack.
//
// NOTE(twiesing): Check if this can be replaced by an internal call to libcompose.
// But probably not.
func (ds Stack) compose(ctx context.Context, io stream.IOStream, args ...string) func() int {
if ds.DockerExecutable == "" {
var err error
ds.DockerExecutable, err = execx.LookPathAbs("docker")
if err != nil {
return execx.CommandErrorFunc
}
}
return execx.Exec(ctx, io, ds.Dir, ds.DockerExecutable, append([]string{"compose"}, args...)...)
}
// StackWithResources represents a Stack that can be automatically installed from a set of resources.
// See the [Install] method.
type StackWithResources struct {
Stack
// Installable enabled installing several resources from a (potentially embedded) filesystem.
//
// The Resources holds these, with appropriate resources specified below.
// These all refer to paths within the Resource filesystem.
Resources fs.FS
ContextPath string // the 'docker compose' stack context. Can, but does not have to, contain 'docker-compose.yml'
ReadComposeFile func() (io.Reader, error) // read the 'docker-compose.yml' (if not contained in context)
EnvPath string // the '.env' template, will be installed using [unpack.InstallTemplate].
EnvContext map[string]string // context when instantiating the '.env' template
CopyContextFiles []string // Files to copy from the installation context
MakeDirsPerm fs.FileMode // permission for dirctories, defaults to [environment.DefaultDirCreate]
MakeDirs []string // directories to ensure that exist
TouchFilesPerm fs.FileMode // permission for new files to touch, defaults to [environment.DefaultFileCreate]
TouchFiles []string // Files to 'touch', i.e. ensure that exist; guaranteed to be run after MakeDirs
}
// InstallationContext is a context to install data in
type InstallationContext map[string]string
// Install installs or updates this stack into the directory specified by stack.Stack().
//
// Installation is non-interactive, but will provide debugging output onto io.
// InstallationContext
func (is StackWithResources) Install(ctx context.Context, progress io.Writer, context InstallationContext) error {
if is.ContextPath != "" {
// setup the base files
if err := unpack.InstallDir(
is.Dir,
is.ContextPath,
is.Resources,
func(dst, src string) {
fmt.Fprintf(progress, "[install] %s\n", dst)
},
); err != nil {
return err
}
} else {
if err := fsx.MkdirAll(is.Dir, fsx.DefaultDirPerm); err != nil {
return err
}
}
// write the docker-compose.yml file
if is.ReadComposeFile != nil {
err := (func() error {
// find the file to install!
dst := filepath.Join(is.Dir, "docker-compose.yml")
defer fmt.Fprintf(progress, "[install] %s\n", dst)
// create the file
yml, err := fsx.Create(dst, fsx.DefaultFilePerm)
if err != nil {
return err
}
defer yml.Close()
// open the source file from the context
src, err := is.ReadComposeFile()
if err != nil {
return err
}
if srcc, ok := src.(io.Closer); ok {
defer srcc.Close()
}
// copy it over!
{
_, err := io.Copy(yml, src)
return err
}
})()
if err != nil {
return err
}
}
// configure .env
envDest := filepath.Join(is.Dir, ".env")
if is.EnvPath != "" && is.EnvContext != nil {
fmt.Fprintf(progress, "[config] %s\n", envDest)
if err := unpack.InstallTemplate(
envDest,
is.EnvContext,
is.EnvPath,
is.Resources,
); err != nil {
return err
}
}
// make sure that certain dirs exist
for _, name := range is.MakeDirs {
// find the destination!
dst := filepath.Join(is.Dir, name)
fmt.Fprintf(progress, "[make] %s\n", dst)
if is.MakeDirsPerm == fs.FileMode(0) {
is.MakeDirsPerm = fsx.DefaultDirPerm
}
if err := fsx.MkdirAll(dst, is.MakeDirsPerm); err != nil {
return err
}
}
// copy files from the context!
for _, name := range is.CopyContextFiles {
// find the source!
src, ok := context[name]
if !ok {
return errors.Errorf("Missing file from context: %q", src)
}
// find the destination!
dst := filepath.Join(is.Dir, name)
// copy over file from context
fmt.Fprintf(progress, "[copy] %s (from %s)\n", dst, src)
if err := fsx.CopyFile(ctx, dst, src); err != nil {
return errors.Wrapf(err, "Unable to copy file %s", src)
}
}
// make sure that certain files exist
for _, name := range is.TouchFiles {
// find the destination!
dst := filepath.Join(is.Dir, name)
fmt.Fprintf(progress, "[touch] %s\n", dst)
if err := fsx.Touch(dst, is.TouchFilesPerm); err != nil {
return err
}
}
// check that the stack can be loaded
{
fmt.Fprintln(progress, "[checking]")
_, err := compose.Open(is.Dir)
if err != nil {
return err
}
}
return nil
}