Do a large chunk of the move to go

This commit moves a huge chunk of the code to go. The TODO.md document
indicates what is left to be done.
This commit is contained in:
Tom Wiesing 2022-08-14 10:57:59 +02:00
parent db2ad9b4bd
commit 7b38fdd801
No known key found for this signature in database
93 changed files with 4689 additions and 645 deletions

View file

@ -0,0 +1,68 @@
// Package bookkeeping implements reading and writing from the bookkeeping table
package bookkeeping
import (
"database/sql/driver"
"errors"
"time"
)
// Instance is a WissKI Instance inside the bookkeeping table.
// It does not represent a running instance; it does not perform any validation.
type Instance struct {
// NOTE: Modifying this struct requires a database migration.
// This should nnever be done unless you know what you're doing.
// Primary key for the instance
Pk uint `gorm:"column:pk;primaryKey"`
// time the instance was created
Created time.Time `gorm:"column:created;autoCreateTime"`
// slug of the system
Slug string `gorm:"column:slug;not null;unique"`
// email address of the system owner (if any)
OwnerEmail string `gorm:"column:owner_email;type:varchar(320)"`
// should we automatically enable updates for the system?
AutoBlindUpdateEnabled SQLBit1 `gorm:"column:auto_blind_update_enabled;default:1"`
// The filesystem path the system can be found under
FilesystemBase string `gorm:"column:filesystem_base;not null"`
// SQL Database credentials for the system
SqlDatabase string `gorm:"column:sql_database;not null"`
SqlUser string `gorm:"column:sql_user;not null"`
SqlPassword string `gorm:"column:sql_password;not null"`
// GraphDB Repository
GraphDBRepository string `gorm:"column:graphdb_repository;not null"`
GraphDBUser string `gorm:"column:graphdb_user;not null"`
GraphDBPassword string `gorm:"column:graphdb_password;not null"`
}
func (i Instance) IsBlindUpdateEnabled() bool {
return bool(i.AutoBlindUpdateEnabled)
}
// SQLBit1 implements a boolean as a BIT(1)
type SQLBit1 bool
func (sb SQLBit1) Value() (driver.Value, error) {
if sb {
return []byte{1}, nil
} else {
return []byte{0}, nil
}
}
var errBadBool = errors.New("SQLBit1: Database does not contain Bit(1)")
func (sb *SQLBit1) Scan(src interface{}) error {
if bytes, ok := src.([]byte); ok && len(bytes) == 1 {
*sb = bytes[0] == 1
return nil
}
return errBadBool
}

153
internal/config/config.go Normal file
View file

