267 lines
8.9 KiB
Go
267 lines
8.9 KiB
Go
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, current.RunID, 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, runID, destinationKey string) (string, error) {
|
|
prefix := artifacts.S3RunRelativeDestinationKey(artifacts.S3RunPrefix(sessionPrefix, runID), "published") + "/"
|
|
rel, ok := strings.CutPrefix(strings.TrimSpace(destinationKey), prefix)
|
|
if !ok || strings.TrimSpace(rel) == "" {
|
|
return "", fmt.Errorf("destination %q is outside selected run published-output prefix %q", destinationKey, prefix)
|
|
}
|
|
normalized, err := pathsafe.NormalizeRelativeDestination(rel)
|
|
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
|
|
}
|