diff --git a/.ai/spec/what/audit-logging.md b/.ai/spec/what/audit-logging.md index 3f262a0..76ff06d 100644 --- a/.ai/spec/what/audit-logging.md +++ b/.ai/spec/what/audit-logging.md @@ -19,17 +19,17 @@ Implementation spec for compliance audit logging in the agentic operator. Parent | `audit.escalation.completed` | EscalationResult CR created | EscalationResult serialization | | `audit.proposal.terminal` | Proposal reaches terminal phase (Completed, Failed, Denied, Escalated) | Final phase, terminal reason | -2. CR serialization MUST include `.spec` plus `metadata.name`, `metadata.namespace`, `metadata.creationTimestamp`, and `metadata.uid`. Not the full Kubernetes metadata. +2. CR serialization MUST include `.spec` plus `metadata.name`, `metadata.namespace`, `metadata.creationTimestamp`, and `metadata.uid`. Not the full Kubernetes metadata. Result CRs (AnalysisResult, ExecutionResult, VerificationResult, EscalationResult) MUST also include `.status` since the useful data (RemediationOptions, ActionsTaken, Checks, etc.) lives in status. -3. Audit events MUST be emitted from the reconciliation loop where the operator already has the Proposal object in scope. The `trace_id` is read from the Proposal's `metadata.uid`. +3. All reconcile-emitted audit events MUST be emitted from the reconciliation loop where the operator already has the Proposal object in scope. The `trace_id` is read from the Proposal's `metadata.uid`. (`audit.approval.received` is webhook-emitted as defined below.) ### OTEL Spans -4. The operator MUST create a root span `proposal.lifecycle` when it first detects a new Proposal CR. The OTEL trace ID MUST be the Proposal's `metadata.uid` with hyphens stripped. +4. The operator MUST create a root span `proposal.received` when it first detects a new Proposal CR. The OTEL trace ID MUST be the Proposal's `metadata.uid` with hyphens stripped. 5. On operator restart, the operator MUST read the Proposal's `metadata.uid` from the CR and resume the trace by constructing a SpanContext with the same trace ID. -6. Child spans MUST be created for each phase: `proposal.analyze`, `proposal.human_approval`, `proposal.execute`, `proposal.verify`, `proposal.escalate`. +6. Child spans MUST be created for each phase: `proposal.analyze`, `proposal.human_approval`, `proposal.execute`, `proposal.verify`, `proposal.escalate`, `proposal.terminal`. 7. `proposal.human_approval` starts when the operator begins waiting for approval and ends when the ProposalApproval PATCH is observed. Duration = human decision time. @@ -60,11 +60,11 @@ Implementation spec for compliance audit logging in the agentic operator. Parent ### Configuration -16. The operator reads audit config from the `AgenticOLSConfig` CR at `spec.audit`. If `spec.audit` is absent, the default is `enabled: true` with no OTEL export. +16. The operator reads audit config from the `AgenticOLSConfig` CR at `spec.audit`. Logging and tracing are independent controls. -17. When `spec.audit.enabled` is `true` (or absent — default), all audit events emit. When explicitly `false`, no audit events emit. +17. `spec.audit.logging` controls structured JSON audit events to stdout. Defaults to `true` — when the CR is absent or the field is not set, audit logging is enabled. Set to `false` to disable structured audit log output. -18. When `spec.audit.otel.endpoint` is set, the operator configures an OTLP exporter pointed at that endpoint. When empty or absent, a no-op exporter is used. +18. `spec.audit.otel.endpoint` controls OTEL trace export. When set, the operator configures an OTLP exporter pointed at that endpoint. When empty or absent, a no-op exporter is used. Independent of the `logging` flag — tracing works regardless of whether logging is on or off. 19. The operator MUST pass the OTEL endpoint to the sandbox via environment variable or config mount so the sandbox can configure its own exporter. diff --git a/api/v1alpha1/agenticolsconfig_types.go b/api/v1alpha1/agenticolsconfig_types.go index 3885265..aa08a27 100644 --- a/api/v1alpha1/agenticolsconfig_types.go +++ b/api/v1alpha1/agenticolsconfig_types.go @@ -20,6 +20,65 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) +// AuditOTELConfig holds the optional OTEL exporter endpoint for audit tracing. +// +// +kubebuilder:validation:MinProperties=1 +type AuditOTELConfig struct { + // endpoint is the OTLP gRPC endpoint URL (e.g. "jaeger-otlp-grpc.observability.svc:4317"). + // When empty or absent, a no-op exporter is used — no traces are sent. + // +optional + // +kubebuilder:validation:MinLength=1 + // +kubebuilder:validation:MaxLength=253 + Endpoint string `json:"endpoint,omitempty"` + + // insecure disables TLS for the OTLP gRPC connection. Defaults to true + // for backward compatibility with in-cluster collectors. Set to false + // to require TLS when connecting to external or production endpoints. + // +optional + Insecure *bool `json:"insecure,omitempty"` //nolint:kubeapilinter // on/off toggle is genuinely binary +} + +// AuditConfig controls compliance audit logging and tracing independently. +// +// +kubebuilder:validation:MinProperties=1 +type AuditConfig struct { + // logging controls whether structured JSON audit events are emitted to stdout. + // Defaults to true. When the CR is absent or this field is not set, audit + // logging is enabled. Set to false to disable structured audit log output. + // +optional + Logging *bool `json:"logging,omitempty"` //nolint:kubeapilinter // on/off toggle is genuinely binary + + // otel holds the optional OTEL exporter configuration for distributed tracing. + // Independent of the logging flag — tracing is enabled when an endpoint is set, + // regardless of whether logging is on or off. + // +optional + OTEL *AuditOTELConfig `json:"otel,omitzero"` //nolint:kubeapilinter // pointer needed for nil vs zero distinction +} + +// LoggingEnabled returns true if structured audit logging is enabled (default: true when nil). +func (c *AuditConfig) LoggingEnabled() bool { + if c == nil || c.Logging == nil { + return true + } + return *c.Logging +} + +// OTELEndpoint returns the configured OTEL endpoint, or empty string if not set. +func (c *AuditConfig) OTELEndpoint() string { + if c == nil || c.OTEL == nil { + return "" + } + return c.OTEL.Endpoint +} + +// OTELInsecure returns whether the OTLP connection should use plaintext (default: true). +func (c *AuditConfig) OTELInsecure() bool { + if c == nil || c.OTEL == nil || c.OTEL.Insecure == nil { + return true + } + return *c.OTEL.Insecure +} + // AgenticOLSConfigSpec defines the desired state of AgenticOLSConfig. // // +kubebuilder:validation:MinProperties=1 @@ -32,6 +91,11 @@ type AgenticOLSConfigSpec struct { // +optional // +default=false Suspended bool `json:"suspended,omitempty"` //nolint:kubeapilinter // kill switch is genuinely binary; bool is the right type + + // audit controls compliance audit logging. When absent, audit logging + // is enabled by default with no OTEL export (structured JSON to stdout only). + // +optional + Audit *AuditConfig `json:"audit,omitzero"` //nolint:kubeapilinter // pointer needed for nil vs zero distinction in AuditEnabled() } // +kubebuilder:object:root=true @@ -56,6 +120,10 @@ type AgenticOLSConfigSpec struct { // name: cluster // spec: // suspended: false +// audit: +// logging: true +// otel: +// endpoint: "jaeger-otlp-grpc.observability.svc:4317" type AgenticOLSConfig struct { metav1.TypeMeta `json:",inline"` diff --git a/cmd/main.go b/cmd/main.go index aae3eca..8c328c4 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -1,19 +1,28 @@ package main import ( + "context" "flag" "os" // Import auth plugins (Azure, GCP, OIDC, etc.) for local and hosted kubeconfigs. _ "k8s.io/client-go/plugin/pkg/client/auth" + "github.com/go-logr/logr" consolev1 "github.com/openshift/api/console/v1" openshiftv1 "github.com/openshift/api/operator/v1" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" + "go.opentelemetry.io/otel/propagation" + "go.opentelemetry.io/otel/sdk/resource" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + semconv "go.opentelemetry.io/otel/semconv/v1.26.0" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/runtime" utilruntime "k8s.io/apimachinery/pkg/util/runtime" clientgoscheme "k8s.io/client-go/kubernetes/scheme" ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/client/config" "sigs.k8s.io/controller-runtime/pkg/healthz" "sigs.k8s.io/controller-runtime/pkg/log/zap" @@ -21,6 +30,7 @@ import ( agenticv1alpha1 "github.com/openshift/lightspeed-agentic-operator/api/v1alpha1" agenticcontroller "github.com/openshift/lightspeed-agentic-operator/controller" + "github.com/openshift/lightspeed-agentic-operator/controller/proposal" ) var scheme = runtime.NewScheme() @@ -87,12 +97,48 @@ func main() { os.Exit(1) } + // Read audit config from AgenticOLSConfig (best-effort at startup). + // spec.audit.logging and spec.audit.otel are independent controls. + auditLogging := true + var otelEndpoint string + otelInsecure := true + directClient, err := client.New(cfg, client.Options{Scheme: scheme}) + if err != nil { + log.Error(err, "unable to create direct client for audit config") + os.Exit(1) + } + var agenticConfig agenticv1alpha1.AgenticOLSConfig + if err := directClient.Get(context.Background(), client.ObjectKey{Name: "cluster"}, &agenticConfig); err == nil { + auditLogging = proposal.ResolveLoggingEnabled(agenticConfig.Spec.Audit) + otelEndpoint = proposal.ResolveOTELEndpoint(agenticConfig.Spec.Audit) + otelInsecure = proposal.ResolveOTELInsecure(agenticConfig.Spec.Audit) + } + + // Create audit logger when logging is enabled (nil when disabled). + var auditLogger *proposal.AuditLogger + if auditLogging { + auditLogger = proposal.NewAuditLogger() + defer func() { + // Sync stdout returns "invalid argument" on many platforms — benign. + _ = auditLogger.Sync() + }() + } + + // Initialize OTEL TracerProvider — no-op when endpoint is empty. + tp := initTracerProvider(otelEndpoint, otelInsecure, log) + defer func() { + if err := tp.Shutdown(context.Background()); err != nil { + log.Error(err, "failed to shut down tracer provider") + } + }() + if err := agenticcontroller.Setup(mgr, agenticcontroller.Options{ Namespace: namespace, AgenticConsoleImage: agenticConsoleImage, AgenticSandboxImage: agenticSandboxImage, SandboxMode: sandboxMode, ImagePullPolicy: imagePullPolicy, + Audit: auditLogger, }); err != nil { log.Error(err, "unable to set up agentic controllers") os.Exit(1) @@ -107,9 +153,51 @@ func main() { os.Exit(1) } - log.Info("starting manager", "namespace", namespace) + log.Info("starting manager", "namespace", namespace, "auditLogging", auditLogging, "otelEndpoint", otelEndpoint) if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { log.Error(err, "problem running manager") os.Exit(1) } } + +func initTracerProvider(endpoint string, insecure bool, log logr.Logger) *sdktrace.TracerProvider { + otel.SetTextMapPropagator(propagation.TraceContext{}) + + if endpoint == "" { + tp := sdktrace.NewTracerProvider() + otel.SetTracerProvider(tp) + log.Info("OTEL tracer: no-op (no endpoint configured)") + return tp + } + + ctx := context.Background() + opts := []otlptracegrpc.Option{ + otlptracegrpc.WithEndpoint(endpoint), + } + if insecure { + opts = append(opts, otlptracegrpc.WithInsecure()) + } + exporter, err := otlptracegrpc.New(ctx, opts...) + if err != nil { + log.Error(err, "failed to create OTLP exporter, falling back to no-op") + tp := sdktrace.NewTracerProvider() + otel.SetTracerProvider(tp) + return tp + } + + res, mergeErr := resource.Merge( + resource.Default(), + resource.NewWithAttributes(semconv.SchemaURL, semconv.ServiceName("agentic-operator")), + ) + if mergeErr != nil { + log.Error(mergeErr, "failed to merge OTEL resources, falling back to default resource") + res = resource.Default() + } + tp := sdktrace.NewTracerProvider( + sdktrace.WithBatcher(exporter), + sdktrace.WithResource(res), + ) + otel.SetTracerProvider(tp) + log.Info("OTEL tracer: OTLP exporter configured", "endpoint", endpoint, "insecure", insecure) + return tp +} diff --git a/config/crd/bases/agentic.openshift.io_agenticolsconfigs.yaml b/config/crd/bases/agentic.openshift.io_agenticolsconfigs.yaml index f044a2b..f8b607c 100644 --- a/config/crd/bases/agentic.openshift.io_agenticolsconfigs.yaml +++ b/config/crd/bases/agentic.openshift.io_agenticolsconfigs.yaml @@ -31,7 +31,7 @@ spec: CR exists, the system behaves as if\nsuspended is false — the CR is not required for normal operation.\n\nExample:\n\n\tapiVersion: agentic.openshift.io/v1alpha1\n\tkind: AgenticOLSConfig\n\tmetadata:\n\t name: cluster\n\tspec:\n\t suspended: - false" + false\n\t audit:\n\t logging: true\n\t otel:\n\t endpoint: \"jaeger-otlp-grpc.observability.svc:4317\"" properties: apiVersion: description: |- @@ -54,6 +54,40 @@ spec: description: spec defines the desired system configuration. minProperties: 1 properties: + audit: + description: |- + audit controls compliance audit logging. When absent, audit logging + is enabled by default with no OTEL export (structured JSON to stdout only). + minProperties: 1 + properties: + logging: + description: |- + logging controls whether structured JSON audit events are emitted to stdout. + Defaults to true. When the CR is absent or this field is not set, audit + logging is enabled. Set to false to disable structured audit log output. + type: boolean + otel: + description: |- + otel holds the optional OTEL exporter configuration for distributed tracing. + Independent of the logging flag — tracing is enabled when an endpoint is set, + regardless of whether logging is on or off. + minProperties: 1 + properties: + endpoint: + description: |- + endpoint is the OTLP gRPC endpoint URL (e.g. "jaeger-otlp-grpc.observability.svc:4317"). + When empty or absent, a no-op exporter is used — no traces are sent. + maxLength: 253 + minLength: 1 + type: string + insecure: + description: |- + insecure disables TLS for the OTLP gRPC connection. Defaults to true + for backward compatibility with in-cluster collectors. Set to false + to require TLS when connecting to external or production endpoints. + type: boolean + type: object + type: object suspended: default: false description: |- diff --git a/controller/proposal/approval.go b/controller/proposal/approval.go index e5e5a41..b6c83db 100644 --- a/controller/proposal/approval.go +++ b/controller/proposal/approval.go @@ -140,6 +140,19 @@ func isStageDenied(approval *agenticv1alpha1.ProposalApproval, stage agenticv1al return false } +// isAutoApproved returns true if the policy grants automatic approval for the stage. +func isAutoApproved(policy *agenticv1alpha1.ApprovalPolicy, stage agenticv1alpha1.SandboxStep) bool { + if policy == nil { + return false + } + for _, ps := range policy.Spec.Stages { + if ps.Name == stage && ps.Approval == agenticv1alpha1.ApprovalModeAutomatic { + return true + } + } + return false +} + func getStageOverrideAgent(approval *agenticv1alpha1.ProposalApproval, stage agenticv1alpha1.SandboxStep) string { if approval == nil { return "" diff --git a/controller/proposal/audit.go b/controller/proposal/audit.go new file mode 100644 index 0000000..d857212 --- /dev/null +++ b/controller/proposal/audit.go @@ -0,0 +1,257 @@ +package proposal + +import ( + "context" + "encoding/json" + "os" + "strings" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/propagation" + "go.opentelemetry.io/otel/trace" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + agenticv1alpha1 "github.com/openshift/lightspeed-agentic-operator/api/v1alpha1" +) + +const ( + lifecycleSpanIDAnnotation = "agentic.openshift.io/lifecycle-span-id" + + AuditProposalReceived = "audit.proposal.received" + AuditAnalysisCompleted = "audit.analysis.completed" + AuditApprovalReceived = "audit.approval.received" + AuditExecutionCompleted = "audit.execution.completed" + AuditVerificationCompleted = "audit.verification.completed" + AuditVerificationRetry = "audit.verification.retry" + AuditEscalationCompleted = "audit.escalation.completed" + AuditProposalTerminal = "audit.proposal.terminal" + + tracerName = "agentic-operator" +) + +var tracer = otel.Tracer(tracerName) + +// AuditLogger is a dedicated logger for compliance audit events. It is +// independent of the operator's logr/zap verbosity — always emits at info +// level when enabled, cannot be suppressed by operator log-level flags. +// Controlled solely by spec.audit.logging (on/off, full fidelity). +type AuditLogger struct { + zap *zap.Logger +} + +// NewAuditLogger creates a dedicated zap logger for audit events that writes +// JSON to stdout with the spec-required field names (timestamp, level, event, trace_id). +func NewAuditLogger() *AuditLogger { + cfg := zap.NewProductionEncoderConfig() + cfg.TimeKey = "timestamp" + cfg.LevelKey = "level" + cfg.MessageKey = "event" + cfg.EncodeTime = zapcore.ISO8601TimeEncoder + cfg.EncodeLevel = zapcore.LowercaseLevelEncoder + + core := zapcore.NewCore( + zapcore.NewJSONEncoder(cfg), + zapcore.AddSync(os.Stdout), + zapcore.InfoLevel, + ) + return &AuditLogger{zap: zap.New(core)} +} + +// ResolveOTELEndpoint returns the OTEL endpoint from an AuditConfig. +// Returns "" when no endpoint is configured (CR absent, spec.audit absent, +// or otel.endpoint empty). OTEL tracing is independent of the logging flag. +func ResolveOTELEndpoint(audit *agenticv1alpha1.AuditConfig) string { + return audit.OTELEndpoint() +} + +// ResolveLoggingEnabled returns whether structured audit logging is enabled. +// Defaults to true when CR or field is absent. +func ResolveLoggingEnabled(audit *agenticv1alpha1.AuditConfig) bool { + return audit.LoggingEnabled() +} + +// ResolveOTELInsecure returns whether OTLP should use plaintext (default: true). +func ResolveOTELInsecure(audit *agenticv1alpha1.AuditConfig) bool { + return audit.OTELInsecure() +} + +// Sync flushes the audit logger. +func (a *AuditLogger) Sync() error { + return a.zap.Sync() +} + +// traceIDFromProposal returns the Proposal's metadata.uid with hyphens stripped, +// producing a 32-char hex string suitable for use as an OTEL trace ID. +func traceIDFromProposal(proposal *agenticv1alpha1.Proposal) string { + return strings.ReplaceAll(string(proposal.UID), "-", "") +} + +// parseTraceID converts a 32-char hex trace ID string into an OTEL trace.TraceID. +func parseTraceID(hexID string) (trace.TraceID, error) { + return trace.TraceIDFromHex(hexID) +} + +// crMetadata holds the select metadata fields serialized for audit events. +type crMetadata struct { + Name string `json:"name"` + Namespace string `json:"namespace,omitempty"` + CreationTimestamp metav1.Time `json:"creationTimestamp"` + UID string `json:"uid"` +} + +// serializedCR holds the select metadata + spec + optional status for CR audit serialization. +type serializedCR struct { + Metadata crMetadata `json:"metadata"` + Spec interface{} `json:"spec"` + Status interface{} `json:"status,omitempty"` +} + +// serializeCR extracts .spec + select metadata fields from a client.Object for audit logging. +func serializeCR(obj client.Object, spec interface{}) serializedCR { + return serializedCR{ + Metadata: crMetadata{ + Name: obj.GetName(), + Namespace: obj.GetNamespace(), + CreationTimestamp: metav1.Time{Time: obj.GetCreationTimestamp().Time}, + UID: string(obj.GetUID()), + }, + Spec: spec, + } +} + +// serializeCRWithStatus extracts .spec + .status + select metadata for result CRs +// where the useful data (e.g. RemediationOptions, ActionsTaken) lives in status. +func serializeCRWithStatus(obj client.Object, spec, status interface{}) serializedCR { + cr := serializeCR(obj, spec) + cr.Status = status + return cr +} + +// emitAuditEvent performs dual emission: structured JSON to stdout via the +// dedicated audit logger, and an OTEL span event on the current span. +// Logging and tracing are independent — either can be nil/disabled without +// affecting the other. +func emitAuditEvent(ctx context.Context, auditLog *AuditLogger, event, traceID string, payload interface{}) { + if auditLog != nil { + fields := []zap.Field{ + zap.String("trace_id", traceID), + } + if payload != nil { + fields = append(fields, zap.Any("payload", payload)) + } + auditLog.zap.Info(event, fields...) + } + + span := trace.SpanFromContext(ctx) + if span.IsRecording() { + attrs := []attribute.KeyValue{ + attribute.String("audit.event", event), + attribute.String("audit.trace_id", traceID), + } + if payload != nil { + if payloadJSON, err := json.Marshal(payload); err == nil { + attrs = append(attrs, attribute.String("audit.payload", string(payloadJSON))) + } + } + span.AddEvent(event, trace.WithAttributes(attrs...)) + } +} + +// terminalPayload is the payload for audit.proposal.terminal events. +type terminalPayload struct { + Phase string `json:"phase"` + Reason string `json:"reason,omitempty"` +} + +// retryPayload is the payload for audit.verification.retry events. +type retryPayload struct { + CR serializedCR `json:"cr"` + RetryCount int32 `json:"retryCount"` +} + +// injectTraceContext injects the current OTEL span context into HTTP headers +// using W3C traceparent format. +func injectTraceContext(ctx context.Context, carrier propagation.TextMapCarrier) { + otel.GetTextMapPropagator().Inject(ctx, carrier) +} + +// httpHeaderCarrier adapts http.Header to propagation.TextMapCarrier. +type httpHeaderCarrier struct { + header map[string][]string +} + +func (c httpHeaderCarrier) Get(key string) string { + vals := c.header[key] + if len(vals) == 0 { + return "" + } + return vals[0] +} + +func (c httpHeaderCarrier) Set(key, value string) { + c.header[key] = []string{value} +} + +func (c httpHeaderCarrier) Keys() []string { + keys := make([]string, 0, len(c.header)) + for k := range c.header { + keys = append(keys, k) + } + return keys +} + +// newHTTPHeaderCarrier returns a carrier that adds headers to the provided map. +// If headers is nil, a new map is allocated. +func newHTTPHeaderCarrier(headers map[string][]string) httpHeaderCarrier { + if headers == nil { + headers = make(map[string][]string) + } + return httpHeaderCarrier{header: headers} +} + +// lifecycleParentContext builds a context with the proposal's lifecycle span as the +// remote parent, so child spans nest under it in Jaeger. If the lifecycle span ID +// annotation is missing, returns the context with just the trace ID (children become +// root-level spans in the same trace). +func lifecycleParentContext(ctx context.Context, proposal *agenticv1alpha1.Proposal) context.Context { + hexTraceID := traceIDFromProposal(proposal) + tid, err := parseTraceID(hexTraceID) + if err != nil { + return ctx + } + + config := trace.SpanContextConfig{ + TraceID: tid, + TraceFlags: trace.FlagsSampled, + } + + if ann := proposal.Annotations[lifecycleSpanIDAnnotation]; ann != "" { + if sid, err := trace.SpanIDFromHex(ann); err == nil { + config.SpanID = sid + } + } + + sc := trace.NewSpanContext(config) + return trace.ContextWithRemoteSpanContext(ctx, sc) +} + +// proposalSpanAttrs returns common OTEL span attributes for a Proposal. +func proposalSpanAttrs(proposal *agenticv1alpha1.Proposal) []attribute.KeyValue { + return []attribute.KeyValue{ + attribute.String("proposal.name", proposal.Name), + attribute.String("proposal.namespace", proposal.Namespace), + attribute.String("proposal.uid", string(proposal.UID)), + attribute.String("proposal.request", truncateString(proposal.Spec.Request, 200)), + } +} + +func truncateString(s string, max int) string { + if len(s) <= max { + return s + } + return s[:max] + "..." +} diff --git a/controller/proposal/audit_integration_test.go b/controller/proposal/audit_integration_test.go new file mode 100644 index 0000000..23a1e78 --- /dev/null +++ b/controller/proposal/audit_integration_test.go @@ -0,0 +1,497 @@ +package proposal + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "strings" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + agenticv1alpha1 "github.com/openshift/lightspeed-agentic-operator/api/v1alpha1" +) + +func newReconcilerWithAudit(objects []client.Object, agent AgentCaller, buf *bytes.Buffer) (*ProposalReconciler, client.WithWatch) { + fc := fake.NewClientBuilder(). + WithScheme(testScheme()). + WithObjects(objects...). + WithStatusSubresource( + &agenticv1alpha1.Proposal{}, + &agenticv1alpha1.ProposalApproval{}, + &agenticv1alpha1.AnalysisResult{}, + &agenticv1alpha1.ExecutionResult{}, + &agenticv1alpha1.VerificationResult{}, + &agenticv1alpha1.EscalationResult{}, + ). + Build() + + return &ProposalReconciler{ + Client: fc, + Agent: agent, + Namespace: "test-ns", + Audit: newTestAuditLogger(buf), + }, fc +} + +func auditEventsFromBuffer(buf *bytes.Buffer) []map[string]interface{} { + var events []map[string]interface{} + for _, line := range strings.Split(buf.String(), "\n") { + if line == "" { + continue + } + var parsed map[string]interface{} + if err := json.Unmarshal([]byte(line), &parsed); err == nil { + events = append(events, parsed) + } + } + return events +} + +func findAuditEvent(events []map[string]interface{}, eventName string) map[string]interface{} { + for _, e := range events { + if e["event"] == eventName { + return e + } + } + return nil +} + +func countAuditEvents(events []map[string]interface{}, eventName string) int { + count := 0 + for _, e := range events { + if e["event"] == eventName { + count++ + } + } + return count +} + +func TestAuditProposalReceivedOnFirstReconcile(t *testing.T) { + var buf bytes.Buffer + proposal := testProposal() + proposal.UID = "f47ac10b-58cc-4372-a567-0e02b2c3d479" + + r, _ := newReconcilerWithAudit( + append(defaultObjects(), proposal), + newTestAgentCaller(), + &buf, + ) + + _, err := reconcileOnce(r, "fix-crash") + if err != nil { + t.Fatalf("reconcile error: %v", err) + } + + events := auditEventsFromBuffer(&buf) + received := findAuditEvent(events, AuditProposalReceived) + if received == nil { + t.Fatal("expected audit.proposal.received event") + } + + if received["trace_id"] != "f47ac10b58cc4372a5670e02b2c3d479" { + t.Errorf("trace_id = %q, want hyphens stripped from UID", received["trace_id"]) + } + + payload, ok := received["payload"].(map[string]interface{}) + if !ok { + t.Fatal("payload should be an object") + } + meta, ok := payload["metadata"].(map[string]interface{}) + if !ok { + t.Fatal("payload.metadata should be an object") + } + if meta["name"] != "fix-crash" { + t.Errorf("payload.metadata.name = %q, want fix-crash", meta["name"]) + } + if _, hasSpec := payload["spec"]; !hasSpec { + t.Error("payload should contain spec") + } +} + +func TestAuditAnalysisCompleted(t *testing.T) { + var buf bytes.Buffer + proposal := testProposal() + proposal.UID = "aaa-bbb-ccc-ddd" + + r, _ := newReconcilerWithAudit( + append(defaultObjects(), proposal), + newTestAgentCaller(), + &buf, + ) + + // First reconcile: adds finalizer + emits proposal.received + reconcileOnce(r, "fix-crash") + // Second reconcile: runs analysis + _, err := reconcileOnce(r, "fix-crash") + if err != nil { + t.Fatalf("reconcile error: %v", err) + } + + events := auditEventsFromBuffer(&buf) + completed := findAuditEvent(events, AuditAnalysisCompleted) + if completed == nil { + t.Fatal("expected audit.analysis.completed event") + } + if completed["trace_id"] != "aaabbbcccddd" { + t.Errorf("trace_id = %q, want aaabbbcccddd", completed["trace_id"]) + } +} + +func TestAuditAnalysisFailedEmitsCompletedEvent(t *testing.T) { + var buf bytes.Buffer + proposal := testProposal() + proposal.UID = "aaa-bbb-ccc-ddd" + + agent := newTestAgentCaller() + agent.analyzeErr = fmt.Errorf("LLM timeout") + + r, _ := newReconcilerWithAudit( + append(defaultObjects(), proposal), + agent, + &buf, + ) + + reconcileOnce(r, "fix-crash") // finalizer + reconcileOnce(r, "fix-crash") // analysis fails + + events := auditEventsFromBuffer(&buf) + + completed := findAuditEvent(events, AuditAnalysisCompleted) + if completed == nil { + t.Fatal("expected audit.analysis.completed even on failure") + } + // Payload should be a CR serialization with metadata + spec + payload, _ := completed["payload"].(map[string]interface{}) + if _, hasMeta := payload["metadata"]; !hasMeta { + t.Error("payload should contain metadata (CR serialization)") + } + if _, hasSpec := payload["spec"]; !hasSpec { + t.Error("payload should contain spec (CR serialization)") + } + + terminal := findAuditEvent(events, AuditProposalTerminal) + if terminal == nil { + t.Fatal("expected audit.proposal.terminal event") + } + termPayload, _ := terminal["payload"].(map[string]interface{}) + if termPayload["phase"] != "Failed" { + t.Errorf("terminal phase = %q, want Failed", termPayload["phase"]) + } +} + +// TestAuditFullLifecycle runs a complete proposal through analysis → approval → +// execution → verification and verifies all expected audit events are emitted +// with the same trace_id. This is the golden-path integration test. +func TestAuditFullLifecycle(t *testing.T) { + var buf bytes.Buffer + proposal := testProposal() + proposal.UID = "1111-2222-3333-4444" + + r, fc := newReconcilerWithAudit( + append(defaultObjects(), proposal), + newTestAgentCaller(), + &buf, + ) + + reconcileOnce(r, "fix-crash") // finalizer + proposal.received + reconcileOnce(r, "fix-crash") // analysis + approveProposal(t, fc, "fix-crash") + reconcileOnce(r, "fix-crash") // execution + reconcileOnce(r, "fix-crash") // verification + + events := auditEventsFromBuffer(&buf) + + expected := []string{ + AuditProposalReceived, + AuditAnalysisCompleted, + AuditApprovalReceived, + AuditExecutionCompleted, + AuditVerificationCompleted, + AuditProposalTerminal, + } + for _, name := range expected { + if findAuditEvent(events, name) == nil { + t.Errorf("missing expected audit event: %s", name) + } + } + + // All events should share the same trace_id + expectedTraceID := "1111222233334444" + for _, e := range events { + if e["trace_id"] != expectedTraceID { + t.Errorf("event %s has trace_id %q, want %q", e["event"], e["trace_id"], expectedTraceID) + } + } +} + +// TestAuditTerminalEmittedOnce verifies that audit.proposal.terminal is emitted +// exactly once — when the terminal condition is set — not on subsequent +// reconcile loops that observe the already-terminal proposal. +func TestAuditTerminalEmittedOnce(t *testing.T) { + var buf bytes.Buffer + proposal := testProposal() + proposal.UID = "5555-6666-7777-8888" + + r, fc := newReconcilerWithAudit( + append(defaultObjects(), proposal), + newTestAgentCaller(), + &buf, + ) + + reconcileOnce(r, "fix-crash") // finalizer + reconcileOnce(r, "fix-crash") // analysis + approveProposal(t, fc, "fix-crash") + reconcileOnce(r, "fix-crash") // execution + reconcileOnce(r, "fix-crash") // verification → completed + + // Reconcile again on the terminal proposal — should NOT emit terminal again + reconcileOnce(r, "fix-crash") + reconcileOnce(r, "fix-crash") + + events := auditEventsFromBuffer(&buf) + count := countAuditEvents(events, AuditProposalTerminal) + if count != 1 { + t.Errorf("audit.proposal.terminal emitted %d times, want exactly 1", count) + } +} + +// TestAuditNilLoggerNoEvents verifies that the reconciler does not panic when +// Audit is nil. This case should not occur in production (AuditLogger is always +// created in cmd/main.go), but defends against misconfiguration. +func TestAuditNilLoggerNoEvents(t *testing.T) { + proposal := testProposal() + + fc := fake.NewClientBuilder(). + WithScheme(testScheme()). + WithObjects(append(defaultObjects(), proposal)...). + WithStatusSubresource( + &agenticv1alpha1.Proposal{}, + &agenticv1alpha1.ProposalApproval{}, + &agenticv1alpha1.AnalysisResult{}, + ). + Build() + + r := &ProposalReconciler{ + Client: fc, + Agent: newTestAgentCaller(), + Namespace: "test-ns", + Audit: nil, // audit logger nil — should not panic + } + + _, err := reconcileOnce(r, "fix-crash") + if err != nil { + t.Fatalf("reconcile with nil audit logger should not error: %v", err) + } +} + +func TestAuditExecutionFailedEmitsCompletedEvent(t *testing.T) { + var buf bytes.Buffer + proposal := testProposal() + proposal.UID = "exec-fail-1234" + + agent := newTestAgentCaller() + agent.executeErr = fmt.Errorf("sandbox pod OOMKilled") + + r, fc := newReconcilerWithAudit( + append(defaultObjects(), proposal), + agent, + &buf, + ) + + reconcileOnce(r, "fix-crash") // finalizer + reconcileOnce(r, "fix-crash") // analysis succeeds + approveProposal(t, fc, "fix-crash") + reconcileOnce(r, "fix-crash") // execution fails + + events := auditEventsFromBuffer(&buf) + + execCompleted := findAuditEvent(events, AuditExecutionCompleted) + if execCompleted == nil { + t.Fatal("expected audit.execution.completed even on failure") + } + payload, _ := execCompleted["payload"].(map[string]interface{}) + if _, hasMeta := payload["metadata"]; !hasMeta { + t.Error("payload should contain metadata (CR serialization)") + } + if _, hasSpec := payload["spec"]; !hasSpec { + t.Error("payload should contain spec (CR serialization)") + } + + terminal := findAuditEvent(events, AuditProposalTerminal) + if terminal == nil { + t.Fatal("expected audit.proposal.terminal") + } +} + +// TestAuditAutoApprovalNoHumanApprovalSpan verifies that when execution is +// auto-approved via policy, the audit.approval.received event still fires +// (with mode=Automatic) but no proposal.human_approval span is created. +func TestAuditAutoApprovalEmitsEvent(t *testing.T) { + var buf bytes.Buffer + proposal := testProposal() + proposal.UID = "auto-approve-uid" + + // Policy that auto-approves ALL stages including execution. + fullAutoPolicy := &agenticv1alpha1.ApprovalPolicy{ + ObjectMeta: metav1.ObjectMeta{Name: "cluster"}, + Spec: agenticv1alpha1.ApprovalPolicySpec{ + Stages: []agenticv1alpha1.ApprovalPolicyStage{ + {Name: agenticv1alpha1.SandboxStepAnalysis, Approval: agenticv1alpha1.ApprovalModeAutomatic}, + {Name: agenticv1alpha1.SandboxStepExecution, Approval: agenticv1alpha1.ApprovalModeAutomatic}, + {Name: agenticv1alpha1.SandboxStepVerification, Approval: agenticv1alpha1.ApprovalModeAutomatic}, + }, + }, + } + + r, _ := newReconcilerWithAudit( + []client.Object{testDefaultAgent(), testLLM("smart"), fullAutoPolicy, proposal}, + newTestAgentCaller(), + &buf, + ) + + reconcileOnce(r, "fix-crash") // finalizer + reconcileOnce(r, "fix-crash") // analysis + reconcileOnce(r, "fix-crash") // execution (auto-approved) + + events := auditEventsFromBuffer(&buf) + + approval := findAuditEvent(events, AuditApprovalReceived) + if approval == nil { + t.Fatal("expected audit.approval.received even for auto-approval") + } + payload, _ := approval["payload"].(map[string]interface{}) + if payload["mode"] != "Automatic" { + t.Errorf("mode = %q, want Automatic", payload["mode"]) + } +} + +func TestAuditDeniedProposal(t *testing.T) { + var buf bytes.Buffer + proposal := testProposal() + proposal.UID = "deny-1234-5678" + + r, fc := newReconcilerWithAudit( + append(defaultObjects(), proposal), + newTestAgentCaller(), + &buf, + ) + + reconcileOnce(r, "fix-crash") // finalizer + reconcileOnce(r, "fix-crash") // analysis + + // Deny execution + var approval agenticv1alpha1.ProposalApproval + if err := fc.Get(context.Background(), types.NamespacedName{Name: "fix-crash", Namespace: "default"}, &approval); err != nil { + t.Fatalf("get approval: %v", err) + } + base := approval.DeepCopy() + approval.Spec.Stages = append(approval.Spec.Stages, agenticv1alpha1.ApprovalStage{ + Type: agenticv1alpha1.ApprovalStageExecution, + Decision: agenticv1alpha1.ApprovalDecisionDenied, + }) + if err := fc.Patch(context.Background(), &approval, client.MergeFrom(base)); err != nil { + t.Fatalf("deny execution: %v", err) + } + + reconcileOnce(r, "fix-crash") // denied + + events := auditEventsFromBuffer(&buf) + + terminal := findAuditEvent(events, AuditProposalTerminal) + if terminal == nil { + t.Fatal("expected audit.proposal.terminal on denial") + } + payload, _ := terminal["payload"].(map[string]interface{}) + if payload["phase"] != "Denied" { + t.Errorf("terminal phase = %q, want Denied", payload["phase"]) + } +} + +func TestAuditTraceIDFormat(t *testing.T) { + var buf bytes.Buffer + proposal := testProposal() + proposal.UID = "f47ac10b-58cc-4372-a567-0e02b2c3d479" + + r, _ := newReconcilerWithAudit( + append(defaultObjects(), proposal), + newTestAgentCaller(), + &buf, + ) + + reconcileOnce(r, "fix-crash") + + events := auditEventsFromBuffer(&buf) + if len(events) == 0 { + t.Fatal("expected at least one audit event") + } + + traceID := events[0]["trace_id"].(string) + if len(traceID) != 32 { + t.Errorf("trace_id length = %d, want 32", len(traceID)) + } + if strings.Contains(traceID, "-") { + t.Error("trace_id should not contain hyphens") + } +} + +func TestAuditApprovalReceivedEvent(t *testing.T) { + var buf bytes.Buffer + proposal := testProposal() + proposal.UID = "approval-test-uid" + + r, fc := newReconcilerWithAudit( + append(defaultObjects(), proposal), + newTestAgentCaller(), + &buf, + ) + + reconcileOnce(r, "fix-crash") // finalizer + reconcileOnce(r, "fix-crash") // analysis + approveProposal(t, fc, "fix-crash") + reconcileOnce(r, "fix-crash") // execution (approval observed here) + + events := auditEventsFromBuffer(&buf) + + approval := findAuditEvent(events, AuditApprovalReceived) + if approval == nil { + t.Fatal("expected audit.approval.received event") + } + payload, _ := approval["payload"].(map[string]interface{}) + if payload["stage"] != "Execution" { + t.Errorf("stage = %q, want Execution", payload["stage"]) + } +} + +func TestAuditEventJsonFormat(t *testing.T) { + var buf bytes.Buffer + proposal := testProposal() + proposal.UID = "json-format-test" + + r, _ := newReconcilerWithAudit( + append(defaultObjects(), proposal), + newTestAgentCaller(), + &buf, + ) + + reconcileOnce(r, "fix-crash") + + lines := strings.Split(strings.TrimSpace(buf.String()), "\n") + for i, line := range lines { + var parsed map[string]interface{} + if err := json.Unmarshal([]byte(line), &parsed); err != nil { + t.Errorf("line %d is not valid JSON: %v", i, err) + continue + } + for _, required := range []string{"timestamp", "level", "event", "trace_id"} { + if _, ok := parsed[required]; !ok { + t.Errorf("line %d missing required field %q", i, required) + } + } + } +} diff --git a/controller/proposal/audit_test.go b/controller/proposal/audit_test.go new file mode 100644 index 0000000..9e0fe85 --- /dev/null +++ b/controller/proposal/audit_test.go @@ -0,0 +1,442 @@ +package proposal + +import ( + "bytes" + "context" + "encoding/json" + "testing" + + "go.opentelemetry.io/otel/trace" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + + agenticv1alpha1 "github.com/openshift/lightspeed-agentic-operator/api/v1alpha1" +) + +func TestTraceIDFromProposal(t *testing.T) { + tests := []struct { + name string + uid types.UID + want string + }{ + { + name: "standard UUID", + uid: "f47ac10b-58cc-4372-a567-0e02b2c3d479", + want: "f47ac10b58cc4372a5670e02b2c3d479", + }, + { + name: "already no hyphens", + uid: "f47ac10b58cc4372a5670e02b2c3d479", + want: "f47ac10b58cc4372a5670e02b2c3d479", + }, + { + name: "empty UID", + uid: "", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := &agenticv1alpha1.Proposal{ + ObjectMeta: metav1.ObjectMeta{UID: tt.uid}, + } + got := traceIDFromProposal(p) + if got != tt.want { + t.Errorf("traceIDFromProposal() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestTraceIDFromProposalLength(t *testing.T) { + p := &agenticv1alpha1.Proposal{ + ObjectMeta: metav1.ObjectMeta{UID: "f47ac10b-58cc-4372-a567-0e02b2c3d479"}, + } + got := traceIDFromProposal(p) + if len(got) != 32 { + t.Errorf("trace ID length = %d, want 32", len(got)) + } +} + +func TestParseTraceID(t *testing.T) { + tests := []struct { + name string + hexID string + wantErr bool + }{ + { + name: "valid 32-char hex", + hexID: "f47ac10b58cc4372a5670e02b2c3d479", + }, + { + name: "too short", + hexID: "f47ac10b", + wantErr: true, + }, + { + name: "invalid hex chars", + hexID: "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", + wantErr: true, + }, + { + name: "empty", + hexID: "", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tid, err := parseTraceID(tt.hexID) + if (err != nil) != tt.wantErr { + t.Errorf("parseTraceID() error = %v, wantErr %v", err, tt.wantErr) + } + if !tt.wantErr && tid == (trace.TraceID{}) { + t.Error("parseTraceID() returned zero TraceID for valid input") + } + }) + } +} + +func TestSerializeCR(t *testing.T) { + proposal := &agenticv1alpha1.Proposal{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-proposal", + Namespace: "test-ns", + UID: "abc-123", + CreationTimestamp: metav1.Now(), + }, + Spec: agenticv1alpha1.ProposalSpec{ + Request: "fix the pod", + }, + } + + result := serializeCR(proposal, proposal.Spec) + + if result.Metadata.Name != "test-proposal" { + t.Errorf("Name = %q, want %q", result.Metadata.Name, "test-proposal") + } + if result.Metadata.Namespace != "test-ns" { + t.Errorf("Namespace = %q, want %q", result.Metadata.Namespace, "test-ns") + } + if result.Metadata.UID != "abc-123" { + t.Errorf("UID = %q, want %q", result.Metadata.UID, "abc-123") + } + + jsonBytes, err := json.Marshal(result) + if err != nil { + t.Fatalf("json.Marshal(result) failed: %v", err) + } + + var parsed map[string]interface{} + if err := json.Unmarshal(jsonBytes, &parsed); err != nil { + t.Fatalf("json.Unmarshal failed: %v", err) + } + + meta, ok := parsed["metadata"].(map[string]interface{}) + if !ok { + t.Fatal("missing metadata in serialized CR") + } + for _, field := range []string{"name", "namespace", "creationTimestamp", "uid"} { + if _, exists := meta[field]; !exists { + t.Errorf("missing metadata field %q", field) + } + } + if _, ok := parsed["spec"]; !ok { + t.Error("missing spec in serialized CR") + } +} + +func TestSerializeCRClusterScoped(t *testing.T) { + agent := &agenticv1alpha1.Agent{ + ObjectMeta: metav1.ObjectMeta{ + Name: "default", + UID: "agent-uid", + }, + } + + result := serializeCR(agent, agent.Spec) + + if result.Metadata.Namespace != "" { + t.Errorf("cluster-scoped CR should have empty namespace, got %q", result.Metadata.Namespace) + } + + jsonBytes, err := json.Marshal(result) + if err != nil { + t.Fatalf("json.Marshal failed: %v", err) + } + var parsed map[string]interface{} + if err := json.Unmarshal(jsonBytes, &parsed); err != nil { + t.Fatalf("json.Unmarshal failed: %v", err) + } + meta := parsed["metadata"].(map[string]interface{}) + if _, exists := meta["namespace"]; exists { + t.Error("cluster-scoped CR should omit namespace from JSON (omitempty)") + } +} + +func newTestAuditLogger(buf *bytes.Buffer) *AuditLogger { + cfg := zap.NewProductionEncoderConfig() + cfg.TimeKey = "timestamp" + cfg.LevelKey = "level" + cfg.MessageKey = "event" + cfg.EncodeTime = zapcore.ISO8601TimeEncoder + cfg.EncodeLevel = zapcore.LowercaseLevelEncoder + + core := zapcore.NewCore( + zapcore.NewJSONEncoder(cfg), + zapcore.AddSync(buf), + zapcore.InfoLevel, + ) + return &AuditLogger{zap: zap.New(core)} +} + +func TestEmitAuditEvent(t *testing.T) { + var buf bytes.Buffer + auditLog := newTestAuditLogger(&buf) + + ctx := context.Background() + emitAuditEvent(ctx, auditLog, AuditProposalReceived, "abc123def456abc123def456abc123de", map[string]string{"key": "value"}) + + var parsed map[string]interface{} + if err := json.Unmarshal(buf.Bytes(), &parsed); err != nil { + t.Fatalf("output is not valid JSON: %v\nraw: %s", err, buf.String()) + } + + for _, field := range []string{"timestamp", "level", "event", "trace_id"} { + if _, ok := parsed[field]; !ok { + t.Errorf("missing required field %q in audit output", field) + } + } + + if parsed["event"] != AuditProposalReceived { + t.Errorf("event = %q, want %q", parsed["event"], AuditProposalReceived) + } + if parsed["trace_id"] != "abc123def456abc123def456abc123de" { + t.Errorf("trace_id = %q, want %q", parsed["trace_id"], "abc123def456abc123def456abc123de") + } + if parsed["level"] != "info" { + t.Errorf("level = %q, want %q", parsed["level"], "info") + } +} + +func TestEmitAuditEventNilLogger(t *testing.T) { + ctx := context.Background() + // Must not panic when audit logger is nil (audit disabled). + emitAuditEvent(ctx, nil, AuditProposalReceived, "trace123", nil) +} + +func TestEmitAuditEventWithPayload(t *testing.T) { + var buf bytes.Buffer + auditLog := newTestAuditLogger(&buf) + + payload := terminalPayload{Phase: "Completed", Reason: "AllChecksPassed"} + emitAuditEvent(context.Background(), auditLog, AuditProposalTerminal, "aabbccdd", payload) + + var parsed map[string]interface{} + if err := json.Unmarshal(buf.Bytes(), &parsed); err != nil { + t.Fatalf("output is not valid JSON: %v", err) + } + + p, ok := parsed["payload"].(map[string]interface{}) + if !ok { + t.Fatal("payload should be an object") + } + if p["phase"] != "Completed" { + t.Errorf("payload.phase = %q, want %q", p["phase"], "Completed") + } + if p["reason"] != "AllChecksPassed" { + t.Errorf("payload.reason = %q, want %q", p["reason"], "AllChecksPassed") + } +} + +func TestEmitAuditEventNilPayload(t *testing.T) { + var buf bytes.Buffer + auditLog := newTestAuditLogger(&buf) + + emitAuditEvent(context.Background(), auditLog, AuditAnalysisCompleted, "trace123", nil) + + var parsed map[string]interface{} + if err := json.Unmarshal(buf.Bytes(), &parsed); err != nil { + t.Fatalf("output is not valid JSON: %v", err) + } + + if _, exists := parsed["payload"]; exists { + t.Error("payload should be absent when nil") + } +} + +func TestNewAuditLogger(t *testing.T) { + logger := NewAuditLogger() + if logger == nil { + t.Fatal("NewAuditLogger() returned nil") + } + if logger.zap == nil { + t.Fatal("NewAuditLogger().zap is nil") + } + // Sync() on stdout returns "invalid argument" on many platforms — benign. + _ = logger.Sync() +} + +func TestAuditConfigLoggingEnabled(t *testing.T) { + boolTrue := true + boolFalse := false + + tests := []struct { + name string + config *agenticv1alpha1.AuditConfig + want bool + }{ + {name: "nil config", config: nil, want: true}, + {name: "nil logging", config: &agenticv1alpha1.AuditConfig{}, want: true}, + {name: "logging=true", config: &agenticv1alpha1.AuditConfig{Logging: &boolTrue}, want: true}, + {name: "logging=false", config: &agenticv1alpha1.AuditConfig{Logging: &boolFalse}, want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.config.LoggingEnabled() + if got != tt.want { + t.Errorf("AuditEnabled() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestAuditConfigOTELEndpoint(t *testing.T) { + tests := []struct { + name string + config *agenticv1alpha1.AuditConfig + want string + }{ + {name: "nil config", config: nil, want: ""}, + {name: "nil otel", config: &agenticv1alpha1.AuditConfig{}, want: ""}, + { + name: "with endpoint", + config: &agenticv1alpha1.AuditConfig{OTEL: &agenticv1alpha1.AuditOTELConfig{Endpoint: "jaeger:4317"}}, + want: "jaeger:4317", + }, + { + name: "empty endpoint", + config: &agenticv1alpha1.AuditConfig{OTEL: &agenticv1alpha1.AuditOTELConfig{Endpoint: ""}}, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := tt.config.OTELEndpoint() + if got != tt.want { + t.Errorf("OTELEndpoint() = %q, want %q", got, tt.want) + } + }) + } +} + +// TestResolveLoggingEnabled verifies that spec.audit.logging controls +// structured JSON audit output independently of OTEL tracing. +func TestResolveLoggingEnabled(t *testing.T) { + boolTrue := true + boolFalse := false + + tests := []struct { + name string + config *agenticv1alpha1.AuditConfig + want bool + }{ + {name: "nil config (CR absent)", config: nil, want: true}, + {name: "empty config (logging absent)", config: &agenticv1alpha1.AuditConfig{}, want: true}, + {name: "logging=true", config: &agenticv1alpha1.AuditConfig{Logging: &boolTrue}, want: true}, + {name: "logging=false", config: &agenticv1alpha1.AuditConfig{Logging: &boolFalse}, want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ResolveLoggingEnabled(tt.config) + if got != tt.want { + t.Errorf("ResolveLoggingEnabled() = %v, want %v", got, tt.want) + } + }) + } +} + +// TestResolveOTELEndpoint verifies that OTEL tracing is independent of the +// logging flag. The endpoint is returned whenever configured, regardless of +// whether logging is on or off. +// +// Scenarios matching real-world deployments: +// - No AgenticOLSConfig CR exists → audit=nil → endpoint "" → no OTEL, stdout still emits +// - CR exists without spec.audit → audit=nil → endpoint "" → no OTEL, stdout still emits +// - CR exists with spec.audit.logging=true + endpoint → endpoint returned → OTEL active +// - CR exists with spec.audit.logging=false + endpoint → endpoint still returned → OTEL active +func TestResolveOTELEndpoint(t *testing.T) { + boolTrue := true + boolFalse := false + + tests := []struct { + name string + config *agenticv1alpha1.AuditConfig + want string + }{ + { + name: "nil config (CR absent or spec.audit absent)", + config: nil, + want: "", + }, + { + name: "logging=true with endpoint", + config: &agenticv1alpha1.AuditConfig{Logging: &boolTrue, OTEL: &agenticv1alpha1.AuditOTELConfig{Endpoint: "jaeger:4317"}}, + want: "jaeger:4317", + }, + { + name: "logging=false with endpoint — OTEL still works", + config: &agenticv1alpha1.AuditConfig{Logging: &boolFalse, OTEL: &agenticv1alpha1.AuditOTELConfig{Endpoint: "jaeger:4317"}}, + want: "jaeger:4317", + }, + { + name: "logging=true without endpoint", + config: &agenticv1alpha1.AuditConfig{Logging: &boolTrue}, + want: "", + }, + { + name: "logging nil (default true) with endpoint", + config: &agenticv1alpha1.AuditConfig{OTEL: &agenticv1alpha1.AuditOTELConfig{Endpoint: "tempo:4317"}}, + want: "tempo:4317", + }, + { + name: "empty config struct (all defaults)", + config: &agenticv1alpha1.AuditConfig{}, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ResolveOTELEndpoint(tt.config) + if got != tt.want { + t.Errorf("ResolveOTELEndpoint() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestHTTPHeaderCarrier(t *testing.T) { + carrier := newHTTPHeaderCarrier(nil) + carrier.Set("traceparent", "00-abc123-def456-01") + + if got := carrier.Get("traceparent"); got != "00-abc123-def456-01" { + t.Errorf("Get(traceparent) = %q, want %q", got, "00-abc123-def456-01") + } + + keys := carrier.Keys() + if len(keys) != 1 || keys[0] != "traceparent" { + t.Errorf("Keys() = %v, want [traceparent]", keys) + } + + if got := carrier.Get("missing"); got != "" { + t.Errorf("Get(missing) = %q, want empty", got) + } +} diff --git a/controller/proposal/client.go b/controller/proposal/client.go index 2ac0838..6acbf4a 100644 --- a/controller/proposal/client.go +++ b/controller/proposal/client.go @@ -10,6 +10,8 @@ import ( "net/http" "time" + "go.opentelemetry.io/otel/propagation" + agenticv1alpha1 "github.com/openshift/lightspeed-agentic-operator/api/v1alpha1" ) @@ -109,6 +111,7 @@ func (c *AgentHTTPClient) Run(ctx context.Context, systemPrompt, query string, o return nil, fmt.Errorf("%s: %w", ErrCreateHTTPRequest, err) } httpReq.Header.Set("Content-Type", "application/json") + injectTraceContext(ctx, propagation.HeaderCarrier(httpReq.Header)) resp, err := c.httpClient.Do(httpReq) if err != nil { diff --git a/controller/proposal/handlers.go b/controller/proposal/handlers.go index b366287..01f421f 100644 --- a/controller/proposal/handlers.go +++ b/controller/proposal/handlers.go @@ -4,6 +4,8 @@ import ( "context" "fmt" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" @@ -85,6 +87,13 @@ func (r *ProposalReconciler) handleAnalysis( return ctrl.Result{}, fmt.Errorf("%s: %w", ErrUpdateToAnalyzing, err) } + ctx, analyzeSpan := tracer.Start(ctx, "proposal.analyze") + analyzeSpan.SetAttributes( + attribute.String("agent", resolved.Analysis.Agent.Name), + attribute.String("model", resolved.Analysis.Agent.Spec.Model), + ) + defer analyzeSpan.End() + analysisResult, err := r.Agent.Analyze(ctx, proposal, resolved.Analysis, proposal.Spec.Request, defaultSandboxSA) if err != nil { return r.failStep(ctx, proposal, agenticv1alpha1.ProposalConditionAnalyzed, err) @@ -95,10 +104,11 @@ func (r *ProposalReconciler) handleAnalysis( base = proposal.DeepCopy() completedAt := metav1.Now() startTime := conditionTime(proposal.Status.Conditions, agenticv1alpha1.ProposalConditionAnalyzed) - crName, crErr := r.createAnalysisResult(ctx, proposal, analysisResult, proposal.Status.Steps.Analysis.Sandbox, startTime, &completedAt, "") + crName, cr, crErr := r.createAnalysisResult(ctx, proposal, analysisResult, proposal.Status.Steps.Analysis.Sandbox, startTime, &completedAt, "") if crErr != nil { return r.failStep(ctx, proposal, agenticv1alpha1.ProposalConditionAnalyzed, fmt.Errorf("%s: %w", ErrCreateAnalysisResult, crErr)) } + emitAuditEvent(ctx, r.Audit, AuditAnalysisCompleted, traceIDFromProposal(proposal), serializeCRWithStatus(cr, cr.Spec, cr.Status)) proposal.Status.Steps.Analysis.Results = append(proposal.Status.Steps.Analysis.Results, agenticv1alpha1.StepResultRef{Name: crName, Outcome: agenticv1alpha1.ActionOutcomeFromBool(analysisResult.Success)}) meta.SetStatusCondition(&proposal.Status.Conditions, metav1.Condition{ Type: agenticv1alpha1.ProposalConditionAnalyzed, @@ -148,6 +158,13 @@ func (r *ProposalReconciler) handleRevision( return ctrl.Result{}, fmt.Errorf("%s: %w", ErrUpdateToAnalyzingRevision, err) } + ctx, analyzeSpan := tracer.Start(ctx, "proposal.analyze") + analyzeSpan.SetAttributes( + attribute.String("agent", resolved.Analysis.Agent.Name), + attribute.String("model", resolved.Analysis.Agent.Spec.Model), + ) + defer analyzeSpan.End() + revisionSuffix := buildRevisionContext(proposal) requestWithRevision := proposal.Spec.Request + "\n\n" + revisionSuffix @@ -162,10 +179,11 @@ func (r *ProposalReconciler) handleRevision( base = proposal.DeepCopy() completedAt := metav1.Now() startTime := conditionTime(proposal.Status.Conditions, agenticv1alpha1.ProposalConditionAnalyzed) - crName, crErr := r.createAnalysisResult(ctx, proposal, analysisResult, proposal.Status.Steps.Analysis.Sandbox, startTime, &completedAt, "") + crName, cr, crErr := r.createAnalysisResult(ctx, proposal, analysisResult, proposal.Status.Steps.Analysis.Sandbox, startTime, &completedAt, "") if crErr != nil { return r.failStep(ctx, proposal, agenticv1alpha1.ProposalConditionAnalyzed, fmt.Errorf("%s: %w", ErrCreateAnalysisResult, crErr)) } + emitAuditEvent(ctx, r.Audit, AuditAnalysisCompleted, traceIDFromProposal(proposal), serializeCRWithStatus(cr, cr.Spec, cr.Status)) proposal.Status.Steps.Analysis.Results = append(proposal.Status.Steps.Analysis.Results, agenticv1alpha1.StepResultRef{Name: crName, Outcome: agenticv1alpha1.ActionOutcomeFromBool(analysisResult.Success)}) meta.SetStatusCondition(&proposal.Status.Conditions, metav1.Condition{ Type: agenticv1alpha1.ProposalConditionAnalyzed, @@ -270,6 +288,47 @@ func (r *ProposalReconciler) handleExecution( return ctrl.Result{}, fmt.Errorf("%s: %w", ErrUpdateToExecuting, err) } + // Emit approval event exactly once — on first execution attempt only. + // On verification retries, the Executed condition is removed but RetryCount is set, + // so we use RetryCount==nil to detect the first attempt. + isFirstAttempt := proposal.Status.Steps.Execution.RetryCount == nil + if isFirstAttempt { + selectedIdx := int32(0) + if opt := getStageOption(approval, policy); opt != nil { + selectedIdx = *opt + } + if !isAutoApproved(policy, agenticv1alpha1.SandboxStepExecution) { + var spanOpts []trace.SpanStartOption + if analyzed := meta.FindStatusCondition(proposal.Status.Conditions, agenticv1alpha1.ProposalConditionAnalyzed); analyzed != nil { + spanOpts = append(spanOpts, trace.WithTimestamp(analyzed.LastTransitionTime.Time)) + } + approvalCtx, approvalSpan := tracer.Start(ctx, "proposal.human_approval", spanOpts...) + approvalSpan.SetAttributes( + attribute.String("stage", "Execution"), + attribute.Int64("selectedOption", int64(selectedIdx)), + ) + emitAuditEvent(approvalCtx, r.Audit, AuditApprovalReceived, traceIDFromProposal(proposal), map[string]interface{}{ + "stage": "Execution", + "selectedOption": selectedIdx, + }) + approvalSpan.End() + } else { + emitAuditEvent(ctx, r.Audit, AuditApprovalReceived, traceIDFromProposal(proposal), map[string]interface{}{ + "stage": "Execution", + "mode": "Automatic", + "selectedOption": selectedIdx, + }) + } + } + + parentCtx := ctx + ctx, executeSpan := tracer.Start(ctx, "proposal.execute") + executeSpan.SetAttributes( + attribute.String("agent", resolved.Execution.Agent.Name), + attribute.String("model", resolved.Execution.Agent.Spec.Model), + ) + defer executeSpan.End() + execResult, err := r.Agent.Execute(ctx, proposal, *resolved.Execution, selectedOption, execSA) if err != nil { return r.failStep(ctx, proposal, agenticv1alpha1.ProposalConditionExecuted, err) @@ -281,10 +340,11 @@ func (r *ProposalReconciler) handleExecution( base = proposal.DeepCopy() completedAt := metav1.Now() startTime := conditionTime(proposal.Status.Conditions, agenticv1alpha1.ProposalConditionExecuted) - execCRName, execCRErr := r.createExecutionResult(ctx, proposal, execResult, proposal.Status.Steps.Execution.Sandbox, startTime, &completedAt, "") + execCRName, execCR, execCRErr := r.createExecutionResult(ctx, proposal, execResult, proposal.Status.Steps.Execution.Sandbox, startTime, &completedAt, "") if execCRErr != nil { return r.failStep(ctx, proposal, agenticv1alpha1.ProposalConditionExecuted, fmt.Errorf("%s: %w", ErrCreateExecutionResult, execCRErr)) } + emitAuditEvent(ctx, r.Audit, AuditExecutionCompleted, traceIDFromProposal(proposal), serializeCRWithStatus(execCR, execCR.Spec, execCR.Status)) proposal.Status.Steps.Execution.Results = append(proposal.Status.Steps.Execution.Results, agenticv1alpha1.StepResultRef{Name: execCRName, Outcome: agenticv1alpha1.ActionOutcomeFromBool(execResult.Success)}) meta.SetStatusCondition(&proposal.Status.Conditions, metav1.Condition{ Type: agenticv1alpha1.ProposalConditionExecuted, @@ -299,6 +359,10 @@ func (r *ProposalReconciler) handleExecution( if err := r.statusPatch(ctx, proposal, base); err != nil { return ctrl.Result{}, fmt.Errorf("%s: %w", ErrUpdateToCompletedTrust, err) } + termCtx, termSpan := tracer.Start(parentCtx, "proposal.terminal") + termSpan.SetAttributes(attribute.String("phase", string(agenticv1alpha1.ProposalPhaseCompleted))) + emitAuditEvent(termCtx, r.Audit, AuditProposalTerminal, traceIDFromProposal(proposal), terminalPayload{Phase: string(agenticv1alpha1.ProposalPhaseCompleted)}) + termSpan.End() log.Info("execution complete, verification skipped") } else { if err := r.statusPatch(ctx, proposal, base); err != nil { @@ -387,6 +451,14 @@ func (r *ProposalReconciler) handleVerification( } } + parentCtx := ctx + ctx, verifySpan := tracer.Start(ctx, "proposal.verify") + verifySpan.SetAttributes( + attribute.String("agent", resolved.Verification.Agent.Name), + attribute.String("model", resolved.Verification.Agent.Spec.Model), + ) + defer verifySpan.End() + verifyResult, err := r.Agent.Verify(ctx, proposal, *resolved.Verification, selectedOption, execOutput, defaultSandboxSA) if err != nil { return r.failStep(ctx, proposal, agenticv1alpha1.ProposalConditionVerified, err) @@ -395,7 +467,7 @@ func (r *ProposalReconciler) handleVerification( base = proposal.DeepCopy() completedAt := metav1.Now() startTime := conditionTime(proposal.Status.Conditions, agenticv1alpha1.ProposalConditionVerified) - verifyCRName, verifyCRErr := r.createVerificationResult(ctx, proposal, verifyResult, proposal.Status.Steps.Verification.Sandbox, startTime, &completedAt, "") + verifyCRName, verifyCR, verifyCRErr := r.createVerificationResult(ctx, proposal, verifyResult, proposal.Status.Steps.Verification.Sandbox, startTime, &completedAt, "") if verifyCRErr != nil { return r.failStep(ctx, proposal, agenticv1alpha1.ProposalConditionVerified, fmt.Errorf("%s: %w", ErrCreateVerificationResult, verifyCRErr)) } @@ -418,6 +490,7 @@ func (r *ProposalReconciler) handleVerification( if int(retryCount) < maxRetries-1 { next := retryCount + 1 + emitAuditEvent(ctx, r.Audit, AuditVerificationRetry, traceIDFromProposal(proposal), retryPayload{CR: serializeCRWithStatus(verifyCR, verifyCR.Spec, verifyCR.Status), RetryCount: next}) log.Info("verification failed, retrying execution", "attempt", next+1, "maxAttempts", maxRetries, LogKeySummary, verifyResult.Summary) proposal.Status.Steps.Execution.RetryCount = &next resetExecutionAndVerification(&proposal.Status.Steps) @@ -467,6 +540,11 @@ func (r *ProposalReconciler) handleVerification( return ctrl.Result{}, fmt.Errorf("%s: %w", ErrUpdateToCompleted, err) } + emitAuditEvent(ctx, r.Audit, AuditVerificationCompleted, traceIDFromProposal(proposal), serializeCRWithStatus(verifyCR, verifyCR.Spec, verifyCR.Status)) + termCtx, termSpan := tracer.Start(parentCtx, "proposal.terminal") + termSpan.SetAttributes(attribute.String("phase", string(agenticv1alpha1.ProposalPhaseCompleted))) + emitAuditEvent(termCtx, r.Audit, AuditProposalTerminal, traceIDFromProposal(proposal), terminalPayload{Phase: string(agenticv1alpha1.ProposalPhaseCompleted)}) + termSpan.End() log.Info("verification passed", LogKeySummary, verifyResult.Summary) return ctrl.Result{}, nil } @@ -523,6 +601,10 @@ func (r *ProposalReconciler) handleSuspension( if err := r.statusPatch(ctx, proposal, base); err != nil { return ctrl.Result{}, fmt.Errorf("patch EmergencyStopped condition: %w", err) } + termCtx, termSpan := tracer.Start(ctx, "proposal.terminal") + termSpan.SetAttributes(attribute.String("phase", string(agenticv1alpha1.ProposalPhaseEmergencyStopped))) + emitAuditEvent(termCtx, r.Audit, AuditProposalTerminal, traceIDFromProposal(proposal), terminalPayload{Phase: string(agenticv1alpha1.ProposalPhaseEmergencyStopped), Reason: reasonSystemSuspended}) + termSpan.End() return ctrl.Result{}, nil } @@ -585,6 +667,14 @@ func (r *ProposalReconciler) handleEscalation( return ctrl.Result{}, fmt.Errorf("%s: %w", ErrUpdateToEscalating, err) } + parentCtx := ctx + ctx, escalateSpan := tracer.Start(ctx, "proposal.escalate") + escalateSpan.SetAttributes( + attribute.String("agent", step.Agent.Name), + attribute.String("model", step.Agent.Spec.Model), + ) + defer escalateSpan.End() + escalationText := buildEscalationRequest(proposal) escalationResult, err := r.Agent.Escalate(ctx, proposal, step, escalationText, defaultSandboxSA) if err != nil { @@ -594,10 +684,15 @@ func (r *ProposalReconciler) handleEscalation( base = proposal.DeepCopy() completedAt := metav1.Now() startTime := conditionTime(proposal.Status.Conditions, agenticv1alpha1.ProposalConditionEscalated) - crName, crErr := r.createEscalationResult(ctx, proposal, escalationResult, proposal.Status.Steps.Escalation.Sandbox, startTime, &completedAt, "") + crName, escCR, crErr := r.createEscalationResult(ctx, proposal, escalationResult, proposal.Status.Steps.Escalation.Sandbox, startTime, &completedAt, "") if crErr != nil { return r.failStep(ctx, proposal, agenticv1alpha1.ProposalConditionEscalated, fmt.Errorf("%s: %w", ErrCreateEscalationResult, crErr)) } + emitAuditEvent(ctx, r.Audit, AuditEscalationCompleted, traceIDFromProposal(proposal), serializeCRWithStatus(escCR, escCR.Spec, escCR.Status)) + termCtx, termSpan := tracer.Start(parentCtx, "proposal.terminal") + termSpan.SetAttributes(attribute.String("phase", string(agenticv1alpha1.ProposalPhaseEscalated))) + emitAuditEvent(termCtx, r.Audit, AuditProposalTerminal, traceIDFromProposal(proposal), terminalPayload{Phase: string(agenticv1alpha1.ProposalPhaseEscalated)}) + termSpan.End() proposal.Status.Steps.Escalation.Results = append(proposal.Status.Steps.Escalation.Results, agenticv1alpha1.StepResultRef{Name: crName, Outcome: agenticv1alpha1.ActionOutcomeFromBool(escalationResult.Success)}) if proposal.Annotations[rbacNamespacesAnnotation] != "" { @@ -647,5 +742,9 @@ func (r *ProposalReconciler) denyProposal( if err := r.statusPatch(ctx, proposal, base); err != nil { return ctrl.Result{}, fmt.Errorf("%s: %w", ErrUpdateToDenied, err) } + termCtx, termSpan := tracer.Start(ctx, "proposal.terminal") + termSpan.SetAttributes(attribute.String("phase", string(agenticv1alpha1.ProposalPhaseDenied))) + emitAuditEvent(termCtx, r.Audit, AuditProposalTerminal, traceIDFromProposal(proposal), terminalPayload{Phase: string(agenticv1alpha1.ProposalPhaseDenied), Reason: message}) + termSpan.End() return ctrl.Result{}, nil } diff --git a/controller/proposal/helpers.go b/controller/proposal/helpers.go index cf3a6eb..6c9d3ef 100644 --- a/controller/proposal/helpers.go +++ b/controller/proposal/helpers.go @@ -9,6 +9,7 @@ import ( "reflect" "text/template" + "go.opentelemetry.io/otel/attribute" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/types" @@ -84,26 +85,35 @@ func (r *ProposalReconciler) failStep(ctx context.Context, proposal *agenticv1al var crName string var createErr error + traceID := traceIDFromProposal(proposal) switch conditionType { case agenticv1alpha1.ProposalConditionAnalyzed: - crName, createErr = r.createAnalysisResult(ctx, proposal, nil, proposal.Status.Steps.Analysis.Sandbox, startTime, &completedAt, err.Error()) + var cr *agenticv1alpha1.AnalysisResult + crName, cr, createErr = r.createAnalysisResult(ctx, proposal, nil, proposal.Status.Steps.Analysis.Sandbox, startTime, &completedAt, err.Error()) if createErr == nil { proposal.Status.Steps.Analysis.Results = append(proposal.Status.Steps.Analysis.Results, agenticv1alpha1.StepResultRef{Name: crName, Outcome: agenticv1alpha1.ActionOutcomeFailed}) + emitAuditEvent(ctx, r.Audit, AuditAnalysisCompleted, traceID, serializeCRWithStatus(cr, cr.Spec, cr.Status)) } case agenticv1alpha1.ProposalConditionExecuted: - crName, createErr = r.createExecutionResult(ctx, proposal, nil, proposal.Status.Steps.Execution.Sandbox, startTime, &completedAt, err.Error()) + var cr *agenticv1alpha1.ExecutionResult + crName, cr, createErr = r.createExecutionResult(ctx, proposal, nil, proposal.Status.Steps.Execution.Sandbox, startTime, &completedAt, err.Error()) if createErr == nil { proposal.Status.Steps.Execution.Results = append(proposal.Status.Steps.Execution.Results, agenticv1alpha1.StepResultRef{Name: crName, Outcome: agenticv1alpha1.ActionOutcomeFailed}) + emitAuditEvent(ctx, r.Audit, AuditExecutionCompleted, traceID, serializeCRWithStatus(cr, cr.Spec, cr.Status)) } case agenticv1alpha1.ProposalConditionVerified: - crName, createErr = r.createVerificationResult(ctx, proposal, nil, proposal.Status.Steps.Verification.Sandbox, startTime, &completedAt, err.Error()) + var cr *agenticv1alpha1.VerificationResult + crName, cr, createErr = r.createVerificationResult(ctx, proposal, nil, proposal.Status.Steps.Verification.Sandbox, startTime, &completedAt, err.Error()) if createErr == nil { proposal.Status.Steps.Verification.Results = append(proposal.Status.Steps.Verification.Results, agenticv1alpha1.StepResultRef{Name: crName, Outcome: agenticv1alpha1.ActionOutcomeFailed}) + emitAuditEvent(ctx, r.Audit, AuditVerificationCompleted, traceID, serializeCRWithStatus(cr, cr.Spec, cr.Status)) } case agenticv1alpha1.ProposalConditionEscalated: - crName, createErr = r.createEscalationResult(ctx, proposal, nil, proposal.Status.Steps.Escalation.Sandbox, startTime, &completedAt, err.Error()) + var cr *agenticv1alpha1.EscalationResult + crName, cr, createErr = r.createEscalationResult(ctx, proposal, nil, proposal.Status.Steps.Escalation.Sandbox, startTime, &completedAt, err.Error()) if createErr == nil { proposal.Status.Steps.Escalation.Results = append(proposal.Status.Steps.Escalation.Results, agenticv1alpha1.StepResultRef{Name: crName, Outcome: agenticv1alpha1.ActionOutcomeFailed}) + emitAuditEvent(ctx, r.Audit, AuditEscalationCompleted, traceID, serializeCRWithStatus(cr, cr.Spec, cr.Status)) } } if createErr != nil { @@ -119,7 +129,13 @@ func (r *ProposalReconciler) failStep(ctx context.Context, proposal *agenticv1al }) if statusErr := r.statusPatch(ctx, proposal, base); statusErr != nil { log.Error(statusErr, "failed to patch status after step failure") + return ctrl.Result{}, statusErr } + termParent := lifecycleParentContext(ctx, proposal) + termCtx, termSpan := tracer.Start(termParent, "proposal.terminal") + termSpan.SetAttributes(attribute.String("phase", string(agenticv1alpha1.ProposalPhaseFailed))) + emitAuditEvent(termCtx, r.Audit, AuditProposalTerminal, traceIDFromProposal(proposal), terminalPayload{Phase: string(agenticv1alpha1.ProposalPhaseFailed), Reason: err.Error()}) + termSpan.End() return ctrl.Result{}, nil } diff --git a/controller/proposal/reconciler.go b/controller/proposal/reconciler.go index aa6e50e..9d267f3 100644 --- a/controller/proposal/reconciler.go +++ b/controller/proposal/reconciler.go @@ -4,6 +4,7 @@ import ( "context" "fmt" + "go.opentelemetry.io/otel/attribute" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ctrl "sigs.k8s.io/controller-runtime" @@ -28,6 +29,7 @@ type ProposalReconciler struct { client.Client Agent AgentCaller Namespace string + Audit *AuditLogger } // +kubebuilder:rbac:groups=agentic.openshift.io,resources=proposals,verbs=get;list;watch;create;update;patch;delete @@ -55,6 +57,11 @@ func (r *ProposalReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c return ctrl.Result{}, client.IgnoreNotFound(err) } + // Set up OTEL trace context with the lifecycle span as parent so all + // child spans nest under proposal.received in the trace view. + // Independent of audit logging — tracing works even when logging is off. + ctx = lifecycleParentContext(ctx, &proposal) + // --- Deletion --- if !proposal.DeletionTimestamp.IsZero() { if controllerutil.ContainsFinalizer(&proposal, rbacCleanupFinalizer) { @@ -92,9 +99,32 @@ func (r *ProposalReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c if !isTerminal(phase) { original := proposal.DeepCopy() controllerutil.AddFinalizer(&proposal, rbacCleanupFinalizer) + + // Create the proposal.lifecycle root span and store its span ID + // so subsequent reconciles can parent their spans under it. + lifecycleCtx, lifecycleSpan := tracer.Start(ctx, "proposal.received") + lifecycleSpan.SetAttributes(proposalSpanAttrs(&proposal)...) + lifecycleSpan.SetAttributes( + attribute.StringSlice("proposal.targetNamespaces", proposal.Spec.TargetNamespaces), + ) + spanID := lifecycleSpan.SpanContext().SpanID().String() + if proposal.Annotations == nil { + proposal.Annotations = make(map[string]string) + } + proposal.Annotations[lifecycleSpanIDAnnotation] = spanID + + // Emit audit event while lifecycle span is active so it appears as a Log in Jaeger. + traceID := traceIDFromProposal(&proposal) + emitAuditEvent(lifecycleCtx, r.Audit, AuditProposalReceived, traceID, serializeCR(&proposal, proposal.Spec)) + lifecycleSpan.End() + if err := r.Patch(ctx, &proposal, client.MergeFrom(original)); err != nil { return ctrl.Result{}, fmt.Errorf("%s: %w", ErrAddFinalizer, err) } + + // Re-establish parent context for any child spans in this reconcile. + ctx = lifecycleParentContext(ctx, &proposal) + if err := r.Get(ctx, req.NamespacedName, &proposal); err != nil { return ctrl.Result{}, client.IgnoreNotFound(err) } @@ -107,6 +137,9 @@ func (r *ProposalReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c agenticv1alpha1.ProposalPhaseDenied, agenticv1alpha1.ProposalPhaseEscalated, agenticv1alpha1.ProposalPhaseEmergencyStopped: + _, termSpan := tracer.Start(ctx, "proposal.terminal") + termSpan.SetAttributes(attribute.String("phase", string(phase))) + termSpan.End() if hasSandboxClaims(&proposal) { if err := r.Agent.ReleaseSandboxes(ctx, &proposal); err != nil { log.Error(err, "sandbox cleanup failed at terminal phase") diff --git a/controller/proposal/results.go b/controller/proposal/results.go index 6439014..d3175a9 100644 --- a/controller/proposal/results.go +++ b/controller/proposal/results.go @@ -79,7 +79,7 @@ func (r *ProposalReconciler) createAnalysisResult( startTime *metav1.Time, completionTime *metav1.Time, failureReason string, -) (string, error) { +) (string, *agenticv1alpha1.AnalysisResult, error) { crName := resultCRName(proposal.Name, "analysis", len(proposal.Status.Steps.Analysis.Results)+1) outcome := agenticv1alpha1.ActionOutcomeFailed @@ -113,7 +113,9 @@ func (r *ProposalReconciler) createAnalysisResult( cr.Status.Options = result.Options } - return crName, createIdempotent(ctx, r.Client, cr, "AnalysisResult") + // DeepCopy before createIdempotent because Create() zeroes status on the original. + snapshot := cr.DeepCopy() + return crName, snapshot, createIdempotent(ctx, r.Client, cr, "AnalysisResult") } func (r *ProposalReconciler) createExecutionResult( @@ -124,7 +126,7 @@ func (r *ProposalReconciler) createExecutionResult( startTime *metav1.Time, completionTime *metav1.Time, failureReason string, -) (string, error) { +) (string, *agenticv1alpha1.ExecutionResult, error) { crName := resultCRName(proposal.Name, "execution", len(proposal.Status.Steps.Execution.Results)+1) outcome := agenticv1alpha1.ActionOutcomeFailed @@ -160,7 +162,8 @@ func (r *ProposalReconciler) createExecutionResult( cr.Status.Verification = result.Verification } - return crName, createIdempotent(ctx, r.Client, cr, "ExecutionResult") + snapshot := cr.DeepCopy() + return crName, snapshot, createIdempotent(ctx, r.Client, cr, "ExecutionResult") } func (r *ProposalReconciler) createVerificationResult( @@ -171,7 +174,7 @@ func (r *ProposalReconciler) createVerificationResult( startTime *metav1.Time, completionTime *metav1.Time, failureReason string, -) (string, error) { +) (string, *agenticv1alpha1.VerificationResult, error) { crName := resultCRName(proposal.Name, "verification", len(proposal.Status.Steps.Verification.Results)+1) outcome := agenticv1alpha1.ActionOutcomeFailed @@ -207,7 +210,8 @@ func (r *ProposalReconciler) createVerificationResult( cr.Status.Summary = result.Summary } - return crName, createIdempotent(ctx, r.Client, cr, "VerificationResult") + snapshot := cr.DeepCopy() + return crName, snapshot, createIdempotent(ctx, r.Client, cr, "VerificationResult") } func (r *ProposalReconciler) createEscalationResult( @@ -218,7 +222,7 @@ func (r *ProposalReconciler) createEscalationResult( startTime *metav1.Time, completionTime *metav1.Time, failureReason string, -) (string, error) { +) (string, *agenticv1alpha1.EscalationResult, error) { crName := resultCRName(proposal.Name, "escalation", len(proposal.Status.Steps.Escalation.Results)+1) outcome := agenticv1alpha1.ActionOutcomeFailed @@ -253,7 +257,8 @@ func (r *ProposalReconciler) createEscalationResult( cr.Status.Content = result.Content } - return crName, createIdempotent(ctx, r.Client, cr, "EscalationResult") + snapshot := cr.DeepCopy() + return crName, snapshot, createIdempotent(ctx, r.Client, cr, "EscalationResult") } type statusHolder interface { diff --git a/controller/setup.go b/controller/setup.go index b256554..511b744 100644 --- a/controller/setup.go +++ b/controller/setup.go @@ -17,6 +17,7 @@ type Options struct { AgenticSandboxImage string SandboxMode string ImagePullPolicy string + Audit *proposal.AuditLogger } func Setup(mgr ctrl.Manager, opts Options) error { @@ -45,6 +46,7 @@ func Setup(mgr ctrl.Manager, opts Options) error { Client: mgr.GetClient(), Agent: agentCaller, Namespace: opts.Namespace, + Audit: opts.Audit, }).SetupWithManager(mgr); err != nil { return err } diff --git a/go.mod b/go.mod index 8417468..6049342 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,8 @@ go 1.25.7 require ( github.com/openshift/api v0.0.0-20260617125622-05673ba6e650 github.com/spf13/cobra v1.10.2 + go.opentelemetry.io/otel v1.44.0 + go.opentelemetry.io/otel/trace v1.44.0 k8s.io/api v0.35.3 k8s.io/apiextensions-apiserver v0.35.3 k8s.io/apimachinery v0.35.3 @@ -16,7 +18,9 @@ require ( ) require ( - github.com/go-logr/logr v1.4.3 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/go-logr/logr v1.4.3 + github.com/go-logr/stdr v1.2.2 // indirect github.com/go-openapi/swag/cmdutils v0.26.1 // indirect github.com/go-openapi/swag/conv v0.26.1 // indirect github.com/go-openapi/swag/fileutils v0.26.1 // indirect @@ -30,8 +34,18 @@ require ( github.com/go-openapi/swag/yamlutils v0.26.1 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/sdk v1.44.0 + go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/grpc v1.81.1 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect ) @@ -72,7 +86,7 @@ require ( github.com/x448/float16 v0.8.4 // indirect github.com/xlab/treeprint v1.2.0 // indirect go.uber.org/multierr v1.11.0 // indirect - go.uber.org/zap v1.28.0 // indirect + go.uber.org/zap v1.28.0 golang.org/x/net v0.56.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.21.0 // indirect diff --git a/go.sum b/go.sum index 82d3aa4..3f1e495 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= @@ -27,8 +29,11 @@ github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk= github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= github.com/go-openapi/jsonpointer v0.23.1 h1:1HBACs7XIwR2RcmItfdSFlALhGbe6S92p0ry4d1GWg4= @@ -67,6 +72,8 @@ github.com/go-openapi/testify/v2 v2.5.1 h1:TMdhCaw8fUNraVSf3Omoob1dO/AzBfhtFAPW0 github.com/go-openapi/testify/v2 v2.5.1/go.mod h1:SgsVHtfooshd0tublTtJ50FPKhujf47YRqauXXOUxfw= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c= @@ -82,6 +89,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA= github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= @@ -150,6 +159,24 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 h1:qazEJlUOQzhCpzQpFETGby7EdqjI1wsd0W+6Gg1SCTU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0/go.mod h1:fOD2Yefuxixkx3ahVNf0O/PERb6r4OlbxfATVnYvzCo= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= @@ -181,6 +208,14 @@ golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0= gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=