@ -0,0 +1,153 @@
// Package config implements reading and validating a WissKIDistillery configuration file.
package config
import (
"fmt"
"io"
"net/url"
"reflect"
"strings"
"github.com/pkg/errors"
)
// Config represents the configuration of a distillery instance
type Config struct {
// Several docker-compose files are created to manage global services and the system itself.
// On top of this all real-system space will be created under this directory.
DeployRoot string `env:"DEPLOY_ROOT" default:"/var/www/deploy" validator:"is_valid_abspath"`
// Each created Drupal Instance corresponds to a single domain name.
// These domain names should either be a complete domain name or a sub-domain of a default domain.
// This setting configures the default domain-name to create subdomains of.
DefaultDomain string `env:"DEFAULT_DOMAIN" default:"localhost.kwarc.info" validator:"is_valid_domain"`
// By default, the default domain redirects to the distillery repository.
// If you want to change this, set an alternate domain name here.
SelfRedirect *url.URL `env:"SELF_REDIRECT" default:"" validator:"is_valid_https_url"`
// By default, only the 'self' domain above is caught.
// To catch additional domains, add them here (comma seperated)
SelfExtraDomains []string `env:"SELF_EXTRA_DOMAINS" default:"" validator:"is_valid_domains"`
// You can override individual URLS in the homepage
// Do this by adding URLs (without trailing '/'s) into a JSON file
SelfOverridesFile string `env:"SELF_OVERRIDES_FILE" default:"" validator:"is_valid_file"`
// The system can support setting up certificate(s) automatically.
// It can be enabled by setting an email for certbot certificates.
// This email address can be configured here.
CertbotEmail string `env:"CERTBOT_EMAIL" default:"" validator:"is_valid_email"`
// Maximum age for backup
MaxBackupAge int `env:"MAX_BACKUP_AGE" default:"" validator:"is_valid_number"`
// Each Drupal instance requires a corresponding system user, database users and databases.
// These are also set by the appropriate domain name.
// To differentiate them from other users of the system, these names can be prefixed.
// The prefix to use can be configured here.
// When changing these please consider that no system user may exist that has the same name as a mysql user.
// This is a MariaDB restriction.
MysqlUserPrefix string `env:"MYSQL_USER_PREFIX" default:"mysql-factory-" validator:"is_valid_slug"`
MysqlDatabasePrefix string `env:"MYSQL_DATABASE_PREFIX" default:"mysql-factory-" validator:"is_valid_slug"`
GraphDBUserPrefix string `env:"GRAPHDB_USER_PREFIX" default:"mysql-factory-" validator:"is_valid_slug"`
GraphDBRepoPrefix string `env:"GRAPHDB_REPO_PREFIX" default:"mysql-factory-" validator:"is_valid_slug"`
// In addition to the filesystem the WissKI distillery requires a single SQL table.
// It uses this database to store a list of installed things.
DistilleryBookkeepingDatabase string `env:"DISTILLERY_BOOKKEEPING_DATABASE" default:"distillery" validator:"is_valid_slug"`
DistilleryBookkeepingTable string `env:"DISTILLERY_BOOKKEEPING_TABLE" default:"distillery" validator:"is_valid_slug"`
// Various components use password-based-authentication.
// These passwords are generated automatically.
// This variable can be used to determine their length.
PasswordLength int `env:"PASSWORD_LENGTH" default:"64" validator:"is_valid_number"`
// A file to be used for global authorized_keys for the ssh server.
GlobalAuthorizedKeysFile string `env:"GLOBAL_AUTHORIZED_KEYS_FILE" default:"/distillery/authorized_keys" validator:"is_valid_file"`
// admin credentials for graphdb
TriplestoreAdminUser string `env:"GRAPHDB_ADMIN_USER" default:"admin" validator:"is_nonempty"`
TriplestoreAdminPassword string `env:"GRAPHDB_ADMIN_PASSWORD" default:"" validator:"is_nonempty"`
// admin credentials for the Mysql database
MysqlAdminUser string `env:"MYSQL_ADMIN_USER" default:"admin" validator:"is_nonempty"`
MysqlAdminPassword string `env:"MYSQL_ADMIN_PASSWORD" default:"admin" validator:"is_nonempty"`
}
func (config Config) String() string {
values := &strings.Builder{}
vConfig := reflect.ValueOf(config)
tConfig := vConfig.Type()
// iterate over the types
numValues := tConfig.NumField()
for i := 0; i < numValues; i++ {
tField := tConfig.Field(i)
vField := vConfig.FieldByName(tField.Name)
fmt.Fprintf(values, "%s=%v\n", tField.Tag.Get("env"), vField.Interface())
}
return values.String()
}
func (config *Config) Unmarshal(src io.Reader) error {
// read all the values!
values, err := ReadAll(src)
if err != nil {
return err
}
vConfig := reflect.ValueOf(config).Elem()
tConfig := vConfig.Type()
// iterate over the types
numValues := tConfig.NumField()
for i := 0; i < numValues; i++ {
tField := tConfig.Field(i)
vField := vConfig.FieldByName(tField.Name)
env := tField.Tag.Get("env")
dflt := tField.Tag.Get("default")
validator := tField.Tag.Get("validator")
// read the value with a default
value, ok := values[env]
if !ok || value == "" {
if dflt == "" {
continue
}
value = dflt
}
// use the validator
vFunc, ok := knownValidators[validator]
if vFunc == nil || !ok {
return errors.Errorf("Unable to read %q refers to unknown validator %s", env, validator)
}
// get the parsed value
checked, err := vFunc(value)
if err != nil {
return errors.Wrapf(err, "Unable to read %q: Validator %s", env, validator)
}
// set the value of the field
var errSet interface{}
func() {
defer func() {
errSet = recover()
}()
vField.Set(reflect.ValueOf(checked))
}()
// capture any error
if errSet != nil {
return errors.Errorf("Unable to parse %q: validator %s returned %q", tField.Name, validator, errSet)
}
}
return nil
}

76
internal/config/file.go Normal file
View file

