Files
narratio/internal/artifactpolicy/policy.go

333 lines
12 KiB
Go

package artifactpolicy
import (
"errors"
"fmt"
"regexp"
"strings"
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
)
const (
SourceBoundsSession = "narratio.bounds.session"
SourceInputPlayers = "narratio.input.players"
SourceInputParty = "narratio.input.party"
SourceInputGlossary = "narratio.input.glossary"
configuredSourcePrefix = "narratio.artifact."
extractionSourcePrefix = "narratio.extraction."
previousConfiguredSrcPrefix = "narratio.previous_session.artifact."
)
var configuredSourceRE = regexp.MustCompile(`^narratio\.artifact\.([a-z][a-z0-9_]*)$`)
var extractionSourceRE = regexp.MustCompile(`^narratio\.extraction\.([a-z][a-z0-9_]*)$`)
var previousSourceRE = regexp.MustCompile(`^narratio\.previous_session\.artifact\.([a-z][a-z0-9_]*)$`)
var configuredKeyRE = regexp.MustCompile(`^[a-z][a-z0-9_]*$`)
var (
ErrUnsupportedScriptoriumInputSource = errors.New("unsupported scriptorium input source")
ErrInvalidPreviousSessionSource = errors.New("invalid previous-session source format")
)
type SourceKind string
const (
SourceKindBuiltIn SourceKind = "built_in"
SourceKindConfiguredArtifact SourceKind = "configured_artifact"
SourceKindExtraction SourceKind = "extraction"
SourceKindPreviousArtifact SourceKind = "previous_session_configured_artifact"
SourceKindStableInput SourceKind = "stable_input"
)
// Source describes one normalized artifact source identifier.
type Source struct {
ID string
Kind SourceKind
ConfiguredKey string
}
// ScriptoriumInputSourceDescriptor describes one validated Scriptorium input source.
type ScriptoriumInputSourceDescriptor struct {
Source Source
PreviousSession *PreviousSessionSourceDescriptor
}
// PreviousSessionSourceDescriptor describes one canonical previous-session input source.
type PreviousSessionSourceDescriptor struct {
SourceID string
ConfiguredKey string
ConfiguredSourceID string
}
// UnknownConfiguredArtifactError reports a source that references an undefined configured artifact key.
type UnknownConfiguredArtifactError struct {
ConfiguredKey string
}
func (e *UnknownConfiguredArtifactError) Error() string {
return fmt.Sprintf("references unknown artifact %q", e.ConfiguredKey)
}
// UnknownExtractionArtifactError reports a source that references an undefined extraction key.
type UnknownExtractionArtifactError struct {
ConfiguredKey string
}
func (e *UnknownExtractionArtifactError) Error() string {
return fmt.Sprintf("references unknown extraction output %q", e.ConfiguredKey)
}
// IsConfiguredKey reports whether a key follows the configured-artifact key grammar.
func IsConfiguredKey(key string) bool {
return configuredKeyRE.MatchString(strings.TrimSpace(key))
}
// ConfiguredSourceID converts a configured artifact key into source id form.
func ConfiguredSourceID(key string) string {
return configuredSourcePrefix + strings.TrimSpace(key)
}
// ExtractionSourceID converts an extraction output key into source id form.
func ExtractionSourceID(key string) string {
return extractionSourcePrefix + 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
}
// ParseExtractionSource extracts configured key from narratio.extraction.<key>.
func ParseExtractionSource(source string) (string, bool) {
matches := extractionSourceRE.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 := ParseExtractionSource(trimmed); ok {
return Source{ID: trimmed, Kind: SourceKindExtraction, 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)
}
// DescribeScriptoriumInputSource classifies one input source and returns descriptor
// metadata used by config validation, analyze input resolution, and previous-cache planning.
func DescribeScriptoriumInputSource(source string) (ScriptoriumInputSourceDescriptor, error) {
trimmed := strings.TrimSpace(source)
if trimmed == "" {
return ScriptoriumInputSourceDescriptor{}, ErrUnsupportedScriptoriumInputSource
}
if IsStableInputSource(trimmed) {
return ScriptoriumInputSourceDescriptor{
Source: Source{ID: trimmed, Kind: SourceKindStableInput},
}, nil
}
if strings.HasPrefix(trimmed, "narratio.previous_session.artifact") {
descriptor, err := DescribePreviousSessionSource(trimmed)
if err != nil {
return ScriptoriumInputSourceDescriptor{}, err
}
return ScriptoriumInputSourceDescriptor{
Source: Source{
ID: descriptor.SourceID,
Kind: SourceKindPreviousArtifact,
ConfiguredKey: descriptor.ConfiguredKey,
},
PreviousSession: &descriptor,
}, nil
}
classified, err := ClassifySource(trimmed)
if err != nil {
return ScriptoriumInputSourceDescriptor{}, ErrUnsupportedScriptoriumInputSource
}
return ScriptoriumInputSourceDescriptor{Source: classified}, nil
}
// IsStableInputSource reports whether source is a prepared stable input source
// available only to Scriptorium input resolution.
func IsStableInputSource(source string) bool {
switch strings.TrimSpace(source) {
case SourceInputPlayers, SourceInputParty, SourceInputGlossary:
return true
default:
return false
}
}
// DescribePreviousSessionSource validates a canonical previous-session source id
// and returns both previous and configured-source vocabulary descriptors.
func DescribePreviousSessionSource(source string) (PreviousSessionSourceDescriptor, error) {
configuredKey, ok := ParsePreviousSessionSource(source)
if !ok {
return PreviousSessionSourceDescriptor{}, ErrInvalidPreviousSessionSource
}
return PreviousSessionSourceDescriptor{
SourceID: PreviousSessionSourceID(configuredKey),
ConfiguredKey: configuredKey,
ConfiguredSourceID: ConfiguredSourceID(configuredKey),
}, nil
}
// PreviousSessionSourceDescriptorForConfiguredKey derives a previous-session source descriptor
// from a configured artifact key.
func PreviousSessionSourceDescriptorForConfiguredKey(configuredKey string) (PreviousSessionSourceDescriptor, error) {
return DescribePreviousSessionSource(PreviousSessionSourceID(configuredKey))
}
// ValidateInputConfiguredReference checks that configured/previous-session sources
// reference configured artifacts known to the current Scriptorium config.
func ValidateInputConfiguredReference(
descriptor ScriptoriumInputSourceDescriptor,
configured map[string]struct{},
) error {
return ValidateInputReference(descriptor, configured, nil)
}
// ValidateInputReference checks that configured and extraction sources are declared
// by the effective pipeline configuration.
func ValidateInputReference(
descriptor ScriptoriumInputSourceDescriptor,
configured map[string]struct{},
extractions map[string]struct{},
) error {
switch descriptor.Source.Kind {
case SourceKindConfiguredArtifact, SourceKindPreviousArtifact:
if _, ok := configured[descriptor.Source.ConfiguredKey]; !ok {
return &UnknownConfiguredArtifactError{ConfiguredKey: descriptor.Source.ConfiguredKey}
}
case SourceKindExtraction:
if _, ok := extractions[descriptor.Source.ConfiguredKey]; !ok {
return &UnknownExtractionArtifactError{ConfiguredKey: descriptor.Source.ConfiguredKey}
}
}
return nil
}
// ValidatePublishSource validates that a source is publish-compatible and references a known configured artifact.
func ValidatePublishSource(source string, configured map[string]string) (Source, error) {
return ValidatePublishSourceWithExtractions(source, configured, nil)
}
// ValidatePublishSourceWithExtractions validates publish sources against the
// configured Scriptorium artifacts and extraction outputs.
func ValidatePublishSourceWithExtractions(
source string,
configured map[string]string,
extractions map[string]struct{},
) (Source, error) {
classified, err := ClassifySource(source)
if err != nil {
return Source{}, fmt.Errorf("must be a built-in source id, narratio.artifact.<name>, or configured narratio.extraction.<name>")
}
if classified.Kind == SourceKindPreviousArtifact {
return Source{}, fmt.Errorf("must be a built-in source id, narratio.artifact.<name>, or configured narratio.extraction.<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)
}
}
if classified.Kind == SourceKindExtraction {
if _, ok := extractions[classified.ConfiguredKey]; !ok {
return Source{}, fmt.Errorf("extraction output %q is not defined in pipeline.notarius.outputs", 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) {
return ResolvePublishedDestinationWithExtractions(sourceID, explicitDest, configured, nil)
}
// ResolvePublishedDestinationWithExtractions validates and normalizes a destination
// while accepting extraction sources declared by the effective Notarius configuration.
func ResolvePublishedDestinationWithExtractions(
sourceID, explicitDest string,
configured map[string]string,
extractions map[string]struct{},
) (string, error) {
source, err := ValidatePublishSourceWithExtractions(sourceID, configured, extractions)
if err != nil {
return "", err
}
if strings.TrimSpace(explicitDest) != "" {
return pathsafe.NormalizeRelativeDestination(explicitDest)
}
return DeriveDefaultPublishedDestination(source, configured)
}