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 }