@ -0,0 +1,76 @@
package config
import (
"bufio"
"io"
"strings"
)
// Scanner scans an io.Reader for a source file
type Scanner struct {
src *bufio.Scanner
key string
value string
}
func NewScanner(r io.Reader) *Scanner {
return &Scanner{
src: bufio.NewScanner(r),
}
}
// Scanner advances the scanner to the next variable
func (scanner *Scanner) Scan() bool {
for scanner.src.Scan() {
// check that we don't have an empty or comment only line
tokens := strings.TrimSpace(scanner.src.Text())
if len(tokens) == 0 || tokens[0] == '#' || strings.HasPrefix(tokens, "//") {
continue
}
// check that we have a 'key=value' pair
values := strings.SplitN(tokens, "=", 2)
if len(values) != 2 {
continue
}
// got a key = value
scanner.key = strings.TrimSpace(values[0])
scanner.value = strings.TrimSpace(values[1])
return true
}
scanner.key = ""
scanner.value = ""
return false
}
// Data reads the current value from the scanner.
// When Scan() has not been called, or returned false, returns two empty strings.
func (scanner Scanner) Data() (key, value string) {
return scanner.key, scanner.value
}
// Error returns an error (if any)
func (scanner Scanner) Error() error {
return scanner.src.Err()
}
// ReadAll reads all key-value pairs from r.
// If a key occurs more than once, a later occurance overwrites a previous one.
func ReadAll(r io.Reader) (values map[string]string, err error) {
scanner := NewScanner(r)
// read and store all values
values = make(map[string]string)
for scanner.Scan() {
key, value := scanner.Data()
values[key] = value
}
// check if there was an error!
if err := scanner.Error(); err != nil {
return nil, err
}
return values, nil
}

View file

@ -0,0 +1,105 @@
package config
import (
"net/url"
"regexp"
"strconv"
"strings"
"github.com/FAU-CDI/wisski-distillery/internal/fsx"
"github.com/pkg/errors"
)
// Validator reads from the configuration file
type Validator func(s string) (interface{}, error)
var knownValidators map[string]Validator = map[string]Validator{
"is_valid_abspath": IsValidAbspath,
"is_valid_domain": IsValidDomain,
"is_valid_domains": IsValidDomains,
"is_valid_number": IsValidNumber,
"is_valid_https_url": IsValidHttpsURL,
"is_valid_slug": IsValidSlug,
"is_valid_file": IsValidFile,
"is_valid_email": IsValidEmail,
"is_nonempty": IsNonEmpty,
}
func IsValidAbspath(s string) (interface{}, error) {
if !fsx.IsDirectory(s) {
return nil, errors.Errorf("%q does not exist or is not a directory", s)
}
return s, nil
}
func IsValidFile(s string) (interface{}, error) {
if !fsx.IsFile(s) {
return nil, errors.Errorf("%q does not exist or is not a regular file", s)
}
return s, nil
}
func IsNonEmpty(s string) (interface{}, error) {
if s == "" {
return nil, errors.New("value is empty")
}
return s, nil
}
var regexpDomain = regexp.MustCompile(`^([a-zA-Z0-9][-a-zA-Z0-9]*\.)*[a-zA-Z0-9][-a-zA-Z0-9]*$`) // TODO: Make this regexp nicer!
func IsValidDomain(s string) (interface{}, error) {
if !regexpDomain.MatchString(s) {
return nil, errors.Errorf("%q is not a valid domain", s)
}
return s, nil
}
func IsValidDomains(s string) (interface{}, error) {
if len(s) == 0 {
return []string{}, nil
}
domains := strings.Split(s, ",")
for _, d := range domains {
if !regexpDomain.MatchString(d) {
return nil, errors.Errorf("%q is not a valid domain", d)
}
}
return domains, nil
}
func IsValidNumber(s string) (interface{}, error) {
value, err := strconv.ParseInt(s, 10, 64)
return int(value), err
}
func IsValidHttpsURL(s string) (interface{}, error) {
url, err := url.Parse(s)
if err != nil {
return nil, errors.Wrapf(err, "%q is not a valid URL", s)
}
if url.Scheme != "https" {
return nil, errors.Errorf("%q is not a valid https URL (%q)", s, url.Scheme)
}
return url, nil
}
var regexpEmail = regexp.MustCompile(`^([-a-zA-Z0-9]+)\@([a-zA-Z0-9][-a-zA-Z0-9]*\.)*[a-zA-Z0-9][-a-zA-Z0-9]*$`) // TODO: Make this regexp nicer!
func IsValidEmail(s string) (interface{}, error) {
if s == "" { // no email provided
return "", nil
}
if !regexpEmail.MatchString(s) {
return nil, errors.Errorf("%q is not a valid email", s)
}
return s, nil
}
var regexpSlug = regexp.MustCompile(`^[a-zA-Z0-9][-a-zA-Z0-9]*$`) // TODO: Make this regexp nicer!
func IsValidSlug(s string) (interface{}, error) {
if !regexpSlug.MatchString(s) {
return nil, errors.Errorf("%q is not a valid slug", s)
}
return s, nil
}

