Publish immutable remote commits
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package stage
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
@@ -12,7 +13,6 @@ import (
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
|
||||
@@ -59,22 +59,20 @@ func (publishStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
|
||||
if publishDisabled(env) {
|
||||
return &StageResult{
|
||||
Metadata: map[string]any{
|
||||
"stage": "publish",
|
||||
"skipped": true,
|
||||
"publish_enabled": false,
|
||||
"audio_upload_skipped": true,
|
||||
"current_pointer_written": false,
|
||||
"stage": "publish",
|
||||
"skipped": true,
|
||||
"publish_enabled": false,
|
||||
"audio_upload_skipped": true,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
if publishRunUploadDisabled(env) {
|
||||
return &StageResult{
|
||||
Metadata: map[string]any{
|
||||
"stage": "publish",
|
||||
"skipped": true,
|
||||
"upload_run_enabled": false,
|
||||
"audio_upload_skipped": true,
|
||||
"current_pointer_written": false,
|
||||
"stage": "publish",
|
||||
"skipped": true,
|
||||
"upload_run_enabled": false,
|
||||
"audio_upload_skipped": true,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
@@ -86,10 +84,11 @@ func (publishStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
|
||||
return nil, fmt.Errorf("publish: remote object store backend is required when publish run upload is enabled")
|
||||
}
|
||||
|
||||
runRoot, err := resolvePublishRunRoot(env, m)
|
||||
runManifestPath, err := resolvePublishRunManifestPath(env, m)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("publish: resolve run root: %w", err)
|
||||
return nil, fmt.Errorf("publish: resolve run manifest path: %w", err)
|
||||
}
|
||||
runRoot := filepath.Dir(runManifestPath)
|
||||
runRootInfo, err := os.Lstat(runRoot)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("publish: run root %q: %w", runRoot, err)
|
||||
@@ -115,7 +114,7 @@ func (publishStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
|
||||
return nil, fmt.Errorf("publish: run id is required")
|
||||
}
|
||||
|
||||
runManifestFile, err := fileops.OpenConfinedRegularFile(runRoot, "manifest.json")
|
||||
runManifestFile, err := fileops.OpenConfinedRegularFile(runRoot, filepath.Base(runManifestPath))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("publish: open run manifest: %w", err)
|
||||
}
|
||||
@@ -170,72 +169,83 @@ func (publishStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
|
||||
env.Config.Pipeline.Publish.Outputs,
|
||||
env.Config.Pipeline.Publish.Locks,
|
||||
env.SelectedArtifactKeys,
|
||||
sessionPrefix,
|
||||
runPrefix,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("publish: resolve publish output rules: %w", err)
|
||||
}
|
||||
runUploaded := make([]string, 0, len(runFiles))
|
||||
for index, file := range runFiles {
|
||||
key := artifacts.S3RunRelativeDestinationKey(runPrefix, file.RelativePath)
|
||||
if _, err := uploader.UploadReader(ctx, runSources[index], key, storage.UploadOptions{}); err != nil {
|
||||
return nil, fmt.Errorf("publish: upload run file %q to %q: %w", file.RelativePath, key, err)
|
||||
}
|
||||
runUploaded = append(runUploaded, file.RelativePath)
|
||||
}
|
||||
|
||||
publishedUploaded := make([]string, 0, len(publishOutputs))
|
||||
for _, publishedOutput := range publishOutputs {
|
||||
key := artifacts.S3PublishedOutputKey(sessionPrefix, publishedOutput.Dest)
|
||||
if _, err := env.ObjectStore.Upload(ctx, publishedOutput.LocalPath, key, storage.UploadOptions{}); err != nil {
|
||||
return nil, fmt.Errorf("publish: upload published output source %q to %q: %w", publishedOutput.Source, key, err)
|
||||
}
|
||||
publishedUploaded = append(publishedUploaded, publishedOutput.Dest)
|
||||
}
|
||||
runUploaded := publishUploadPaths(runFiles)
|
||||
publishedUploaded := publishOutputPaths(publishOutputs)
|
||||
previousUploaded := publishUploadPaths(previousFiles)
|
||||
commitKey := artifacts.S3RunCommitKey(sessionPrefix, runID)
|
||||
pointerKey := artifacts.S3CurrentCommitPointerKey(sessionPrefix)
|
||||
|
||||
previousUploaded := make([]string, 0, len(previousFiles))
|
||||
for index, file := range previousFiles {
|
||||
key := artifacts.S3PublishedOutputKey(sessionPrefix, file.RelativePath)
|
||||
if _, err := uploader.UploadReader(ctx, previousSources[index], key, storage.UploadOptions{}); err != nil {
|
||||
return nil, fmt.Errorf("publish: upload previous file %q to %q: %w", file.RelativePath, key, err)
|
||||
}
|
||||
previousUploaded = append(previousUploaded, file.RelativePath)
|
||||
transfers, err := buildPublishTransferPlan(runPrefix, runFiles, runSources, previousFiles, previousSources, publishOutputs)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("publish: build immutable upload mapping: %w", err)
|
||||
}
|
||||
defer func() { closePublishTransfers(transfers) }()
|
||||
|
||||
currentManifestKey, currentRunPointerKey := artifacts.ResolveCurrentStateKeys(sessionPrefix)
|
||||
manifestTempPath, err := writeCurrentManifestSnapshot(m, publishMetadataPreview(
|
||||
manifestTempPath, err := writeCommittedSessionManifest(m, publishMetadata(
|
||||
bucket,
|
||||
runPrefix,
|
||||
sessionPrefix,
|
||||
runUploaded,
|
||||
publishedUploaded,
|
||||
previousUploaded,
|
||||
skippedOptionalOutputs,
|
||||
skippedUnselectedOutputs,
|
||||
lockedOutputs,
|
||||
currentManifestKey,
|
||||
commitKey,
|
||||
pointerKey,
|
||||
))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("publish: build current manifest snapshot: %w", err)
|
||||
return nil, fmt.Errorf("publish: build committed session manifest: %w", err)
|
||||
}
|
||||
defer func() { _ = os.Remove(manifestTempPath) }()
|
||||
|
||||
if _, err := env.ObjectStore.Upload(ctx, manifestTempPath, currentManifestKey, storage.UploadOptions{
|
||||
ContentType: "application/json",
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("publish: upload current manifest to %q: %w", currentManifestKey, err)
|
||||
if err := appendCommittedSessionManifest(&transfers, manifestTempPath, artifacts.S3RunSessionManifestKey(sessionPrefix, runID)); err != nil {
|
||||
return nil, fmt.Errorf("publish: add committed session manifest: %w", err)
|
||||
}
|
||||
if err := validatePublishTransferPlan(transfers, commitKey); err != nil {
|
||||
return nil, fmt.Errorf("publish: validate immutable upload mapping: %w", err)
|
||||
}
|
||||
|
||||
runIDTempPath, err := writeCurrentRunIDPointer(runID)
|
||||
commit := artifacts.RemoteCommitManifest{
|
||||
FormatVersion: artifacts.RemoteCommitFormatVersion,
|
||||
Campaign: strings.TrimSpace(m.Campaign),
|
||||
SessionID: strings.TrimSpace(m.SessionID),
|
||||
RunID: runID,
|
||||
Artifacts: make([]artifacts.RemoteArtifact, 0, len(transfers)),
|
||||
}
|
||||
for index := range transfers {
|
||||
artifact, err := uploadVerifiedPublishTransfer(ctx, env.ObjectStore, uploader, &transfers[index])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("publish: upload immutable object %q: %w", transfers[index].Artifact.DestinationKey, err)
|
||||
}
|
||||
commit.Artifacts = append(commit.Artifacts, artifact)
|
||||
}
|
||||
commitData, err := artifacts.EncodeRemoteCommitManifest(commit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("publish: build current run id pointer: %w", err)
|
||||
return nil, fmt.Errorf("publish: encode immutable commit: %w", err)
|
||||
}
|
||||
defer func() { _ = os.Remove(runIDTempPath) }()
|
||||
|
||||
if _, err := env.ObjectStore.Upload(ctx, runIDTempPath, currentRunPointerKey, storage.UploadOptions{
|
||||
ContentType: "text/plain; charset=utf-8",
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("publish: upload current run pointer to %q: %w", currentRunPointerKey, err)
|
||||
commitInfo, err := uploadVerifiedPublishBytes(ctx, env.ObjectStore, uploader, commitKey, commitData, "application/json")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("publish: upload immutable commit %q: %w", commitKey, err)
|
||||
}
|
||||
pointerData, err := artifacts.EncodeCurrentCommitPointer(artifacts.CurrentCommitPointer{
|
||||
FormatVersion: artifacts.RemoteCommitFormatVersion,
|
||||
Campaign: commit.Campaign,
|
||||
SessionID: commit.SessionID,
|
||||
RunID: commit.RunID,
|
||||
CommitKey: commitKey,
|
||||
CommitSHA256: publishSHA256(commitData),
|
||||
CommitSize: int64(len(commitData)),
|
||||
CommitGeneration: commitInfo.ETag,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("publish: encode current commit pointer: %w", err)
|
||||
}
|
||||
if _, err := uploader.UploadReader(ctx, bytes.NewReader(pointerData), pointerKey, storage.UploadOptions{ContentType: "application/json"}); err != nil {
|
||||
return nil, fmt.Errorf("publish: update current commit pointer %q: %w", pointerKey, err)
|
||||
}
|
||||
|
||||
return &StageResult{
|
||||
@@ -254,9 +264,8 @@ func (publishStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*S
|
||||
"skipped_unselected_outputs": skippedUnselectedOutputMetadata(skippedUnselectedOutputs),
|
||||
"locked_output_count": len(lockedOutputs),
|
||||
"locked_outputs": lockedOutputMetadata(lockedOutputs),
|
||||
"current_manifest_key": currentManifestKey,
|
||||
"current_run_id_key": currentRunPointerKey,
|
||||
"current_pointer_written": true,
|
||||
"remote_commit_key": commitKey,
|
||||
"current_commit_pointer_key": pointerKey,
|
||||
"audio_upload_skipped": true,
|
||||
},
|
||||
}, nil
|
||||
@@ -270,6 +279,12 @@ type publishOutput struct {
|
||||
Provenance string
|
||||
}
|
||||
|
||||
type publishTransfer struct {
|
||||
Artifact artifacts.RemoteArtifact
|
||||
Source *os.File
|
||||
TemporaryPath string
|
||||
}
|
||||
|
||||
type publishLockedOutput struct {
|
||||
Source string
|
||||
Dest string
|
||||
@@ -286,6 +301,292 @@ type publishSkippedUnselectedOutput struct {
|
||||
Required bool
|
||||
}
|
||||
|
||||
func buildPublishTransferPlan(
|
||||
runPrefix string,
|
||||
runFiles []publishUploadFile,
|
||||
runSources []*os.File,
|
||||
previousFiles []publishUploadFile,
|
||||
previousSources []*os.File,
|
||||
publishOutputs []publishOutput,
|
||||
) ([]publishTransfer, error) {
|
||||
if len(runFiles) != len(runSources) || len(previousFiles) != len(previousSources) {
|
||||
return nil, fmt.Errorf("verified publish sources do not match their declared files")
|
||||
}
|
||||
transfers := make([]publishTransfer, 0, len(runFiles)+len(previousFiles)+len(publishOutputs))
|
||||
add := func(artifact artifacts.RemoteArtifact, source *os.File) error {
|
||||
if source == nil {
|
||||
return fmt.Errorf("source for %q is required", artifact.DestinationKey)
|
||||
}
|
||||
transfers = append(transfers, publishTransfer{Artifact: artifact, Source: source})
|
||||
return nil
|
||||
}
|
||||
for index, file := range runFiles {
|
||||
artifactType := artifacts.RemoteArtifactTypeRunFile
|
||||
source := "run:" + file.RelativePath
|
||||
if file.RelativePath == config.S3ManifestFile {
|
||||
artifactType = artifacts.RemoteArtifactTypeRunManifest
|
||||
source = "run.manifest"
|
||||
}
|
||||
if err := add(artifacts.RemoteArtifact{
|
||||
Type: artifactType,
|
||||
Source: source,
|
||||
DestinationKey: artifacts.S3RunRelativeDestinationKey(runPrefix, file.RelativePath),
|
||||
}, runSources[index]); err != nil {
|
||||
closePublishTransfers(transfers)
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for index, file := range previousFiles {
|
||||
if err := add(artifacts.RemoteArtifact{
|
||||
Type: artifacts.RemoteArtifactTypePreviousArtifact,
|
||||
Source: "previous:" + file.SourceRelativePath,
|
||||
DestinationKey: artifacts.S3RunRelativeDestinationKey(runPrefix, file.RelativePath),
|
||||
}, previousSources[index]); err != nil {
|
||||
closePublishTransfers(transfers)
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for _, output := range publishOutputs {
|
||||
source, err := openPublishOutputSource(output.LocalPath)
|
||||
if err != nil {
|
||||
closePublishTransfers(transfers)
|
||||
return nil, fmt.Errorf("open output source %q: %w", output.Source, err)
|
||||
}
|
||||
if err := add(artifacts.RemoteArtifact{
|
||||
Type: artifacts.RemoteArtifactTypePublishedOutput,
|
||||
Source: output.Source,
|
||||
DestinationKey: publishOutputRemoteKey(runPrefix, output.Dest),
|
||||
}, source); err != nil {
|
||||
_ = source.Close()
|
||||
closePublishTransfers(transfers)
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
sort.Slice(transfers, func(i, j int) bool {
|
||||
return transfers[i].Artifact.DestinationKey < transfers[j].Artifact.DestinationKey
|
||||
})
|
||||
return transfers, nil
|
||||
}
|
||||
|
||||
func appendCommittedSessionManifest(transfers *[]publishTransfer, localPath, destinationKey string) error {
|
||||
if transfers == nil {
|
||||
return fmt.Errorf("transfer plan is required")
|
||||
}
|
||||
source, err := os.Open(localPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*transfers = append(*transfers, publishTransfer{
|
||||
Artifact: artifacts.RemoteArtifact{
|
||||
Type: artifacts.RemoteArtifactTypeSessionManifest,
|
||||
Source: "session.manifest",
|
||||
DestinationKey: destinationKey,
|
||||
},
|
||||
Source: source,
|
||||
TemporaryPath: localPath,
|
||||
})
|
||||
sort.Slice(*transfers, func(i, j int) bool {
|
||||
return (*transfers)[i].Artifact.DestinationKey < (*transfers)[j].Artifact.DestinationKey
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func validatePublishTransferPlan(transfers []publishTransfer, commitKey string) error {
|
||||
artifactsToUpload := make([]artifacts.RemoteArtifact, 0, len(transfers))
|
||||
for _, transfer := range transfers {
|
||||
if transfer.Artifact.DestinationKey == commitKey {
|
||||
return fmt.Errorf("artifact destination %q is reserved for the immutable commit", commitKey)
|
||||
}
|
||||
artifactsToUpload = append(artifactsToUpload, transfer.Artifact)
|
||||
}
|
||||
return artifacts.ValidateRemoteArtifactMapping(artifactsToUpload)
|
||||
}
|
||||
|
||||
func openPublishOutputSource(localPath string) (*os.File, error) {
|
||||
path := filepath.Clean(strings.TrimSpace(localPath))
|
||||
if path == "" || path == "." {
|
||||
return nil, fmt.Errorf("local path is required")
|
||||
}
|
||||
return fileops.OpenConfinedRegularFile(filepath.Dir(path), filepath.Base(path))
|
||||
}
|
||||
|
||||
func publishOutputRemoteKey(runPrefix, destination string) string {
|
||||
return artifacts.S3RunRelativeDestinationKey(runPrefix, filepath.ToSlash(filepath.Join("published", destination)))
|
||||
}
|
||||
|
||||
func uploadVerifiedPublishTransfer(
|
||||
ctx context.Context,
|
||||
store storage.ObjectStore,
|
||||
uploader storage.ReaderUploader,
|
||||
transfer *publishTransfer,
|
||||
) (artifacts.RemoteArtifact, error) {
|
||||
if transfer == nil || transfer.Source == nil {
|
||||
return artifacts.RemoteArtifact{}, fmt.Errorf("transfer source is required")
|
||||
}
|
||||
if err := preparePublishTransfer(transfer); err != nil {
|
||||
return artifacts.RemoteArtifact{}, err
|
||||
}
|
||||
if existing, found, err := verifyExistingImmutableObject(ctx, store, transfer.Artifact.DestinationKey, transfer.Artifact.SHA256, transfer.Artifact.Size); err != nil {
|
||||
return artifacts.RemoteArtifact{}, err
|
||||
} else if found {
|
||||
transfer.Artifact.Generation = existing.ETag
|
||||
return transfer.Artifact, nil
|
||||
}
|
||||
info, err := uploader.UploadReader(ctx, transfer.Source, transfer.Artifact.DestinationKey, storage.UploadOptions{})
|
||||
if err != nil {
|
||||
return artifacts.RemoteArtifact{}, err
|
||||
}
|
||||
verified, err := verifyPublishedObject(ctx, store, transfer.Artifact.DestinationKey, transfer.Artifact.Size, info.ETag)
|
||||
if err != nil {
|
||||
return artifacts.RemoteArtifact{}, err
|
||||
}
|
||||
transfer.Artifact.Generation = verified.ETag
|
||||
return transfer.Artifact, nil
|
||||
}
|
||||
|
||||
func uploadVerifiedPublishBytes(
|
||||
ctx context.Context,
|
||||
store storage.ObjectStore,
|
||||
uploader storage.ReaderUploader,
|
||||
key string,
|
||||
data []byte,
|
||||
contentType string,
|
||||
) (storage.ObjectInfo, error) {
|
||||
checksum := publishSHA256(data)
|
||||
if existing, found, err := verifyExistingImmutableObject(ctx, store, key, checksum, int64(len(data))); err != nil {
|
||||
return storage.ObjectInfo{}, err
|
||||
} else if found {
|
||||
return existing, nil
|
||||
}
|
||||
info, err := uploader.UploadReader(ctx, bytes.NewReader(data), key, storage.UploadOptions{ContentType: contentType})
|
||||
if err != nil {
|
||||
return storage.ObjectInfo{}, err
|
||||
}
|
||||
return verifyPublishedObject(ctx, store, key, int64(len(data)), info.ETag)
|
||||
}
|
||||
|
||||
func verifyExistingImmutableObject(ctx context.Context, store storage.ObjectStore, key, wantSHA256 string, wantSize int64) (storage.ObjectInfo, bool, error) {
|
||||
exists, err := store.Exists(ctx, key)
|
||||
if err != nil {
|
||||
return storage.ObjectInfo{}, false, fmt.Errorf("check immutable object %q: %w", key, err)
|
||||
}
|
||||
if !exists {
|
||||
return storage.ObjectInfo{}, false, nil
|
||||
}
|
||||
path, err := storage.DownloadObjectToTemp(ctx, store, key, "narratio-verify-immutable-*.bin")
|
||||
if err != nil {
|
||||
return storage.ObjectInfo{}, false, fmt.Errorf("download existing immutable object %q: %w", key, err)
|
||||
}
|
||||
defer func() { _ = os.Remove(path) }()
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return storage.ObjectInfo{}, false, fmt.Errorf("open existing immutable object %q: %w", key, err)
|
||||
}
|
||||
hash := sha256.New()
|
||||
size, copyErr := io.Copy(hash, file)
|
||||
closeErr := file.Close()
|
||||
if copyErr != nil {
|
||||
return storage.ObjectInfo{}, false, fmt.Errorf("checksum existing immutable object %q: %w", key, copyErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
return storage.ObjectInfo{}, false, fmt.Errorf("close existing immutable object %q: %w", key, closeErr)
|
||||
}
|
||||
if size != wantSize || hex.EncodeToString(hash.Sum(nil)) != wantSHA256 {
|
||||
return storage.ObjectInfo{}, false, fmt.Errorf("immutable object %q already exists with different content", key)
|
||||
}
|
||||
info, err := verifyPublishedObject(ctx, store, key, wantSize, "")
|
||||
if err != nil {
|
||||
return storage.ObjectInfo{}, false, err
|
||||
}
|
||||
return info, true, nil
|
||||
}
|
||||
|
||||
func preparePublishTransfer(transfer *publishTransfer) error {
|
||||
if _, err := transfer.Source.Seek(0, io.SeekStart); err != nil {
|
||||
return fmt.Errorf("rewind source %q: %w", transfer.Artifact.Source, err)
|
||||
}
|
||||
info, err := transfer.Source.Stat()
|
||||
if err != nil {
|
||||
return fmt.Errorf("stat source %q: %w", transfer.Artifact.Source, err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return fmt.Errorf("source %q is not a regular file", transfer.Artifact.Source)
|
||||
}
|
||||
hash := sha256.New()
|
||||
if _, err := io.Copy(hash, transfer.Source); err != nil {
|
||||
return fmt.Errorf("checksum source %q: %w", transfer.Artifact.Source, err)
|
||||
}
|
||||
if _, err := transfer.Source.Seek(0, io.SeekStart); err != nil {
|
||||
return fmt.Errorf("rewind source %q after checksum: %w", transfer.Artifact.Source, err)
|
||||
}
|
||||
transfer.Artifact.Size = info.Size()
|
||||
transfer.Artifact.SHA256 = hex.EncodeToString(hash.Sum(nil))
|
||||
return nil
|
||||
}
|
||||
|
||||
func verifyPublishedObject(ctx context.Context, store storage.ObjectStore, key string, wantSize int64, uploadedGeneration string) (storage.ObjectInfo, error) {
|
||||
objects, err := store.List(ctx, key)
|
||||
if err != nil {
|
||||
return storage.ObjectInfo{}, fmt.Errorf("list uploaded object %q: %w", key, err)
|
||||
}
|
||||
var found *storage.ObjectInfo
|
||||
for _, object := range objects {
|
||||
if object.Key != key {
|
||||
continue
|
||||
}
|
||||
if found != nil {
|
||||
return storage.ObjectInfo{}, fmt.Errorf("uploaded object %q is ambiguous", key)
|
||||
}
|
||||
copy := object
|
||||
found = ©
|
||||
}
|
||||
if found == nil {
|
||||
return storage.ObjectInfo{}, fmt.Errorf("uploaded object %q is missing", key)
|
||||
}
|
||||
if found.Size != wantSize {
|
||||
return storage.ObjectInfo{}, fmt.Errorf("uploaded object %q size is %d, want %d", key, found.Size, wantSize)
|
||||
}
|
||||
if strings.TrimSpace(found.ETag) == "" {
|
||||
return storage.ObjectInfo{}, fmt.Errorf("uploaded object %q has no generation", key)
|
||||
}
|
||||
if strings.TrimSpace(uploadedGeneration) != "" && found.ETag != uploadedGeneration {
|
||||
return storage.ObjectInfo{}, fmt.Errorf("uploaded object %q generation changed during verification", key)
|
||||
}
|
||||
return *found, nil
|
||||
}
|
||||
|
||||
func closePublishTransfers(transfers []publishTransfer) {
|
||||
for _, transfer := range transfers {
|
||||
if transfer.Source != nil {
|
||||
_ = transfer.Source.Close()
|
||||
}
|
||||
if transfer.TemporaryPath != "" {
|
||||
_ = os.Remove(transfer.TemporaryPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func publishUploadPaths(files []publishUploadFile) []string {
|
||||
paths := make([]string, 0, len(files))
|
||||
for _, file := range files {
|
||||
paths = append(paths, file.RelativePath)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func publishOutputPaths(outputs []publishOutput) []string {
|
||||
paths := make([]string, 0, len(outputs))
|
||||
for _, output := range outputs {
|
||||
paths = append(paths, output.Dest)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func publishSHA256(data []byte) string {
|
||||
sum := sha256.Sum256(data)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func publishDisabled(env *Env) bool {
|
||||
cfg := env.Config.Pipeline.Publish
|
||||
if cfg == nil {
|
||||
@@ -318,7 +619,7 @@ func validatePublishPrerequisites(m *manifest.Manifest) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func resolvePublishRunRoot(env *Env, m *manifest.Manifest) (string, error) {
|
||||
func resolvePublishRunManifestPath(env *Env, m *manifest.Manifest) (string, error) {
|
||||
sessionID := strings.TrimSpace(env.Config.Session.SessionID)
|
||||
if sessionID == "" && m != nil {
|
||||
sessionID = strings.TrimSpace(m.SessionID)
|
||||
@@ -338,13 +639,13 @@ func resolvePublishRunRoot(env *Env, m *manifest.Manifest) (string, error) {
|
||||
return "", fmt.Errorf("run id is required")
|
||||
}
|
||||
|
||||
canonical := filepath.Clean(artifacts.SessionRunRootForCampaign(env.Config.Pipeline.Workspace.Root, campaign, sessionID, runID))
|
||||
canonicalExists, err := directoryExists(canonical)
|
||||
canonical := filepath.Clean(artifacts.SessionRunManifestPathForCampaign(env.Config.Pipeline.Workspace.Root, campaign, sessionID, runID))
|
||||
canonicalExists, err := directoryExists(filepath.Dir(canonical))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("check canonical run root %q: %w", canonical, err)
|
||||
return "", fmt.Errorf("check canonical run manifest directory %q: %w", filepath.Dir(canonical), err)
|
||||
}
|
||||
if !canonicalExists {
|
||||
return "", fmt.Errorf("run root not found for campaign %q session %q run %q at canonical path %q", campaign, sessionID, runID, canonical)
|
||||
return "", fmt.Errorf("run manifest directory not found for campaign %q session %q run %q at canonical path %q", campaign, sessionID, runID, filepath.Dir(canonical))
|
||||
}
|
||||
return canonical, nil
|
||||
}
|
||||
@@ -387,7 +688,7 @@ func resolvePublishOutputs(
|
||||
rules []config.PublishOutputRule,
|
||||
locks []config.PublishLockRule,
|
||||
selectedArtifactKeys []string,
|
||||
sessionPrefix string,
|
||||
runPrefix string,
|
||||
) ([]publishOutput, []string, []publishSkippedUnselectedOutput, []publishLockedOutput, error) {
|
||||
out := make([]publishOutput, 0, len(rules))
|
||||
skippedOptionalOutputs := make([]string, 0)
|
||||
@@ -423,7 +724,7 @@ func resolvePublishOutputs(
|
||||
lockedOutputs = append(lockedOutputs, publishLockedOutput{
|
||||
Source: source,
|
||||
Dest: dest,
|
||||
RemoteKey: artifacts.S3PublishedOutputKey(sessionPrefix, dest),
|
||||
RemoteKey: publishOutputRemoteKey(runPrefix, dest),
|
||||
Reason: strings.TrimSpace(lock.Reason),
|
||||
Required: required,
|
||||
})
|
||||
@@ -442,7 +743,7 @@ func resolvePublishOutputs(
|
||||
lockedOutputs = append(lockedOutputs, publishLockedOutput{
|
||||
Source: source,
|
||||
Dest: dest,
|
||||
RemoteKey: artifacts.S3PublishedOutputKey(sessionPrefix, dest),
|
||||
RemoteKey: publishOutputRemoteKey(runPrefix, dest),
|
||||
Reason: strings.TrimSpace(lock.Reason),
|
||||
Required: required,
|
||||
LocalPath: resolved.Path,
|
||||
@@ -810,7 +1111,7 @@ func directoryExists(path string) (bool, error) {
|
||||
return false, err
|
||||
}
|
||||
|
||||
func writeCurrentManifestSnapshot(m *manifest.Manifest, publishMetadata map[string]any) (string, error) {
|
||||
func writeCommittedSessionManifest(m *manifest.Manifest, publishMetadata map[string]any) (string, error) {
|
||||
if m == nil {
|
||||
return "", fmt.Errorf("manifest is required")
|
||||
}
|
||||
@@ -840,8 +1141,7 @@ func writeCurrentManifestSnapshot(m *manifest.Manifest, publishMetadata map[stri
|
||||
clone.Stages[name] = &stageCopy
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
clone.MarkStageSucceeded("publish", now, nil)
|
||||
clone.MarkStageSucceeded("publish", clone.UpdatedAt, nil)
|
||||
if sr := clone.Stages["publish"]; sr != nil {
|
||||
sr.Metadata = publishMetadata
|
||||
}
|
||||
@@ -852,7 +1152,7 @@ func writeCurrentManifestSnapshot(m *manifest.Manifest, publishMetadata map[stri
|
||||
}
|
||||
data = append(data, '\n')
|
||||
|
||||
tmp, err := os.CreateTemp("", "narratio-current-manifest-*.json")
|
||||
tmp, err := os.CreateTemp("", "narratio-committed-session-manifest-*.json")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create temp file: %w", err)
|
||||
}
|
||||
@@ -867,31 +1167,16 @@ func writeCurrentManifestSnapshot(m *manifest.Manifest, publishMetadata map[stri
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func writeCurrentRunIDPointer(runID string) (string, error) {
|
||||
tmp, err := os.CreateTemp("", "narratio-current-run-id-*.txt")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create temp file: %w", err)
|
||||
}
|
||||
path := tmp.Name()
|
||||
if _, err := tmp.WriteString(runID + "\n"); err != nil {
|
||||
_ = tmp.Close()
|
||||
return "", fmt.Errorf("write temp run id pointer: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
return "", fmt.Errorf("close temp run id pointer: %w", err)
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func publishMetadataPreview(
|
||||
bucket, runPrefix, sessionPrefix string,
|
||||
func publishMetadata(
|
||||
bucket, runPrefix string,
|
||||
runUploaded []string,
|
||||
publishedUploaded []string,
|
||||
previousUploaded []string,
|
||||
skippedOptionalOutputs []string,
|
||||
skippedUnselectedOutputs []publishSkippedUnselectedOutput,
|
||||
lockedOutputs []publishLockedOutput,
|
||||
currentManifestKey string,
|
||||
commitKey string,
|
||||
pointerKey string,
|
||||
) map[string]any {
|
||||
return map[string]any{
|
||||
"stage": "publish",
|
||||
@@ -908,9 +1193,8 @@ func publishMetadataPreview(
|
||||
"skipped_unselected_outputs": skippedUnselectedOutputMetadata(skippedUnselectedOutputs),
|
||||
"locked_output_count": len(lockedOutputs),
|
||||
"locked_outputs": lockedOutputMetadata(lockedOutputs),
|
||||
"current_manifest_key": currentManifestKey,
|
||||
"current_run_id_key": artifacts.S3CurrentRunPointerKey(sessionPrefix),
|
||||
"current_pointer_written": false,
|
||||
"remote_commit_key": commitKey,
|
||||
"current_commit_pointer_key": pointerKey,
|
||||
"audio_upload_skipped": true,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user