Files
narratio/internal/app/restore_plan.go

529 lines
16 KiB
Go

package app
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"path"
"path/filepath"
"sort"
"strings"
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
"gitea.maximumdirect.net/eric/narratio/internal/config"
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
"gitea.maximumdirect.net/eric/narratio/internal/previouscache"
)
// RestoreActionKind identifies one restore planner action.
type RestoreActionKind string
const (
RestoreActionDownload RestoreActionKind = "download"
RestoreActionSkipSame RestoreActionKind = "skip_same"
RestoreActionConflict RestoreActionKind = "conflict"
)
// RestoreConflictKind identifies why a local target cannot be restored safely.
type RestoreConflictKind string
const (
RestoreConflictContentMismatch RestoreConflictKind = "content_mismatch"
RestoreConflictDirectory RestoreConflictKind = "directory"
RestoreConflictNonRegular RestoreConflictKind = "non_regular"
)
// RestoreAction is one deterministic planner action.
type RestoreAction struct {
Kind RestoreActionKind
RemoteKey string
LocalRelativePath string
LocalPath string
SHA256 string
Generation string
Size int64
ETag string
ExistsLocal bool
SameLocal bool
Conflict bool
ConflictKind RestoreConflictKind
Reason string
VerifiedContent []byte
}
// RestorePlan is the deterministic output of restore planning.
type RestorePlan struct {
Actions []RestoreAction
DownloadCount int
SkipSameCount int
ConflictCount int
}
// RestorePlanOptions control restore planning scope and classification.
type RestorePlanOptions struct {
IncludeAudio bool
Force bool
DryRun bool
}
func buildRestorePlan(ctx context.Context, cfg *config.Config, current *RemoteCurrentState, store storage.ObjectStore, opts RestorePlanOptions) (*RestorePlan, error) {
if cfg == nil || cfg.Pipeline == nil || cfg.Session == nil {
return nil, fmt.Errorf("resolved config with pipeline/session is required")
}
if current == nil {
return nil, fmt.Errorf("remote current state is required")
}
if store == nil {
return nil, fmt.Errorf("remote object store is required")
}
sessionPaths := artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root).SessionPathsFor(cfg.Session.Campaign, cfg.Session.SessionID)
if err := fileops.EnsureWorkspaceDirectory(sessionPaths.Root); err != nil {
return nil, fmt.Errorf("create session root for restore planning: %w", err)
}
sessionRoot, err := os.OpenRoot(sessionPaths.Root)
if err != nil {
return nil, fmt.Errorf("open session root for restore planning: %w", err)
}
defer func() { _ = sessionRoot.Close() }()
actions, err := buildCurrentRestoreActions(ctx, current, store, sessionPaths, sessionRoot, opts)
if err != nil {
return nil, err
}
previousActions, err := buildPreviousCacheRestoreActions(ctx, cfg, sessionPaths, sessionRoot, store, opts.Force)
if err != nil {
return nil, err
}
actions = append(actions, previousActions...)
sort.Slice(actions, func(i, j int) bool {
if actions[i].LocalRelativePath == actions[j].LocalRelativePath {
return actions[i].RemoteKey < actions[j].RemoteKey
}
return actions[i].LocalRelativePath < actions[j].LocalRelativePath
})
plan := &RestorePlan{Actions: actions}
for _, action := range actions {
switch action.Kind {
case RestoreActionDownload:
plan.DownloadCount++
case RestoreActionSkipSame:
plan.SkipSameCount++
case RestoreActionConflict:
plan.ConflictCount++
}
}
_ = opts.DryRun
return plan, nil
}
func buildCurrentRestoreActions(
ctx context.Context,
current *RemoteCurrentState,
store storage.ObjectStore,
sessionPaths artifacts.SessionPaths,
sessionRoot *os.Root,
opts RestorePlanOptions,
) ([]RestoreAction, error) {
return buildCommittedRestoreActions(ctx, current, store, sessionPaths, sessionRoot, opts)
}
func buildCommittedRestoreActions(
ctx context.Context,
current *RemoteCurrentState,
store storage.ObjectStore,
sessionPaths artifacts.SessionPaths,
sessionRoot *os.Root,
opts RestorePlanOptions,
) ([]RestoreAction, error) {
if current == nil || current.Commit == nil {
return nil, fmt.Errorf("committed remote current state is required")
}
actions := make([]RestoreAction, 0, len(current.Commit.Artifacts))
for _, artifact := range current.Commit.Artifacts {
rel, include, err := restoreLocalRelativePathForCommittedArtifact(current.SessionPrefix, artifact, opts.IncludeAudio)
if err != nil {
return nil, fmt.Errorf("map committed object %q: %w", artifact.DestinationKey, err)
}
if !include {
continue
}
localPath, err := joinWithinSessionRoot(sessionPaths.Root, rel)
if err != nil {
return nil, fmt.Errorf("map committed object %q: %w", artifact.DestinationKey, err)
}
action, err := classifyRestoreAction(ctx, store, storage.ObjectInfo{
Key: artifact.DestinationKey, Size: artifact.Size, ETag: artifact.Generation,
}, artifact.SHA256, rel, localPath, sessionRoot, opts.Force)
if err != nil {
return nil, fmt.Errorf("classify committed object %q: %w", artifact.DestinationKey, err)
}
if artifact.Type == artifacts.RemoteArtifactTypeSessionManifest && artifact.DestinationKey == current.CurrentManifestKey {
action.VerifiedContent = append([]byte(nil), current.ManifestData...)
}
actions = append(actions, action)
}
return actions, nil
}
func restoreLocalRelativePathForCommittedArtifact(sessionPrefix string, artifact artifacts.RemoteArtifact, includeAudio bool) (string, bool, error) {
if artifact.Type == artifacts.RemoteArtifactTypeSessionManifest {
return config.PathManifestFile, true, nil
}
if artifact.Type != artifacts.RemoteArtifactTypePublishedOutput {
return "", false, nil
}
identity, err := artifacts.ParsePublishedOutputObjectKey(sessionPrefix, artifact.DestinationKey)
if err != nil {
return "", false, err
}
cleanRel, err := pathsafe.NormalizeRelativeDestination(identity.Destination)
if err != nil {
return "", false, fmt.Errorf("committed destination is unsafe: %w", err)
}
if cleanRel == config.PathManifestFile {
return "", false, fmt.Errorf("published output conflicts with the session manifest path")
}
if strings.HasPrefix(cleanRel, config.PathTranscriptsSegment+"/") || strings.HasPrefix(cleanRel, config.PathArtifactsDirSegment+"/") {
return cleanRel, true, nil
}
if includeAudio && strings.HasPrefix(cleanRel, config.PathAudioDirSegment+"/") {
return cleanRel, true, nil
}
return "", false, nil
}
func normalizeRemoteKey(v string) string {
return strings.Trim(strings.ReplaceAll(strings.TrimSpace(v), "\\", "/"), "/")
}
func joinWithinSessionRoot(sessionRoot, relative string) (string, error) {
if strings.TrimSpace(sessionRoot) == "" {
return "", fmt.Errorf("session root is required")
}
joined, err := pathsafe.JoinSlashRelativeUnderRoot(sessionRoot, filepath.ToSlash(strings.TrimSpace(relative)))
if err != nil {
if errors.Is(err, pathsafe.ErrRelativePathRequired) {
return "", fmt.Errorf("relative path is required")
}
if errors.Is(err, pathsafe.ErrRelativePathEscape) || errors.Is(err, pathsafe.ErrRelativePathAbsolute) {
return "", fmt.Errorf("relative path escapes session root")
}
return "", fmt.Errorf("join relative path under session root: %w", err)
}
return joined, nil
}
func buildPreviousCacheRestoreActions(
ctx context.Context,
cfg *config.Config,
sessionPaths artifacts.SessionPaths,
sessionRoot *os.Root,
store storage.ObjectStore,
force bool,
) ([]RestoreAction, error) {
if cfg == nil || cfg.Pipeline == nil || cfg.Pipeline.Scriptorium == nil {
return nil, nil
}
effective, err := resolveEffectiveArtifacts(cfg, nil)
if err != nil {
return nil, err
}
requirements := artifacts.CollectPreviousArtifactRequirements(cfg.Pipeline.Scriptorium.Artifacts, effective)
if len(requirements) == 0 {
return nil, nil
}
plan, err := previouscache.Resolve(ctx, cfg, sessionPaths, requirements, store)
if err != nil {
return nil, fmt.Errorf("plan previous-session cache restore: %w", err)
}
actions := make([]RestoreAction, 0, len(plan.Records))
for _, record := range plan.Records {
action, err := classifyRestoreAction(ctx, store, storage.ObjectInfo{
Key: record.RemoteKey, Size: record.Size, ETag: record.Generation,
}, record.SHA256, record.LocalRelativePath, record.LocalPath, sessionRoot, force)
if err != nil {
return nil, fmt.Errorf("classify previous-session cache object %q: %w", record.RemoteKey, err)
}
action.VerifiedContent = append([]byte(nil), record.VerifiedContent...)
actions = append(actions, action)
}
return actions, nil
}
func classifyRestoreAction(
ctx context.Context,
store storage.ObjectStore,
object storage.ObjectInfo,
expectedSHA256 string,
localRelPath string,
localPath string,
sessionRoot *os.Root,
force bool,
) (RestoreAction, error) {
action := RestoreAction{
RemoteKey: normalizeRemoteKey(object.Key),
LocalRelativePath: localRelPath,
LocalPath: localPath,
SHA256: strings.TrimSpace(expectedSHA256),
Size: object.Size,
ETag: object.ETag,
}
if action.SHA256 != "" {
action.Generation = strings.TrimSpace(object.ETag)
}
target, err := inspectRestoreTarget(sessionRoot, localRelPath)
if err != nil {
return RestoreAction{}, err
}
if target.kind == restoreTargetMissing {
action.Kind = RestoreActionDownload
action.Reason = "local file missing"
return action, nil
}
action.ExistsLocal = true
if target.kind == restoreTargetDirectory {
action.Kind = RestoreActionConflict
action.Conflict = true
action.ConflictKind = RestoreConflictDirectory
action.Reason = "local path is a directory"
return action, nil
}
if target.kind == restoreTargetNonRegular {
action.Kind = RestoreActionConflict
action.Conflict = true
action.ConflictKind = RestoreConflictNonRegular
action.Reason = "local path is not a regular file"
return action, nil
}
defer func() { _ = target.file.Close() }()
info, err := target.file.Stat()
if err != nil {
return RestoreAction{}, fmt.Errorf("stat opened local file: %w", err)
}
if action.SHA256 != "" {
localDigest, err := sha256OpenedFile(target.file)
if err != nil {
return RestoreAction{}, fmt.Errorf("checksum local file: %w", err)
}
if localDigest == action.SHA256 {
action.Kind = RestoreActionSkipSame
action.SameLocal = true
action.Reason = "local file matches committed content"
return action, nil
}
if force {
action.Kind = RestoreActionDownload
action.Reason = "local file differs from committed content; overwrite with --force"
return action, nil
}
action.Kind = RestoreActionConflict
action.Conflict = true
action.ConflictKind = RestoreConflictContentMismatch
action.Reason = "local file differs from committed content"
return action, nil
}
if restoreRelativePathIsAudio(localRelPath) {
if force {
action.Kind = RestoreActionDownload
action.Reason = "local audio identity requires verified replacement; overwrite with --force"
return action, nil
}
action.Kind = RestoreActionConflict
action.Conflict = true
action.ConflictKind = RestoreConflictContentMismatch
action.Reason = "local audio identity is not verified; use --force to replace it"
return action, nil
}
if object.Size > 0 && info.Size() != object.Size {
if force {
action.Kind = RestoreActionDownload
action.Reason = "local file differs (size mismatch); overwrite with --force"
return action, nil
}
action.Kind = RestoreActionConflict
action.Conflict = true
action.ConflictKind = RestoreConflictContentMismatch
action.Reason = "local file differs (size mismatch)"
return action, nil
}
localDigest, err := sha256OpenedFile(target.file)
if err != nil {
return RestoreAction{}, fmt.Errorf("checksum local file: %w", err)
}
remotePath, err := storage.DownloadObjectToTemp(ctx, store, action.RemoteKey, "narratio-restore-plan-remote-*.tmp")
if err != nil {
return RestoreAction{}, fmt.Errorf("download remote object: %w", err)
}
defer func() { _ = os.Remove(remotePath) }()
remoteDigest, err := artifacts.SHA256File(remotePath)
if err != nil {
return RestoreAction{}, fmt.Errorf("checksum remote object: %w", err)
}
if remoteDigest == localDigest {
action.Kind = RestoreActionSkipSame
action.SameLocal = true
action.Reason = "local file matches remote content"
return action, nil
}
if force {
action.Kind = RestoreActionDownload
action.Reason = "local file differs; overwrite with --force"
return action, nil
}
action.Kind = RestoreActionConflict
action.Conflict = true
action.ConflictKind = RestoreConflictContentMismatch
action.Reason = "local file differs"
return action, nil
}
type restoreTargetKind uint8
const (
restoreTargetMissing restoreTargetKind = iota
restoreTargetRegular
restoreTargetDirectory
restoreTargetNonRegular
)
type restoreTarget struct {
kind restoreTargetKind
file *os.File
}
func inspectRestoreTarget(root *os.Root, relative string) (restoreTarget, error) {
if root == nil {
return restoreTarget{}, fmt.Errorf("restore session root is required")
}
name := filepath.Clean(filepath.FromSlash(strings.TrimSpace(relative)))
if name == "." || name == ".." || filepath.IsAbs(name) || strings.HasPrefix(name, ".."+string(filepath.Separator)) {
return restoreTarget{}, fmt.Errorf("restore path %q is not a normalized relative path", relative)
}
parts := strings.Split(name, string(filepath.Separator))
current := root
var opened []*os.Root
defer func() {
for _, directory := range opened {
_ = directory.Close()
}
}()
for _, part := range parts[:len(parts)-1] {
info, err := current.Lstat(part)
if errors.Is(err, os.ErrNotExist) {
return restoreTarget{kind: restoreTargetMissing}, nil
}
if err != nil {
return restoreTarget{}, fmt.Errorf("inspect restore ancestor %q: %w", part, err)
}
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return restoreTarget{kind: restoreTargetNonRegular}, nil
}
next, err := current.OpenRoot(part)
if err != nil {
return restoreTarget{}, fmt.Errorf("open restore ancestor %q: %w", part, err)
}
opened = append(opened, next)
current = next
}
leaf := parts[len(parts)-1]
info, err := current.Lstat(leaf)
if errors.Is(err, os.ErrNotExist) {
return restoreTarget{kind: restoreTargetMissing}, nil
}
if err != nil {
return restoreTarget{}, fmt.Errorf("inspect restore target %q: %w", relative, err)
}
if info.IsDir() {
return restoreTarget{kind: restoreTargetDirectory}, nil
}
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
return restoreTarget{kind: restoreTargetNonRegular}, nil
}
file, err := current.Open(leaf)
if err != nil {
return restoreTarget{}, fmt.Errorf("open restore target %q: %w", relative, err)
}
return restoreTarget{kind: restoreTargetRegular, file: file}, nil
}
func sha256OpenedFile(file *os.File) (string, error) {
if _, err := file.Seek(0, io.SeekStart); err != nil {
return "", err
}
hash := sha256.New()
if _, err := io.Copy(hash, file); err != nil {
return "", err
}
return hex.EncodeToString(hash.Sum(nil)), nil
}
func restoreActionIsAudio(action RestoreAction) bool {
return restoreRelativePathIsAudio(action.LocalRelativePath)
}
func restoreRelativePathIsAudio(rel string) bool {
cleanRel := path.Clean(strings.TrimSpace(rel))
return cleanRel == config.PathAudioDirSegment || strings.HasPrefix(cleanRel, config.PathAudioDirSegment+"/")
}
func writeRestorePlan(out io.Writer, current *RemoteCurrentState, plan *RestorePlan, opts RestorePlanOptions) error {
if out == nil {
return fmt.Errorf("output writer is required")
}
if current == nil {
return fmt.Errorf("remote current state is required")
}
if plan == nil {
return fmt.Errorf("restore plan is required")
}
if _, err := fmt.Fprintf(
out,
"restore plan: session %s/%s run=%s actions=%d download=%d skip_same=%d conflict=%d dry_run=%t force=%t include_audio=%t\n",
current.Campaign,
current.SessionID,
current.RunID,
len(plan.Actions),
plan.DownloadCount,
plan.SkipSameCount,
plan.ConflictCount,
opts.DryRun,
opts.Force,
opts.IncludeAudio,
); err != nil {
return err
}
for _, action := range plan.Actions {
if _, err := fmt.Fprintf(out, "%s %s <- %s", action.Kind, action.LocalRelativePath, action.RemoteKey); err != nil {
return err
}
if strings.TrimSpace(action.Reason) != "" {
if _, err := fmt.Fprintf(out, " (%s)", action.Reason); err != nil {
return err
}
}
if _, err := fmt.Fprintln(out); err != nil {
return err
}
}
return nil
}