Centralize artifact source and publish destination policy
This commit is contained in:
@@ -12,10 +12,10 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
|
"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/artifacts"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
|
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -999,7 +999,7 @@ func writeArtifactList(out io.Writer, cfg *config.Config, catalog *artifacts.Art
|
|||||||
}
|
}
|
||||||
fmt.Fprintln(out, "Previous-session:")
|
fmt.Fprintln(out, "Previous-session:")
|
||||||
for _, req := range artifacts.CollectPreviousArtifactRequirements(configuredScriptoriumArtifacts(cfg)) {
|
for _, req := range artifacts.CollectPreviousArtifactRequirements(configuredScriptoriumArtifacts(cfg)) {
|
||||||
fmt.Fprintf(out, "- narratio.previous_session.artifact.%s required=%t\n", req.Name, req.Required)
|
fmt.Fprintf(out, "- %s required=%t\n", artifactpolicy.PreviousSessionSourceID(req.Name), req.Required)
|
||||||
}
|
}
|
||||||
fmt.Fprintln(out, "Published:")
|
fmt.Fprintln(out, "Published:")
|
||||||
for _, rule := range cfg.Pipeline.Publish.Outputs {
|
for _, rule := range cfg.Pipeline.Publish.Outputs {
|
||||||
@@ -1060,18 +1060,7 @@ func remotePublishedOutputAvailability(ctx context.Context, cfg *config.Config,
|
|||||||
|
|
||||||
func helperPublishedOutputDest(rule config.PublishOutputRule, catalog *artifacts.ArtifactCatalog) (string, bool, error) {
|
func helperPublishedOutputDest(rule config.PublishOutputRule, catalog *artifacts.ArtifactCatalog) (string, bool, error) {
|
||||||
source := strings.TrimSpace(rule.Source)
|
source := strings.TrimSpace(rule.Source)
|
||||||
dest := strings.TrimSpace(rule.Dest)
|
normalized, err := artifactpolicy.ResolvePublishedDestination(source, rule.Dest, helperConfiguredOutputPathMap(catalog))
|
||||||
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 := pathsafe.NormalizeRelativeDestination(dest)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", false, err
|
return "", false, err
|
||||||
}
|
}
|
||||||
@@ -1080,6 +1069,20 @@ func helperPublishedOutputDest(rule config.PublishOutputRule, catalog *artifacts
|
|||||||
return normalized, showDest, nil
|
return normalized, showDest, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func helperConfiguredOutputPathMap(catalog *artifacts.ArtifactCatalog) map[string]string {
|
||||||
|
out := map[string]string{}
|
||||||
|
if catalog == nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
for _, entry := range catalog.ListConfigured() {
|
||||||
|
if strings.TrimSpace(entry.ConfiguredKey) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[entry.ConfiguredKey] = strings.TrimSpace(entry.CanonicalRelPath)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func publishedOutputRemoteStateKey(source, dest string) string {
|
func publishedOutputRemoteStateKey(source, dest string) string {
|
||||||
return strings.TrimSpace(source) + "\x00" + strings.TrimSpace(dest)
|
return strings.TrimSpace(source) + "\x00" + strings.TrimSpace(dest)
|
||||||
}
|
}
|
||||||
|
|||||||
145
internal/artifactpolicy/policy.go
Normal file
145
internal/artifactpolicy/policy.go
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
package artifactpolicy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
SourceBoundsSession = "narratio.bounds.session"
|
||||||
|
|
||||||
|
configuredSourcePrefix = "narratio.artifact."
|
||||||
|
previousConfiguredSrcPrefix = "narratio.previous_session.artifact."
|
||||||
|
)
|
||||||
|
|
||||||
|
var configuredSourceRE = regexp.MustCompile(`^narratio\.artifact\.([a-z][a-z0-9_]*)$`)
|
||||||
|
var previousSourceRE = regexp.MustCompile(`^narratio\.previous_session\.artifact\.([a-z][a-z0-9_]*)$`)
|
||||||
|
|
||||||
|
type SourceKind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
SourceKindBuiltIn SourceKind = "built_in"
|
||||||
|
SourceKindConfiguredArtifact SourceKind = "configured_artifact"
|
||||||
|
SourceKindPreviousArtifact SourceKind = "previous_session_configured_artifact"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Source describes one normalized artifact source identifier.
|
||||||
|
type Source struct {
|
||||||
|
ID string
|
||||||
|
Kind SourceKind
|
||||||
|
ConfiguredKey string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfiguredSourceID converts a configured artifact key into source id form.
|
||||||
|
func ConfiguredSourceID(key string) string {
|
||||||
|
return configuredSourcePrefix + strings.TrimSpace(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PreviousSessionSourceID converts a configured artifact key into previous-session source id form.
|
||||||
|
func PreviousSessionSourceID(key string) string {
|
||||||
|
return previousConfiguredSrcPrefix + strings.TrimSpace(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseConfiguredSource extracts configured key from narratio.artifact.<key>.
|
||||||
|
func ParseConfiguredSource(source string) (string, bool) {
|
||||||
|
matches := configuredSourceRE.FindStringSubmatch(strings.TrimSpace(source))
|
||||||
|
if len(matches) != 2 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return matches[1], true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParsePreviousSessionSource extracts configured key from narratio.previous_session.artifact.<key>.
|
||||||
|
func ParsePreviousSessionSource(source string) (string, bool) {
|
||||||
|
matches := previousSourceRE.FindStringSubmatch(strings.TrimSpace(source))
|
||||||
|
if len(matches) != 2 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return matches[1], true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClassifySource classifies a source id as built-in, configured, or previous-session configured.
|
||||||
|
func ClassifySource(source string) (Source, error) {
|
||||||
|
trimmed := strings.TrimSpace(source)
|
||||||
|
if trimmed == "" {
|
||||||
|
return Source{}, fmt.Errorf("artifact source is required")
|
||||||
|
}
|
||||||
|
if _, ok := artifactmodel.LookupRuntimeTranscriptArtifact(trimmed); ok {
|
||||||
|
return Source{ID: trimmed, Kind: SourceKindBuiltIn}, nil
|
||||||
|
}
|
||||||
|
if trimmed == SourceBoundsSession {
|
||||||
|
return Source{ID: trimmed, Kind: SourceKindBuiltIn}, nil
|
||||||
|
}
|
||||||
|
if key, ok := ParseConfiguredSource(trimmed); ok {
|
||||||
|
return Source{ID: trimmed, Kind: SourceKindConfiguredArtifact, ConfiguredKey: key}, nil
|
||||||
|
}
|
||||||
|
if key, ok := ParsePreviousSessionSource(trimmed); ok {
|
||||||
|
return Source{ID: trimmed, Kind: SourceKindPreviousArtifact, ConfiguredKey: key}, nil
|
||||||
|
}
|
||||||
|
return Source{}, fmt.Errorf("unsupported artifact source %q", source)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidatePublishSource validates that a source is publish-compatible and references a known configured artifact.
|
||||||
|
func ValidatePublishSource(source string, configured map[string]string) (Source, error) {
|
||||||
|
classified, err := ClassifySource(source)
|
||||||
|
if err != nil {
|
||||||
|
return Source{}, fmt.Errorf("must be a built-in source id or narratio.artifact.<name>")
|
||||||
|
}
|
||||||
|
if classified.Kind == SourceKindPreviousArtifact {
|
||||||
|
return Source{}, fmt.Errorf("must be a built-in source id or narratio.artifact.<name>")
|
||||||
|
}
|
||||||
|
if classified.Kind == SourceKindConfiguredArtifact {
|
||||||
|
if configured == nil {
|
||||||
|
return Source{}, fmt.Errorf("configured artifact %q is not defined in pipeline.scriptorium.artifacts", classified.ConfiguredKey)
|
||||||
|
}
|
||||||
|
if _, ok := configured[classified.ConfiguredKey]; !ok {
|
||||||
|
return Source{}, fmt.Errorf("configured artifact %q is not defined in pipeline.scriptorium.artifacts", classified.ConfiguredKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return classified, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeriveDefaultPublishedDestination returns the default publish destination for one source.
|
||||||
|
func DeriveDefaultPublishedDestination(source Source, configured map[string]string) (string, error) {
|
||||||
|
switch source.Kind {
|
||||||
|
case SourceKindBuiltIn:
|
||||||
|
if spec, ok := artifactmodel.LookupRuntimeTranscriptArtifact(source.ID); ok {
|
||||||
|
return pathsafe.NormalizeRelativeDestination(spec.CanonicalRelPath)
|
||||||
|
}
|
||||||
|
if source.ID == SourceBoundsSession {
|
||||||
|
return pathsafe.NormalizeRelativeDestination("artifacts/session_bounds.json")
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("unsupported built-in source %q", source.ID)
|
||||||
|
case SourceKindConfiguredArtifact:
|
||||||
|
if configured == nil {
|
||||||
|
return "", fmt.Errorf("configured artifact %q is not defined in pipeline.scriptorium.artifacts", source.ConfiguredKey)
|
||||||
|
}
|
||||||
|
outputPath, ok := configured[source.ConfiguredKey]
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("configured artifact %q is not defined in pipeline.scriptorium.artifacts", source.ConfiguredKey)
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(outputPath) == "" {
|
||||||
|
return "", fmt.Errorf("pipeline.scriptorium.artifacts.%s.output_path is empty", source.ConfiguredKey)
|
||||||
|
}
|
||||||
|
return pathsafe.NormalizeRelativeDestination(outputPath)
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("publish destination cannot be derived from source %q", source.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResolvePublishedDestination validates and normalizes an explicit destination,
|
||||||
|
// or derives one when omitted.
|
||||||
|
func ResolvePublishedDestination(sourceID, explicitDest string, configured map[string]string) (string, error) {
|
||||||
|
source, err := ValidatePublishSource(sourceID, configured)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(explicitDest) != "" {
|
||||||
|
return pathsafe.NormalizeRelativeDestination(explicitDest)
|
||||||
|
}
|
||||||
|
return DeriveDefaultPublishedDestination(source, configured)
|
||||||
|
}
|
||||||
92
internal/artifactpolicy/policy_test.go
Normal file
92
internal/artifactpolicy/policy_test.go
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
package artifactpolicy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestClassifySource(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
source string
|
||||||
|
wantKind SourceKind
|
||||||
|
wantKey string
|
||||||
|
wantErrLike string
|
||||||
|
}{
|
||||||
|
{name: "built in transcript", source: "narratio.transcript.final_trimmed", wantKind: SourceKindBuiltIn},
|
||||||
|
{name: "built in bounds", source: "narratio.bounds.session", wantKind: SourceKindBuiltIn},
|
||||||
|
{name: "configured artifact", source: "narratio.artifact.session_recap", wantKind: SourceKindConfiguredArtifact, wantKey: "session_recap"},
|
||||||
|
{name: "previous session configured", source: "narratio.previous_session.artifact.session_recap", wantKind: SourceKindPreviousArtifact, wantKey: "session_recap"},
|
||||||
|
{name: "unsupported", source: "narratio.unknown", wantErrLike: "unsupported artifact source"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := ClassifySource(tt.source)
|
||||||
|
if tt.wantErrLike != "" {
|
||||||
|
if err == nil || !strings.Contains(err.Error(), tt.wantErrLike) {
|
||||||
|
t.Fatalf("ClassifySource() error = %v, want like %q", err, tt.wantErrLike)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ClassifySource() error = %v", err)
|
||||||
|
}
|
||||||
|
if got.Kind != tt.wantKind {
|
||||||
|
t.Fatalf("ClassifySource().Kind = %q, want %q", got.Kind, tt.wantKind)
|
||||||
|
}
|
||||||
|
if got.ConfiguredKey != tt.wantKey {
|
||||||
|
t.Fatalf("ClassifySource().ConfiguredKey = %q, want %q", got.ConfiguredKey, tt.wantKey)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidatePublishSource(t *testing.T) {
|
||||||
|
configured := map[string]string{"session_recap": "artifacts/session_recap.md"}
|
||||||
|
if _, err := ValidatePublishSource("narratio.artifact.session_recap", configured); err != nil {
|
||||||
|
t.Fatalf("ValidatePublishSource(configured) error = %v", err)
|
||||||
|
}
|
||||||
|
if _, err := ValidatePublishSource("narratio.previous_session.artifact.session_recap", configured); err == nil {
|
||||||
|
t.Fatal("ValidatePublishSource(previous) error = nil, want error")
|
||||||
|
}
|
||||||
|
if _, err := ValidatePublishSource("narratio.artifact.missing", configured); err == nil {
|
||||||
|
t.Fatal("ValidatePublishSource(missing configured) error = nil, want error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolvePublishedDestination(t *testing.T) {
|
||||||
|
configured := map[string]string{"session_recap": "artifacts/session_recap.md"}
|
||||||
|
|
||||||
|
got, err := ResolvePublishedDestination("narratio.transcript.final_trimmed", "", configured)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolvePublishedDestination(built-in) error = %v", err)
|
||||||
|
}
|
||||||
|
if got != "transcripts/final.trimmed.json" {
|
||||||
|
t.Fatalf("built-in destination = %q, want transcripts/final.trimmed.json", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err = ResolvePublishedDestination("narratio.artifact.session_recap", "", configured)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolvePublishedDestination(configured) error = %v", err)
|
||||||
|
}
|
||||||
|
if got != "artifacts/session_recap.md" {
|
||||||
|
t.Fatalf("configured destination = %q, want artifacts/session_recap.md", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err = ResolvePublishedDestination("narratio.transcript.final_trimmed", "published/../published/final.json", configured)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ResolvePublishedDestination(explicit) error = %v", err)
|
||||||
|
}
|
||||||
|
if got != "published/final.json" {
|
||||||
|
t.Fatalf("explicit destination = %q, want published/final.json", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolvePublishedDestinationRejectsTraversal(t *testing.T) {
|
||||||
|
configured := map[string]string{"session_recap": "artifacts/session_recap.md"}
|
||||||
|
_, err := ResolvePublishedDestination("narratio.transcript.final_trimmed", "../escape.txt", configured)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("ResolvePublishedDestination() error = nil, want traversal rejection")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,10 +6,10 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"regexp"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
|
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -40,8 +40,6 @@ const (
|
|||||||
|
|
||||||
// ErrSessionArtifactNotFound is returned when no readable artifact exists for a known ID.
|
// ErrSessionArtifactNotFound is returned when no readable artifact exists for a known ID.
|
||||||
var ErrSessionArtifactNotFound = errors.New("session artifact not found")
|
var ErrSessionArtifactNotFound = errors.New("session artifact not found")
|
||||||
var configuredArtifactSourceRE = regexp.MustCompile(`^narratio\.artifact\.([a-z][a-z0-9_]*)$`)
|
|
||||||
var previousSessionArtifactSourceRE = regexp.MustCompile(`^narratio\.previous_session\.artifact\.([a-z][a-z0-9_]*)$`)
|
|
||||||
|
|
||||||
type artifactContentKind string
|
type artifactContentKind string
|
||||||
|
|
||||||
@@ -107,43 +105,39 @@ func (e *SessionArtifactNotFoundError) Unwrap() error {
|
|||||||
|
|
||||||
// NormalizeSessionArtifactSource validates canonical artifact IDs.
|
// NormalizeSessionArtifactSource validates canonical artifact IDs.
|
||||||
func NormalizeSessionArtifactSource(source string) (string, error) {
|
func NormalizeSessionArtifactSource(source string) (string, error) {
|
||||||
normalized := strings.TrimSpace(source)
|
classified, err := artifactpolicy.ClassifySource(source)
|
||||||
if normalized == "" {
|
if err != nil {
|
||||||
return "", fmt.Errorf("artifact source is required")
|
|
||||||
}
|
|
||||||
if _, ok := artifactRegistry[normalized]; !ok {
|
|
||||||
return "", fmt.Errorf("unsupported artifact source %q", source)
|
return "", fmt.Errorf("unsupported artifact source %q", source)
|
||||||
}
|
}
|
||||||
return normalized, nil
|
if classified.Kind != artifactpolicy.SourceKindBuiltIn {
|
||||||
|
return "", fmt.Errorf("unsupported artifact source %q", source)
|
||||||
|
}
|
||||||
|
if _, ok := artifactRegistry[classified.ID]; !ok {
|
||||||
|
return "", fmt.Errorf("unsupported artifact source %q", source)
|
||||||
|
}
|
||||||
|
return classified.ID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsConfiguredArtifactSource returns true when source is narratio.artifact.<name>.
|
// IsConfiguredArtifactSource returns true when source is narratio.artifact.<name>.
|
||||||
func IsConfiguredArtifactSource(source string) bool {
|
func IsConfiguredArtifactSource(source string) bool {
|
||||||
return configuredArtifactSourceRE.MatchString(strings.TrimSpace(source))
|
_, ok := artifactpolicy.ParseConfiguredSource(source)
|
||||||
|
return ok
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConfiguredArtifactName extracts <name> from narratio.artifact.<name>.
|
// ConfiguredArtifactName extracts <name> from narratio.artifact.<name>.
|
||||||
func ConfiguredArtifactName(source string) (string, bool) {
|
func ConfiguredArtifactName(source string) (string, bool) {
|
||||||
matches := configuredArtifactSourceRE.FindStringSubmatch(strings.TrimSpace(source))
|
return artifactpolicy.ParseConfiguredSource(source)
|
||||||
if len(matches) != 2 {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
return matches[1], true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// IsPreviousSessionArtifactSource returns true when source is narratio.previous_session.artifact.<name>.
|
// IsPreviousSessionArtifactSource returns true when source is narratio.previous_session.artifact.<name>.
|
||||||
func IsPreviousSessionArtifactSource(source string) bool {
|
func IsPreviousSessionArtifactSource(source string) bool {
|
||||||
_, ok := PreviousSessionArtifactName(source)
|
_, ok := artifactpolicy.ParsePreviousSessionSource(source)
|
||||||
return ok
|
return ok
|
||||||
}
|
}
|
||||||
|
|
||||||
// PreviousSessionArtifactName extracts <name> from narratio.previous_session.artifact.<name>.
|
// PreviousSessionArtifactName extracts <name> from narratio.previous_session.artifact.<name>.
|
||||||
func PreviousSessionArtifactName(source string) (string, bool) {
|
func PreviousSessionArtifactName(source string) (string, bool) {
|
||||||
matches := previousSessionArtifactSourceRE.FindStringSubmatch(strings.TrimSpace(source))
|
return artifactpolicy.ParsePreviousSessionSource(source)
|
||||||
if len(matches) != 2 {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
return matches[1], true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResolveSessionArtifact resolves a symbolic source to a readable local session artifact path.
|
// ResolveSessionArtifact resolves a symbolic source to a readable local session artifact path.
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -47,7 +49,7 @@ func NewArtifactCatalog() *ArtifactCatalog {
|
|||||||
|
|
||||||
// ConfiguredArtifactSourceID converts a configured artifact key into canonical source ID.
|
// ConfiguredArtifactSourceID converts a configured artifact key into canonical source ID.
|
||||||
func ConfiguredArtifactSourceID(key string) string {
|
func ConfiguredArtifactSourceID(key string) string {
|
||||||
return "narratio.artifact." + strings.TrimSpace(key)
|
return artifactpolicy.ConfiguredSourceID(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RegisterBuiltIns registers built-in source definitions used by runtime artifact resolution.
|
// RegisterBuiltIns registers built-in source definitions used by runtime artifact resolution.
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
|
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
|
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -135,6 +136,7 @@ func validatePublish(cfg *PublishConfig, scriptorium *ScriptoriumConfig) error {
|
|||||||
if cfg == nil {
|
if cfg == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
configuredOutputs := scriptoriumOutputPathMap(scriptorium)
|
||||||
seenDest := map[string]struct{}{}
|
seenDest := map[string]struct{}{}
|
||||||
for i, item := range cfg.Outputs {
|
for i, item := range cfg.Outputs {
|
||||||
prefix := fmt.Sprintf("pipeline.publish.outputs[%d]", i)
|
prefix := fmt.Sprintf("pipeline.publish.outputs[%d]", i)
|
||||||
@@ -142,12 +144,12 @@ func validatePublish(cfg *PublishConfig, scriptorium *ScriptoriumConfig) error {
|
|||||||
if source == "" {
|
if source == "" {
|
||||||
return fmt.Errorf("%s.source is required", prefix)
|
return fmt.Errorf("%s.source is required", prefix)
|
||||||
}
|
}
|
||||||
if _, err := publishSourceKnown(source, scriptorium); err != nil {
|
if _, err := artifactpolicy.ValidatePublishSource(source, configuredOutputs); err != nil {
|
||||||
return fmt.Errorf("%s.source %q is unsupported: %w", prefix, item.Source, err)
|
return fmt.Errorf("%s.source %q is unsupported: %w", prefix, item.Source, err)
|
||||||
}
|
}
|
||||||
dest := strings.TrimSpace(item.Dest)
|
dest := strings.TrimSpace(item.Dest)
|
||||||
if dest == "" {
|
if dest == "" {
|
||||||
derivedDest, err := derivePublishOutputDest(source, scriptorium)
|
derivedDest, err := artifactpolicy.ResolvePublishedDestination(source, "", configuredOutputs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("%s.dest is required when destination cannot be derived from %q: %w", prefix, source, err)
|
return fmt.Errorf("%s.dest is required when destination cannot be derived from %q: %w", prefix, source, err)
|
||||||
}
|
}
|
||||||
@@ -182,6 +184,7 @@ func validatePublish(cfg *PublishConfig, scriptorium *ScriptoriumConfig) error {
|
|||||||
func ValidatePublishLockRules(locks []PublishLockRule, scriptorium *ScriptoriumConfig, label string) ([]PublishLockRule, error) {
|
func ValidatePublishLockRules(locks []PublishLockRule, scriptorium *ScriptoriumConfig, label string) ([]PublishLockRule, error) {
|
||||||
seenLocks := map[string]struct{}{}
|
seenLocks := map[string]struct{}{}
|
||||||
out := make([]PublishLockRule, 0, len(locks))
|
out := make([]PublishLockRule, 0, len(locks))
|
||||||
|
configuredOutputs := scriptoriumOutputPathMap(scriptorium)
|
||||||
if strings.TrimSpace(label) == "" {
|
if strings.TrimSpace(label) == "" {
|
||||||
label = "publish.locks"
|
label = "publish.locks"
|
||||||
}
|
}
|
||||||
@@ -191,7 +194,7 @@ func ValidatePublishLockRules(locks []PublishLockRule, scriptorium *ScriptoriumC
|
|||||||
if source == "" {
|
if source == "" {
|
||||||
return nil, fmt.Errorf("%s.source is required", prefix)
|
return nil, fmt.Errorf("%s.source is required", prefix)
|
||||||
}
|
}
|
||||||
if _, err := publishSourceKnown(source, scriptorium); err != nil {
|
if _, err := artifactpolicy.ValidatePublishSource(source, configuredOutputs); err != nil {
|
||||||
return nil, fmt.Errorf("%s.source %q is unsupported: %w", prefix, item.Source, err)
|
return nil, fmt.Errorf("%s.source %q is unsupported: %w", prefix, item.Source, err)
|
||||||
}
|
}
|
||||||
if _, ok := seenLocks[source]; ok {
|
if _, ok := seenLocks[source]; ok {
|
||||||
@@ -233,48 +236,15 @@ func MergePublishLockRules(staticLocks, remoteLocks []PublishLockRule) []Publish
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func publishSourceKnown(source string, scriptorium *ScriptoriumConfig) (string, error) {
|
func scriptoriumOutputPathMap(scriptorium *ScriptoriumConfig) map[string]string {
|
||||||
trimmed := strings.TrimSpace(source)
|
out := map[string]string{}
|
||||||
if _, ok := artifactmodel.LookupRuntimeTranscriptArtifact(trimmed); ok {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
switch trimmed {
|
|
||||||
case "narratio.bounds.session":
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
matches := narratioArtifactSourceRE.FindStringSubmatch(trimmed)
|
|
||||||
if len(matches) != 2 {
|
|
||||||
return "", fmt.Errorf("must be a built-in source id or narratio.artifact.<name>")
|
|
||||||
}
|
|
||||||
artifactKey := matches[1]
|
|
||||||
if scriptorium == nil || len(scriptorium.Artifacts) == 0 {
|
if scriptorium == nil || len(scriptorium.Artifacts) == 0 {
|
||||||
return "", fmt.Errorf("configured artifact %q is not defined in pipeline.scriptorium.artifacts", artifactKey)
|
return out
|
||||||
}
|
}
|
||||||
if _, ok := scriptorium.Artifacts[artifactKey]; !ok {
|
for key, artifactCfg := range scriptorium.Artifacts {
|
||||||
return "", fmt.Errorf("configured artifact %q is not defined in pipeline.scriptorium.artifacts", artifactKey)
|
out[strings.TrimSpace(key)] = strings.TrimSpace(artifactCfg.OutputPath)
|
||||||
}
|
}
|
||||||
return artifactKey, nil
|
return out
|
||||||
}
|
|
||||||
|
|
||||||
func derivePublishOutputDest(source string, scriptorium *ScriptoriumConfig) (string, error) {
|
|
||||||
trimmed := strings.TrimSpace(source)
|
|
||||||
if spec, ok := artifactmodel.LookupRuntimeTranscriptArtifact(trimmed); ok {
|
|
||||||
return spec.CanonicalRelPath, nil
|
|
||||||
}
|
|
||||||
switch trimmed {
|
|
||||||
case "narratio.bounds.session":
|
|
||||||
return filepath.ToSlash(filepath.Join(PathArtifactsDirSegment, "session_bounds.json")), nil
|
|
||||||
}
|
|
||||||
artifactKey, err := publishSourceKnown(trimmed, scriptorium)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
artifactCfg := scriptorium.Artifacts[artifactKey]
|
|
||||||
outputPath := strings.TrimSpace(artifactCfg.OutputPath)
|
|
||||||
if outputPath == "" {
|
|
||||||
return "", fmt.Errorf("pipeline.scriptorium.artifacts.%s.output_path is empty", artifactKey)
|
|
||||||
}
|
|
||||||
return outputPath, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateSecrets(cfg *SecretsConfig) error {
|
func validateSecrets(cfg *SecretsConfig) error {
|
||||||
@@ -666,8 +636,6 @@ func publishUploadConfiguredForS3(pipeline *PipelineConfig) bool {
|
|||||||
var windowsAbsPathRE = regexp.MustCompile(`^[A-Za-z]:[\\/].*`)
|
var windowsAbsPathRE = regexp.MustCompile(`^[A-Za-z]:[\\/].*`)
|
||||||
var envVarNameRE = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
var envVarNameRE = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
||||||
var scriptoriumArtifactKeyRE = regexp.MustCompile(`^[a-z][a-z0-9_]*$`)
|
var scriptoriumArtifactKeyRE = regexp.MustCompile(`^[a-z][a-z0-9_]*$`)
|
||||||
var narratioArtifactSourceRE = regexp.MustCompile(`^narratio\.artifact\.([a-z][a-z0-9_]*)$`)
|
|
||||||
var narratioPreviousSessionArtifactSourceRE = regexp.MustCompile(`^narratio\.previous_session\.artifact\.([a-z][a-z0-9_]*)$`)
|
|
||||||
|
|
||||||
func validateScriptoriumInputSource(artifactName, inputName, source string, configuredArtifacts map[string]struct{}) (string, error) {
|
func validateScriptoriumInputSource(artifactName, inputName, source string, configuredArtifacts map[string]struct{}) (string, error) {
|
||||||
trimmedSource := strings.TrimSpace(source)
|
trimmedSource := strings.TrimSpace(source)
|
||||||
@@ -676,8 +644,8 @@ func validateScriptoriumInputSource(artifactName, inputName, source string, conf
|
|||||||
}
|
}
|
||||||
|
|
||||||
if strings.HasPrefix(trimmedSource, "narratio.previous_session.artifact") {
|
if strings.HasPrefix(trimmedSource, "narratio.previous_session.artifact") {
|
||||||
matches := narratioPreviousSessionArtifactSourceRE.FindStringSubmatch(trimmedSource)
|
referenced, ok := artifactpolicy.ParsePreviousSessionSource(trimmedSource)
|
||||||
if len(matches) != 2 {
|
if !ok {
|
||||||
return "", fmt.Errorf(
|
return "", fmt.Errorf(
|
||||||
"pipeline.scriptorium.artifacts.%s.inputs.%s.source %q must reference configured artifact key matching ^[a-z][a-z0-9_]*$",
|
"pipeline.scriptorium.artifacts.%s.inputs.%s.source %q must reference configured artifact key matching ^[a-z][a-z0-9_]*$",
|
||||||
artifactName,
|
artifactName,
|
||||||
@@ -685,7 +653,6 @@ func validateScriptoriumInputSource(artifactName, inputName, source string, conf
|
|||||||
source,
|
source,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
referenced := matches[1]
|
|
||||||
if _, ok := configuredArtifacts[referenced]; !ok {
|
if _, ok := configuredArtifacts[referenced]; !ok {
|
||||||
return "", fmt.Errorf(
|
return "", fmt.Errorf(
|
||||||
"pipeline.scriptorium.artifacts.%s.inputs.%s.source %q references unknown artifact %q",
|
"pipeline.scriptorium.artifacts.%s.inputs.%s.source %q references unknown artifact %q",
|
||||||
@@ -698,8 +665,8 @@ func validateScriptoriumInputSource(artifactName, inputName, source string, conf
|
|||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
matches := narratioArtifactSourceRE.FindStringSubmatch(trimmedSource)
|
referenced, ok := artifactpolicy.ParseConfiguredSource(trimmedSource)
|
||||||
if len(matches) != 2 {
|
if !ok {
|
||||||
return "", fmt.Errorf(
|
return "", fmt.Errorf(
|
||||||
"pipeline.scriptorium.artifacts.%s.inputs.%s.source %q is unsupported",
|
"pipeline.scriptorium.artifacts.%s.inputs.%s.source %q is unsupported",
|
||||||
artifactName,
|
artifactName,
|
||||||
@@ -707,7 +674,6 @@ func validateScriptoriumInputSource(artifactName, inputName, source string, conf
|
|||||||
source,
|
source,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
referenced := matches[1]
|
|
||||||
if _, ok := configuredArtifacts[referenced]; !ok {
|
if _, ok := configuredArtifacts[referenced]; !ok {
|
||||||
return "", fmt.Errorf(
|
return "", fmt.Errorf(
|
||||||
"pipeline.scriptorium.artifacts.%s.inputs.%s.source %q references unknown artifact %q",
|
"pipeline.scriptorium.artifacts.%s.inputs.%s.source %q references unknown artifact %q",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
|
"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/artifacts"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||||
@@ -286,7 +287,7 @@ func artifactRelativePathCandidates(
|
|||||||
candidates = append(candidates, normalized)
|
candidates = append(candidates, normalized)
|
||||||
}
|
}
|
||||||
|
|
||||||
sourceID := artifacts.ConfiguredArtifactSourceID(artifactName)
|
sourceID := artifactpolicy.ConfiguredSourceID(artifactName)
|
||||||
if rel, ok := manifestArtifactRelativePathBySourceID(previousManifest, sourceID); ok {
|
if rel, ok := manifestArtifactRelativePathBySourceID(previousManifest, sourceID); ok {
|
||||||
appendCandidate(rel)
|
appendCandidate(rel)
|
||||||
base := path.Base(rel)
|
base := path.Base(rel)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/scriptorium"
|
"gitea.maximumdirect.net/eric/narratio/internal/adapters/scriptorium"
|
||||||
|
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||||
@@ -549,12 +550,8 @@ func extractPlanNames(plans []analyzeArtifactExecutionPlan) []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func configuredArtifactNameFromSourceID(sourceID string) string {
|
func configuredArtifactNameFromSourceID(sourceID string) string {
|
||||||
trimmed := strings.TrimSpace(sourceID)
|
name, _ := artifactpolicy.ParseConfiguredSource(sourceID)
|
||||||
const prefix = "narratio.artifact."
|
return name
|
||||||
if !strings.HasPrefix(trimmed, prefix) {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return strings.TrimPrefix(trimmed, prefix)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func discoverProcessedTranscript(m *manifest.Manifest, paths artifacts.SessionPaths) (string, string, error) {
|
func discoverProcessedTranscript(m *manifest.Manifest, paths artifacts.SessionPaths) (string, string, error) {
|
||||||
@@ -631,7 +628,11 @@ func resolveScriptoriumInput(
|
|||||||
runtimeCatalog *artifacts.ArtifactCatalog,
|
runtimeCatalog *artifacts.ArtifactCatalog,
|
||||||
) (string, bool, *artifacts.ResolvedSessionArtifact, error) {
|
) (string, bool, *artifacts.ResolvedSessionArtifact, error) {
|
||||||
source := strings.TrimSpace(inputCfg.Source)
|
source := strings.TrimSpace(inputCfg.Source)
|
||||||
if artifacts.IsPreviousSessionArtifactSource(source) {
|
classified, classifyErr := artifactpolicy.ClassifySource(source)
|
||||||
|
if classifyErr != nil {
|
||||||
|
return "", false, nil, classifyErr
|
||||||
|
}
|
||||||
|
if classified.Kind == artifactpolicy.SourceKindPreviousArtifact {
|
||||||
resolved, err := artifacts.ResolvePreviousSessionArtifactWithCatalog(paths, m, source, runtimeCatalog)
|
resolved, err := artifacts.ResolvePreviousSessionArtifactWithCatalog(paths, m, source, runtimeCatalog)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
copy := resolved
|
copy := resolved
|
||||||
@@ -656,17 +657,13 @@ func resolveScriptoriumInput(
|
|||||||
return resolved.Path, true, ©, nil
|
return resolved.Path, true, ©, nil
|
||||||
}
|
}
|
||||||
if errors.Is(err, artifacts.ErrSessionArtifactNotFound) {
|
if errors.Is(err, artifacts.ErrSessionArtifactNotFound) {
|
||||||
if artifacts.IsConfiguredArtifactSource(source) {
|
if classified.Kind == artifactpolicy.SourceKindConfiguredArtifact {
|
||||||
if inputCfg.Required {
|
if inputCfg.Required {
|
||||||
return "", false, nil, fmt.Errorf("configured artifact source %q is unavailable", source)
|
return "", false, nil, fmt.Errorf("configured artifact source %q is unavailable", source)
|
||||||
}
|
}
|
||||||
return "", false, nil, nil
|
return "", false, nil, nil
|
||||||
}
|
}
|
||||||
normalized, normalizeErr := artifacts.NormalizeSessionArtifactSource(source)
|
switch classified.ID {
|
||||||
if normalizeErr != nil {
|
|
||||||
return "", false, nil, normalizeErr
|
|
||||||
}
|
|
||||||
switch normalized {
|
|
||||||
case artifacts.ArtifactTranscriptPolished:
|
case artifacts.ArtifactTranscriptPolished:
|
||||||
return "", false, nil, nil
|
return "", false, nil, nil
|
||||||
case artifacts.ArtifactTranscriptFinal:
|
case artifacts.ArtifactTranscriptFinal:
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
|
"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/artifacts"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||||
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type archiveStage struct{}
|
type archiveStage struct{}
|
||||||
@@ -359,15 +359,16 @@ func resolvePublishOutputs(
|
|||||||
lockedOutputs := make([]publishLockedOutput, 0)
|
lockedOutputs := make([]publishLockedOutput, 0)
|
||||||
lockSet := archiveLockSet(locks)
|
lockSet := archiveLockSet(locks)
|
||||||
selectedSet := archiveSelectedArtifactSet(selectedArtifactKeys)
|
selectedSet := archiveSelectedArtifactSet(selectedArtifactKeys)
|
||||||
|
configuredOutputs := configuredOutputPathMapFromCatalog(catalog)
|
||||||
for _, rule := range rules {
|
for _, rule := range rules {
|
||||||
source := strings.TrimSpace(rule.Source)
|
source := strings.TrimSpace(rule.Source)
|
||||||
required := rule.Required == nil || *rule.Required
|
required := rule.Required == nil || *rule.Required
|
||||||
dest, err := resolvePublishOutputDest(rule, catalog)
|
dest, err := resolvePublishOutputDest(rule, configuredOutputs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, nil, nil, fmt.Errorf("source %q: %w", source, err)
|
return nil, nil, nil, nil, fmt.Errorf("source %q: %w", source, err)
|
||||||
}
|
}
|
||||||
if len(selectedSet) > 0 {
|
if len(selectedSet) > 0 {
|
||||||
if key, ok := artifacts.ConfiguredArtifactName(source); ok {
|
if key, ok := artifactpolicy.ParseConfiguredSource(source); ok {
|
||||||
if _, selected := selectedSet[key]; !selected {
|
if _, selected := selectedSet[key]; !selected {
|
||||||
skippedUnselectedOutputs = append(skippedUnselectedOutputs, publishSkippedUnselectedOutput{
|
skippedUnselectedOutputs = append(skippedUnselectedOutputs, publishSkippedUnselectedOutput{
|
||||||
Source: source,
|
Source: source,
|
||||||
@@ -452,19 +453,22 @@ func archiveLockSet(locks []config.PublishLockRule) map[string]config.PublishLoc
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolvePublishOutputDest(rule config.PublishOutputRule, catalog *artifacts.ArtifactCatalog) (string, error) {
|
func resolvePublishOutputDest(rule config.PublishOutputRule, configured map[string]string) (string, error) {
|
||||||
dest := strings.TrimSpace(rule.Dest)
|
return artifactpolicy.ResolvePublishedDestination(rule.Source, rule.Dest, configured)
|
||||||
if dest == "" {
|
}
|
||||||
entry, ok := catalog.Lookup(strings.TrimSpace(rule.Source))
|
|
||||||
if !ok {
|
func configuredOutputPathMapFromCatalog(catalog *artifacts.ArtifactCatalog) map[string]string {
|
||||||
return "", fmt.Errorf("destination omitted and source is unknown")
|
out := map[string]string{}
|
||||||
}
|
if catalog == nil {
|
||||||
dest = strings.TrimSpace(entry.CanonicalRelPath)
|
return out
|
||||||
if dest == "" {
|
|
||||||
return "", fmt.Errorf("destination omitted and no canonical destination is available")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return pathsafe.NormalizeRelativeDestination(dest)
|
for _, entry := range catalog.ListConfigured() {
|
||||||
|
if strings.TrimSpace(entry.ConfiguredKey) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[entry.ConfiguredKey] = strings.TrimSpace(entry.CanonicalRelPath)
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildArchiveRuntimeArtifactCatalog(
|
func buildArchiveRuntimeArtifactCatalog(
|
||||||
|
|||||||
Reference in New Issue
Block a user