feat(cli): add transactional installation backups
This commit is contained in:
@@ -14,9 +14,11 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/backup"
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/compose"
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/config"
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/doctor"
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/lifecycle"
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/output"
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/pi"
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/project"
|
||||
@@ -42,6 +44,8 @@ Commands:
|
||||
start [--build] Start the installation; --build builds current-checkout images first.
|
||||
stop Stop the installation.
|
||||
update --check-only Validate the current installation without changing containers.
|
||||
backup [--output PATH] [--include-secrets --yes] [--drain]
|
||||
Create one transactional installation backup.
|
||||
sessions migrate --yes
|
||||
Run only the server session migrator and verify pending=[] and drifted=[].
|
||||
remove Display exact stopped app container IDs without mutation.
|
||||
@@ -169,6 +173,8 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
||||
return commandUsageError(stderr, "update currently requires --check-only")
|
||||
}
|
||||
result, err = runner.Run(ctx, installation.ComposeArgs("config", "--quiet"), nil)
|
||||
case "backup":
|
||||
return backupCommand(ctx, installation, commandArgs, stdout, stderr)
|
||||
case "doctor":
|
||||
return doctorCommand(ctx, installation, runner, commandArgs, stdout, stderr)
|
||||
case "pi":
|
||||
@@ -419,6 +425,13 @@ func piCommand(ctx context.Context, installation config.Installation, runner com
|
||||
if len(args) == 0 {
|
||||
return commandUsageError(stderr, "pi requires a subcommand")
|
||||
}
|
||||
if piMutationRequiresLifecycleLock(args) {
|
||||
lock, err := lifecycle.Acquire(installation)
|
||||
if err != nil {
|
||||
return lifecycleFailure(stderr, err, secretValues)
|
||||
}
|
||||
defer func() { _ = lock.Release() }()
|
||||
}
|
||||
controlled := compose.InstallationRunner{Installation: installation, Runner: runner}
|
||||
switch args[0] {
|
||||
case "status":
|
||||
@@ -790,6 +803,98 @@ func logsArgs(args []string) ([]string, error) {
|
||||
return nil, errors.New("logs does not accept arguments; use bounded snapshots")
|
||||
}
|
||||
|
||||
type backupExecutor func(context.Context, config.Installation, backup.CreateRequest) (backup.Result, error)
|
||||
|
||||
func backupCommand(ctx context.Context, installation config.Installation, args []string, stdout, stderr io.Writer) int {
|
||||
return backupCommandWith(ctx, installation, args, backup.Create, stdout, stderr)
|
||||
}
|
||||
|
||||
func backupCommandWith(
|
||||
ctx context.Context,
|
||||
installation config.Installation,
|
||||
args []string,
|
||||
execute backupExecutor,
|
||||
stdout, stderr io.Writer,
|
||||
) int {
|
||||
request, err := parseBackupArgs(args)
|
||||
if err != nil {
|
||||
return commandUsageError(stderr, err.Error())
|
||||
}
|
||||
if execute == nil {
|
||||
return commandUsageError(stderr, "backup executor is unavailable")
|
||||
}
|
||||
result, err := execute(ctx, installation, request)
|
||||
if err != nil {
|
||||
message := output.Sanitize(err.Error(), nil)
|
||||
fmt.Fprintf(stderr, "tht: %s\n", message)
|
||||
if errors.Is(err, backup.ErrActiveSessions) || errors.Is(err, backup.ErrConfirmationRequired) || errors.Is(err, lifecycle.ErrLocked) {
|
||||
return 2
|
||||
}
|
||||
return 1
|
||||
}
|
||||
fmt.Fprintf(stdout, "Backup created: %s\n", result.Path)
|
||||
if result.Warning != "" {
|
||||
fmt.Fprintln(stderr, output.Sanitize(result.Warning, nil))
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func parseBackupArgs(args []string) (backup.CreateRequest, error) {
|
||||
request := backup.CreateRequest{}
|
||||
for len(args) > 0 {
|
||||
option := args[0]
|
||||
args = args[1:]
|
||||
switch option {
|
||||
case "--output":
|
||||
if request.Output != "" {
|
||||
return backup.CreateRequest{}, errors.New("--output may be supplied once")
|
||||
}
|
||||
if len(args) == 0 || args[0] == "" {
|
||||
return backup.CreateRequest{}, errors.New("--output requires a path")
|
||||
}
|
||||
request.Output, args = args[0], args[1:]
|
||||
case "--include-secrets":
|
||||
if request.IncludeSecrets {
|
||||
return backup.CreateRequest{}, errors.New("--include-secrets may be supplied once")
|
||||
}
|
||||
request.IncludeSecrets = true
|
||||
case "--yes":
|
||||
if request.Confirm {
|
||||
return backup.CreateRequest{}, errors.New("--yes may be supplied once")
|
||||
}
|
||||
request.Confirm = true
|
||||
case "--drain":
|
||||
if request.Drain {
|
||||
return backup.CreateRequest{}, errors.New("--drain may be supplied once")
|
||||
}
|
||||
request.Drain = true
|
||||
default:
|
||||
return backup.CreateRequest{}, fmt.Errorf("unknown backup option %q", option)
|
||||
}
|
||||
}
|
||||
if request.IncludeSecrets && !request.Confirm {
|
||||
return backup.CreateRequest{}, errors.New("--include-secrets requires --yes")
|
||||
}
|
||||
if request.Confirm && !request.IncludeSecrets {
|
||||
return backup.CreateRequest{}, errors.New("--yes is only valid with --include-secrets")
|
||||
}
|
||||
return request, nil
|
||||
}
|
||||
|
||||
func piMutationRequiresLifecycleLock(args []string) bool {
|
||||
if len(args) == 0 {
|
||||
return false
|
||||
}
|
||||
switch args[0] {
|
||||
case "configure", "restart", "update", "rollback":
|
||||
return true
|
||||
case "maintenance":
|
||||
return len(args) > 1 && args[1] == "recover"
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func commandUsageError(stderr io.Writer, message string) int {
|
||||
fmt.Fprintf(stderr, "tht: %s\n", message)
|
||||
return 2
|
||||
|
||||
Reference in New Issue
Block a user