mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-23 19:57:09 +00:00
716 lines
26 KiB
Go
716 lines
26 KiB
Go
package qualification
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/rcourtman/pulse-go-rewrite/pkg/aicontracts"
|
|
)
|
|
|
|
type RunnerConfig struct {
|
|
Manifest Manifest
|
|
Lab *DockerLab
|
|
Client *PulseClient
|
|
ArtifactRoot string
|
|
RunID string
|
|
ExpectedModel string
|
|
GitSHA string
|
|
GitDirty bool
|
|
ExpectedPulseVersion string
|
|
ChallengeNonce string
|
|
// AuthorizeRemediation is a second, independent operator gate. Live lab
|
|
// authorization alone never permits an action decision or execution.
|
|
AuthorizeRemediation bool
|
|
}
|
|
|
|
type QualificationRunner struct {
|
|
config RunnerConfig
|
|
}
|
|
|
|
func NewRunner(config RunnerConfig) (*QualificationRunner, error) {
|
|
if err := config.Manifest.Validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
if config.Manifest.Lab.Driver == "docker" && config.Lab == nil {
|
|
return nil, errors.New("Docker lab is required")
|
|
}
|
|
if config.Client == nil {
|
|
return nil, errors.New("Pulse client is required")
|
|
}
|
|
if config.Manifest.Track == TrackRemediation && config.Manifest.Remediation != nil &&
|
|
config.Manifest.Remediation.Decision != "observe" && !config.AuthorizeRemediation {
|
|
return nil, errors.New("remediation decision or execution requires explicit authorization")
|
|
}
|
|
if config.RunID == "" {
|
|
config.RunID = newRunID()
|
|
}
|
|
config.ChallengeNonce = strings.TrimSpace(config.ChallengeNonce)
|
|
if err := ValidateContributionChallenge(config.ChallengeNonce); err != nil {
|
|
return nil, err
|
|
}
|
|
if !safeID.MatchString(config.RunID) {
|
|
return nil, errors.New("invalid run id")
|
|
}
|
|
if config.ArtifactRoot == "" {
|
|
config.ArtifactRoot = filepath.Join("tmp", "patrol-qualification")
|
|
}
|
|
return &QualificationRunner{config: config}, nil
|
|
}
|
|
|
|
func newRunID() string {
|
|
var suffix [4]byte
|
|
_, _ = rand.Read(suffix[:])
|
|
return "q-" + time.Now().UTC().Format("20060102-150405") + "-" + hex.EncodeToString(suffix[:])
|
|
}
|
|
|
|
func (r *QualificationRunner) Run(ctx context.Context) (report RunReport, terminalErr error) {
|
|
manifest := r.config.Manifest
|
|
digest, err := manifest.Digest()
|
|
if err != nil {
|
|
return RunReport{}, err
|
|
}
|
|
report = RunReport{
|
|
SchemaVersion: ReportSchemaVersion,
|
|
RunID: r.config.RunID,
|
|
GeneratedAt: time.Now().UTC(),
|
|
Manifest: manifest,
|
|
Environment: r.initialEnvironment(),
|
|
Collected: make(map[string]CollectedTruth),
|
|
Investigation: make(map[string]aicontracts.InvestigationSession),
|
|
}
|
|
artifactDir := filepath.Join(r.config.ArtifactRoot, r.config.RunID)
|
|
var prepared *PreparedLab
|
|
finish := func() {
|
|
if prepared != nil {
|
|
cleanupCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
|
defer cancel()
|
|
report.Teardown = r.config.Lab.Cleanup(cleanupCtx, manifest, prepared)
|
|
if !report.Teardown.Passed {
|
|
report.Errors = append(report.Errors, "lab teardown verification failed")
|
|
}
|
|
}
|
|
report.Passed = report.Score.Passed && report.Teardown.Passed && len(report.Errors) == 0
|
|
if writeErr := WriteReport(artifactDir, report); writeErr != nil {
|
|
terminalErr = errors.Join(terminalErr, fmt.Errorf("write report: %w", writeErr))
|
|
}
|
|
}
|
|
defer finish()
|
|
|
|
if err := r.phase(&report, "preflight", func() error {
|
|
settings, settingsErr := r.config.Client.Settings(ctx)
|
|
if settingsErr != nil {
|
|
return settingsErr
|
|
}
|
|
status, statusErr := r.config.Client.Status(ctx)
|
|
if statusErr != nil {
|
|
return statusErr
|
|
}
|
|
version, versionErr := r.config.Client.Version(ctx)
|
|
if versionErr != nil {
|
|
return versionErr
|
|
}
|
|
if strings.TrimSpace(version.Version) == "" {
|
|
return errors.New("Pulse runtime version endpoint returned no version identity")
|
|
}
|
|
if expected := strings.TrimSpace(r.config.ExpectedPulseVersion); expected != "" && version.Version != expected {
|
|
return fmt.Errorf("Pulse runtime version %q does not match required version %q", version.Version, expected)
|
|
}
|
|
if manifest.Patrol.RequireRealModel {
|
|
if !settings.Enabled || !settings.PatrolEnabled || !status.Readiness.Ready {
|
|
return fmt.Errorf("Patrol real-model readiness failed: %s", status.Readiness.Summary)
|
|
}
|
|
if status.Readiness.Provider == "" || status.Readiness.Model == "" || strings.EqualFold(status.Readiness.Provider, "demo") {
|
|
return errors.New("Patrol readiness did not identify a real provider and model")
|
|
}
|
|
}
|
|
autonomy, autonomyErr := r.config.Client.Autonomy(ctx)
|
|
if autonomyErr != nil {
|
|
return autonomyErr
|
|
}
|
|
if expected := strings.TrimSpace(manifest.Patrol.Mode); expected != "" && !strings.EqualFold(expected, autonomy.Effective()) {
|
|
return fmt.Errorf("scenario requires Patrol mode %q, effective mode is %q", expected, autonomy.Effective())
|
|
}
|
|
if expected := strings.TrimSpace(r.config.ExpectedModel); expected != "" {
|
|
if err := validatePatrolRoute(expected, settings, status); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
model := settings.EffectivePatrolModel()
|
|
provider := strings.ToLower(strings.TrimSpace(status.Readiness.Provider))
|
|
if provider == "" {
|
|
provider, _ = splitModel(model)
|
|
}
|
|
report.Environment = Environment{
|
|
GitSHA: r.config.GitSHA, GitDirty: r.config.GitDirty,
|
|
PulseVersion: version.Version,
|
|
PulseBaseURL: r.config.Client.config.BaseURL,
|
|
DockerTarget: dockerTargetLabel(r.config.Lab.target),
|
|
Model: model, Provider: provider, InferenceRoute: inferenceRouteForProviderEndpoint(provider, settings.ZaiBaseURL), ChallengeNonce: r.config.ChallengeNonce,
|
|
CapturedAt: time.Now().UTC(),
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
report.Errors = append(report.Errors, err.Error())
|
|
terminalErr = err
|
|
return report, terminalErr
|
|
}
|
|
|
|
if err := r.phase(&report, "provision_and_baseline", func() error {
|
|
var prepareErr error
|
|
prepared, prepareErr = r.config.Lab.Prepare(ctx, manifest, r.config.RunID)
|
|
report.PreparedLab = prepared
|
|
if prepareErr != nil {
|
|
return prepareErr
|
|
}
|
|
baseline, observeErr := r.config.Lab.Observe(ctx, manifest, prepared, manifest.Baseline)
|
|
if observeErr != nil {
|
|
return observeErr
|
|
}
|
|
report.GroundTruth = GroundTruth{
|
|
SchemaVersion: "patrol.qualification.ground-truth/v2",
|
|
ManifestID: manifest.ID, ManifestDigest: digest, RunID: r.config.RunID,
|
|
CreatedAt: time.Now().UTC(), Baseline: baseline,
|
|
Resources: make(map[string]CollectedTruth),
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
report.Errors = append(report.Errors, err.Error())
|
|
terminalErr = err
|
|
return report, terminalErr
|
|
}
|
|
|
|
faultStarted := time.Now().UTC()
|
|
if err := r.phase(&report, "inject_and_confirm_ground_truth", func() error {
|
|
for _, fault := range manifest.Faults {
|
|
if err := r.config.Lab.ApplyFault(ctx, manifest, prepared, fault); err != nil {
|
|
return fmt.Errorf("apply fault %s: %w", fault.ID, err)
|
|
}
|
|
observations, err := r.config.Lab.Observe(ctx, manifest, prepared, fault.Oracle)
|
|
truth := FaultTruth{
|
|
ID: fault.ID, CausalGroup: fault.CausalGroup, TargetAlias: fault.Target,
|
|
TargetName: prepared.ResourceNames[fault.Target], Active: err == nil && allObservationsPassed(observations),
|
|
ConfirmedAt: time.Now().UTC(), Observations: observations,
|
|
}
|
|
report.GroundTruth.Faults = append(report.GroundTruth.Faults, truth)
|
|
if err != nil {
|
|
return fmt.Errorf("confirm fault %s: %w", fault.ID, err)
|
|
}
|
|
}
|
|
for _, control := range manifest.NegativeControls {
|
|
report.GroundTruth.Negative = append(report.GroundTruth.Negative, NegativeTruth{Alias: control.Resource, Name: prepared.ResourceNames[control.Resource], Reason: control.Reason})
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
report.Errors = append(report.Errors, err.Error())
|
|
terminalErr = err
|
|
return report, terminalErr
|
|
}
|
|
|
|
var collected map[string]Resource
|
|
if err := r.phase(&report, "normal_collection_convergence", func() error {
|
|
timeout, _ := positiveDuration(manifest.Collection.ConvergenceTimeout)
|
|
poll, _ := positiveDuration(manifest.Collection.PollInterval)
|
|
var collectionErr error
|
|
collected, collectionErr = r.config.Client.WaitForResourcesMatching(ctx, prepared.ResourceNames, timeout, poll, func(resources map[string]Resource) error {
|
|
return validateCollectedScenarioProjection(manifest, resources)
|
|
})
|
|
if collectionErr != nil {
|
|
return collectionErr
|
|
}
|
|
for alias, resource := range collected {
|
|
truth := CollectedTruth{Alias: alias, Name: resource.Name, ResourceID: resource.ID, ResourceType: resource.Type, Status: resource.Status, ObservedAt: time.Now().UTC()}
|
|
report.Collected[alias] = truth
|
|
report.GroundTruth.Resources[alias] = truth
|
|
}
|
|
for i := range report.GroundTruth.Faults {
|
|
truth := &report.GroundTruth.Faults[i]
|
|
resource := collected[truth.TargetAlias]
|
|
truth.ResourceID, truth.ResourceType = resource.ID, resource.Type
|
|
fault := findFault(manifest.Faults, truth.ID)
|
|
expected := collected[fault.Expected.Resource]
|
|
truth.ExpectedResourceAlias = fault.Expected.Resource
|
|
truth.ExpectedResourceName = expected.Name
|
|
truth.ExpectedResourceID = expected.ID
|
|
truth.ExpectedResourceType = expected.Type
|
|
for _, alias := range fault.RelatedResources {
|
|
truth.RelatedResourceIDs = append(truth.RelatedResourceIDs, collected[alias].ID)
|
|
}
|
|
}
|
|
for i := range report.GroundTruth.Negative {
|
|
truth := &report.GroundTruth.Negative[i]
|
|
truth.ResourceID = collected[truth.Alias].ID
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
report.Errors = append(report.Errors, err.Error())
|
|
terminalErr = err
|
|
return report, terminalErr
|
|
}
|
|
|
|
var beforeFindings, afterFindings []Finding
|
|
triggeredAt := time.Now().UTC()
|
|
if err := r.phase(&report, "real_model_patrol", func() error {
|
|
var err error
|
|
beforeFindings, err = r.config.Client.Findings(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
resourceIDs := patrolScopeResourceIDs(manifest, collected)
|
|
if manifest.Patrol.RequireExistingReconfirmation {
|
|
warmupTriggeredAt := time.Now().UTC()
|
|
runTimeout, _ := positiveDuration(manifest.Patrol.RunTimeout)
|
|
warmup, warmupErr := r.config.Client.TriggerAndWait(ctx, resourceIDs, "", runTimeout)
|
|
if warmupErr != nil {
|
|
return fmt.Errorf("existing-finding prerequisite Patrol run: %w", warmupErr)
|
|
}
|
|
report.PrerequisitePatrolRun = &warmup
|
|
afterWarmup, findingsErr := r.config.Client.Findings(ctx)
|
|
if findingsErr != nil {
|
|
return findingsErr
|
|
}
|
|
warmupFindings := filterRunFindings(beforeFindings, afterWarmup, warmup, collected, warmupTriggeredAt)
|
|
if err := validateExistingFindingPrerequisite(manifest, collected, warmup, warmupFindings); err != nil {
|
|
return err
|
|
}
|
|
beforeFindings = afterWarmup
|
|
}
|
|
triggeredAt = time.Now().UTC()
|
|
runTimeout, _ := positiveDuration(manifest.Patrol.RunTimeout)
|
|
report.PatrolRun, err = r.config.Client.TriggerAndWait(ctx, resourceIDs, "", runTimeout)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if manifest.Patrol.RequireToolCallEvidence && len(report.PatrolRun.ToolCalls) == 0 {
|
|
return errors.New("completed Patrol run has no persisted tool-call evidence")
|
|
}
|
|
if manifest.Patrol.RequireExistingReconfirmation {
|
|
if report.PatrolRun.ExistingFindings == 0 {
|
|
return errors.New("qualification run did not reconfirm an existing finding")
|
|
}
|
|
if len(report.PatrolRun.FindingAssessments) == 0 {
|
|
return errors.New("qualification run reconfirmed an existing finding without a persisted assessment")
|
|
}
|
|
}
|
|
afterFindings, err = r.config.Client.Findings(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
report.Findings = filterRunFindings(beforeFindings, afterFindings, report.PatrolRun, collected, triggeredAt)
|
|
return nil
|
|
}); err != nil {
|
|
report.Errors = append(report.Errors, err.Error())
|
|
terminalErr = err
|
|
return report, terminalErr
|
|
}
|
|
|
|
faultsIntact, noMutation := true, true
|
|
if err := r.phase(&report, "post_patrol_safety_oracle", func() error {
|
|
for _, fault := range manifest.Faults {
|
|
observations, err := r.config.Lab.Observe(ctx, manifest, prepared, fault.Oracle)
|
|
report.PostPatrol = append(report.PostPatrol, observations...)
|
|
if err != nil {
|
|
faultsIntact = false
|
|
if manifest.Security.RequireFaultIntact {
|
|
return fmt.Errorf("fault %s did not remain intact: %w", fault.ID, err)
|
|
}
|
|
}
|
|
}
|
|
current, err := r.config.Lab.inventory(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
noMutation = inventoryEqual(current, prepared.ExpectedInventory)
|
|
if manifest.Security.RequireNoMutation && !noMutation {
|
|
return errors.New("Docker inventory changed during Watch/investigation run")
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
report.Errors = append(report.Errors, err.Error())
|
|
}
|
|
faultsIntactCaptured := faultsIntact
|
|
noMutationCaptured := noMutation
|
|
report.SafetyOracle = &SafetyOracleResult{
|
|
FaultsIntact: &faultsIntactCaptured,
|
|
NoUnexpectedMutation: &noMutationCaptured,
|
|
}
|
|
|
|
if manifest.Track == TrackInvestigation || manifest.Track == TrackRemediation {
|
|
if err := r.phase(&report, "investigation", func() error {
|
|
timeout := 10 * time.Minute
|
|
if manifest.Patrol.InvestigationTimeout != "" {
|
|
timeout, _ = positiveDuration(manifest.Patrol.InvestigationTimeout)
|
|
}
|
|
if len(report.Findings) == 0 {
|
|
return errors.New("investigation track produced no finding to investigate")
|
|
}
|
|
for _, finding := range report.Findings {
|
|
investigation, err := r.config.Client.WaitForInvestigation(ctx, finding.ID, timeout)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
report.Investigation[finding.ID] = investigation
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
report.Errors = append(report.Errors, err.Error())
|
|
}
|
|
}
|
|
if manifest.Track == TrackRemediation {
|
|
if err := r.phase(&report, "governed_remediation", func() error {
|
|
return r.runRemediation(ctx, &report, prepared, collected)
|
|
}); err != nil {
|
|
report.Errors = append(report.Errors, err.Error())
|
|
}
|
|
}
|
|
|
|
report.Score = ScoreRun(ScoringInput{
|
|
Manifest: manifest, GroundTruth: report.GroundTruth,
|
|
Run: report.PatrolRun, Findings: report.Findings,
|
|
Provider: report.Environment.Provider, Model: report.Environment.Model,
|
|
InferenceRoute: report.Environment.InferenceRoute,
|
|
CollectionLatency: phaseDuration(report.Phases, "normal_collection_convergence"),
|
|
EndToEndLatency: time.Since(faultStarted),
|
|
FaultsIntact: faultsIntact, NoMutation: noMutation,
|
|
})
|
|
if manifest.Track != TrackWatch && len(report.Investigation) == 0 {
|
|
report.Score.HardFailures = append(report.Score.HardFailures, "no completed investigation evidence")
|
|
report.Score.Passed = false
|
|
}
|
|
ApplyProTrackGates(&report.Score, manifest, report.GroundTruth, report.Investigation, report.Remediation)
|
|
|
|
if err := r.phase(&report, "revert_and_verify", func() error {
|
|
for i := len(manifest.Faults) - 1; i >= 0; i-- {
|
|
fault := manifest.Faults[i]
|
|
if err := r.config.Lab.RevertFault(ctx, manifest, prepared, fault); err != nil {
|
|
return fmt.Errorf("revert fault %s: %w", fault.ID, err)
|
|
}
|
|
predicates := fault.RevertOracle
|
|
if len(predicates) == 0 {
|
|
predicates = manifest.Baseline
|
|
}
|
|
observations, err := r.config.Lab.Observe(ctx, manifest, prepared, predicates)
|
|
report.Revert = append(report.Revert, observations...)
|
|
if err != nil {
|
|
return fmt.Errorf("verify revert %s: %w", fault.ID, err)
|
|
}
|
|
}
|
|
if len(manifest.Faults) == 0 {
|
|
observations, err := r.config.Lab.Observe(ctx, manifest, prepared, manifest.Baseline)
|
|
report.Revert = append(report.Revert, observations...)
|
|
return err
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
report.Errors = append(report.Errors, err.Error())
|
|
terminalErr = errors.Join(terminalErr, err)
|
|
}
|
|
return report, terminalErr
|
|
}
|
|
|
|
func validateExistingFindingPrerequisite(manifest Manifest, collected map[string]Resource, warmup PatrolRun, findings []Finding) error {
|
|
if strings.EqualFold(strings.TrimSpace(warmup.Status), "error") || warmup.ErrorCount > 0 {
|
|
return fmt.Errorf("existing-finding prerequisite Patrol run failed: status=%s errors=%d", warmup.Status, warmup.ErrorCount)
|
|
}
|
|
if len(findings) == 0 {
|
|
return errors.New("existing-finding prerequisite run did not create a run-owned finding")
|
|
}
|
|
|
|
foundResources := make(map[string]struct{}, len(findings))
|
|
for _, finding := range findings {
|
|
foundResources[finding.ResourceID] = struct{}{}
|
|
}
|
|
checkedAliases := make(map[string]struct{})
|
|
for _, fault := range manifest.Faults {
|
|
if !fault.Required {
|
|
continue
|
|
}
|
|
alias := strings.TrimSpace(fault.Expected.Resource)
|
|
if alias == "" {
|
|
alias = strings.TrimSpace(fault.Target)
|
|
}
|
|
if _, seen := checkedAliases[alias]; seen {
|
|
continue
|
|
}
|
|
checkedAliases[alias] = struct{}{}
|
|
resource, ok := collected[alias]
|
|
if !ok || strings.TrimSpace(resource.ID) == "" {
|
|
return fmt.Errorf("existing-finding prerequisite expected resource %q was not collected", alias)
|
|
}
|
|
if _, ok := foundResources[resource.ID]; !ok {
|
|
return fmt.Errorf("existing-finding prerequisite run created no finding for expected resource %q", alias)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func patrolScopeResourceIDs(manifest Manifest, collected map[string]Resource) []string {
|
|
if !manifest.Patrol.Scoped {
|
|
return nil
|
|
}
|
|
resourceIDs := make([]string, 0, len(collected))
|
|
if len(manifest.Patrol.ScopeResources) > 0 {
|
|
for _, alias := range manifest.Patrol.ScopeResources {
|
|
resourceIDs = append(resourceIDs, collected[alias].ID)
|
|
}
|
|
} else {
|
|
for _, resource := range collected {
|
|
resourceIDs = append(resourceIDs, resource.ID)
|
|
}
|
|
}
|
|
sort.Strings(resourceIDs)
|
|
return resourceIDs
|
|
}
|
|
|
|
func (r *QualificationRunner) initialEnvironment() Environment {
|
|
provider, _ := splitModel(r.config.ExpectedModel)
|
|
dockerTarget := ""
|
|
if r.config.Lab != nil {
|
|
dockerTarget = dockerTargetLabel(r.config.Lab.target)
|
|
}
|
|
return Environment{
|
|
GitSHA: r.config.GitSHA,
|
|
GitDirty: r.config.GitDirty,
|
|
PulseBaseURL: r.config.Client.config.BaseURL,
|
|
DockerTarget: dockerTarget,
|
|
Model: strings.TrimSpace(r.config.ExpectedModel),
|
|
Provider: provider,
|
|
InferenceRoute: inferenceRouteForProvider(provider),
|
|
ChallengeNonce: r.config.ChallengeNonce,
|
|
CapturedAt: time.Now().UTC(),
|
|
}
|
|
}
|
|
|
|
func validateCollectedScenarioProjection(manifest Manifest, resources map[string]Resource) error {
|
|
for _, fault := range manifest.Faults {
|
|
for _, predicate := range fault.Oracle {
|
|
if err := validateCollectedDockerPredicate("fault "+fault.ID, predicate, resources); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
negativeControls := make(map[string]struct{}, len(manifest.NegativeControls))
|
|
for _, control := range manifest.NegativeControls {
|
|
negativeControls[control.Resource] = struct{}{}
|
|
}
|
|
for _, predicate := range manifest.Baseline {
|
|
if _, ok := negativeControls[predicate.Target]; !ok {
|
|
continue
|
|
}
|
|
if err := validateCollectedDockerPredicate("negative control "+predicate.Target, predicate, resources); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateCollectedDockerPredicate(subject string, predicate Predicate, resources map[string]Resource) error {
|
|
resource, ok := resources[predicate.Target]
|
|
if !ok {
|
|
return fmt.Errorf("%s target %s is absent from collected resources", subject, predicate.Target)
|
|
}
|
|
if resource.Docker == nil {
|
|
return fmt.Errorf("%s target %s has no collected Docker projection", subject, predicate.Target)
|
|
}
|
|
switch predicate.Probe {
|
|
case "docker.health":
|
|
var expected string
|
|
if err := json.Unmarshal(predicate.Value, &expected); err != nil {
|
|
return fmt.Errorf("%s has invalid collected health expectation: %w", subject, err)
|
|
}
|
|
if !strings.EqualFold(strings.TrimSpace(resource.Docker.Health), strings.TrimSpace(expected)) {
|
|
return fmt.Errorf("%s target %s collected health=%q want %q", subject, predicate.Target, resource.Docker.Health, expected)
|
|
}
|
|
case "docker.running":
|
|
var expected bool
|
|
if err := json.Unmarshal(predicate.Value, &expected); err != nil {
|
|
return fmt.Errorf("%s has invalid collected running expectation: %w", subject, err)
|
|
}
|
|
running := strings.EqualFold(strings.TrimSpace(resource.Docker.ContainerState), "running")
|
|
if running != expected {
|
|
return fmt.Errorf("%s target %s collected running=%t state=%q want %t", subject, predicate.Target, running, resource.Docker.ContainerState, expected)
|
|
}
|
|
case "docker.restart_count":
|
|
var expected int
|
|
if err := json.Unmarshal(predicate.Value, &expected); err != nil {
|
|
return fmt.Errorf("%s has invalid collected restart-count expectation: %w", subject, err)
|
|
}
|
|
observed := resource.Docker.RestartCount
|
|
satisfied := predicate.Operator == "eq" && observed == expected || predicate.Operator == "gte" && observed >= expected
|
|
if !satisfied {
|
|
return fmt.Errorf("%s target %s collected restart_count=%d does not satisfy %s %d", subject, predicate.Target, observed, predicate.Operator, expected)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func phaseDuration(phases []PhaseTiming, name string) time.Duration {
|
|
for _, phase := range phases {
|
|
if phase.Name == name {
|
|
return phase.Duration
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func (r *QualificationRunner) runRemediation(ctx context.Context, report *RunReport, prepared *PreparedLab, collected map[string]Resource) error {
|
|
spec := r.config.Manifest.Remediation
|
|
if spec == nil {
|
|
return errors.New("remediation specification is missing")
|
|
}
|
|
resource, ok := collected[spec.ActionTarget]
|
|
if !ok || resource.ID == "" {
|
|
return fmt.Errorf("remediation target %q was not collected", spec.ActionTarget)
|
|
}
|
|
result := RemediationResult{ResourceID: resource.ID, Decision: spec.Decision, Authorized: r.config.AuthorizeRemediation || spec.Decision == "observe"}
|
|
defer func() { report.Remediation = result }()
|
|
|
|
var reference *aicontracts.ActionReference
|
|
for findingID, investigation := range report.Investigation {
|
|
if investigation.Action == nil || investigation.Action.ResourceID != resource.ID {
|
|
continue
|
|
}
|
|
copy := *investigation.Action
|
|
reference = ©
|
|
result.FindingID = findingID
|
|
result.InvestigationID = investigation.ID
|
|
break
|
|
}
|
|
if reference == nil {
|
|
return fmt.Errorf("no investigation action references exact resource %s", resource.ID)
|
|
}
|
|
result.ActionID = reference.ActionID
|
|
result.CapabilityName = reference.CapabilityName
|
|
if result.ActionID == "" || !stringInFold(spec.ExpectedCapabilities, reference.CapabilityName) {
|
|
return fmt.Errorf("investigation action %q has unexpected capability %q", result.ActionID, reference.CapabilityName)
|
|
}
|
|
|
|
detail, err := r.config.Client.Action(ctx, result.ActionID)
|
|
if err != nil {
|
|
return fmt.Errorf("load exact action %s: %w", result.ActionID, err)
|
|
}
|
|
result.Before = &detail
|
|
audit := detail.Audit.ActionAuditRecord
|
|
result.PlanHashBound = audit.Plan.PlanHash != "" && audit.Plan.PlanHash == reference.Plan.PlanHash
|
|
result.OriginBound = audit.Origin != nil && audit.Origin.FindingID == result.FindingID &&
|
|
audit.Origin.InvestigationID == result.InvestigationID && audit.Request.ResourceID == resource.ID
|
|
if !result.PlanHashBound {
|
|
return errors.New("investigation action plan hash does not match authoritative action audit")
|
|
}
|
|
if spec.RequireExactOrigin && !result.OriginBound {
|
|
return errors.New("action origin is not bound to the exact finding, investigation, and resource")
|
|
}
|
|
|
|
pending, pendingErr := r.config.Client.Actions(ctx, "pending")
|
|
settled, settledErr := r.config.Client.Actions(ctx, "settled")
|
|
if pendingErr == nil {
|
|
report.Actions = append(report.Actions, pending...)
|
|
}
|
|
if settledErr == nil {
|
|
report.Actions = append(report.Actions, settled...)
|
|
}
|
|
if pendingErr != nil && settledErr != nil {
|
|
return errors.Join(pendingErr, settledErr)
|
|
}
|
|
|
|
switch spec.Decision {
|
|
case "observe":
|
|
result.Passed = true
|
|
return nil
|
|
case "reject":
|
|
if _, err := r.config.Client.DecideAction(ctx, result.ActionID, "rejected", spec.DecisionReason, audit.Plan.PlanHash); err != nil {
|
|
return fmt.Errorf("reject exact action: %w", err)
|
|
}
|
|
case "approve_execute":
|
|
if _, err := r.config.Client.DecideAction(ctx, result.ActionID, "approved", spec.DecisionReason, audit.Plan.PlanHash); err != nil {
|
|
return fmt.Errorf("approve exact action: %w", err)
|
|
}
|
|
if _, err := r.config.Client.ExecuteAction(ctx, result.ActionID, spec.DecisionReason, audit.Plan.PlanHash); err != nil {
|
|
return fmt.Errorf("execute exact approved action: %w", err)
|
|
}
|
|
default:
|
|
return fmt.Errorf("unsupported remediation decision %q", spec.Decision)
|
|
}
|
|
|
|
timeout, _ := positiveDuration(spec.ActionTimeout)
|
|
after, err := r.config.Client.WaitForAction(ctx, result.ActionID, timeout)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
result.After = &after
|
|
if spec.Decision == "reject" && string(after.Audit.State) != "rejected" {
|
|
return fmt.Errorf("rejected action reached unexpected state %q", after.Audit.State)
|
|
}
|
|
if spec.Decision == "approve_execute" && string(after.Audit.State) != "completed" {
|
|
return fmt.Errorf("approved action reached unexpected state %q", after.Audit.State)
|
|
}
|
|
if len(spec.Postconditions) > 0 {
|
|
observations, observeErr := r.config.Lab.Observe(ctx, r.config.Manifest, prepared, spec.Postconditions)
|
|
result.Postconditions = observations
|
|
result.IndependentVerified = observeErr == nil && allObservationsPassed(observations)
|
|
if observeErr != nil {
|
|
result.Errors = append(result.Errors, observeErr.Error())
|
|
}
|
|
} else {
|
|
result.IndependentVerified = spec.Decision != "approve_execute"
|
|
}
|
|
verificationStatus := string(after.Audit.VerificationOutcome.Status)
|
|
result.LifecycleVerified = !spec.RequireLifecycleVerification || stringInFold(spec.AllowedVerificationStatuses, verificationStatus)
|
|
if spec.RequireLifecycleVerification && !result.LifecycleVerified {
|
|
result.Errors = append(result.Errors, fmt.Sprintf("lifecycle verification status %q is not allowed", verificationStatus))
|
|
}
|
|
result.Passed = result.OriginBound && result.PlanHashBound && result.IndependentVerified && result.LifecycleVerified && len(result.Errors) == 0
|
|
if !result.Passed {
|
|
return errors.New("governed remediation verification failed")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *QualificationRunner) phase(report *RunReport, name string, operation func() error) error {
|
|
phase := PhaseTiming{Name: name, StartedAt: time.Now().UTC()}
|
|
err := operation()
|
|
phase.EndedAt = time.Now().UTC()
|
|
phase.Duration = phase.EndedAt.Sub(phase.StartedAt)
|
|
phase.Passed = err == nil
|
|
if err != nil {
|
|
phase.Error = sanitizeArtifactText(err.Error())
|
|
}
|
|
report.Phases = append(report.Phases, phase)
|
|
return err
|
|
}
|
|
|
|
func dockerTargetLabel(target DockerTarget) string {
|
|
if target.SSHHost != "" {
|
|
return "ssh:" + target.SSHHost
|
|
}
|
|
return "context:" + target.Context
|
|
}
|
|
|
|
func GitEnvironment(ctx context.Context, runner CommandRunner, repo string) (string, bool) {
|
|
if runner == nil {
|
|
runner = ExecCommandRunner{}
|
|
}
|
|
sha, err := runner.Run(ctx, "git", "-C", repo, "rev-parse", "HEAD")
|
|
if err != nil {
|
|
return "", true
|
|
}
|
|
status, err := runner.Run(ctx, "git", "-C", repo, "status", "--porcelain")
|
|
return strings.TrimSpace(sha.Stdout), err != nil || strings.TrimSpace(status.Stdout) != ""
|
|
}
|
|
|
|
func EnsureArtifactRoot(root string) error {
|
|
if root == "" {
|
|
return errors.New("artifact root is empty")
|
|
}
|
|
return os.MkdirAll(root, 0o700)
|
|
}
|