package previouscache import ( "context" "errors" "fmt" "path/filepath" "sort" "strings" "gitea.maximumdirect.net/eric/narratio/internal/adapters/storage" "gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy" "gitea.maximumdirect.net/eric/narratio/internal/artifacts" "gitea.maximumdirect.net/eric/narratio/internal/config" "gitea.maximumdirect.net/eric/narratio/internal/pathsafe" ) const ( InputKindManifest = "previous_manifest" InputKindArtifact = "previous_artifact" InputSource = "previous_session_publish.current" ) type Plan struct { Records []Record SkippedMissing []string PreviousRunID string } type Record struct { Kind string RequirementName string Required bool LocalRelativePath string LocalPath string RemoteKey string SHA256 string Size int64 Generation string S3Bucket string VerifiedContent []byte } // Resolve evaluates previous-session requirements against the one selected // remote current state. Optional absence is represented in SkippedMissing; // required absence returns an error. func Resolve( ctx context.Context, cfg *config.Config, paths artifacts.SessionPaths, requirements []artifacts.PreviousArtifactRequirement, store storage.ObjectStore, ) (*Plan, error) { if len(requirements) == 0 { return &Plan{}, nil } if cfg == nil || cfg.Session == nil || cfg.Pipeline == nil { return nil, fmt.Errorf("resolved config with session/pipeline is required") } orderedRequirements := append([]artifacts.PreviousArtifactRequirement(nil), requirements...) sort.Slice(orderedRequirements, func(i, j int) bool { return orderedRequirements[i].Name < orderedRequirements[j].Name }) requiredNames := requiredPreviousArtifactNames(orderedRequirements) optionalNames := optionalPreviousArtifactNames(orderedRequirements) previousSessionID := strings.TrimSpace(cfg.Session.PreviousSessionID) if previousSessionID == "" { if len(requiredNames) > 0 { return nil, fmt.Errorf( "previous_session_id is required for required previous-session artifacts: %s", strings.Join(requiredNames, ", "), ) } return &Plan{SkippedMissing: optionalNames}, nil } if store == nil { return nil, fmt.Errorf("previous-session artifact hydration requires object store backend") } if cfg.Pipeline.Storage.S3 == nil { return nil, fmt.Errorf("pipeline.storage.s3 configuration is required for previous-session artifact hydration") } campaign := strings.TrimSpace(cfg.Session.Campaign) if campaign == "" { return nil, fmt.Errorf("session campaign is required for previous-session artifact hydration") } bucket := strings.TrimSpace(cfg.Pipeline.Storage.S3.Bucket) if bucket == "" { return nil, fmt.Errorf("pipeline.storage.s3.bucket is required for previous-session artifact hydration") } previousSessionPrefix := artifacts.S3SessionPrefix( cfg.Pipeline.Storage.S3.RootPrefix, campaign, previousSessionID, ) result := &Plan{} current, err := artifacts.LoadCurrentState(ctx, store, previousSessionPrefix, artifacts.CurrentStateValidation{ ExpectedSessionID: previousSessionID, ExpectedCampaign: campaign, ValidateRunID: true, }) if err != nil { var pointerMissing *artifacts.CurrentCommitPointerMissingError if errors.As(err, &pointerMissing) { if len(requiredNames) > 0 { return nil, fmt.Errorf("required previous-session artifacts unavailable: remote %w", err) } result.SkippedMissing = optionalNames return result, nil } return nil, fmt.Errorf("load previous-session current state: %w", err) } result.PreviousRunID = strings.TrimSpace(current.RunID) currentManifestKey := current.CurrentManifestKey if current.Commit == nil { return nil, fmt.Errorf("previous-session current state does not include a committed remote declaration") } manifestRel, err := relativeToSession(paths, paths.PreviousManifestPath) if err != nil { return nil, err } manifestRecord := Record{ Kind: InputKindManifest, LocalRelativePath: manifestRel, LocalPath: paths.PreviousManifestPath, RemoteKey: currentManifestKey, S3Bucket: bucket, } committedManifest, ok := current.Commit.Artifact(artifacts.RemoteArtifactTypeSessionManifest) if !ok || committedManifest.DestinationKey != currentManifestKey { return nil, fmt.Errorf("previous-session remote commit does not declare its session manifest") } manifestRecord.SHA256 = committedManifest.SHA256 manifestRecord.Size = committedManifest.Size manifestRecord.Generation = committedManifest.Generation manifestRecord.VerifiedContent = append([]byte(nil), current.ManifestData...) result.Records = append(result.Records, manifestRecord) for _, requirement := range orderedRequirements { sourceID := previousArtifactSourceID(requirement.Name) selectedArtifact, ok := committedPublishedArtifactBySource(current.Commit, sourceID) if !ok { if requirement.Required { return nil, fmt.Errorf( "required previous-session artifact %q is not declared by the selected remote commit", requirement.Name, ) } result.SkippedMissing = append(result.SkippedMissing, requirement.Name) continue } selectedRel, err := publishedOutputRelativePath(previousSessionPrefix, selectedArtifact.DestinationKey) if err != nil { return nil, fmt.Errorf("resolve declared previous-session artifact %q: %w", requirement.Name, err) } localPath, err := artifacts.SessionPreviousArtifactPath(paths, selectedRel) if err != nil { return nil, fmt.Errorf("resolve previous-session artifact path: %w", err) } localRel, err := relativeToSession(paths, localPath) if err != nil { return nil, err } record := Record{ Kind: InputKindArtifact, RequirementName: requirement.Name, Required: requirement.Required, LocalRelativePath: localRel, LocalPath: localPath, RemoteKey: selectedArtifact.DestinationKey, S3Bucket: bucket, SHA256: selectedArtifact.SHA256, Size: selectedArtifact.Size, Generation: selectedArtifact.Generation, } result.Records = append(result.Records, record) } sort.Strings(result.SkippedMissing) sort.Slice(result.Records, func(i, j int) bool { if result.Records[i].LocalRelativePath != result.Records[j].LocalRelativePath { return result.Records[i].LocalRelativePath < result.Records[j].LocalRelativePath } return result.Records[i].RemoteKey < result.Records[j].RemoteKey }) return result, nil } func committedPublishedArtifactBySource(commit *artifacts.RemoteCommitManifest, source string) (artifacts.RemoteArtifact, bool) { if commit == nil { return artifacts.RemoteArtifact{}, false } want := strings.TrimSpace(source) if want == "" { return artifacts.RemoteArtifact{}, false } for _, artifact := range commit.Artifacts { if artifact.Type == artifacts.RemoteArtifactTypePublishedOutput && artifact.Source == want { return artifact, true } } return artifacts.RemoteArtifact{}, false } func publishedOutputRelativePath(sessionPrefix, destinationKey string) (string, error) { identity, err := artifacts.ParsePublishedOutputObjectKey(sessionPrefix, destinationKey) if err != nil { return "", err } normalized, err := pathsafe.NormalizeRelativeDestination(identity.Destination) if err != nil { return "", fmt.Errorf("published output destination %q: %w", destinationKey, err) } return normalized, nil } func requiredPreviousArtifactNames(requirements []artifacts.PreviousArtifactRequirement) []string { names := make([]string, 0, len(requirements)) for _, requirement := range requirements { if requirement.Required { names = append(names, strings.TrimSpace(requirement.Name)) } } sort.Strings(names) return names } func optionalPreviousArtifactNames(requirements []artifacts.PreviousArtifactRequirement) []string { names := make([]string, 0, len(requirements)) for _, requirement := range requirements { if requirement.Required { continue } names = append(names, strings.TrimSpace(requirement.Name)) } sort.Strings(names) return names } func previousArtifactSourceID(artifactName string) string { sourceID := artifactpolicy.ConfiguredSourceID(artifactName) if sourceDescriptor, err := artifactpolicy.PreviousSessionSourceDescriptorForConfiguredKey(artifactName); err == nil { sourceID = sourceDescriptor.ConfiguredSourceID } return sourceID } func relativeToSession(paths artifacts.SessionPaths, localPath string) (string, error) { root := filepath.Clean(paths.Root) if strings.TrimSpace(root) == "" { return "", fmt.Errorf("session root is required") } normalized, err := pathsafe.SlashRelativeFromRoot(root, localPath) if err != nil { return "", fmt.Errorf("resolve previous-cache relative path: %w", err) } return normalized, nil }