Files
ThothII/tools/tht/internal/backup/restore.go
T

108 lines
4.8 KiB
Go

package backup
import (
"archive/zip"
"context"
"errors"
"fmt"
"io"
"time"
"github.com/aritmolab/thothii/tools/tht/internal/config"
)
var ErrRestoreConfirmationRequired = errors.New("restore requires --yes")
// RestoreRequest describes the deliberately-confirmed archive restoration.
type RestoreRequest struct {
Archive string
Confirm bool
Drain bool
}
// RestoreResult records the retained recovery point and the final service state.
type RestoreResult struct {
Checkpoint string
Restarted bool
Verified bool
}
type restoreLock interface { Release() error }
type restoreVerify func(context.Context, config.Installation, archiveRunner) error
type restoreDependencies struct {
preflight func(context.Context, config.Installation, PreflightRequest) (PreflightResult, error)
checkpoint func(context.Context, config.Installation, CreateRequest) (Result, error)
acquireLock func(config.Installation) (restoreLock, error)
runner archiveRunner
sleep func(duration time.Duration)
restoreFile func(context.Context, config.Installation, ArchiveEntryMetadata, io.Reader) error
restoreVolume func(context.Context, config.Installation, VolumeMetadata, io.Reader) error
verify map[string]restoreVerify
}
// Restore runs the host transaction. Concrete host dependencies are intentionally kept outside
// the deterministic core so callers cannot bypass its preflight and checkpoint boundaries.
func Restore(ctx context.Context, installation config.Installation, request RestoreRequest) (RestoreResult, error) {
return RestoreResult{}, errors.New("restore host dependencies are unavailable")
}
func restoreWithDependencies(ctx context.Context, installation config.Installation, request RestoreRequest, deps restoreDependencies) (result RestoreResult, resultErr error) {
if !request.Confirm { return RestoreResult{}, ErrRestoreConfirmationRequired }
if request.Archive == "" { return RestoreResult{}, errors.New("restore archive is required") }
if deps.preflight == nil || deps.checkpoint == nil || deps.acquireLock == nil || deps.runner == nil || deps.restoreFile == nil || deps.restoreVolume == nil || deps.verify == nil {
return RestoreResult{}, errors.New("restore dependencies are incomplete")
}
preflight, err := deps.preflight(ctx, installation, PreflightRequest{Archive: request.Archive, Confirm: true, AllowExternalSecrets: true})
if err != nil { return RestoreResult{}, err }
defer preflight.CloseArchive()
archive, err := preflight.RevalidateArchive()
if err != nil { return RestoreResult{}, err }
checkpoint, err := deps.checkpoint(ctx, installation, CreateRequest{})
if err != nil { return RestoreResult{}, fmt.Errorf("create recovery checkpoint: %w", err) }
result.Checkpoint = checkpoint.Path
lock, err := deps.acquireLock(installation)
if err != nil { return result, err }
defer func() { if releaseErr := lock.Release(); releaseErr != nil && resultErr == nil { resultErr = releaseErr } }()
wasRunning, err := installationRunning(ctx, installation, deps.runner)
if err != nil { return result, err }
mutated := false
defer func() {
if resultErr != nil && mutated { _ = runCompose(context.Background(), installation, deps.runner, "stop") }
}()
if wasRunning {
if err := maintenance(ctx, installation, deps.runner, true); err != nil { return result, err }
if err := waitForNoActiveSessions(ctx, installation, deps.runner, request.Drain, deps.sleep); err != nil { return result, err }
if err := runCompose(ctx, installation, deps.runner, "stop"); err != nil { return result, err }
}
reader, err := zip.NewReader(archive, preflight.ArchiveSize)
if err != nil { return result, fmt.Errorf("read verified restore archive: %w", err) }
members := make(map[string]*zip.File, len(reader.File))
for _, member := range reader.File { members[member.Name] = member }
for _, entry := range preflight.Entries {
if entry.Kind == EntryVolume { continue }
member := members[entry.Path]
if member == nil { return result, fmt.Errorf("verified archive is missing %q", entry.Path) }
stream, openErr := member.Open()
if openErr != nil { return result, fmt.Errorf("open verified archive member %q: %w", entry.Path, openErr) }
mutated = true
restoreErr := deps.restoreFile(ctx, installation, entry, stream)
closeErr := stream.Close()
if restoreErr != nil { return result, restoreErr }
if closeErr != nil { return result, closeErr }
}
if wasRunning {
if err := composeStartAndVerify(ctx, installation, deps.runner); err != nil { return result, err }
result.Restarted = true
}
for _, name := range []string{"health", "doctor", "pi", "workspace"} {
check := deps.verify[name]
if check == nil { return result, fmt.Errorf("restore verification %q is unavailable", name) }
if err := check(ctx, installation, deps.runner); err != nil { return result, fmt.Errorf("restore verification %s: %w", name, err) }
}
result.Verified = true
return result, nil
}