Files
narratio/internal/app/restore_execute.go

181 lines
5.5 KiB
Go

package app
import (
"context"
"fmt"
"os"
"path/filepath"
"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/manifest"
)
// RestoreExecutionResult captures concrete file-install results for one restore execution.
type RestoreExecutionResult struct {
DownloadedCount int
}
func executeRestorePlan(
ctx context.Context,
cfg *config.Config,
current *RemoteCurrentState,
plan *RestorePlan,
report *RestoreReport,
store storage.ObjectStore,
) (*RestoreExecutionResult, 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 plan == nil {
return nil, fmt.Errorf("restore plan is required")
}
if store == nil {
return nil, fmt.Errorf("remote object store is required")
}
sessionRoot := artifacts.SessionWorkDirForCampaign(cfg.Pipeline.Workspace.Root, cfg.Session.Campaign, cfg.Session.SessionID)
manifestActions := make([]RestoreAction, 0, 1)
actions := make([]RestoreAction, 0, len(plan.Actions))
for _, action := range plan.Actions {
if action.Kind != RestoreActionDownload {
continue
}
if action.LocalRelativePath == config.PathManifestFile {
manifestActions = append(manifestActions, action)
continue
}
actions = append(actions, action)
}
if len(manifestActions) > 1 {
return nil, fmt.Errorf("restore plan includes multiple manifest download actions")
}
if len(manifestActions) == 1 {
actions = append(actions, manifestActions[0])
}
result := &RestoreExecutionResult{}
for _, action := range actions {
if err := executeRestoreDownloadAction(ctx, cfg, sessionRoot, current, action, store); err != nil {
if report != nil {
report.markFailed(action, err)
}
return nil, fmt.Errorf("install %q from %q: %w", action.LocalRelativePath, action.RemoteKey, err)
}
if report != nil {
report.markDownloaded(action)
}
result.DownloadedCount++
}
return result, nil
}
func executeRestoreDownloadAction(
ctx context.Context,
cfg *config.Config,
sessionRoot string,
current *RemoteCurrentState,
action RestoreAction,
store storage.ObjectStore,
) error {
safeLocalPath, err := joinWithinSessionRoot(sessionRoot, action.LocalRelativePath)
if err != nil {
return fmt.Errorf("resolve safe local path: %w", err)
}
if strings.TrimSpace(action.LocalPath) != "" && filepath.Clean(action.LocalPath) != safeLocalPath {
return fmt.Errorf("restore plan local path mismatch for %q", action.LocalRelativePath)
}
tmpPath, err := downloadObjectToSiblingTemp(ctx, store, action.RemoteKey, safeLocalPath)
if err != nil {
return fmt.Errorf("download to temp file: %w", err)
}
removeTmp := true
defer func() {
if removeTmp {
_ = os.Remove(tmpPath)
}
}()
if action.LocalRelativePath == config.PathManifestFile {
if err := validateRestoredManifest(ctx, cfg, current, tmpPath); err != nil {
return err
}
}
if err := os.Chmod(tmpPath, 0o644); err != nil {
return fmt.Errorf("set file permissions: %w", err)
}
if err := os.Rename(tmpPath, safeLocalPath); err != nil {
return fmt.Errorf("install file atomically: %w", err)
}
removeTmp = false
return nil
}
func downloadObjectToSiblingTemp(ctx context.Context, store storage.ObjectStore, remoteKey, destPath string) (string, error) {
if strings.TrimSpace(destPath) == "" {
return "", fmt.Errorf("destination path is required")
}
dir := filepath.Dir(destPath)
if err := os.MkdirAll(dir, 0o755); err != nil {
return "", fmt.Errorf("create destination directory: %w", err)
}
base := filepath.Base(destPath)
tmp, err := os.CreateTemp(dir, "."+base+".restore-*.tmp")
if err != nil {
return "", fmt.Errorf("create temp file: %w", err)
}
tmpPath := tmp.Name()
if err := tmp.Close(); err != nil {
_ = os.Remove(tmpPath)
return "", fmt.Errorf("close temp file: %w", err)
}
if err := store.Download(ctx, remoteKey, tmpPath); err != nil {
_ = os.Remove(tmpPath)
return "", err
}
return tmpPath, nil
}
func validateRestoredManifest(ctx context.Context, cfg *config.Config, current *RemoteCurrentState, path string) error {
manifestStore := &manifest.LocalStore{}
m, err := manifestStore.Load(ctx, path)
if err != nil {
return fmt.Errorf("validate manifest decode: %w", err)
}
requestedSession := strings.TrimSpace(cfg.Session.SessionID)
requestedCampaign := strings.TrimSpace(cfg.Session.Campaign)
manifestSession := strings.TrimSpace(m.SessionID)
manifestCampaign := strings.TrimSpace(m.Campaign)
if manifestSession != requestedSession {
return fmt.Errorf("manifest session_id %q does not match requested session_id %q", manifestSession, requestedSession)
}
if manifestCampaign == "" {
return fmt.Errorf("manifest campaign is required")
}
if manifestCampaign != requestedCampaign {
return fmt.Errorf("manifest campaign %q does not match requested campaign %q", manifestCampaign, requestedCampaign)
}
if current != nil {
if expected := strings.TrimSpace(current.SessionID); expected != "" && manifestSession != expected {
return fmt.Errorf("manifest session_id %q does not match discovered session_id %q", manifestSession, expected)
}
if expected := strings.TrimSpace(current.Campaign); expected != "" && manifestCampaign != expected {
return fmt.Errorf("manifest campaign %q does not match discovered campaign %q", manifestCampaign, expected)
}
}
return nil
}