4
internal/docs.go Normal file
View file

@ -0,0 +1,4 @@
// Package internal contains various utility functions.
//
// These are not subject to version guarantees and may be changed
package internal

11
internal/execx/compose.go Normal file
View file

@ -0,0 +1,11 @@
package execx
import (
"github.com/tkw1536/goprogram/stream"
)
// Compose runs a docker-compose command in a specific directory, with the provided arguments and streams.
// It then waits for the process to exit, and returns the exit code.
func Compose(io stream.IOStream, workdir string, args ...string) int {
return Exec(io, workdir, "docker", append([]string{"compose"}, args...)...)
}

46
internal/execx/exec.go Normal file
View file

@ -0,0 +1,46 @@
// Package execx defines extensions to the "os/exec" package
package execx
import (
"os/exec"
"github.com/tkw1536/goprogram/stream"
)
// ExecCommandError is returned by Exec when a command could not be executed.
// This typically hints that the executable cannot be found, but may have other causes.
const ExecCommandError = 127
// Exec executes a system command with the specified input/output streams, working directory, and arguments.
//
// If the command executes, it's exit code will be returned.
// If the command can not be executed, returns [ExecCommandError].
func Exec(io stream.IOStream, workdir string, exe string, argv ...string) int {
// setup the command
cmd := exec.Command(exe, argv...)
cmd.Dir = workdir
cmd.Stdin = io.Stdin
cmd.Stdout = io.Stdout
cmd.Stderr = io.Stderr
// run it
err := cmd.Run()
// non-zero exit
if err, ok := err.(*exec.ExitError); ok {
return err.ExitCode()
}
// unknown error
if err != nil {
return ExecCommandError
}
// everything is fine!
return 0
}
// MustExec is like Exec, except that it returns true if the command exited successfully, and else false.
func MustExec(io stream.IOStream, workdir string, exe string, argv ...string) bool {
return Exec(io, workdir, exe, argv...) == 0
}

41
internal/fsx/copy.go Normal file
View file

@ -0,0 +1,41 @@
package fsx
import (
"errors"
"io"
"os"
)
var ErrCopySameFile = errors.New("src and dst must be different files")
// CopyFile copies a file from src to dst.
// When dst and src are the same file, returns ErrCopySameFile.
func CopyFile(dst, src string) error {
if src == dst {
return ErrCopySameFile
}
// open the source
srcFile, err := os.Open(src)
if err != nil {
return err
}
defer srcFile.Close()
// stat it to get the mode!
srcStat, err := srcFile.Stat()
if err != nil {
return err
}
// open or create the destination
dstFile, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE, srcStat.Mode())
if err != nil {
return err
}
defer dstFile.Close()
// and do the copy!
_, err = io.Copy(dstFile, srcFile)
return err
}

25
internal/fsx/touch.go Normal file
View file

@ -0,0 +1,25 @@
package fsx
import (
"os"
"time"
)
// Touch touches a file
func Touch(path string) error {
_, err := os.Stat(path)
switch {
case os.IsNotExist(err):
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
return nil
case err != nil:
return err
default:
now := time.Now().Local()
return os.Chtimes(path, now, now)
}
}

13
internal/fsx/type.go Normal file
View file

@ -0,0 +1,13 @@
package fsx
import "os"
func IsDirectory(path string) bool {
info, err := os.Stat(path)
return err == nil && info.Mode().IsDir()
}
func IsFile(path string) bool {
info, err := os.Stat(path)
return err == nil && info.Mode().IsRegular()
}

View file

@ -0,0 +1,32 @@
// Package hostname provides hostname
package hostname
import (
"os"
"github.com/Showmax/go-fqdn"
)
// FQDN attempts to return the fully qualified domain name of the host system.
// If an error occurs, may fall back to the empty string.
func FQDN() string {
// try the hostname function
{
fqdn, err := fqdn.FqdnHostname()
if err == nil {
return fqdn
}
}
// fallback to os hostname
{
hostname, err := os.Hostname()
if err == nil {
return hostname
}
}
// use the empty string
return ""
}

