Bind restore to committed remote snapshots
This commit is contained in:
@@ -27,17 +27,29 @@ const (
|
||||
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
|
||||
}
|
||||
|
||||
@@ -66,55 +78,10 @@ func buildRestorePlan(ctx context.Context, cfg *config.Config, current *RemoteCu
|
||||
if store == nil {
|
||||
return nil, fmt.Errorf("remote object store is required")
|
||||
}
|
||||
prefix := normalizeRemoteKey(current.SessionPrefix)
|
||||
if strings.TrimSpace(prefix) == "" {
|
||||
return nil, fmt.Errorf("remote session prefix is required")
|
||||
}
|
||||
if !strings.HasSuffix(prefix, "/") {
|
||||
prefix += "/"
|
||||
}
|
||||
|
||||
sessionPaths := artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root).SessionPathsFor(cfg.Session.Campaign, cfg.Session.SessionID)
|
||||
objects, err := store.List(ctx, prefix)
|
||||
actions, err := buildCurrentRestoreActions(ctx, current, store, sessionPaths, opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list remote session objects under %q: %w", prefix, err)
|
||||
}
|
||||
|
||||
candidates := make(map[string]storage.ObjectInfo, len(objects)+1)
|
||||
for _, obj := range objects {
|
||||
key := normalizeRemoteKey(obj.Key)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
obj.Key = key
|
||||
candidates[key] = obj
|
||||
}
|
||||
if strings.TrimSpace(current.CurrentManifestKey) != "" {
|
||||
key := normalizeRemoteKey(current.CurrentManifestKey)
|
||||
if _, ok := candidates[key]; !ok {
|
||||
candidates[key] = storage.ObjectInfo{Key: key}
|
||||
}
|
||||
}
|
||||
|
||||
actions := make([]RestoreAction, 0, len(candidates))
|
||||
for key, obj := range candidates {
|
||||
rel, include, err := restoreLocalRelativePathForKey(prefix, normalizeRemoteKey(current.CurrentManifestKey), key, opts.IncludeAudio)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("map remote key %q: %w", key, err)
|
||||
}
|
||||
if !include {
|
||||
continue
|
||||
}
|
||||
localPath, err := joinWithinSessionRoot(sessionPaths.Root, rel)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("map remote key %q: %w", key, err)
|
||||
}
|
||||
|
||||
action, err := classifyRestoreAction(ctx, store, obj, rel, localPath, opts.Force)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("classify remote key %q: %w", key, err)
|
||||
}
|
||||
actions = append(actions, action)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
previousActions, err := buildPreviousCacheRestoreActions(ctx, cfg, sessionPaths, store, opts.Force)
|
||||
@@ -146,6 +113,138 @@ func buildRestorePlan(ctx context.Context, cfg *config.Config, current *RemoteCu
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
func buildCurrentRestoreActions(
|
||||
ctx context.Context,
|
||||
current *RemoteCurrentState,
|
||||
store storage.ObjectStore,
|
||||
sessionPaths artifacts.SessionPaths,
|
||||
opts RestorePlanOptions,
|
||||
) ([]RestoreAction, error) {
|
||||
if current != nil && current.Commit != nil {
|
||||
return buildCommittedRestoreActions(ctx, current, store, sessionPaths, opts)
|
||||
}
|
||||
return buildLegacyRestoreActions(ctx, current, store, sessionPaths, opts)
|
||||
}
|
||||
|
||||
func buildCommittedRestoreActions(
|
||||
ctx context.Context,
|
||||
current *RemoteCurrentState,
|
||||
store storage.ObjectStore,
|
||||
sessionPaths artifacts.SessionPaths,
|
||||
opts RestorePlanOptions,
|
||||
) ([]RestoreAction, error) {
|
||||
if current == nil || current.Commit == nil {
|
||||
return nil, fmt.Errorf("committed remote current state is required")
|
||||
}
|
||||
runPrefix := artifacts.S3RunPrefix(current.SessionPrefix, current.RunID)
|
||||
if runPrefix == "" {
|
||||
return nil, fmt.Errorf("committed run prefix is required")
|
||||
}
|
||||
actions := make([]RestoreAction, 0, len(current.Commit.Artifacts))
|
||||
for _, artifact := range current.Commit.Artifacts {
|
||||
rel, include, err := restoreLocalRelativePathForCommittedArtifact(runPrefix, 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, opts.Force)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("classify committed object %q: %w", artifact.DestinationKey, err)
|
||||
}
|
||||
actions = append(actions, action)
|
||||
}
|
||||
return actions, nil
|
||||
}
|
||||
|
||||
func buildLegacyRestoreActions(
|
||||
ctx context.Context,
|
||||
current *RemoteCurrentState,
|
||||
store storage.ObjectStore,
|
||||
sessionPaths artifacts.SessionPaths,
|
||||
opts RestorePlanOptions,
|
||||
) ([]RestoreAction, error) {
|
||||
prefix := normalizeRemoteKey(current.SessionPrefix)
|
||||
if strings.TrimSpace(prefix) == "" {
|
||||
return nil, fmt.Errorf("remote session prefix is required")
|
||||
}
|
||||
if !strings.HasSuffix(prefix, "/") {
|
||||
prefix += "/"
|
||||
}
|
||||
objects, err := store.List(ctx, prefix)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list remote session objects under %q: %w", prefix, err)
|
||||
}
|
||||
candidates := make(map[string]storage.ObjectInfo, len(objects)+1)
|
||||
for _, obj := range objects {
|
||||
key := normalizeRemoteKey(obj.Key)
|
||||
if key != "" {
|
||||
obj.Key = key
|
||||
candidates[key] = obj
|
||||
}
|
||||
}
|
||||
if key := normalizeRemoteKey(current.CurrentManifestKey); key != "" {
|
||||
if _, ok := candidates[key]; !ok {
|
||||
candidates[key] = storage.ObjectInfo{Key: key}
|
||||
}
|
||||
}
|
||||
actions := make([]RestoreAction, 0, len(candidates))
|
||||
for key, object := range candidates {
|
||||
rel, include, err := restoreLocalRelativePathForKey(prefix, normalizeRemoteKey(current.CurrentManifestKey), key, opts.IncludeAudio)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("map remote key %q: %w", key, err)
|
||||
}
|
||||
if !include {
|
||||
continue
|
||||
}
|
||||
localPath, err := joinWithinSessionRoot(sessionPaths.Root, rel)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("map remote key %q: %w", key, err)
|
||||
}
|
||||
action, err := classifyRestoreAction(ctx, store, object, "", rel, localPath, opts.Force)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("classify remote key %q: %w", key, err)
|
||||
}
|
||||
actions = append(actions, action)
|
||||
}
|
||||
return actions, nil
|
||||
}
|
||||
|
||||
func restoreLocalRelativePathForCommittedArtifact(runPrefix 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
|
||||
}
|
||||
key := normalizeRemoteKey(artifact.DestinationKey)
|
||||
if !strings.HasPrefix(key, runPrefix) {
|
||||
return "", false, fmt.Errorf("object is outside committed run prefix %q", runPrefix)
|
||||
}
|
||||
rel := strings.TrimPrefix(key, runPrefix)
|
||||
cleanRel, err := pathsafe.NormalizeRelativeDestination(rel)
|
||||
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), "\\", "/"), "/")
|
||||
}
|
||||
@@ -250,7 +349,9 @@ func buildPreviousCacheRestoreActions(
|
||||
}
|
||||
actions := make([]RestoreAction, 0, len(plan.Records))
|
||||
for _, record := range plan.Records {
|
||||
action, err := classifyRestoreAction(ctx, store, storage.ObjectInfo{Key: record.RemoteKey}, record.LocalRelativePath, record.LocalPath, force)
|
||||
action, err := classifyRestoreAction(ctx, store, storage.ObjectInfo{
|
||||
Key: record.RemoteKey, Size: record.Size, ETag: record.Generation,
|
||||
}, record.SHA256, record.LocalRelativePath, record.LocalPath, force)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("classify previous-session cache object %q: %w", record.RemoteKey, err)
|
||||
}
|
||||
@@ -263,6 +364,7 @@ func classifyRestoreAction(
|
||||
ctx context.Context,
|
||||
store storage.ObjectStore,
|
||||
object storage.ObjectInfo,
|
||||
expectedSHA256 string,
|
||||
localRelPath string,
|
||||
localPath string,
|
||||
force bool,
|
||||
@@ -271,9 +373,13 @@ func classifyRestoreAction(
|
||||
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)
|
||||
}
|
||||
|
||||
info, err := os.Stat(localPath)
|
||||
if err != nil {
|
||||
@@ -289,9 +395,39 @@ func classifyRestoreAction(
|
||||
if info.IsDir() {
|
||||
action.Kind = RestoreActionConflict
|
||||
action.Conflict = true
|
||||
action.ConflictKind = RestoreConflictDirectory
|
||||
action.Reason = "local path is a directory"
|
||||
return action, nil
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
action.Kind = RestoreActionConflict
|
||||
action.Conflict = true
|
||||
action.ConflictKind = RestoreConflictNonRegular
|
||||
action.Reason = "local path is not a regular file"
|
||||
return action, nil
|
||||
}
|
||||
if action.SHA256 != "" {
|
||||
localDigest, err := artifacts.SHA256File(localPath)
|
||||
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 object.Size > 0 {
|
||||
@@ -308,6 +444,7 @@ func classifyRestoreAction(
|
||||
}
|
||||
action.Kind = RestoreActionConflict
|
||||
action.Conflict = true
|
||||
action.ConflictKind = RestoreConflictContentMismatch
|
||||
action.Reason = "local audio differs (size mismatch)"
|
||||
return action, nil
|
||||
}
|
||||
@@ -318,6 +455,7 @@ func classifyRestoreAction(
|
||||
}
|
||||
action.Kind = RestoreActionConflict
|
||||
action.Conflict = true
|
||||
action.ConflictKind = RestoreConflictContentMismatch
|
||||
action.Reason = "local audio exists; remote size unavailable"
|
||||
return action, nil
|
||||
}
|
||||
@@ -330,6 +468,7 @@ func classifyRestoreAction(
|
||||
}
|
||||
action.Kind = RestoreActionConflict
|
||||
action.Conflict = true
|
||||
action.ConflictKind = RestoreConflictContentMismatch
|
||||
action.Reason = "local file differs (size mismatch)"
|
||||
return action, nil
|
||||
}
|
||||
@@ -364,6 +503,7 @@ func classifyRestoreAction(
|
||||
|
||||
action.Kind = RestoreActionConflict
|
||||
action.Conflict = true
|
||||
action.ConflictKind = RestoreConflictContentMismatch
|
||||
action.Reason = "local file differs"
|
||||
return action, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user