1146 lines
40 KiB
Go
1146 lines
40 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
|
|
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
|
|
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
type commonConfigFlags struct {
|
|
pipelinePath string
|
|
campaignPath string
|
|
campaignFilePath string
|
|
sessionPath string
|
|
sessionID string
|
|
previousSessionID string
|
|
}
|
|
|
|
type finding struct {
|
|
Severity string
|
|
Category string
|
|
Message string
|
|
}
|
|
|
|
type findingError struct {
|
|
count int
|
|
}
|
|
|
|
func (e findingError) Error() string {
|
|
return fmt.Sprintf("%d validation error(s)", e.count)
|
|
}
|
|
|
|
func addCommonConfigFlags(fs *flag.FlagSet, flags *commonConfigFlags) {
|
|
fs.StringVar(&flags.pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
|
|
fs.StringVar(&flags.campaignPath, "campaign", "", "campaign ID")
|
|
fs.StringVar(&flags.campaignFilePath, "campaign-file", "", "path to campaign.yml")
|
|
fs.StringVar(&flags.sessionPath, "session", "", "path to session.yml")
|
|
fs.StringVar(&flags.previousSessionID, "previous-session-id", "", "expected previous session identifier")
|
|
}
|
|
|
|
func (f commonConfigFlags) sessionOptions() config.SessionLoadOptions {
|
|
return config.SessionLoadOptions{
|
|
SessionID: f.sessionID,
|
|
PreviousSessionID: f.previousSessionID,
|
|
}
|
|
}
|
|
|
|
// Session dispatches session helper subcommands.
|
|
func Session(ctx context.Context, args []string, out io.Writer) error {
|
|
if len(args) == 0 {
|
|
return fmt.Errorf("session: expected subcommand: init|validate|status|plan|restore|artifacts|locks")
|
|
}
|
|
switch args[0] {
|
|
case "init":
|
|
return SessionInit(ctx, args[1:], out)
|
|
case "validate":
|
|
return SessionValidate(ctx, args[1:], out)
|
|
case "status":
|
|
return Status(ctx, args[1:], out)
|
|
case "plan":
|
|
return Plan(ctx, args[1:], out)
|
|
case "restore":
|
|
return Restore(ctx, args[1:], out)
|
|
case "artifacts":
|
|
return ArtifactsList(ctx, args[1:], out)
|
|
case "locks":
|
|
return SessionLocks(ctx, args[1:], out)
|
|
default:
|
|
return fmt.Errorf("session: unknown subcommand %q", args[0])
|
|
}
|
|
}
|
|
|
|
// SessionLocks dispatches session-oriented archive lock list and mutation
|
|
// helpers while preserving the existing lock implementations.
|
|
func SessionLocks(ctx context.Context, args []string, out io.Writer) error {
|
|
if len(args) > 0 && !isCLIFlagToken(args[0]) {
|
|
switch args[0] {
|
|
case "add":
|
|
return LocksAdd(ctx, args[1:], out)
|
|
case "remove":
|
|
return LocksRemove(ctx, args[1:], out)
|
|
}
|
|
}
|
|
return LocksList(ctx, args, out)
|
|
}
|
|
|
|
// Artifacts dispatches artifact helper subcommands.
|
|
func Artifacts(ctx context.Context, args []string, out io.Writer) error {
|
|
if len(args) == 0 {
|
|
return fmt.Errorf("artifacts: expected subcommand: list")
|
|
}
|
|
switch args[0] {
|
|
case "list":
|
|
return ArtifactsList(ctx, args[1:], out)
|
|
default:
|
|
return fmt.Errorf("artifacts: unknown subcommand %q", args[0])
|
|
}
|
|
}
|
|
|
|
// SessionValidate performs a read-only session preflight.
|
|
func SessionValidate(ctx context.Context, args []string, out io.Writer) error {
|
|
positionalSessionID, args := pullLeadingSessionID(args)
|
|
fs := flag.NewFlagSet("session validate", flag.ContinueOnError)
|
|
fs.SetOutput(io.Discard)
|
|
var flags commonConfigFlags
|
|
addCommonConfigFlags(fs, &flags)
|
|
if err := fs.Parse(args); err != nil {
|
|
return fmt.Errorf("session validate: invalid flags: %w", err)
|
|
}
|
|
if positionalSessionID == "" {
|
|
if err := applyParsedSessionIDArg("session validate", fs, &flags.sessionID); err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
if fs.NArg() != 0 {
|
|
return fmt.Errorf("session validate: unexpected positional arguments")
|
|
}
|
|
if err := applyPositionalSessionID("session validate", positionalSessionID, &flags.sessionID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if strings.TrimSpace(flags.sessionID) == "" {
|
|
return fmt.Errorf("session validate: session_id is required")
|
|
}
|
|
|
|
findings := []finding{}
|
|
cfg, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions())
|
|
if err != nil {
|
|
findings = append(findings, errorFinding("config", err.Error()))
|
|
return renderFindings(out, "", "", findings)
|
|
}
|
|
if err := config.Validate(cfg); err != nil {
|
|
findings = append(findings, errorFinding("config", err.Error()))
|
|
} else {
|
|
findings = append(findings, okFinding("config", "resolved pipeline, campaign, and session config"))
|
|
}
|
|
findings = append(findings, okFinding("session", fmt.Sprintf("session source: %s", sessionSourceSummary(cfg))))
|
|
|
|
paths := artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root).SessionPathsFor(cfg.Session.Campaign, cfg.Session.SessionID)
|
|
findings = append(findings, validateStableInputFindings(cfg)...)
|
|
findings = append(findings, validateLocalAudioFindings(cfg)...)
|
|
|
|
store, storeErr := objectStoreIfConfigured(ctx, cfg)
|
|
if storeErr != nil {
|
|
findings = append(findings, errorFinding("storage", storeErr.Error()))
|
|
}
|
|
if cfg.Session.Inputs.AudioS3 != nil {
|
|
if storeErr != nil {
|
|
findings = append(findings, errorFinding("audio", "remote audio cannot be checked because storage is unavailable"))
|
|
} else {
|
|
findings = append(findings, validateRemoteAudioFinding(ctx, cfg, store))
|
|
}
|
|
}
|
|
|
|
requirements := artifacts.CollectPreviousArtifactRequirements(configuredScriptoriumArtifacts(cfg))
|
|
if len(requirements) == 0 {
|
|
findings = append(findings, okFinding("previous", "no previous-session artifacts required"))
|
|
} else if strings.TrimSpace(cfg.Session.PreviousSessionID) == "" {
|
|
findings = append(findings, errorFinding("previous", "previous_session_id is required by configured previous-session artifacts"))
|
|
} else if storeErr != nil {
|
|
findings = append(findings, errorFinding("previous", "previous-session artifacts cannot be checked because storage is unavailable"))
|
|
} else {
|
|
findings = append(findings, validatePreviousArtifactFindings(ctx, cfg, store, requirements)...)
|
|
}
|
|
|
|
locks, lockErr := loadEffectiveLocks(ctx, cfg, store)
|
|
if lockErr != nil {
|
|
findings = append(findings, errorFinding("locks", lockErr.Error()))
|
|
} else if len(locks.All) == 0 {
|
|
findings = append(findings, okFinding("locks", "no effective archive locks"))
|
|
} else {
|
|
for _, lock := range locks.All {
|
|
findings = append(findings, warnFinding("locks", fmt.Sprintf("%s locked: %s", lock.Source, strings.TrimSpace(lock.Reason))))
|
|
}
|
|
}
|
|
if paths.ManifestPath != "" {
|
|
findings = append(findings, infoFinding("workspace", "manifest path: "+paths.ManifestPath))
|
|
}
|
|
return renderFindings(out, cfg.Session.Campaign, cfg.Session.SessionID, findings)
|
|
}
|
|
|
|
// Status reports effective local/remote session state.
|
|
func Status(ctx context.Context, args []string, out io.Writer) error {
|
|
positionalSessionID, args := pullLeadingSessionID(args)
|
|
fs := flag.NewFlagSet("status", flag.ContinueOnError)
|
|
fs.SetOutput(io.Discard)
|
|
var flags commonConfigFlags
|
|
addCommonConfigFlags(fs, &flags)
|
|
if err := fs.Parse(args); err != nil {
|
|
return fmt.Errorf("status: invalid flags: %w", err)
|
|
}
|
|
if positionalSessionID == "" {
|
|
if err := applyParsedSessionIDArg("status", fs, &flags.sessionID); err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
if fs.NArg() != 0 {
|
|
return fmt.Errorf("status: unexpected positional arguments")
|
|
}
|
|
if err := applyPositionalSessionID("status", positionalSessionID, &flags.sessionID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if strings.TrimSpace(flags.sessionID) == "" {
|
|
return fmt.Errorf("status: session_id is required")
|
|
}
|
|
cfg, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions())
|
|
if err != nil {
|
|
return fmt.Errorf("status: %w", err)
|
|
}
|
|
if err := config.Validate(cfg); err != nil {
|
|
return fmt.Errorf("status: %w", err)
|
|
}
|
|
paths := artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root).SessionPathsFor(cfg.Session.Campaign, cfg.Session.SessionID)
|
|
fmt.Fprintf(out, "Session: %s\n", cfg.Session.SessionID)
|
|
fmt.Fprintf(out, "Campaign: %s\n", cfg.Session.Campaign)
|
|
fmt.Fprintf(out, "Workspace: %s\n", paths.Root)
|
|
fmt.Fprintf(out, "Session config: %s\n", sessionSourceSummary(cfg))
|
|
|
|
if m, err := loadLocalManifest(ctx, paths.ManifestPath); err != nil {
|
|
fmt.Fprintf(out, "Local manifest: error: %v\n", err)
|
|
} else if m == nil {
|
|
fmt.Fprintln(out, "Local manifest: missing")
|
|
} else {
|
|
fmt.Fprintf(out, "Local manifest: %s\n", paths.ManifestPath)
|
|
writeStageStatuses(out, m)
|
|
}
|
|
|
|
store, storeErr := objectStoreIfConfigured(ctx, cfg)
|
|
if storeErr != nil {
|
|
fmt.Fprintf(out, "Remote publish: unavailable: %v\n", storeErr)
|
|
} else if store != nil {
|
|
current, err := discoverRemoteCurrentStateFn(ctx, cfg, store)
|
|
if err != nil {
|
|
fmt.Fprintf(out, "Remote publish: missing or unavailable: %v\n", err)
|
|
} else {
|
|
fmt.Fprintf(out, "Remote publish: current run %s\n", current.RunID)
|
|
fmt.Fprintf(out, "Remote manifest: %s\n", current.CurrentManifestKey)
|
|
}
|
|
}
|
|
|
|
locks, err := loadEffectiveLocks(ctx, cfg, store)
|
|
if catalog, catalogErr := buildHelperArtifactCatalog(cfg); catalogErr != nil {
|
|
fmt.Fprintf(out, "Remote outputs: error: %v\n", catalogErr)
|
|
} else if storeErr == nil {
|
|
catalogLocks := locks
|
|
if err != nil {
|
|
catalogLocks = &effectiveLocks{
|
|
Static: staticArchiveLocks(cfg),
|
|
All: staticArchiveLocks(cfg),
|
|
}
|
|
}
|
|
publishedRemoteState := map[string]string{}
|
|
if store != nil {
|
|
publishedRemoteState = remotePublishedOutputAvailability(ctx, cfg, store, catalog)
|
|
}
|
|
fmt.Fprintln(out, "Remote outputs:")
|
|
writeArtifactList(out, cfg, catalog, catalogLocks, publishedRemoteState)
|
|
}
|
|
if err != nil {
|
|
fmt.Fprintf(out, "Publish locks: error: %v\n", err)
|
|
} else {
|
|
writeLocks(out, cfg, locks)
|
|
}
|
|
fmt.Fprintln(out, "Next actions:")
|
|
fmt.Fprintf(out, "- narratio session validate %s\n", cfg.Session.SessionID)
|
|
fmt.Fprintf(out, "- narratio session restore %s --dry-run\n", cfg.Session.SessionID)
|
|
return nil
|
|
}
|
|
|
|
// SessionInit creates a local or remote session.yml skeleton.
|
|
func SessionInit(ctx context.Context, args []string, out io.Writer) error {
|
|
positionalSessionID, args := pullLeadingSessionID(args)
|
|
fs := flag.NewFlagSet("session init", flag.ContinueOnError)
|
|
fs.SetOutput(io.Discard)
|
|
var pipelinePath, campaignPath, campaignFilePath, sessionID, previousSessionID, date, title, output, audioS3Prefix, audioDir string
|
|
var remote, force bool
|
|
fs.StringVar(&pipelinePath, "config", "", "path to pipeline.yml (optional; defaults searched)")
|
|
fs.StringVar(&campaignPath, "campaign", "", "campaign ID")
|
|
fs.StringVar(&campaignFilePath, "campaign-file", "", "path to campaign.yml")
|
|
fs.StringVar(&previousSessionID, "previous-session-id", "", "previous session identifier")
|
|
fs.StringVar(&date, "date", "", "session date")
|
|
fs.StringVar(&title, "title", "", "session title")
|
|
fs.StringVar(&output, "output", "", "local output session.yml path")
|
|
fs.StringVar(&audioS3Prefix, "audio-s3-prefix", "", "session audio S3 prefix")
|
|
fs.StringVar(&audioDir, "audio-dir", "", "local audio directory")
|
|
fs.BoolVar(&remote, "remote", false, "write session.yml to S3 session prefix")
|
|
fs.BoolVar(&force, "force", false, "overwrite existing target")
|
|
if err := fs.Parse(args); err != nil {
|
|
return fmt.Errorf("session init: invalid flags: %w", err)
|
|
}
|
|
if positionalSessionID == "" {
|
|
if err := applyParsedSessionIDArg("session init", fs, &sessionID); err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
if fs.NArg() != 0 {
|
|
return fmt.Errorf("session init: unexpected positional arguments")
|
|
}
|
|
if err := applyPositionalSessionID("session init", positionalSessionID, &sessionID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if strings.TrimSpace(sessionID) == "" {
|
|
return fmt.Errorf("session init: session_id is required")
|
|
}
|
|
if (strings.TrimSpace(output) == "") == !remote {
|
|
return fmt.Errorf("session init: specify exactly one target: --output <path> or --remote")
|
|
}
|
|
if strings.TrimSpace(audioDir) != "" && strings.TrimSpace(audioS3Prefix) != "" {
|
|
return fmt.Errorf("session init: --audio-dir and --audio-s3-prefix are mutually exclusive")
|
|
}
|
|
|
|
base, err := loadPipelineCampaignConfig(pipelinePath, campaignPath, campaignFilePath)
|
|
if err != nil {
|
|
return fmt.Errorf("session init: %w", err)
|
|
}
|
|
|
|
input := sessionInitInput{
|
|
Campaign: config.CampaignID(base.Campaign),
|
|
CampaignPath: base.CampaignPath,
|
|
TemplateFile: base.Campaign.SessionTemplateFile,
|
|
SessionID: sessionID,
|
|
PreviousSessionID: previousSessionID,
|
|
Date: date,
|
|
Title: title,
|
|
AudioS3Prefix: audioS3Prefix,
|
|
AudioDir: audioDir,
|
|
}
|
|
data, err := buildSessionInitYAML(input)
|
|
if err != nil {
|
|
return fmt.Errorf("session init: %w", err)
|
|
}
|
|
label := strings.TrimSpace(output)
|
|
if label == "" {
|
|
label = "remote session.yml"
|
|
}
|
|
sessionCfg, err := config.LoadSessionBytesWithOptions(label, data, config.SessionLoadOptions{
|
|
SessionID: sessionID,
|
|
PreviousSessionID: previousSessionID,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("session init: %w", err)
|
|
}
|
|
cfg, err := config.Resolve(base.PipelinePath, base.Pipeline, base.CampaignPath, base.Campaign, label, sessionCfg, config.SessionSource{Source: "session_config", LocalPath: label})
|
|
if err != nil {
|
|
return fmt.Errorf("session init: %w", err)
|
|
}
|
|
if err := config.Validate(cfg); err != nil {
|
|
return fmt.Errorf("session init: %w", err)
|
|
}
|
|
|
|
if !remote {
|
|
if err := writeLocalFile(output, data, force); err != nil {
|
|
return fmt.Errorf("session init: %w", err)
|
|
}
|
|
_, err := fmt.Fprintf(out, "narratio session init: wrote %s\n", filepath.Clean(output))
|
|
return err
|
|
}
|
|
|
|
store, err := newCommandObjectStore(ctx, cfg, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("session init: %w", err)
|
|
}
|
|
sessionPrefix := artifacts.S3SessionPrefix(base.Pipeline.Storage.S3.RootPrefix, config.CampaignID(base.Campaign), sessionID)
|
|
key := artifacts.S3SessionConfigKey(sessionPrefix)
|
|
exists, err := store.Exists(ctx, key)
|
|
if err != nil {
|
|
return fmt.Errorf("session init: check remote session %q: %w", key, err)
|
|
}
|
|
if exists && !force {
|
|
return fmt.Errorf("session init: remote session %q already exists; pass --force to overwrite", key)
|
|
}
|
|
tmp, err := os.CreateTemp("", "narratio-session-init-*.yml")
|
|
if err != nil {
|
|
return fmt.Errorf("session init: create temp file: %w", err)
|
|
}
|
|
tmpPath := tmp.Name()
|
|
defer func() { _ = os.Remove(tmpPath) }()
|
|
if _, err := tmp.Write(data); err != nil {
|
|
_ = tmp.Close()
|
|
return fmt.Errorf("session init: write temp file: %w", err)
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
return fmt.Errorf("session init: close temp file: %w", err)
|
|
}
|
|
if _, err := store.Upload(ctx, tmpPath, key, storage.UploadOptions{ContentType: "application/x-yaml; charset=utf-8"}); err != nil {
|
|
return fmt.Errorf("session init: upload remote session %q: %w", key, err)
|
|
}
|
|
_, err = fmt.Fprintf(out, "narratio session init: wrote s3://%s/%s\n", s3BucketName(base.Pipeline), key)
|
|
return err
|
|
}
|
|
|
|
// ArtifactsList lists effective artifact sources.
|
|
func ArtifactsList(ctx context.Context, args []string, out io.Writer) error {
|
|
positionalSessionID, args := pullLeadingSessionID(args)
|
|
fs := flag.NewFlagSet("artifacts list", flag.ContinueOnError)
|
|
fs.SetOutput(io.Discard)
|
|
var flags commonConfigFlags
|
|
var remote bool
|
|
addCommonConfigFlags(fs, &flags)
|
|
fs.BoolVar(&remote, "remote", false, "inspect remote archive availability")
|
|
if err := fs.Parse(args); err != nil {
|
|
return fmt.Errorf("artifacts list: invalid flags: %w", err)
|
|
}
|
|
if positionalSessionID == "" {
|
|
if err := applyParsedSessionIDArg("artifacts list", fs, &flags.sessionID); err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
if fs.NArg() != 0 {
|
|
return fmt.Errorf("artifacts list: unexpected positional arguments")
|
|
}
|
|
if err := applyPositionalSessionID("artifacts list", positionalSessionID, &flags.sessionID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if strings.TrimSpace(flags.sessionID) == "" {
|
|
return fmt.Errorf("artifacts list: session_id is required")
|
|
}
|
|
cfg, store, locks, _, err := loadHelperContext(ctx, flags, remote)
|
|
if err != nil {
|
|
return fmt.Errorf("artifacts list: %w", err)
|
|
}
|
|
catalog, err := buildHelperArtifactCatalog(cfg)
|
|
if err != nil {
|
|
return fmt.Errorf("artifacts list: %w", err)
|
|
}
|
|
publishedRemoteState := map[string]string{}
|
|
if remote && store != nil {
|
|
publishedRemoteState = remotePublishedOutputAvailability(ctx, cfg, store, catalog)
|
|
}
|
|
writeArtifactList(out, cfg, catalog, locks, publishedRemoteState)
|
|
return nil
|
|
}
|
|
|
|
// Locks dispatches archive lock list and mutation helpers.
|
|
func Locks(ctx context.Context, args []string, out io.Writer) error {
|
|
if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
|
|
switch args[0] {
|
|
case "add":
|
|
return LocksAdd(ctx, args[1:], out)
|
|
case "remove":
|
|
return LocksRemove(ctx, args[1:], out)
|
|
default:
|
|
return fmt.Errorf("locks: unknown subcommand %q", args[0])
|
|
}
|
|
}
|
|
return LocksList(ctx, args, out)
|
|
}
|
|
|
|
// LocksList lists effective archive locks.
|
|
func LocksList(ctx context.Context, args []string, out io.Writer) error {
|
|
positionalSessionID, args := pullLeadingSessionID(args)
|
|
fs := flag.NewFlagSet("locks", flag.ContinueOnError)
|
|
fs.SetOutput(io.Discard)
|
|
var flags commonConfigFlags
|
|
addCommonConfigFlags(fs, &flags)
|
|
if err := fs.Parse(args); err != nil {
|
|
return fmt.Errorf("locks: invalid flags: %w", err)
|
|
}
|
|
if positionalSessionID == "" {
|
|
if err := applyParsedSessionIDArg("locks", fs, &flags.sessionID); err != nil {
|
|
return err
|
|
}
|
|
} else {
|
|
if fs.NArg() != 0 {
|
|
return fmt.Errorf("locks: unexpected positional arguments")
|
|
}
|
|
if err := applyPositionalSessionID("locks", positionalSessionID, &flags.sessionID); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if strings.TrimSpace(flags.sessionID) == "" {
|
|
return fmt.Errorf("locks: session_id is required")
|
|
}
|
|
cfg, _, locks, _, err := loadHelperContext(ctx, flags, true)
|
|
if err != nil {
|
|
return fmt.Errorf("locks: %w", err)
|
|
}
|
|
writeLocks(out, cfg, locks)
|
|
return nil
|
|
}
|
|
|
|
// LocksAdd adds or updates one remote lock.
|
|
func LocksAdd(ctx context.Context, args []string, out io.Writer) error {
|
|
var positionalSessionID string
|
|
var source string
|
|
if len(args) >= 2 && !isCLIFlagToken(args[0]) && !isCLIFlagToken(args[1]) {
|
|
positionalSessionID = strings.TrimSpace(args[0])
|
|
source = strings.TrimSpace(args[1])
|
|
args = append([]string(nil), args[2:]...)
|
|
}
|
|
fs := flag.NewFlagSet("locks add", flag.ContinueOnError)
|
|
fs.SetOutput(io.Discard)
|
|
var flags commonConfigFlags
|
|
var reason string
|
|
var force bool
|
|
addCommonConfigFlags(fs, &flags)
|
|
fs.StringVar(&reason, "reason", "", "lock reason")
|
|
fs.BoolVar(&force, "force", false, "update existing remote lock")
|
|
if err := fs.Parse(args); err != nil {
|
|
return fmt.Errorf("locks add: invalid flags: %w", err)
|
|
}
|
|
if source == "" {
|
|
if fs.NArg() != 2 {
|
|
return fmt.Errorf("locks add: expected session_id and source id")
|
|
}
|
|
positionalSessionID = strings.TrimSpace(fs.Arg(0))
|
|
source = strings.TrimSpace(fs.Arg(1))
|
|
} else if fs.NArg() != 0 {
|
|
return fmt.Errorf("locks add: unexpected positional arguments")
|
|
}
|
|
if err := applyPositionalSessionID("locks add", positionalSessionID, &flags.sessionID); err != nil {
|
|
return err
|
|
}
|
|
if strings.TrimSpace(flags.sessionID) == "" {
|
|
return fmt.Errorf("locks add: session_id is required")
|
|
}
|
|
cfg, store, locks, _, err := loadHelperContext(ctx, flags, true)
|
|
if err != nil {
|
|
return fmt.Errorf("locks add: %w", err)
|
|
}
|
|
if _, err := config.ValidatePublishLockRules([]config.PublishLockRule{{Source: source}}, cfg.Pipeline.Scriptorium, "locks add"); err != nil {
|
|
return fmt.Errorf("locks add: %w", err)
|
|
}
|
|
if _, ok := lockSourceSet(locks.Static)[source]; ok {
|
|
return fmt.Errorf("locks add: source %q is locked by pipeline config and cannot be modified remotely", source)
|
|
}
|
|
remoteSet := lockSourceSet(locks.Remote)
|
|
if _, exists := remoteSet[source]; exists && !force {
|
|
return fmt.Errorf("locks add: remote lock for %q already exists; pass --force to update", source)
|
|
}
|
|
remoteSet[source] = config.PublishLockRule{Source: source, Reason: strings.TrimSpace(reason)}
|
|
remoteLocks := lockMapValues(remoteSet)
|
|
if _, err := config.ValidatePublishLockRules(remoteLocks, cfg.Pipeline.Scriptorium, "locks"); err != nil {
|
|
return fmt.Errorf("locks add: %w", err)
|
|
}
|
|
if err := uploadRemoteLockStore(ctx, store, locks.Key, &config.PublishLockStore{Locks: remoteLocks}); err != nil {
|
|
return fmt.Errorf("locks add: %w", err)
|
|
}
|
|
_, err = fmt.Fprintf(out, "narratio session locks add: locked %s\n", source)
|
|
return err
|
|
}
|
|
|
|
// LocksRemove removes one remote lock.
|
|
func LocksRemove(ctx context.Context, args []string, out io.Writer) error {
|
|
var positionalSessionID string
|
|
var source string
|
|
if len(args) >= 2 && !isCLIFlagToken(args[0]) && !isCLIFlagToken(args[1]) {
|
|
positionalSessionID = strings.TrimSpace(args[0])
|
|
source = strings.TrimSpace(args[1])
|
|
args = append([]string(nil), args[2:]...)
|
|
}
|
|
fs := flag.NewFlagSet("locks remove", flag.ContinueOnError)
|
|
fs.SetOutput(io.Discard)
|
|
var flags commonConfigFlags
|
|
addCommonConfigFlags(fs, &flags)
|
|
if err := fs.Parse(args); err != nil {
|
|
return fmt.Errorf("locks remove: invalid flags: %w", err)
|
|
}
|
|
if source == "" {
|
|
if fs.NArg() != 2 {
|
|
return fmt.Errorf("locks remove: expected session_id and source id")
|
|
}
|
|
positionalSessionID = strings.TrimSpace(fs.Arg(0))
|
|
source = strings.TrimSpace(fs.Arg(1))
|
|
} else if fs.NArg() != 0 {
|
|
return fmt.Errorf("locks remove: unexpected positional arguments")
|
|
}
|
|
if err := applyPositionalSessionID("locks remove", positionalSessionID, &flags.sessionID); err != nil {
|
|
return err
|
|
}
|
|
if strings.TrimSpace(flags.sessionID) == "" {
|
|
return fmt.Errorf("locks remove: session_id is required")
|
|
}
|
|
cfg, store, locks, _, err := loadHelperContext(ctx, flags, true)
|
|
if err != nil {
|
|
return fmt.Errorf("locks remove: %w", err)
|
|
}
|
|
if _, err := config.ValidatePublishLockRules([]config.PublishLockRule{{Source: source}}, cfg.Pipeline.Scriptorium, "locks remove"); err != nil {
|
|
return fmt.Errorf("locks remove: %w", err)
|
|
}
|
|
remoteSet := lockSourceSet(locks.Remote)
|
|
if _, ok := remoteSet[source]; !ok {
|
|
if _, static := lockSourceSet(locks.Static)[source]; static {
|
|
return fmt.Errorf("locks remove: source %q is locked by pipeline config and cannot be unlocked remotely", source)
|
|
}
|
|
return fmt.Errorf("locks remove: remote lock for %q does not exist", source)
|
|
}
|
|
delete(remoteSet, source)
|
|
remoteLocks := lockMapValues(remoteSet)
|
|
if err := uploadRemoteLockStore(ctx, store, locks.Key, &config.PublishLockStore{Locks: remoteLocks}); err != nil {
|
|
return fmt.Errorf("locks remove: %w", err)
|
|
}
|
|
_, err = fmt.Fprintf(out, "narratio session locks remove: unlocked %s\n", source)
|
|
return err
|
|
}
|
|
|
|
func loadHelperContext(ctx context.Context, flags commonConfigFlags, needStore bool) (*config.Config, storage.ObjectStore, *effectiveLocks, *manifest.Manifest, error) {
|
|
cfg, err := loadCommandConfig(ctx, flags.pipelinePath, flags.campaignPath, flags.campaignFilePath, flags.sessionPath, flags.sessionOptions())
|
|
if err != nil {
|
|
return nil, nil, nil, nil, err
|
|
}
|
|
if err := config.Validate(cfg); err != nil {
|
|
return nil, nil, nil, nil, err
|
|
}
|
|
var store storage.ObjectStore
|
|
if needStore {
|
|
store, err = newCommandObjectStore(ctx, cfg, nil)
|
|
if err != nil {
|
|
return nil, nil, nil, nil, err
|
|
}
|
|
} else {
|
|
store, _ = objectStoreIfConfigured(ctx, cfg)
|
|
}
|
|
locks, err := loadEffectiveLocks(ctx, cfg, store)
|
|
if err != nil {
|
|
return nil, nil, nil, nil, err
|
|
}
|
|
paths := artifacts.NewLocalStore(cfg.Pipeline.Workspace.Root).SessionPathsFor(cfg.Session.Campaign, cfg.Session.SessionID)
|
|
m, err := loadLocalManifest(ctx, paths.ManifestPath)
|
|
if err != nil {
|
|
return nil, nil, nil, nil, err
|
|
}
|
|
return cfg, store, locks, m, nil
|
|
}
|
|
|
|
func objectStoreIfConfigured(ctx context.Context, cfg *config.Config) (storage.ObjectStore, error) {
|
|
if cfg == nil || cfg.Pipeline == nil || cfg.Pipeline.Storage.S3 == nil || strings.TrimSpace(cfg.Pipeline.Storage.S3.Bucket) == "" {
|
|
return nil, nil
|
|
}
|
|
store, err := newCommandObjectStore(ctx, cfg, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return store, nil
|
|
}
|
|
|
|
func buildSessionYAML(campaign, sessionID, previousSessionID, date, title, audioS3Prefix, audioDir string) ([]byte, error) {
|
|
if strings.TrimSpace(date) == "" && regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`).MatchString(strings.TrimSpace(sessionID)) {
|
|
date = strings.TrimSpace(sessionID)
|
|
}
|
|
type audioS3 struct {
|
|
Prefix string `yaml:"prefix"`
|
|
}
|
|
type inputs struct {
|
|
AudioDir string `yaml:"audio_dir,omitempty"`
|
|
AudioS3 *audioS3 `yaml:"audio_s3,omitempty"`
|
|
}
|
|
type sessionYAML struct {
|
|
Campaign string `yaml:"campaign"`
|
|
SessionID string `yaml:"session_id"`
|
|
PreviousSessionID string `yaml:"previous_session_id,omitempty"`
|
|
Date string `yaml:"date,omitempty"`
|
|
Title string `yaml:"title,omitempty"`
|
|
Inputs inputs `yaml:"inputs"`
|
|
}
|
|
in := inputs{AudioDir: strings.TrimSpace(audioDir)}
|
|
if in.AudioDir == "" {
|
|
prefix := strings.TrimSpace(audioS3Prefix)
|
|
if prefix == "" {
|
|
prefix = "audio/"
|
|
}
|
|
in.AudioS3 = &audioS3{Prefix: prefix}
|
|
}
|
|
data, err := yaml.Marshal(sessionYAML{
|
|
Campaign: strings.TrimSpace(campaign),
|
|
SessionID: strings.TrimSpace(sessionID),
|
|
PreviousSessionID: strings.TrimSpace(previousSessionID),
|
|
Date: strings.TrimSpace(date),
|
|
Title: strings.TrimSpace(title),
|
|
Inputs: in,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return data, nil
|
|
}
|
|
|
|
type sessionInitInput struct {
|
|
Campaign string
|
|
CampaignPath string
|
|
TemplateFile string
|
|
SessionID string
|
|
PreviousSessionID string
|
|
Date string
|
|
Title string
|
|
AudioS3Prefix string
|
|
AudioDir string
|
|
}
|
|
|
|
func buildSessionInitYAML(in sessionInitInput) ([]byte, error) {
|
|
if strings.TrimSpace(in.TemplateFile) == "" {
|
|
return buildSessionYAML(in.Campaign, in.SessionID, in.PreviousSessionID, in.Date, in.Title, in.AudioS3Prefix, in.AudioDir)
|
|
}
|
|
templatePath := resolveSessionInitTemplatePath(in.CampaignPath, in.TemplateFile)
|
|
templateBytes, err := os.ReadFile(templatePath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read session template %q: %w", templatePath, err)
|
|
}
|
|
rendered, err := renderSessionInitTemplate(string(templateBytes), in)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("render session template %q: %w", templatePath, err)
|
|
}
|
|
return []byte(rendered), nil
|
|
}
|
|
|
|
func resolveSessionInitTemplatePath(campaignPath, templateFile string) string {
|
|
templateFile = strings.TrimSpace(templateFile)
|
|
if filepath.IsAbs(templateFile) {
|
|
return filepath.Clean(templateFile)
|
|
}
|
|
return filepath.Clean(filepath.Join(filepath.Dir(campaignPath), templateFile))
|
|
}
|
|
|
|
var sessionInitTemplatePattern = regexp.MustCompile(`\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}`)
|
|
|
|
func renderSessionInitTemplate(content string, in sessionInitInput) (string, error) {
|
|
values := map[string]string{
|
|
"session_id": strings.TrimSpace(in.SessionID),
|
|
"previous_session_id": strings.TrimSpace(in.PreviousSessionID),
|
|
"date": strings.TrimSpace(in.Date),
|
|
"title": strings.TrimSpace(in.Title),
|
|
"audio_s3_prefix": strings.TrimSpace(in.AudioS3Prefix),
|
|
"audio_dir": strings.TrimSpace(in.AudioDir),
|
|
}
|
|
used := map[string]struct{}{}
|
|
unknown := map[string]struct{}{}
|
|
missing := map[string]struct{}{}
|
|
rendered := sessionInitTemplatePattern.ReplaceAllStringFunc(content, func(match string) string {
|
|
parts := sessionInitTemplatePattern.FindStringSubmatch(match)
|
|
if len(parts) < 2 {
|
|
return match
|
|
}
|
|
name := parts[1]
|
|
value, ok := values[name]
|
|
if !ok {
|
|
unknown[name] = struct{}{}
|
|
return match
|
|
}
|
|
used[name] = struct{}{}
|
|
if value == "" {
|
|
missing[name] = struct{}{}
|
|
return match
|
|
}
|
|
return value
|
|
})
|
|
if len(unknown) > 0 {
|
|
return "", fmt.Errorf("unsupported template variable(s): %s", sortedStringSet(unknown))
|
|
}
|
|
if len(missing) > 0 {
|
|
return "", fmt.Errorf("missing required template variable value(s): %s", sortedStringSet(missing))
|
|
}
|
|
unused := map[string]struct{}{}
|
|
for _, name := range []string{"previous_session_id", "date", "title", "audio_s3_prefix", "audio_dir"} {
|
|
if values[name] == "" {
|
|
continue
|
|
}
|
|
if _, ok := used[name]; !ok {
|
|
unused[name] = struct{}{}
|
|
}
|
|
}
|
|
if len(unused) > 0 {
|
|
return "", fmt.Errorf("unused template variable value(s): %s", sortedStringSet(unused))
|
|
}
|
|
return rendered, nil
|
|
}
|
|
|
|
func sortedStringSet(set map[string]struct{}) string {
|
|
items := make([]string, 0, len(set))
|
|
for item := range set {
|
|
items = append(items, item)
|
|
}
|
|
sort.Strings(items)
|
|
return strings.Join(items, ", ")
|
|
}
|
|
|
|
func renderFindings(out io.Writer, campaign, sessionID string, findings []finding) error {
|
|
if campaign != "" || sessionID != "" {
|
|
fmt.Fprintf(out, "Campaign: %s\n", campaign)
|
|
fmt.Fprintf(out, "Session: %s\n\n", sessionID)
|
|
}
|
|
errorsCount := 0
|
|
for _, f := range findings {
|
|
if f.Severity == "ERROR" {
|
|
errorsCount++
|
|
}
|
|
fmt.Fprintf(out, "%-5s %-10s %s\n", f.Severity, f.Category, f.Message)
|
|
}
|
|
if errorsCount > 0 {
|
|
return findingError{count: errorsCount}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func okFinding(category, msg string) finding { return finding{"OK", category, msg} }
|
|
func infoFinding(category, msg string) finding { return finding{"INFO", category, msg} }
|
|
func warnFinding(category, msg string) finding { return finding{"WARN", category, msg} }
|
|
func errorFinding(category, msg string) finding { return finding{"ERROR", category, msg} }
|
|
|
|
func sessionSourceSummary(cfg *config.Config) string {
|
|
source := cfg.SessionSource.Source
|
|
if source == "" {
|
|
source = "session_config"
|
|
}
|
|
if cfg.SessionSource.S3Key != "" {
|
|
return source + " " + cfg.SessionSource.S3Key
|
|
}
|
|
return source + " " + cfg.SessionPath
|
|
}
|
|
|
|
func validateStableInputFindings(cfg *config.Config) []finding {
|
|
items := []struct {
|
|
name string
|
|
in config.ResolvedInputFile
|
|
}{
|
|
{"speakers", cfg.StableInputs.SpeakersFile},
|
|
{"autocorrect", cfg.StableInputs.AutocorrectFile},
|
|
{"glossary", cfg.StableInputs.GlossaryFile},
|
|
}
|
|
out := make([]finding, 0, len(items))
|
|
for _, item := range items {
|
|
path, err := resolveHelperConfigRelativePath(item.in)
|
|
if err != nil {
|
|
out = append(out, errorFinding("inputs", item.name+": "+err.Error()))
|
|
continue
|
|
}
|
|
if _, err := os.Stat(path); err != nil {
|
|
out = append(out, errorFinding("inputs", fmt.Sprintf("%s missing: %v", item.name, err)))
|
|
} else {
|
|
out = append(out, okFinding("inputs", item.name+": "+path))
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func resolveHelperConfigRelativePath(input config.ResolvedInputFile) (string, error) {
|
|
if strings.TrimSpace(input.ConfigPath) == "" {
|
|
return "", fmt.Errorf("source config path is required")
|
|
}
|
|
path := strings.TrimSpace(input.Path)
|
|
if path == "" {
|
|
return "", fmt.Errorf("path is required")
|
|
}
|
|
if filepath.IsAbs(path) {
|
|
return filepath.Clean(path), nil
|
|
}
|
|
return filepath.Clean(filepath.Join(filepath.Dir(input.ConfigPath), path)), nil
|
|
}
|
|
|
|
func validateLocalAudioFindings(cfg *config.Config) []finding {
|
|
if cfg.Session.Inputs.AudioS3 != nil {
|
|
return nil
|
|
}
|
|
audioDir := strings.TrimSpace(cfg.Session.Inputs.AudioDir)
|
|
if audioDir == "" && len(cfg.Session.Inputs.AudioFiles) == 0 {
|
|
return []finding{errorFinding("audio", "audio_dir, audio_files, or audio_s3 is required")}
|
|
}
|
|
base := filepath.Dir(cfg.SessionPath)
|
|
paths := []string{}
|
|
if audioDir != "" {
|
|
dir := audioDir
|
|
if !filepath.IsAbs(dir) {
|
|
dir = filepath.Join(base, dir)
|
|
}
|
|
matches, err := filepath.Glob(filepath.Join(dir, "*.flac"))
|
|
if err != nil || len(matches) == 0 {
|
|
return []finding{errorFinding("audio", "no .flac files found in "+dir)}
|
|
}
|
|
paths = append(paths, matches...)
|
|
}
|
|
for _, file := range cfg.Session.Inputs.AudioFiles {
|
|
p := file
|
|
if !filepath.IsAbs(p) {
|
|
p = filepath.Join(base, p)
|
|
}
|
|
paths = append(paths, p)
|
|
}
|
|
for _, p := range paths {
|
|
if _, err := os.Stat(p); err != nil {
|
|
return []finding{errorFinding("audio", fmt.Sprintf("audio file missing: %v", err))}
|
|
}
|
|
}
|
|
return []finding{okFinding("audio", fmt.Sprintf("%d local audio file(s)", len(paths)))}
|
|
}
|
|
|
|
func validateRemoteAudioFinding(ctx context.Context, cfg *config.Config, store storage.ObjectStore) finding {
|
|
sessionPrefix := artifacts.S3SessionPrefix(cfg.Pipeline.Storage.S3.RootPrefix, cfg.Session.Campaign, cfg.Session.SessionID)
|
|
audioPrefix := artifacts.S3AudioPrefix(sessionPrefix, cfg.Session.Inputs.AudioS3.Prefix)
|
|
objects, err := store.List(ctx, audioPrefix)
|
|
if err != nil {
|
|
return errorFinding("audio", err.Error())
|
|
}
|
|
count := 0
|
|
for _, obj := range objects {
|
|
if strings.HasSuffix(strings.ToLower(obj.Key), ".flac") {
|
|
count++
|
|
}
|
|
}
|
|
if count == 0 {
|
|
return errorFinding("audio", "no remote .flac objects found under "+audioPrefix)
|
|
}
|
|
return okFinding("audio", fmt.Sprintf("%d remote .flac object(s)", count))
|
|
}
|
|
|
|
func validatePreviousArtifactFindings(ctx context.Context, cfg *config.Config, store storage.ObjectStore, requirements []artifacts.PreviousArtifactRequirement) []finding {
|
|
out := []finding{}
|
|
prefix := artifacts.S3SessionPrefix(cfg.Pipeline.Storage.S3.RootPrefix, cfg.Session.Campaign, cfg.Session.PreviousSessionID)
|
|
manifestKey, runIDKey := artifacts.ResolveArchiveCurrentStateKeys(prefix)
|
|
for _, key := range []string{runIDKey, manifestKey} {
|
|
exists, err := store.Exists(ctx, key)
|
|
if err != nil {
|
|
out = append(out, errorFinding("previous", fmt.Sprintf("check %s: %v", key, err)))
|
|
return out
|
|
}
|
|
if !exists {
|
|
out = append(out, errorFinding("previous", "missing "+key))
|
|
return out
|
|
}
|
|
}
|
|
for _, req := range requirements {
|
|
out = append(out, okFinding("previous", fmt.Sprintf("%s required=%t", req.Name, req.Required)))
|
|
}
|
|
return out
|
|
}
|
|
|
|
func loadLocalManifest(ctx context.Context, path string) (*manifest.Manifest, error) {
|
|
if _, err := os.Stat(path); err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
store := &manifest.LocalStore{}
|
|
return store.Load(ctx, path)
|
|
}
|
|
|
|
func writeStageStatuses(out io.Writer, m *manifest.Manifest) {
|
|
if m == nil || len(m.Stages) == 0 {
|
|
fmt.Fprintln(out, "stages: no stages recorded")
|
|
return
|
|
}
|
|
fmt.Fprintln(out, "stages:")
|
|
names := make([]string, 0, len(m.Stages))
|
|
for name := range m.Stages {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
for _, name := range names {
|
|
fmt.Fprintf(out, "- %s: %s\n", name, m.Stages[name].Status)
|
|
}
|
|
}
|
|
|
|
func buildHelperArtifactCatalog(cfg *config.Config) (*artifacts.ArtifactCatalog, error) {
|
|
catalog := artifacts.NewArtifactCatalog()
|
|
if err := catalog.RegisterBuiltIns(); err != nil {
|
|
return nil, err
|
|
}
|
|
configured := map[string]artifacts.ConfiguredArtifactDefinition{}
|
|
if cfg.Pipeline.Scriptorium != nil {
|
|
for key, item := range cfg.Pipeline.Scriptorium.Artifacts {
|
|
configured[key] = artifacts.ConfiguredArtifactDefinition{Enabled: item.Enabled, OutputPath: item.OutputPath}
|
|
}
|
|
}
|
|
if err := catalog.RegisterConfiguredArtifacts(configured, nil); err != nil {
|
|
return nil, err
|
|
}
|
|
return catalog, nil
|
|
}
|
|
|
|
func writeArtifactList(out io.Writer, cfg *config.Config, catalog *artifacts.ArtifactCatalog, locks *effectiveLocks, publishedRemoteState map[string]string) {
|
|
lockSet := lockSourceSet(locks.All)
|
|
fmt.Fprintln(out, "Built-in:")
|
|
for _, id := range []string{
|
|
artifacts.ArtifactTranscriptBase,
|
|
artifacts.ArtifactTranscriptPolished,
|
|
artifacts.ArtifactTranscriptFinal,
|
|
artifacts.ArtifactTranscriptFinalTrimmed,
|
|
artifacts.ArtifactBoundsSession,
|
|
} {
|
|
writeArtifactLine(out, id, lockSet)
|
|
}
|
|
fmt.Fprintln(out, "Configured:")
|
|
for _, entry := range catalog.ListConfigured() {
|
|
writeArtifactLine(out, entry.SourceID, lockSet)
|
|
}
|
|
fmt.Fprintln(out, "Previous-session:")
|
|
for _, req := range artifacts.CollectPreviousArtifactRequirements(configuredScriptoriumArtifacts(cfg)) {
|
|
fmt.Fprintf(out, "- narratio.previous_session.artifact.%s required=%t\n", req.Name, req.Required)
|
|
}
|
|
fmt.Fprintln(out, "Published:")
|
|
for _, rule := range cfg.Pipeline.Publish.Outputs {
|
|
writePublishedOutputLine(out, rule, catalog, lockSet, publishedRemoteState)
|
|
}
|
|
}
|
|
|
|
func writeArtifactLine(out io.Writer, source string, lockSet map[string]config.PublishLockRule) {
|
|
parts := []string{source}
|
|
if _, ok := lockSet[source]; ok {
|
|
parts = append(parts, "locked")
|
|
}
|
|
fmt.Fprintf(out, "- %s\n", strings.Join(parts, " "))
|
|
}
|
|
|
|
func writePublishedOutputLine(out io.Writer, rule config.PublishOutputRule, catalog *artifacts.ArtifactCatalog, lockSet map[string]config.PublishLockRule, remoteState map[string]string) {
|
|
source := strings.TrimSpace(rule.Source)
|
|
parts := []string{source}
|
|
if _, ok := lockSet[source]; ok {
|
|
parts = append(parts, "locked")
|
|
}
|
|
dest, showDest, err := helperPublishedOutputDest(rule, catalog)
|
|
if err != nil {
|
|
parts = append(parts, "remote=error")
|
|
fmt.Fprintf(out, "- %s\n", strings.Join(parts, " "))
|
|
return
|
|
}
|
|
if showDest {
|
|
parts = append(parts, "dest="+dest)
|
|
}
|
|
if state := remoteState[publishedOutputRemoteStateKey(source, dest)]; state != "" {
|
|
parts = append(parts, state)
|
|
}
|
|
fmt.Fprintf(out, "- %s\n", strings.Join(parts, " "))
|
|
}
|
|
|
|
func remotePublishedOutputAvailability(ctx context.Context, cfg *config.Config, store storage.ObjectStore, catalog *artifacts.ArtifactCatalog) map[string]string {
|
|
out := map[string]string{}
|
|
sessionPrefix := artifacts.S3SessionPrefix(cfg.Pipeline.Storage.S3.RootPrefix, cfg.Session.Campaign, cfg.Session.SessionID)
|
|
for _, rule := range cfg.Pipeline.Publish.Outputs {
|
|
source := strings.TrimSpace(rule.Source)
|
|
dest, _, err := helperPublishedOutputDest(rule, catalog)
|
|
if err != nil {
|
|
out[publishedOutputRemoteStateKey(source, "")] = "remote=error"
|
|
continue
|
|
}
|
|
key := artifacts.S3PublishedOutputKey(sessionPrefix, dest)
|
|
if exists, err := store.Exists(ctx, key); err == nil && exists {
|
|
out[publishedOutputRemoteStateKey(source, dest)] = "remote=published"
|
|
} else if err != nil {
|
|
out[publishedOutputRemoteStateKey(source, dest)] = "remote=error"
|
|
} else {
|
|
out[publishedOutputRemoteStateKey(source, dest)] = "remote=missing"
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func helperPublishedOutputDest(rule config.PublishOutputRule, catalog *artifacts.ArtifactCatalog) (string, bool, error) {
|
|
source := strings.TrimSpace(rule.Source)
|
|
dest := strings.TrimSpace(rule.Dest)
|
|
if dest == "" {
|
|
entry, ok := catalog.Lookup(source)
|
|
if !ok {
|
|
return "", false, fmt.Errorf("destination omitted and source is unknown")
|
|
}
|
|
dest = strings.TrimSpace(entry.CanonicalRelPath)
|
|
if dest == "" {
|
|
return "", false, fmt.Errorf("destination omitted and no canonical destination is available")
|
|
}
|
|
}
|
|
normalized, err := normalizeHelperArchiveRelativePath(dest)
|
|
if err != nil {
|
|
return "", false, err
|
|
}
|
|
entry, ok := catalog.Lookup(source)
|
|
showDest := !ok || strings.TrimSpace(entry.CanonicalRelPath) != normalized
|
|
return normalized, showDest, nil
|
|
}
|
|
|
|
func normalizeHelperArchiveRelativePath(rel string) (string, error) {
|
|
trimmed := strings.TrimSpace(rel)
|
|
if trimmed == "" {
|
|
return "", fmt.Errorf("relative path is required")
|
|
}
|
|
cleaned := filepath.ToSlash(filepath.Clean(filepath.FromSlash(trimmed)))
|
|
if cleaned == "." || cleaned == "" {
|
|
return "", fmt.Errorf("relative path is required")
|
|
}
|
|
if filepath.IsAbs(trimmed) || strings.HasPrefix(cleaned, "/") || cleaned == ".." || strings.HasPrefix(cleaned, "../") {
|
|
return "", fmt.Errorf("path must be a clean relative path")
|
|
}
|
|
return cleaned, nil
|
|
}
|
|
|
|
func publishedOutputRemoteStateKey(source, dest string) string {
|
|
return strings.TrimSpace(source) + "\x00" + strings.TrimSpace(dest)
|
|
}
|
|
|
|
func writeLocks(out io.Writer, cfg *config.Config, locks *effectiveLocks) {
|
|
if locks == nil || len(locks.All) == 0 {
|
|
fmt.Fprintln(out, "Publish locks: none")
|
|
return
|
|
}
|
|
fmt.Fprintln(out, "Publish locks:")
|
|
published := map[string]config.PublishOutputRule{}
|
|
if cfg != nil && cfg.Pipeline != nil && cfg.Pipeline.Publish != nil {
|
|
for _, rule := range cfg.Pipeline.Publish.Outputs {
|
|
published[strings.TrimSpace(rule.Source)] = rule
|
|
}
|
|
}
|
|
staticSet := lockSourceSet(locks.Static)
|
|
for _, lock := range locks.All {
|
|
origin := "remote"
|
|
if _, ok := staticSet[lock.Source]; ok {
|
|
origin = "pipeline"
|
|
}
|
|
promo := "not-published"
|
|
if _, ok := published[lock.Source]; ok {
|
|
promo = "published"
|
|
}
|
|
reason := strings.TrimSpace(lock.Reason)
|
|
if reason == "" {
|
|
reason = "(no reason)"
|
|
}
|
|
fmt.Fprintf(out, "- %s origin=%s %s reason=%s\n", lock.Source, origin, promo, reason)
|
|
}
|
|
}
|
|
|
|
func lockMapValues(in map[string]config.PublishLockRule) []config.PublishLockRule {
|
|
keys := make([]string, 0, len(in))
|
|
for key := range in {
|
|
keys = append(keys, key)
|
|
}
|
|
sort.Strings(keys)
|
|
out := make([]config.PublishLockRule, 0, len(keys))
|
|
for _, key := range keys {
|
|
item := in[key]
|
|
item.Source = key
|
|
item.Reason = strings.TrimSpace(item.Reason)
|
|
out = append(out, item)
|
|
}
|
|
return out
|
|
}
|