4
internal/legal/legal.go Normal file
View file

@ -0,0 +1,4 @@
// Package legal contains legal notices.
package legal
//go:generate gogenlicense -m

616
internal/legal/legal_notices.go Executable file

File diff suppressed because one or more lines are too long

59
internal/logging/level.go Normal file
View file

@ -0,0 +1,59 @@
package logging
import (
"sync"
"github.com/tkw1536/goprogram/stream"
)
var logLevelMutex sync.Mutex
var logLevelMap = make(map[uintptr]int)
func getIndent(io stream.IOStream) int {
logLevelMutex.Lock()
defer logLevelMutex.Unlock()
id, ok := logID(io)
if !ok {
return 0
}
return logLevelMap[id]
}
func incIndent(io stream.IOStream) int {
logLevelMutex.Lock()
defer logLevelMutex.Unlock()
id, ok := logID(io)
if !ok { // if we don't have an id, then inc statically returns 1
return 1
}
logLevelMap[id]++
return logLevelMap[id]
}
func decIndent(io stream.IOStream) int {
logLevelMutex.Lock()
defer logLevelMutex.Unlock()
id, ok := logID(io)
if !ok { // if we don't have an id, then dec statically returns 0
return 0
}
logLevelMap[id]--
if logLevelMap[id] < 0 {
panic("DecLogIdent: decrease below 0")
}
return logLevelMap[id]
}
func logID(io stream.IOStream) (uintptr, bool) {
file, ok := io.Stdin.(interface{ Fd() uintptr })
if !ok {
return 0, false
}
return file.Fd(), true
}

30
internal/logging/log.go Normal file
View file

@ -0,0 +1,30 @@
package logging
import (
"strings"
"github.com/tkw1536/goprogram/stream"
)
// LogOperation logs a message that is displayed to the user, and then increases the log indent level.
func LogOperation(operation func() error, io stream.IOStream, format string, args ...interface{}) error {
logOperation(io, getIndent(io), format, args...)
incIndent(io)
defer decIndent(io)
return operation()
}
// LogMessage logs a message that is displayed to the user
func LogMessage(io stream.IOStream, format string, args ...interface{}) (int, error) {
return logOperation(io, getIndent(io), format, args...)
}
func logOperation(io stream.IOStream, indent int, format string, args ...interface{}) (int, error) {
message := "\033[1m" + strings.Repeat(" ", indent+1) + "=> " + format + "\033[0m\n"
if !io.StdinIsATerminal() {
message = " => " + format
}
return io.Printf(message, args...)
}

View file

@ -0,0 +1,41 @@
// Package password allows generating random passwords
package password
import (
"crypto/rand"
"math/big"
"strings"
)
// NOTE(twiesing): A bunch of scripts cannot properly handle the extra characters in the password.
// For now it is disabled, but it should be re-enabled later.
const PasswordCharSet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" // + "!@#$%&*"
const PasswordCharCount = len(PasswordCharSet)
// Password returns a randomly generated password with the provided length.
// [rand.Reader] is used as the source of randomness.
func Password(length int) (string, error) {
if length < 0 {
panic("length < 0")
}
var password strings.Builder
password.Grow(length)
for i := 0; i < length; i++ {
// grab a random index!
index, err := rand.Int(rand.Reader, big.NewInt(int64(PasswordCharCount)))
if err != nil {
return "", err
}
// and use that index!
if err := password.WriteByte(PasswordCharSet[int(index.Int64())]); err != nil {
return "", err
}
}
// return the password!
return password.String(), nil
}

39
internal/sqle/name.go Normal file

File diff suppressed because one or more lines are too long

10
internal/sqle/sqle.go Normal file
View file

@ -0,0 +1,10 @@
package sqle
import (
"github.com/feiin/sqlstring"
)
// Format formats the provided query with the given parameters.
func Format(query string, params ...interface{}) string {
return sqlstring.Format(query, params...)
}

View file

