Previously, there was a function to manually read bytes for a docker-compose.yml. But this proved to be akward at runtime. Instead, this code automatically reads an existing docker-compose.yml, and takes care of marshalling and unmarshalling.
366 lines
11 KiB
Go
366 lines
11 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/unpack"
|
|
"github.com/pkg/errors"
|
|
"github.com/tkw1536/pkglib/fsx/umaskfree"
|
|
"github.com/tkw1536/pkglib/stream"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
// 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, "-T")
|
|
}
|
|
|
|
compose = append(compose, service)
|
|
compose = append(compose, executable)
|
|
compose = append(compose, args...)
|
|
|
|
return ds.compose(ctx, io, compose...)
|
|
}
|
|
|
|
type RunFlags struct {
|
|
AutoRemove bool
|
|
Detach bool
|
|
}
|
|
|
|
// 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, flags RunFlags, service, command string, args ...string) (int, error) {
|
|
compose := []string{"run"}
|
|
if flags.AutoRemove {
|
|
compose = append(compose, "--rm")
|
|
}
|
|
if !io.StdinIsATerminal() {
|
|
compose = append(compose, "--no-TTY")
|
|
}
|
|
if flags.Detach {
|
|
compose = append(compose, "--detach")
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// DownAll stops and removes all containers in this Stack, and those not defined in the compose file.
|
|
// It is equivalent to 'docker compose down -v --remove-orphans' on the shell.
|
|
func (ds Stack) DownAll(ctx context.Context, progress io.Writer) error {
|
|
code := ds.compose(ctx, stream.NonInteractive(progress), "down", "-v", "--remove-orphans")()
|
|
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. May or may not contain 'docker-compose.yml'
|
|
|
|
ComposerYML func(*yaml.Node) (*yaml.Node, error) // update 'docker-compose.yml', if no 'docker-compose.yml' exists, the passed node is nil.
|
|
|
|
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 := umaskfree.MkdirAll(is.Dir, umaskfree.DefaultDirPerm); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// update the docker compose file
|
|
if is.ComposerYML != nil {
|
|
dst := filepath.Join(is.Dir, "docker-compose.yml")
|
|
fmt.Fprintf(progress, "[install] %s\n", dst)
|
|
|
|
if err := doComposeFile(dst, is.ComposerYML); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
// configure .env
|
|
envDest := filepath.Join(is.Dir, ".env")
|
|
if is.EnvContext != nil {
|
|
fmt.Fprintf(progress, "[config] %s\n", envDest)
|
|
|
|
if err := writeEnvFile(envDest, is.TouchFilesPerm, is.EnvContext); 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 = umaskfree.DefaultDirPerm
|
|
}
|
|
if err := umaskfree.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 := umaskfree.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 := umaskfree.Touch(dst, umaskfree.DefaultFilePerm); 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
|
|
}
|
|
|
|
// doComposeFile updates the compose file using the update function.
|
|
//
|
|
// If the file at path already exists, calls update with the existing data.
|
|
// if the file at path does not exist, calls update(nil).
|
|
func doComposeFile(path string, update func(node *yaml.Node) (*yaml.Node, error)) error {
|
|
var mode fs.FileMode
|
|
var node *yaml.Node
|
|
|
|
{
|
|
stat, err := os.Stat(path)
|
|
switch {
|
|
case err == nil:
|
|
// file exists => use the previous file mode
|
|
mode = stat.Mode()
|
|
|
|
// read the yaml bytes
|
|
bytes, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return errors.Wrap(err, "unable to read existing file")
|
|
}
|
|
|
|
// unmarshal it into a node, or bail out!
|
|
node = new(yaml.Node)
|
|
if err := yaml.Unmarshal(bytes, node); err != nil {
|
|
return errors.Wrap(err, "unable to unmarshal existing file")
|
|
}
|
|
case errors.Is(err, fs.ErrNotExist):
|
|
// file does not exist => use default mode
|
|
mode = umaskfree.DefaultFilePerm
|
|
|
|
// use a nil existing node
|
|
node = nil
|
|
default:
|
|
return err
|
|
}
|
|
}
|
|
|
|
// update the node
|
|
node, err := update(node)
|
|
if err != nil {
|
|
return errors.Wrap(err, "update function failed")
|
|
}
|
|
|
|
// re-encode the bytes
|
|
result, err := yaml.Marshal(node)
|
|
if err != nil {
|
|
return errors.Wrap(err, "failed to re-marshal")
|
|
}
|
|
|
|
// write the bytes back!
|
|
return umaskfree.WriteFile(path, result, mode)
|
|
}
|
|
|
|
// writeEnvFile writes an environment file
|
|
func writeEnvFile(path string, perm fs.FileMode, variables map[string]string) error {
|
|
// create the environment file
|
|
file, err := umaskfree.Create(path, perm)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer file.Close()
|
|
|
|
// write the file!
|
|
_, err = compose.WriteEnvFile(file, variables)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// and return nil
|
|
return nil
|
|
}
|