463 lines
17 KiB
Go
463 lines
17 KiB
Go
package stage
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"gitea.maximumdirect.net/eric/narratio/internal/adapters/notarius"
|
|
"gitea.maximumdirect.net/eric/narratio/internal/artifactmodel"
|
|
"gitea.maximumdirect.net/eric/narratio/internal/artifacts"
|
|
"gitea.maximumdirect.net/eric/narratio/internal/config"
|
|
"gitea.maximumdirect.net/eric/narratio/internal/fileops"
|
|
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
|
"gitea.maximumdirect.net/eric/narratio/internal/pathsafe"
|
|
)
|
|
|
|
const (
|
|
extractSkipReason = "notarius_disabled"
|
|
extractLaneOutputKind = "notarius_lane"
|
|
extractIndexOutputKind = "notarius_index"
|
|
maxDiagnosticSummaries = 100
|
|
)
|
|
|
|
type extractStage struct{}
|
|
|
|
func (extractStage) Name() string { return "extract" }
|
|
|
|
func (extractStage) Declares() IODecl {
|
|
return IODecl{
|
|
Inputs: []artifacts.Ref{{
|
|
Kind: artifactmodel.TranscriptOutputKindFinalTrimmed,
|
|
SourceID: artifactmodel.SourceTranscriptFinalTrimmed,
|
|
Category: "transcripts",
|
|
RelativePath: artifactmodel.TranscriptPathFinalTrimmed,
|
|
}},
|
|
Outputs: []artifacts.Ref{
|
|
{Kind: extractLaneOutputKind, Category: "artifacts", RelativePath: "artifacts/notarius/<run-id>/lanes/*.json"},
|
|
{Kind: extractIndexOutputKind, Category: "artifacts", RelativePath: "artifacts/notarius/<run-id>/index.json"},
|
|
},
|
|
}
|
|
}
|
|
|
|
func (extractStage) Run(ctx context.Context, env *Env, m *manifest.Manifest) (*StageResult, error) {
|
|
if env == nil || env.Config == nil || env.Config.Pipeline == nil || env.Config.Session == nil {
|
|
return nil, fmt.Errorf("extract: resolved stage environment config is required")
|
|
}
|
|
notariusConfig := env.Config.Pipeline.Notarius
|
|
if notariusConfig == nil || !notariusConfig.Enabled {
|
|
return &StageResult{
|
|
Disposition: StageDispositionSkipped,
|
|
SkipReason: extractSkipReason,
|
|
Metadata: map[string]any{
|
|
"stage": "extract",
|
|
"notarius_enabled": false,
|
|
"reason": extractSkipReason,
|
|
},
|
|
}, nil
|
|
}
|
|
if env.ArtifactStore == nil {
|
|
return nil, fmt.Errorf("extract: artifact store is required")
|
|
}
|
|
if env.Notarius == nil {
|
|
return nil, fmt.Errorf("extract: notarius adapter is required")
|
|
}
|
|
if m == nil {
|
|
return nil, fmt.Errorf("extract: session manifest is required")
|
|
}
|
|
|
|
sessionID := strings.TrimSpace(m.SessionID)
|
|
if sessionID == "" {
|
|
sessionID = strings.TrimSpace(env.Config.Session.SessionID)
|
|
}
|
|
campaign := strings.TrimSpace(m.Campaign)
|
|
if campaign == "" {
|
|
campaign = strings.TrimSpace(env.Config.Session.Campaign)
|
|
}
|
|
runID := strings.TrimSpace(m.RunID)
|
|
if sessionID == "" || campaign == "" || runID == "" {
|
|
return nil, fmt.Errorf("extract: session id, campaign, and run id are required")
|
|
}
|
|
if !safePathSegment(runID) {
|
|
return nil, fmt.Errorf("extract: run id %q is not a safe path segment", runID)
|
|
}
|
|
|
|
paths := sessionPathsForEnv(env, sessionID)
|
|
input, err := artifacts.ResolveSessionArtifact(paths, m, artifacts.ArtifactTranscriptFinalTrimmed)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("extract: resolve final-trimmed transcript: %w", err)
|
|
}
|
|
|
|
timeout, err := time.ParseDuration(strings.TrimSpace(notariusConfig.Timeout))
|
|
if err != nil || timeout <= 0 {
|
|
return nil, fmt.Errorf("extract: invalid notarius timeout %q", notariusConfig.Timeout)
|
|
}
|
|
resolvedBinary, err := resolveExecutable(notariusConfig.Binary)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("extract: resolve notarius binary: %w", err)
|
|
}
|
|
configPath, err := absolutePath(notariusConfig.ConfigPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("extract: resolve notarius config path: %w", err)
|
|
}
|
|
inputPath, err := absolutePath(input.Path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("extract: resolve transcript input path: %w", err)
|
|
}
|
|
workingDirectory, err := absolutePath(notariusConfig.WorkingDirectory)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("extract: resolve notarius working directory: %w", err)
|
|
}
|
|
|
|
workspaceRoot := strings.TrimSpace(paths.WorkspaceRoot)
|
|
if workspaceRoot == "" {
|
|
workspaceRoot = env.Config.Pipeline.Workspace.Root
|
|
}
|
|
receiptPath, err := absolutePath(artifacts.SessionRunNotariusReceiptPathForCampaign(workspaceRoot, campaign, sessionID, runID))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("extract: resolve receipt path: %w", err)
|
|
}
|
|
logPath, err := absolutePath(artifacts.SessionRunNotariusLogPathForCampaign(workspaceRoot, campaign, sessionID, runID))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("extract: resolve stderr path: %w", err)
|
|
}
|
|
outputRoot, err := absolutePath(artifacts.SessionRunNotariusOutputRootForCampaign(workspaceRoot, campaign, sessionID, runID))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("extract: resolve output root: %w", err)
|
|
}
|
|
durableBundle, err := absolutePath(artifacts.SessionNotariusBundleDirForCampaign(workspaceRoot, campaign, sessionID, runID))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("extract: resolve durable bundle path: %w", err)
|
|
}
|
|
for _, directory := range []string{filepath.Dir(receiptPath), outputRoot, filepath.Dir(durableBundle)} {
|
|
if err := fileops.EnsureWorkspaceDirectory(directory); err != nil {
|
|
return nil, fmt.Errorf("extract: create directory %q: %w", directory, err)
|
|
}
|
|
}
|
|
|
|
fingerprint, err := extractionFingerprint(resolvedBinary, configPath, notariusConfig, timeout, workingDirectory)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("extract: build configuration fingerprint: %w", err)
|
|
}
|
|
request := notarius.RunRequest{
|
|
Binary: resolvedBinary, ConfigPath: configPath, PipelineID: notariusConfig.PipelineID,
|
|
InputPath: inputPath, OutputRoot: outputRoot, WorkingDirectory: workingDirectory,
|
|
ReceiptPath: receiptPath, LogPath: logPath, Timeout: timeout,
|
|
}
|
|
adapterResult, err := env.Notarius.Run(ctx, request)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("extract: run notarius: %w", err)
|
|
}
|
|
if strings.TrimSpace(adapterResult.BundleRoot) == "" || strings.TrimSpace(adapterResult.Index.Path) == "" {
|
|
return nil, fmt.Errorf("extract: notarius result is missing bundle or index path")
|
|
}
|
|
if adapterResult.Receipt.RunID == "" || adapterResult.Receipt.PipelineID != notariusConfig.PipelineID {
|
|
return nil, fmt.Errorf("extract: notarius receipt identity is missing or incompatible")
|
|
}
|
|
|
|
selected, err := selectRequiredNotariusLanes(env.ArtifactStore, notariusConfig.Outputs, adapterResult)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
indexRelative, err := pathsafe.SlashRelativeFromRoot(adapterResult.BundleRoot, adapterResult.Index.Path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("extract: resolve staging index relative path: %w", err)
|
|
}
|
|
rejectionsRelative, err := pathsafe.SlashRelativeFromRoot(adapterResult.BundleRoot, adapterResult.Index.RejectedPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("extract: resolve staging rejections relative path: %w", err)
|
|
}
|
|
warningsRelative, err := pathsafe.SlashRelativeFromRoot(adapterResult.BundleRoot, adapterResult.Index.WarningsPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("extract: resolve staging warnings relative path: %w", err)
|
|
}
|
|
stagingIndexChecksum, err := checksumRegularFile(adapterResult.Index.Path, false)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("extract: validate staging index: %w", err)
|
|
}
|
|
|
|
if err := fileops.PromoteDirectory(adapterResult.BundleRoot, durableBundle); err != nil {
|
|
return nil, fmt.Errorf("extract: promote notarius bundle: %w", err)
|
|
}
|
|
promotedIndexPath, err := pathsafe.JoinSlashRelativeUnderRoot(durableBundle, indexRelative)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("extract: resolve promoted index: %w", err)
|
|
}
|
|
promotedIndexChecksum, err := checksumRegularFile(promotedIndexPath, false)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("extract: checksum promoted index: %w", err)
|
|
}
|
|
if promotedIndexChecksum != stagingIndexChecksum {
|
|
return nil, fmt.Errorf("extract: promoted index checksum differs from staging index")
|
|
}
|
|
promotedRejectionsPath, err := pathsafe.JoinSlashRelativeUnderRoot(durableBundle, rejectionsRelative)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("extract: resolve promoted rejections: %w", err)
|
|
}
|
|
promotedWarningsPath, err := pathsafe.JoinSlashRelativeUnderRoot(durableBundle, warningsRelative)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("extract: resolve promoted warnings: %w", err)
|
|
}
|
|
|
|
outputs := make([]artifacts.Ref, 0, len(selected)+1)
|
|
for _, lane := range selected {
|
|
promotedPath, err := pathsafe.JoinSlashRelativeUnderRoot(durableBundle, lane.RelativePath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("extract: resolve promoted lane %q: %w", lane.Descriptor.LaneID, err)
|
|
}
|
|
checksum, err := checksumRegularFile(promotedPath, true)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("extract: validate promoted lane %q: %w", lane.Descriptor.LaneID, err)
|
|
}
|
|
if checksum != lane.StagingChecksum {
|
|
return nil, fmt.Errorf("extract: promoted lane %q checksum differs from staging payload", lane.Descriptor.LaneID)
|
|
}
|
|
relativePath, err := pathsafe.SlashRelativeFromRoot(paths.Root, promotedPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("extract: derive lane %q session-relative path: %w", lane.Descriptor.LaneID, err)
|
|
}
|
|
outputs = append(outputs, artifacts.Ref{
|
|
Kind: extractLaneOutputKind, SourceID: artifacts.ExtractionArtifactSourceID(lane.Key),
|
|
Category: "artifacts", SessionID: sessionID, RelativePath: relativePath,
|
|
AbsolutePath: promotedPath, Checksum: checksum,
|
|
Contract: &artifactmodel.ContractMetadata{
|
|
MediaType: lane.Descriptor.MediaType, SchemaID: lane.Descriptor.SchemaID,
|
|
SchemaVersion: lane.Descriptor.SchemaVersion, ModuleKey: lane.Descriptor.ModuleKey,
|
|
},
|
|
ExternalProvenance: &artifactmodel.ExternalProvenance{
|
|
System: "notarius", RunID: adapterResult.Receipt.RunID,
|
|
PipelineID: adapterResult.Receipt.PipelineID, ArtifactID: lane.Descriptor.LaneID,
|
|
},
|
|
})
|
|
}
|
|
indexSessionRelative, err := pathsafe.SlashRelativeFromRoot(paths.Root, promotedIndexPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("extract: derive index session-relative path: %w", err)
|
|
}
|
|
outputs = append(outputs, artifacts.Ref{
|
|
Kind: extractIndexOutputKind, Category: "artifacts", SessionID: sessionID,
|
|
RelativePath: indexSessionRelative, AbsolutePath: promotedIndexPath, Checksum: promotedIndexChecksum,
|
|
})
|
|
|
|
metadata := map[string]any{
|
|
"stage": "extract",
|
|
"notarius_enabled": true,
|
|
"bundle_root": durableBundle,
|
|
"receipt_path": receiptPath,
|
|
"diagnostic_path": logPath,
|
|
"rejections_path": promotedRejectionsPath,
|
|
"warnings_path": promotedWarningsPath,
|
|
"narratio_run_id": runID,
|
|
"configuration_fingerprint": fingerprint,
|
|
"receipt": map[string]any{
|
|
"run_id": adapterResult.Receipt.RunID, "pipeline_id": adapterResult.Receipt.PipelineID,
|
|
"normalized_output_count": adapterResult.Receipt.NormalizedOutputCount,
|
|
"rejected_output_count": adapterResult.Receipt.RejectedOutputCount,
|
|
"warning_count": adapterResult.Receipt.WarningCount,
|
|
"validation_status": adapterResult.Receipt.ValidationStatus,
|
|
},
|
|
"rejections": boundedRejectionMetadata(adapterResult.Rejections),
|
|
"warnings": boundedWarningMetadata(adapterResult.Warnings),
|
|
}
|
|
return &StageResult{
|
|
Outputs: outputs,
|
|
Logs: []string{receiptPath, logPath},
|
|
Metadata: metadata,
|
|
}, nil
|
|
}
|
|
|
|
type selectedNotariusLane struct {
|
|
Key string
|
|
Descriptor notarius.LaneDescriptor
|
|
RelativePath string
|
|
StagingChecksum string
|
|
}
|
|
|
|
func selectRequiredNotariusLanes(
|
|
store artifacts.Store,
|
|
required map[string]config.NotariusOutputConfig,
|
|
result notarius.RunResult,
|
|
) ([]selectedNotariusLane, error) {
|
|
keys := make([]string, 0, len(required))
|
|
for key := range required {
|
|
keys = append(keys, key)
|
|
}
|
|
sort.Strings(keys)
|
|
selected := make([]selectedNotariusLane, 0, len(keys))
|
|
for _, key := range keys {
|
|
expected := required[key]
|
|
for _, rejection := range result.Rejections {
|
|
if rejection.LaneID == expected.LaneID {
|
|
return nil, fmt.Errorf("extract: required lane %q was rejected (reason_code=%q)", expected.LaneID, rejection.ReasonCode)
|
|
}
|
|
}
|
|
matches := make([]notarius.LaneDescriptor, 0, 1)
|
|
for _, descriptor := range result.Index.Lanes {
|
|
if descriptor.LaneID == expected.LaneID {
|
|
matches = append(matches, descriptor)
|
|
}
|
|
}
|
|
if len(matches) != 1 {
|
|
return nil, fmt.Errorf("extract: required lane %q has %d descriptors, want exactly one", expected.LaneID, len(matches))
|
|
}
|
|
descriptor := matches[0]
|
|
if descriptor.MediaType != expected.MediaType || descriptor.SchemaID != expected.SchemaID ||
|
|
descriptor.SchemaVersion != expected.SchemaVersion ||
|
|
(expected.ModuleKey != "" && descriptor.ModuleKey != expected.ModuleKey) {
|
|
return nil, fmt.Errorf("extract: required lane %q descriptor contract is incompatible", expected.LaneID)
|
|
}
|
|
checksum, err := checksumRegularFile(descriptor.Path, true)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("extract: validate required lane %q: %w", expected.LaneID, err)
|
|
}
|
|
if store != nil {
|
|
storeChecksum, err := store.Checksum(descriptor.Path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("extract: checksum required lane %q: %w", expected.LaneID, err)
|
|
}
|
|
if storeChecksum != checksum {
|
|
return nil, fmt.Errorf("extract: inconsistent staging checksum for required lane %q", expected.LaneID)
|
|
}
|
|
}
|
|
relative, err := pathsafe.SlashRelativeFromRoot(result.BundleRoot, descriptor.Path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("extract: resolve required lane %q relative path: %w", expected.LaneID, err)
|
|
}
|
|
selected = append(selected, selectedNotariusLane{
|
|
Key: key, Descriptor: descriptor, RelativePath: relative, StagingChecksum: checksum,
|
|
})
|
|
}
|
|
return selected, nil
|
|
}
|
|
|
|
func checksumRegularFile(path string, requireJSON bool) (string, error) {
|
|
info, err := os.Lstat(path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() {
|
|
return "", fmt.Errorf("path %q must be a regular file without symlinks", path)
|
|
}
|
|
if info.Size() == 0 {
|
|
return "", fmt.Errorf("path %q must be non-empty", path)
|
|
}
|
|
data, err := fileops.ReadRegularFile(path, artifacts.MaxExtractionPayloadBytes)
|
|
if err != nil {
|
|
return "", fmt.Errorf("notarius lane result exceeds or cannot be read within %d-byte limit: %w", artifacts.MaxExtractionPayloadBytes, err)
|
|
}
|
|
if requireJSON && !json.Valid(data) {
|
|
return "", fmt.Errorf("path %q is not valid JSON", path)
|
|
}
|
|
digest := sha256.Sum256(data)
|
|
return hex.EncodeToString(digest[:]), nil
|
|
}
|
|
|
|
type fingerprintOutput struct {
|
|
Key string `json:"key"`
|
|
LaneID string `json:"lane_id"`
|
|
MediaType string `json:"media_type"`
|
|
SchemaID string `json:"schema_id"`
|
|
SchemaVersion string `json:"schema_version"`
|
|
ModuleKey string `json:"module_key,omitempty"`
|
|
}
|
|
|
|
type fingerprintDocument struct {
|
|
Binary string `json:"binary"`
|
|
ConfigPath string `json:"config_path"`
|
|
PipelineID string `json:"pipeline_id"`
|
|
Timeout string `json:"timeout"`
|
|
WorkingDirectory string `json:"working_directory"`
|
|
Outputs []fingerprintOutput `json:"outputs"`
|
|
}
|
|
|
|
func extractionFingerprint(
|
|
binary, configPath string,
|
|
cfg *config.NotariusConfig,
|
|
timeout time.Duration,
|
|
workingDirectory string,
|
|
) (string, error) {
|
|
keys := make([]string, 0, len(cfg.Outputs))
|
|
for key := range cfg.Outputs {
|
|
keys = append(keys, key)
|
|
}
|
|
sort.Strings(keys)
|
|
outputs := make([]fingerprintOutput, 0, len(keys))
|
|
for _, key := range keys {
|
|
output := cfg.Outputs[key]
|
|
outputs = append(outputs, fingerprintOutput{
|
|
Key: key, LaneID: output.LaneID, MediaType: output.MediaType,
|
|
SchemaID: output.SchemaID, SchemaVersion: output.SchemaVersion, ModuleKey: output.ModuleKey,
|
|
})
|
|
}
|
|
payload, err := json.Marshal(fingerprintDocument{
|
|
Binary: binary, ConfigPath: configPath, PipelineID: cfg.PipelineID,
|
|
Timeout: timeout.String(), WorkingDirectory: workingDirectory, Outputs: outputs,
|
|
})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
digest := sha256.Sum256(payload)
|
|
return hex.EncodeToString(digest[:]), nil
|
|
}
|
|
|
|
func resolveExecutable(value string) (string, error) {
|
|
resolved, err := exec.LookPath(strings.TrimSpace(value))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return absolutePath(resolved)
|
|
}
|
|
|
|
func absolutePath(value string) (string, error) {
|
|
trimmed := strings.TrimSpace(value)
|
|
if trimmed == "" {
|
|
return "", fmt.Errorf("path is required")
|
|
}
|
|
resolved, err := filepath.Abs(trimmed)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return filepath.Clean(resolved), nil
|
|
}
|
|
|
|
func safePathSegment(value string) bool {
|
|
return value != "" && value != "." && value != ".." && filepath.Base(value) == value &&
|
|
!strings.ContainsAny(value, `/\\`)
|
|
}
|
|
|
|
func boundedRejectionMetadata(values []notarius.RejectionSummary) []map[string]any {
|
|
limit := len(values)
|
|
if limit > maxDiagnosticSummaries {
|
|
limit = maxDiagnosticSummaries
|
|
}
|
|
result := make([]map[string]any, 0, limit)
|
|
for _, value := range values[:limit] {
|
|
result = append(result, map[string]any{
|
|
"stage": value.Stage, "step_id": value.StepID, "lane_id": value.LaneID,
|
|
"module_key": value.ModuleKey, "chunk_id": value.ChunkID,
|
|
"validator_name": value.ValidatorName, "reason_code": value.ReasonCode,
|
|
})
|
|
}
|
|
return result
|
|
}
|
|
|
|
func boundedWarningMetadata(values []notarius.WarningSummary) []map[string]any {
|
|
limit := len(values)
|
|
if limit > maxDiagnosticSummaries {
|
|
limit = maxDiagnosticSummaries
|
|
}
|
|
result := make([]map[string]any, 0, limit)
|
|
for _, value := range values[:limit] {
|
|
result = append(result, map[string]any{"scope": value.Scope, "reason_code": value.ReasonCode})
|
|
}
|
|
return result
|
|
}
|