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 resetAuthenticationState func(context.Context, config.Installation, archiveRunner) error verify map[string]restoreVerify } // Restore runs the host transaction through the same concrete Docker/filesystem boundaries used // by backup creation. The injectable core below exists only to make every failure boundary // deterministic in tests. func Restore(ctx context.Context, installation config.Installation, request RestoreRequest) (RestoreResult, error) { return restoreWithDependencies(ctx, installation, request, productionRestoreDependencies(installation)) } 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.resetAuthenticationState == 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 { 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 var restoreErr error if entry.Kind == EntryVolume { volume, found := restoreVolumeMetadata(preflight.Manifest, entry.LogicalName) if !found { _ = stream.Close() return result, errors.New("verified volume metadata is incomplete") } restoreErr = deps.restoreVolume(ctx, installation, volume, stream) } else { restoreErr = deps.restoreFile(ctx, installation, entry, stream) } closeErr := stream.Close() if restoreErr != nil { return result, restoreErr } if closeErr != nil { return result, closeErr } } if err := deps.resetAuthenticationState(ctx, installation, deps.runner); err != nil { return result, fmt.Errorf("reset authentication state: %w", err) } 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 } func restoreVolumeMetadata(manifest Manifest, logicalName string) (VolumeMetadata, bool) { if logicalName == "" { return VolumeMetadata{}, false } for _, volume := range manifest.Volumes { if volume.LogicalName == logicalName { return volume, true } } return VolumeMetadata{}, false } // resetAuthenticationState clears browser sessions and pending OIDC transactions without touching // installation-global auth.yaml or users.yaml. The command runs as the unprivileged core user so // the recreated state root is private to the service on both the local volume and server /data bind. func resetAuthenticationState(ctx context.Context, installation config.Installation, runner archiveRunner) error { result, err := runner.Run(ctx, installation.ComposeArgs( "run", "--rm", "--no-deps", "--no-TTY", "--entrypoint", "sh", "core", "-ceu", "find /data/auth -mindepth 1 -maxdepth 1 -exec rm -rf -- {} + && install -d -m 0700 /data/auth /data/auth/sessions /data/auth/oidc && test -z \"$(find /data/auth/sessions /data/auth/oidc -mindepth 1 -print -quit)\"", ), nil) if err != nil { return dockerError("reset authentication state", result, err) } if result.ExitCode != 0 { return dockerError("reset authentication state", result, errors.New("Compose returned a nonzero exit status")) } return nil }