Add versioned analysis artifact state
This commit is contained in:
355
internal/manifest/analyze_state.go
Normal file
355
internal/manifest/analyze_state.go
Normal file
@@ -0,0 +1,355 @@
|
||||
package manifest
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
|
||||
)
|
||||
|
||||
const (
|
||||
// AnalyzeStateContractVersion identifies the supported per-artifact state
|
||||
// representation owned by the analyze stage.
|
||||
AnalyzeStateContractVersion = 1
|
||||
// AnalyzeFingerprintContractVersion identifies the fingerprint representation
|
||||
// stored by the supported analyze state contract.
|
||||
AnalyzeFingerprintContractVersion = 1
|
||||
)
|
||||
|
||||
const (
|
||||
maxAnalyzeArtifactErrorLength = 512
|
||||
maxAnalyzeArtifactTextLength = 4096
|
||||
maxAnalyzeArtifactListEntries = 128
|
||||
)
|
||||
|
||||
// AnalyzeArtifactStatus describes whether one configured analysis artifact is
|
||||
// currently available or why it is not.
|
||||
type AnalyzeArtifactStatus string
|
||||
|
||||
const (
|
||||
AnalyzeArtifactCurrent AnalyzeArtifactStatus = "current"
|
||||
AnalyzeArtifactStale AnalyzeArtifactStatus = "stale"
|
||||
AnalyzeArtifactMissing AnalyzeArtifactStatus = "missing"
|
||||
AnalyzeArtifactFailed AnalyzeArtifactStatus = "failed"
|
||||
AnalyzeArtifactUnselected AnalyzeArtifactStatus = "unselected"
|
||||
)
|
||||
|
||||
// AnalyzeArtifactProvenance records useful non-secret Scriptorium invocation
|
||||
// identity without making adapter diagnostics part of the generic artifact schema.
|
||||
type AnalyzeArtifactProvenance struct {
|
||||
PromptID string `json:"prompt_id,omitempty"`
|
||||
ProfileID string `json:"profile_id,omitempty"`
|
||||
CommandMode string `json:"command_mode,omitempty"`
|
||||
}
|
||||
|
||||
// AnalyzeArtifactRecord is analyze-owned state for one configured artifact.
|
||||
// Output is present only while the record is current.
|
||||
type AnalyzeArtifactRecord struct {
|
||||
Key string `json:"key"`
|
||||
Status AnalyzeArtifactStatus `json:"status"`
|
||||
FingerprintVersion int `json:"fingerprint_version,omitempty"`
|
||||
Fingerprint string `json:"fingerprint,omitempty"`
|
||||
Dependencies []string `json:"dependencies,omitempty"`
|
||||
Output *ArtifactRecord `json:"output,omitempty"`
|
||||
OutputSize int64 `json:"output_size,omitempty"`
|
||||
ProducerRunID string `json:"producer_run_id"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Scriptorium *AnalyzeArtifactProvenance `json:"scriptorium,omitempty"`
|
||||
Logs []string `json:"logs,omitempty"`
|
||||
GeneratedConfigs []string `json:"generated_configs,omitempty"`
|
||||
}
|
||||
|
||||
// HasVersionedAnalyzeState reports whether an analyze stage record carries the
|
||||
// supported per-artifact authority. A legacy aggregate-only record returns false.
|
||||
func (s *StageRecord) HasVersionedAnalyzeState() bool {
|
||||
return s != nil && s.Name == "analyze" && s.AnalyzeStateVersion == AnalyzeStateContractVersion
|
||||
}
|
||||
|
||||
// ValidateAnalyzeArtifactCollection validates one complete session or
|
||||
// invocation collection independently of its containing manifest.
|
||||
func ValidateAnalyzeArtifactCollection(version int, records map[string]AnalyzeArtifactRecord) error {
|
||||
if version != AnalyzeStateContractVersion {
|
||||
return fmt.Errorf("unsupported analyze state version %d", version)
|
||||
}
|
||||
for key, record := range records {
|
||||
if err := validateAnalyzeArtifactRecord(key, record); err != nil {
|
||||
return fmt.Errorf("analyze artifact %q: %w", key, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAnalyzeStageState(stageName string, version int, records map[string]AnalyzeArtifactRecord) error {
|
||||
if stageName != "analyze" {
|
||||
if version != 0 || records != nil {
|
||||
return fmt.Errorf("stage %q cannot contain analyze-owned state", stageName)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if version == 0 {
|
||||
if records != nil {
|
||||
return fmt.Errorf("legacy analyze stage without a state version cannot contain analyze_artifacts")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return ValidateAnalyzeArtifactCollection(version, records)
|
||||
}
|
||||
|
||||
func validateAnalyzeArtifactRecord(mapKey string, record AnalyzeArtifactRecord) error {
|
||||
if !isNormalizedAnalyzeArtifactKey(mapKey) {
|
||||
return fmt.Errorf("map key must match ^[a-z][a-z0-9_]*$ without normalization")
|
||||
}
|
||||
if record.Key != mapKey {
|
||||
return fmt.Errorf("record key %q does not match map key", record.Key)
|
||||
}
|
||||
switch record.Status {
|
||||
case AnalyzeArtifactCurrent, AnalyzeArtifactStale, AnalyzeArtifactMissing, AnalyzeArtifactFailed, AnalyzeArtifactUnselected:
|
||||
default:
|
||||
return fmt.Errorf("unsupported status %q", record.Status)
|
||||
}
|
||||
if err := validateAnalyzeDependencies(record.Dependencies); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateAnalyzeFingerprint(record.FingerprintVersion, record.Fingerprint, record.Status == AnalyzeArtifactCurrent); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := pathsafe.ValidateOpaqueSegment(record.ProducerRunID); err != nil {
|
||||
return fmt.Errorf("producer_run_id is invalid: %w", err)
|
||||
}
|
||||
if record.UpdatedAt.IsZero() {
|
||||
return fmt.Errorf("updated_at is required")
|
||||
}
|
||||
if len(record.Error) > maxAnalyzeArtifactErrorLength {
|
||||
return fmt.Errorf("error exceeds %d bytes", maxAnalyzeArtifactErrorLength)
|
||||
}
|
||||
if strings.TrimSpace(record.Error) != record.Error {
|
||||
return fmt.Errorf("error must be trimmed")
|
||||
}
|
||||
if record.Status == AnalyzeArtifactFailed {
|
||||
if record.Error == "" {
|
||||
return fmt.Errorf("failed status requires error")
|
||||
}
|
||||
} else if record.Error != "" {
|
||||
return fmt.Errorf("status %q forbids error", record.Status)
|
||||
}
|
||||
if record.Status == AnalyzeArtifactCurrent {
|
||||
if err := validateCurrentAnalyzeOutput(record); err != nil {
|
||||
return err
|
||||
}
|
||||
} else if record.Output != nil || record.OutputSize != 0 {
|
||||
return fmt.Errorf("status %q forbids output and output_size", record.Status)
|
||||
}
|
||||
if err := validateAnalyzeProvenance(record.Scriptorium); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateAnalyzeTextList("logs", record.Logs); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateAnalyzeTextList("generated_configs", record.GeneratedConfigs); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCurrentAnalyzeOutput(record AnalyzeArtifactRecord) error {
|
||||
if record.Output == nil {
|
||||
return fmt.Errorf("current status requires output")
|
||||
}
|
||||
if record.OutputSize <= 0 {
|
||||
return fmt.Errorf("current status requires positive output_size")
|
||||
}
|
||||
output := record.Output
|
||||
if strings.TrimSpace(output.Kind) == "" {
|
||||
return fmt.Errorf("current output kind is required")
|
||||
}
|
||||
if strings.TrimSpace(output.Kind) != output.Kind {
|
||||
return fmt.Errorf("current output kind must be trimmed")
|
||||
}
|
||||
wantSource := artifactpolicy.ConfiguredSourceID(record.Key)
|
||||
if output.SourceID != wantSource {
|
||||
return fmt.Errorf("current output source_id %q must equal %q", output.SourceID, wantSource)
|
||||
}
|
||||
normalizedPath, err := pathsafe.NormalizeRelativeDestination(output.LocalPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("current output local_path is unsafe: %w", err)
|
||||
}
|
||||
if normalizedPath != output.LocalPath {
|
||||
return fmt.Errorf("current output local_path %q is not canonical %q", output.LocalPath, normalizedPath)
|
||||
}
|
||||
if output.Contract == nil || strings.TrimSpace(output.Contract.MediaType) == "" || strings.TrimSpace(output.Contract.SchemaID) == "" || strings.TrimSpace(output.Contract.SchemaVersion) == "" {
|
||||
return fmt.Errorf("current output contract media_type, schema_id, and schema_version are required")
|
||||
}
|
||||
for field, value := range map[string]string{
|
||||
"media_type": output.Contract.MediaType, "schema_id": output.Contract.SchemaID,
|
||||
"schema_version": output.Contract.SchemaVersion, "module_key": output.Contract.ModuleKey,
|
||||
} {
|
||||
if strings.TrimSpace(value) != value {
|
||||
return fmt.Errorf("current output contract %s must be trimmed", field)
|
||||
}
|
||||
}
|
||||
if err := validateSHA256("current output checksum", output.Checksum); err != nil {
|
||||
return err
|
||||
}
|
||||
if output.ProducerRunID != "" && output.ProducerRunID != record.ProducerRunID {
|
||||
return fmt.Errorf("current output producer_run_id %q does not match record", output.ProducerRunID)
|
||||
}
|
||||
for field, value := range map[string]string{
|
||||
"output kind": output.Kind,
|
||||
"output source_id": output.SourceID,
|
||||
"output local_path": output.LocalPath,
|
||||
"output contract media_type": output.Contract.MediaType,
|
||||
"output contract schema_id": output.Contract.SchemaID,
|
||||
"output contract schema_version": output.Contract.SchemaVersion,
|
||||
"output contract module_key": output.Contract.ModuleKey,
|
||||
} {
|
||||
if err := validateAnalyzeText(field, value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if output.ExternalProvenance != nil {
|
||||
if strings.TrimSpace(output.ExternalProvenance.System) == "" {
|
||||
return fmt.Errorf("output provenance system is required when provenance is present")
|
||||
}
|
||||
for field, value := range map[string]string{
|
||||
"output provenance system": output.ExternalProvenance.System,
|
||||
"output provenance run_id": output.ExternalProvenance.RunID,
|
||||
"output provenance pipeline_id": output.ExternalProvenance.PipelineID,
|
||||
"output provenance artifact_id": output.ExternalProvenance.ArtifactID,
|
||||
} {
|
||||
if err := validateAnalyzeText(field, value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAnalyzeDependencies(dependencies []string) error {
|
||||
seen := make(map[string]struct{}, len(dependencies))
|
||||
for index, dependency := range dependencies {
|
||||
if !isNormalizedAnalyzeArtifactKey(dependency) {
|
||||
return fmt.Errorf("dependencies[%d] must match ^[a-z][a-z0-9_]*$ without normalization", index)
|
||||
}
|
||||
if _, duplicate := seen[dependency]; duplicate {
|
||||
return fmt.Errorf("duplicate dependency %q", dependency)
|
||||
}
|
||||
seen[dependency] = struct{}{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAnalyzeFingerprint(version int, fingerprint string, required bool) error {
|
||||
if version == 0 && fingerprint == "" {
|
||||
if required {
|
||||
return fmt.Errorf("current status requires fingerprint version and fingerprint")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if version != AnalyzeFingerprintContractVersion {
|
||||
return fmt.Errorf("unsupported fingerprint version %d", version)
|
||||
}
|
||||
return validateSHA256("fingerprint", fingerprint)
|
||||
}
|
||||
|
||||
func validateSHA256(field, value string) error {
|
||||
if len(value) != 64 || strings.ToLower(value) != value {
|
||||
return fmt.Errorf("%s must be a canonical lowercase SHA-256 hex digest", field)
|
||||
}
|
||||
decoded, err := hex.DecodeString(value)
|
||||
if err != nil || len(decoded) != 32 {
|
||||
return fmt.Errorf("%s must be a canonical lowercase SHA-256 hex digest", field)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAnalyzeProvenance(provenance *AnalyzeArtifactProvenance) error {
|
||||
if provenance == nil {
|
||||
return nil
|
||||
}
|
||||
if provenance.PromptID == "" && provenance.ProfileID == "" && provenance.CommandMode == "" {
|
||||
return fmt.Errorf("scriptorium provenance must contain at least one identifier")
|
||||
}
|
||||
for field, value := range map[string]string{
|
||||
"scriptorium prompt_id": provenance.PromptID,
|
||||
"scriptorium profile_id": provenance.ProfileID,
|
||||
"scriptorium command_mode": provenance.CommandMode,
|
||||
} {
|
||||
if err := validateAnalyzeText(field, value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAnalyzeTextList(field string, values []string) error {
|
||||
if len(values) > maxAnalyzeArtifactListEntries {
|
||||
return fmt.Errorf("%s exceeds %d entries", field, maxAnalyzeArtifactListEntries)
|
||||
}
|
||||
for index, value := range values {
|
||||
if err := validateAnalyzeText(fmt.Sprintf("%s[%d]", field, index), value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAnalyzeText(field, value string) error {
|
||||
if len(value) > maxAnalyzeArtifactTextLength {
|
||||
return fmt.Errorf("%s exceeds %d bytes", field, maxAnalyzeArtifactTextLength)
|
||||
}
|
||||
if strings.ContainsRune(value, '\x00') {
|
||||
return fmt.Errorf("%s contains a NUL byte", field)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isNormalizedAnalyzeArtifactKey(key string) bool {
|
||||
return strings.TrimSpace(key) == key && artifactpolicy.IsConfiguredKey(key)
|
||||
}
|
||||
|
||||
func normalizeAnalyzeArtifactCollection(records map[string]AnalyzeArtifactRecord) {
|
||||
for key, record := range records {
|
||||
if len(record.Dependencies) > 1 {
|
||||
record.Dependencies = append([]string(nil), record.Dependencies...)
|
||||
sort.Strings(record.Dependencies)
|
||||
}
|
||||
record.Logs = cloneStrings(record.Logs)
|
||||
record.GeneratedConfigs = cloneStrings(record.GeneratedConfigs)
|
||||
record.Output = cloneArtifactRecord(record.Output)
|
||||
record.Scriptorium = cloneAnalyzeProvenance(record.Scriptorium)
|
||||
records[key] = record
|
||||
}
|
||||
}
|
||||
|
||||
func cloneStrings(values []string) []string {
|
||||
return append([]string(nil), values...)
|
||||
}
|
||||
|
||||
func cloneArtifactRecord(record *ArtifactRecord) *ArtifactRecord {
|
||||
if record == nil {
|
||||
return nil
|
||||
}
|
||||
clone := *record
|
||||
if record.Contract != nil {
|
||||
contract := *record.Contract
|
||||
clone.Contract = &contract
|
||||
}
|
||||
if record.ExternalProvenance != nil {
|
||||
provenance := *record.ExternalProvenance
|
||||
clone.ExternalProvenance = &provenance
|
||||
}
|
||||
return &clone
|
||||
}
|
||||
|
||||
func cloneAnalyzeProvenance(provenance *AnalyzeArtifactProvenance) *AnalyzeArtifactProvenance {
|
||||
if provenance == nil {
|
||||
return nil
|
||||
}
|
||||
clone := *provenance
|
||||
return &clone
|
||||
}
|
||||
Reference in New Issue
Block a user