@ -0,0 +1,108 @@
package stack
import (
"io/fs"
"os"
"path/filepath"
"github.com/FAU-CDI/wisski-distillery/distillery"
"github.com/FAU-CDI/wisski-distillery/internal/fsx"
"github.com/pkg/errors"
"github.com/tkw1536/goprogram/stream"
)
// Installable represents a Stack that can be automatically installed from a set of resources
// See the Install() method.
type Installable struct {
Stack
ContextResource string // Path to the resource containing 'docker compose' context
EnvFileResource string // Path to the resource containing dynamically generated env file
EnvFileContext map[string]string // Context of variables to replace in the env file
CopyContextFiles []string // Files to copy from the installation context
TouchFiles []string // Files to 'touch', i.e. ensure that exist
MakeDirsPerm fs.FileMode // permission for diretories, defaults to fs.ModeDir
MakeDirs []string // directories to ensure that exist
}
// 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 Installable) Install(io stream.IOStream, context InstallationContext) error {
// setup the base files
if err := distillery.InstallResource(
is.Dir,
is.ContextResource,
func(dst, src string) {
io.Printf("[install] %s\n", dst)
},
); err != nil {
return err
}
// configure .env
envDest := filepath.Join(is.Dir, ".env")
if is.EnvFileResource != "" && is.EnvFileContext != nil {
io.Printf("[config] %s\n", envDest)
if err := distillery.InstallTemplate(
envDest,
is.EnvFileResource,
is.EnvFileContext,
); err != nil {
return err
}
}
// make sure that certain files exist
for _, name := range is.MakeDirs {
// find the destination!
dst := filepath.Join(is.Dir, name)
io.Printf("[make] %s\n", dst)
if is.MakeDirsPerm == fs.FileMode(0) {
is.MakeDirsPerm = fs.ModeDir
}
if err := os.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: %s", src)
}
// find the destination!
dst := filepath.Join(is.Dir, name)
// copy over file from context
io.Printf("[copy] %s (from %s)\n", dst, src)
if err := fsx.CopyFile(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)
io.Printf("[touch] %s\n", dst)
if err := fsx.Touch(dst); err != nil {
return err
}
}
return nil
}

110
internal/stack/stack.go Normal file
View file

@ -0,0 +1,110 @@
// Package stack implements a docker compose stack
package stack
import (
"errors"
"github.com/FAU-CDI/wisski-distillery/internal/execx"
"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 {
Name string // Name of this stack, TODO: Do we need this?
Dir string // Directory of this stack
}
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(io stream.IOStream, start bool) error {
if ds.compose(io, "pull") != 0 {
return errStackUpdatePull
}
if ds.compose(io, "build", "--pull") != 0 {
return errStackUpdateBuild
}
if start {
return ds.Up(io)
}
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 -d' on the shell.
func (ds Stack) Up(io stream.IOStream) error {
if ds.compose(io, "up", "-d") != 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(io stream.IOStream, service, executable string, args ...string) int {
compose := []string{"exec"}
if io.StdinIsATerminal() {
compose = append(compose, "-ti")
}
compose = append(compose, executable)
compose = append(compose, args...)
return ds.compose(io, compose...)
}
// Run executes the provided service 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(io stream.IOStream, autoRemove bool, service, command string, args ...string) int {
compose := []string{"run"}
if autoRemove {
compose = append(compose, "--rm")
}
if !io.StdinIsATerminal() {
compose = append(compose, "-T")
}
compose = append(compose, command)
compose = append(compose, args...)
return ds.compose(io, compose...)
}
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(io stream.IOStream) error {
if ds.compose(io, "restart") != 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(io stream.IOStream) error {
if ds.compose(io, "down", "-v") != 0 {
return errStackDown
}
return nil
}
// Compose executes a 'docker compose' command on this stack.
// TODO: This should be removed and replaced by an internal call directly to libcompose.
func (ds Stack) compose(io stream.IOStream, args ...string) int {
// TODO: can we migrate to a built-in version of this?
return execx.Compose(io, ds.Dir, args...)
}

31
internal/wait/wait.go Normal file
View file

@ -0,0 +1,31 @@
package wait
import (
"context"
"time"
)
// Wait repeatedly invokes f, until it returns true or the context is closed.
// The invocation interval is determined by interval.
func Wait(f func() bool, interval time.Duration, context context.Context) error {
// create a new timer
timer := time.NewTimer(interval)
if !timer.Stop() {
<-timer.C
}
defer timer.Stop()
for {
if f() {
return nil
}
// reset the timer, and wait for it again!
timer.Reset(interval)
select {
case <-timer.C:
case <-context.Done():
return context.Err()
}
}
}