217 lines
7.2 KiB
Go
217 lines
7.2 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"path"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
|
)
|
|
|
|
func prepareRestoredManifest(
|
|
ctx context.Context,
|
|
cfg *config.Config,
|
|
current *RemoteCurrentState,
|
|
source io.Reader,
|
|
destinationRoot string,
|
|
requireCurrentIdentity bool,
|
|
) (*manifest.Manifest, error) {
|
|
manifestStore := &manifest.LocalStore{}
|
|
m, err := manifestStore.LoadReader(ctx, source)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("validate manifest decode: %w", err)
|
|
}
|
|
if requireCurrentIdentity {
|
|
if err := validateRestoredManifestIdentity(cfg, current, m); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
if err := rebaseRestoredManifestPaths(cfg, m, destinationRoot); err != nil {
|
|
return nil, err
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
func validateRestoredManifestIdentity(cfg *config.Config, current *RemoteCurrentState, m *manifest.Manifest) error {
|
|
if cfg == nil || cfg.Session == nil {
|
|
return fmt.Errorf("resolved session config is required")
|
|
}
|
|
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)
|
|
}
|
|
if expected := strings.TrimSpace(current.RunID); expected != "" && strings.TrimSpace(m.RunID) != expected {
|
|
return fmt.Errorf("manifest run_id %q does not match discovered run_id %q", strings.TrimSpace(m.RunID), expected)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func rebaseRestoredManifestPaths(cfg *config.Config, m *manifest.Manifest, destinationRoot string) error {
|
|
if cfg == nil || cfg.Pipeline == nil || m == nil {
|
|
return fmt.Errorf("resolved config and manifest are required")
|
|
}
|
|
destinationRoot = filepath.Clean(strings.TrimSpace(destinationRoot))
|
|
if destinationRoot == "" || destinationRoot == "." {
|
|
return fmt.Errorf("restored manifest destination root is required")
|
|
}
|
|
producerRoot, hasProducerRoot := restoredManifestSessionRoot(m)
|
|
rebase := func(field, value string) (string, error) {
|
|
return rebaseRestoredLocalReference(field, value, producerRoot, hasProducerRoot, destinationRoot)
|
|
}
|
|
|
|
for i := range m.Inputs {
|
|
value, err := rebase("inputs.path", m.Inputs[i].Path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
m.Inputs[i].Path = value
|
|
// Spool and cache locations are host-local implementation details. They
|
|
// are deliberately not authoritative after a restore.
|
|
m.Inputs[i].SpoolPath = ""
|
|
m.Inputs[i].CachePath = ""
|
|
}
|
|
for i := range m.Artifacts {
|
|
value, err := rebase("artifacts.local_path", m.Artifacts[i].LocalPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
m.Artifacts[i].LocalPath = value
|
|
}
|
|
for stageName, record := range m.Stages {
|
|
if record == nil {
|
|
continue
|
|
}
|
|
for i := range record.Outputs {
|
|
value, err := rebase("stages."+stageName+".outputs.local_path", record.Outputs[i].LocalPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
record.Outputs[i].LocalPath = value
|
|
}
|
|
// Logs and generated configuration files are invocation-local diagnostics,
|
|
// not restored artifacts. Dropping them prevents a producer-machine path
|
|
// from becoming a usable local reference.
|
|
record.Logs = nil
|
|
record.GeneratedConfigs = nil
|
|
}
|
|
|
|
if runID := strings.TrimSpace(m.RunID); runID != "" {
|
|
m.LocalWorkDir = filepath.Join(destinationRoot, config.PathRunsDirSegment, runID)
|
|
} else {
|
|
m.LocalWorkDir = destinationRoot
|
|
}
|
|
m.LocalSpoolDir = ""
|
|
// A post-publish cleanup record is authority to delete producer-local
|
|
// directories. It must never cross a restore boundary.
|
|
m.PostPublishCleanup = nil
|
|
return nil
|
|
}
|
|
|
|
func restoredManifestSessionRoot(m *manifest.Manifest) (string, bool) {
|
|
if m == nil {
|
|
return "", false
|
|
}
|
|
runRoot := portableCleanPath(m.LocalWorkDir)
|
|
runID := strings.TrimSpace(m.RunID)
|
|
if !portableAbsolutePath(runRoot) || runID == "" || path.Base(runRoot) != runID {
|
|
return "", false
|
|
}
|
|
runsDir := path.Dir(runRoot)
|
|
if path.Base(runsDir) != config.PathRunsDirSegment {
|
|
return "", false
|
|
}
|
|
return path.Dir(runsDir), true
|
|
}
|
|
|
|
func rebaseRestoredLocalReference(field, value, producerRoot string, hasProducerRoot bool, destinationRoot string) (string, error) {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
return "", nil
|
|
}
|
|
if portableDrivePath(value) && !portableAbsolutePath(value) {
|
|
return "", fmt.Errorf("%s has an unsafe relative path %q", field, value)
|
|
}
|
|
if portableAbsolutePath(value) {
|
|
if !hasProducerRoot {
|
|
return "", fmt.Errorf("%s has an absolute path without a producer session root", field)
|
|
}
|
|
relative, ok := portableRelativeWithinRoot(producerRoot, value)
|
|
if !ok || relative == "" {
|
|
return "", fmt.Errorf("%s absolute path is outside the producer session root", field)
|
|
}
|
|
resolved, err := joinWithinSessionRoot(destinationRoot, relative)
|
|
if err != nil {
|
|
return "", fmt.Errorf("%s: %w", field, err)
|
|
}
|
|
return resolved, nil
|
|
}
|
|
resolved, err := joinWithinSessionRoot(destinationRoot, strings.ReplaceAll(value, "\\", "/"))
|
|
if err != nil {
|
|
return "", fmt.Errorf("%s has an unsafe relative path: %w", field, err)
|
|
}
|
|
return resolved, nil
|
|
}
|
|
|
|
func portableRelativeWithinRoot(root, candidate string) (string, bool) {
|
|
root = strings.TrimSuffix(portableCleanPath(root), "/")
|
|
candidate = portableCleanPath(candidate)
|
|
if root == "" || candidate == "" {
|
|
return "", false
|
|
}
|
|
compareRoot, compareCandidate := root, candidate
|
|
if portableDrivePath(root) || portableDrivePath(candidate) || strings.HasPrefix(root, "//") || strings.HasPrefix(candidate, "//") {
|
|
compareRoot = strings.ToLower(compareRoot)
|
|
compareCandidate = strings.ToLower(compareCandidate)
|
|
}
|
|
if compareCandidate == compareRoot {
|
|
return "", true
|
|
}
|
|
if !strings.HasPrefix(compareCandidate, compareRoot+"/") {
|
|
return "", false
|
|
}
|
|
return strings.TrimPrefix(candidate, root+"/"), true
|
|
}
|
|
|
|
func portableCleanPath(value string) string {
|
|
value = strings.ReplaceAll(strings.TrimSpace(value), "\\", "/")
|
|
if value == "" {
|
|
return ""
|
|
}
|
|
return path.Clean(value)
|
|
}
|
|
|
|
func portableAbsolutePath(value string) bool {
|
|
value = strings.TrimSpace(value)
|
|
return strings.HasPrefix(value, "/") || strings.HasPrefix(value, "\\") || (len(value) >= 3 && isASCIIAlpha(value[0]) && value[1] == ':' && (value[2] == '/' || value[2] == '\\'))
|
|
}
|
|
|
|
func portableDrivePath(value string) bool {
|
|
value = strings.TrimSpace(value)
|
|
return len(value) >= 2 && isASCIIAlpha(value[0]) && value[1] == ':'
|
|
}
|
|
|
|
func isASCIIAlpha(value byte) bool {
|
|
return (value >= 'a' && value <= 'z') || (value >= 'A' && value <= 'Z')
|
|
}
|