diff --git a/api/v1alpha1/agentworkload_types.go b/api/v1alpha1/agentworkload_types.go index 037c678..b118917 100644 --- a/api/v1alpha1/agentworkload_types.go +++ b/api/v1alpha1/agentworkload_types.go @@ -367,6 +367,10 @@ type AgentWorkloadStatus struct { // +optional ModelRoutingOperationID string `json:"modelRoutingOperationID,omitempty"` + // sandboxExecutionUID identifies the execution that last produced sandbox evidence. + // +optional + SandboxExecutionUID string `json:"sandboxExecutionUID,omitempty"` + // conditions represent the current state of the AgentWorkload resource. // Each condition has a unique type and reflects the status of a specific aspect of the resource. // @@ -386,6 +390,14 @@ type AgentWorkloadStatus struct { AgentStatuses []AgentInstanceStatus `json:"agentStatuses,omitempty"` } +const ( + ConditionTypeSandboxEnforced = "SandboxEnforced" + SandboxEnforcedVerified = "Verified" + SandboxEnforcedRuntimeClassMissing = "RuntimeClassMissing" + SandboxEnforcedNoReadyNode = "NoReadyNode" + SandboxEnforcedBestEffortUnsandboxed = "BestEffortUnsandboxed" +) + // AgentInstanceStatus reports the status of a single agent in a collaborative workload type AgentInstanceStatus struct { // name matches the AgentCard name from spec.agentRefs[] diff --git a/cmd/main.go b/cmd/main.go index 57c2fa7..b120835 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -30,6 +30,8 @@ import ( "github.com/go-logr/logr" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/labels" // Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.) // to ensure that exec-entrypoint and run can make use of them. _ "k8s.io/client-go/plugin/pkg/client/auth" @@ -38,6 +40,8 @@ import ( 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/cache" + "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/healthz" "sigs.k8s.io/controller-runtime/pkg/log/zap" ctrlmetrics "sigs.k8s.io/controller-runtime/pkg/metrics" @@ -50,6 +54,7 @@ import ( "github.com/Clawdlinux/agentic-operator-core/internal/controller" "github.com/Clawdlinux/agentic-operator-core/pkg/evaluation" "github.com/Clawdlinux/agentic-operator-core/pkg/finops" + "github.com/Clawdlinux/agentic-operator-core/pkg/governance" "github.com/Clawdlinux/agentic-operator-core/pkg/multitenancy" "github.com/Clawdlinux/agentic-operator-core/pkg/otel/genai" // +kubebuilder:scaffold:imports @@ -204,8 +209,12 @@ func main() { metricsServerOptions.KeyName = metricsCertKey } + sandboxConfig := runtimeadmission.RuntimeClassInjectionConfigFromEnv() mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ - Scheme: scheme, + Scheme: scheme, + Cache: cache.Options{ByObject: map[client.Object]cache.ByObject{ + &corev1.Pod{}: {Label: labels.SelectorFromSet(labels.Set{governance.ManagedByLabelKey: governance.ManagedByLabelValue})}, + }}, Metrics: metricsServerOptions, WebhookServer: webhookServer, HealthProbeBindAddress: probeAddr, @@ -264,6 +273,8 @@ func main() { workloadReconciler.QuotaMgr = quotaMgr // Phase 7: Quota enforcement workloadReconciler.SLAMonitor = slaMonitor // Phase 7: SLA tracking workloadReconciler.TenantRes = tenantResolver // Phase 7: Tenant isolation + workloadReconciler.SandboxClass = sandboxConfig.RuntimeClassName + workloadReconciler.Recorder = mgr.GetEventRecorder("agentworkload-controller") if err := workloadReconciler.SetupWithManager(mgr); err != nil { setupLog.Error(err, "Failed to create controller", "controller", "AgentWorkload") @@ -283,8 +294,6 @@ func main() { setupLog.Error(err, "Failed to setup webhook", "webhook", "AgentWorkload") os.Exit(1) } - sandboxConfig := runtimeadmission.RuntimeClassInjectionConfigFromEnv() - mgr.GetWebhookServer().Register("/mutate-v1-pod-runtimeclass", &webhook.Admission{ Handler: &runtimeadmission.RuntimeClassInjector{ Config: sandboxConfig, diff --git a/config/crd/agentworkload_crd.yaml b/config/crd/agentworkload_crd.yaml index 3d6be44..d6214e1 100644 --- a/config/crd/agentworkload_crd.yaml +++ b/config/crd/agentworkload_crd.yaml @@ -164,6 +164,8 @@ spec: type: string modelRoutingOperationID: type: string + sandboxExecutionUID: + type: string workflowArtifacts: type: object additionalProperties: diff --git a/config/crd/bases/agentic.clawdlinux.org_agentworkloads.yaml b/config/crd/bases/agentic.clawdlinux.org_agentworkloads.yaml index 69c4350..d76695b 100644 --- a/config/crd/bases/agentic.clawdlinux.org_agentworkloads.yaml +++ b/config/crd/bases/agentic.clawdlinux.org_agentworkloads.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.20.1 + controller-gen.kubebuilder.io/version: v0.22.0 name: agentworkloads.agentic.clawdlinux.org spec: group: agentic.clawdlinux.org @@ -546,6 +546,10 @@ spec: description: readyAgents is the number of agents ready to execute format: int32 type: integer + sandboxExecutionUID: + description: sandboxExecutionUID identifies the execution that last + produced sandbox evidence. + type: string workflowArtifacts: additionalProperties: type: string diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 04c6161..3464e20 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -15,6 +15,14 @@ rules: - patch - update - watch +- apiGroups: + - "" + resources: + - pods + verbs: + - get + - list + - watch - apiGroups: - "" resources: diff --git a/internal/admission/runtimeclass_injector.go b/internal/admission/runtimeclass_injector.go index d995635..c01cad9 100644 --- a/internal/admission/runtimeclass_injector.go +++ b/internal/admission/runtimeclass_injector.go @@ -9,14 +9,17 @@ import ( corev1 "k8s.io/api/core/v1" ctrladmission "sigs.k8s.io/controller-runtime/pkg/webhook/admission" + + "github.com/Clawdlinux/agentic-operator-core/pkg/governance" ) const ( DefaultRuntimeClassName = "gvisor" - DefaultRuntimeLabelKey = "agentic.clawdlinux.org/runtime-sandbox" - DefaultRuntimeLabelValue = "gvisor" + DefaultRuntimeLabelKey = governance.RuntimeSandboxLabelKey + DefaultRuntimeLabelValue = governance.RuntimeSandboxLabelValue EnforcementModeStrict = "strict" EnforcementModeBestEffort = "best-effort" + SandboxDenialPrefix = "sandbox readiness unverified: " ) type RuntimeClassInjectionConfig struct { @@ -98,9 +101,13 @@ func (i *RuntimeClassInjector) Handle(ctx context.Context, req ctrladmission.Req func (i *RuntimeClassInjector) readinessFailureResponse(reason string) ctrladmission.Response { if enforcementMode(i.Config.EnforcementMode) == EnforcementModeBestEffort { - return ctrladmission.Allowed("sandbox readiness unverified: " + reason) + return ctrladmission.Allowed(SandboxDenialPrefix + reason) } - return ctrladmission.Denied("sandbox readiness unverified: " + reason) + return ctrladmission.Denied(SandboxDenialPrefix + reason) +} + +func IsSandboxDenial(message string) bool { + return strings.Contains(message, SandboxDenialPrefix) } func enforcementMode(value string) string { diff --git a/internal/admission/runtimeclass_injector_test.go b/internal/admission/runtimeclass_injector_test.go index 3ee1a33..6e24132 100644 --- a/internal/admission/runtimeclass_injector_test.go +++ b/internal/admission/runtimeclass_injector_test.go @@ -271,6 +271,15 @@ func TestRuntimeClassInjectorHandleRejectsInvalidPodJSON(t *testing.T) { } } +func TestIsSandboxDenial(t *testing.T) { + if !IsSandboxDenial("pod adapter execute: create pod: admission webhook denied the request: " + SandboxDenialPrefix + SandboxReadinessNoReadyNode) { + t.Fatal("wrapped sandbox denial was not recognized") + } + if IsSandboxDenial("admission webhook denied the request: another reason") { + t.Fatal("non-sandbox denial was recognized") + } +} + func mustMarshalPod(t *testing.T, pod *corev1.Pod) []byte { t.Helper() raw, err := json.Marshal(pod) diff --git a/internal/controller/agentworkload_controller.go b/internal/controller/agentworkload_controller.go index cdaad15..0727295 100644 --- a/internal/controller/agentworkload_controller.go +++ b/internal/controller/agentworkload_controller.go @@ -33,6 +33,7 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/events" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" @@ -77,6 +78,8 @@ type AgentWorkloadReconciler struct { Metrics *metrics.RoutingMetrics // Singleton metrics recorder (initialized once) RetryConfig *resilience.RetryConfig // optional test seam; nil uses production defaults RuntimeRegistry *runtimeadapter.Registry // runtime adapter registry; nil lazily defaults to Argo + SandboxClass string // RuntimeClass required for sandbox enforcement + Recorder events.EventRecorder // Optional Kubernetes event recorder } type quotaChecker interface { @@ -148,6 +151,7 @@ func (r *AgentWorkloadReconciler) ensureRuntimeDefaults() { // +kubebuilder:rbac:groups=agentic.clawdlinux.org,resources=agentworkloads/status,verbs=get;update;patch // +kubebuilder:rbac:groups=agentic.clawdlinux.org,resources=agentworkloads/finalizers,verbs=update // +kubebuilder:rbac:groups="",resources=namespaces,verbs=get;list;watch;patch;update +// +kubebuilder:rbac:groups="",resources=pods,verbs=get;list;watch // Reconcile reconciles the AgentWorkload by: // 1. Fetching the AgentWorkload CR @@ -741,6 +745,7 @@ func (r *AgentWorkloadReconciler) reconcileViaRuntime(ctx context.Context, workl // Update phase based on normalized execution status workload.Status.ArgoPhase = execStatus.Phase + r.observeSandboxStatus(ctx, workload, execStatus.Message) switch execStatus.Phase { case "Succeeded": workload.Status.Phase = "Completed" @@ -766,6 +771,7 @@ func (r *AgentWorkloadReconciler) reconcileViaRuntime(ctx context.Context, workl log.Error(err, "failed to create execution") workload.Status.Phase = "Failed" workload.Status.ArgoPhase = "Error" + r.observeSandboxStatus(ctx, workload, err.Error()) if err := r.Status().Update(ctx, workload); err != nil { log.Error(err, "failed to update status") } @@ -784,6 +790,7 @@ func (r *AgentWorkloadReconciler) reconcileViaRuntime(ctx context.Context, workl UID: execStatus.UID, CreatedAt: &metav1.Time{Time: time.Now()}, } + r.observeSandboxStatus(ctx, workload, execStatus.Message) if err := r.Status().Update(ctx, workload); err != nil { log.Error(err, "failed to update status with execution reference") diff --git a/internal/controller/sandbox_observer_test.go b/internal/controller/sandbox_observer_test.go new file mode 100644 index 0000000..24ea7a1 --- /dev/null +++ b/internal/controller/sandbox_observer_test.go @@ -0,0 +1,69 @@ +package controller + +import ( + "context" + "strings" + "testing" + + corev1 "k8s.io/api/core/v1" + apiMeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/tools/events" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + agenticv1alpha1 "github.com/Clawdlinux/agentic-operator-core/api/v1alpha1" + "github.com/Clawdlinux/agentic-operator-core/pkg/governance" +) + +func TestObserveSandboxStatus_SelectsPodsByWorkloadUID(t *testing.T) { + gvisor := "gvisor" + workload := &agenticv1alpha1.AgentWorkload{ + ObjectMeta: metav1.ObjectMeta{Name: "research", Namespace: "tenant-a", UID: types.UID("workload-uid"), Generation: 2}, + Status: agenticv1alpha1.AgentWorkloadStatus{ArgoWorkflow: &agenticv1alpha1.ArgoWorkflowRef{Name: "run", UID: "execution-uid"}}, + } + matchingPod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "run-1", Namespace: "argo-workflows", Labels: map[string]string{governance.WorkloadUIDLabelKey: "workload-uid", governance.ManagedByLabelKey: governance.ManagedByLabelValue}}, Spec: corev1.PodSpec{RuntimeClassName: &gvisor}} + otherPod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "other", Namespace: "argo-workflows", Labels: map[string]string{governance.WorkloadUIDLabelKey: "other-uid", governance.ManagedByLabelKey: governance.ManagedByLabelValue}}} + reconciler := &AgentWorkloadReconciler{Client: fake.NewClientBuilder().WithScheme(newControllerTestScheme(t)).WithObjects(matchingPod, otherPod).Build()} + + reconciler.observeSandboxStatus(context.Background(), workload, "") + condition := apiMeta.FindStatusCondition(workload.Status.Conditions, agenticv1alpha1.ConditionTypeSandboxEnforced) + if condition == nil || condition.Status != metav1.ConditionTrue || condition.Reason != agenticv1alpha1.SandboxEnforcedVerified { + t.Fatalf("sandbox condition = %#v, want verified", condition) + } + if workload.Status.SandboxExecutionUID != "execution-uid" { + t.Fatalf("sandbox execution UID = %q, want execution-uid", workload.Status.SandboxExecutionUID) + } +} + +func TestObserveSandboxStatus_EmitsWarningOnlyOnFalseTransition(t *testing.T) { + workload := &agenticv1alpha1.AgentWorkload{ + ObjectMeta: metav1.ObjectMeta{Name: "research", Namespace: "tenant-a", UID: types.UID("workload-uid")}, + Status: agenticv1alpha1.AgentWorkloadStatus{ArgoWorkflow: &agenticv1alpha1.ArgoWorkflowRef{Name: "run"}}, + } + recorder := events.NewFakeRecorder(1) + reconciler := &AgentWorkloadReconciler{Client: fake.NewClientBuilder().WithScheme(newControllerTestScheme(t)).WithObjects(&corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "run-1", Labels: map[string]string{governance.WorkloadUIDLabelKey: "workload-uid", governance.ManagedByLabelKey: governance.ManagedByLabelValue}}}).Build(), Recorder: recorder} + + reconciler.observeSandboxStatus(context.Background(), workload, "") + select { + case event := <-recorder.Events: + if !strings.Contains(event, agenticv1alpha1.SandboxEnforcedBestEffortUnsandboxed) { + t.Fatalf("event = %q, want unsandboxed reason", event) + } + default: + t.Fatal("expected warning event") + } + reconciler.observeSandboxStatus(context.Background(), workload, "") + select { + case event := <-recorder.Events: + t.Fatalf("unexpected repeated warning event: %q", event) + default: + } + workload.Status.Conditions[0].Reason = "PreviousUnsandboxedReason" + reconciler.observeSandboxStatus(context.Background(), workload, "") + select { + case event := <-recorder.Events: + t.Fatalf("unexpected warning for false condition reason change: %q", event) + default: + } +} diff --git a/internal/controller/sandbox_status.go b/internal/controller/sandbox_status.go new file mode 100644 index 0000000..97fa3f6 --- /dev/null +++ b/internal/controller/sandbox_status.go @@ -0,0 +1,118 @@ +package controller + +import ( + "context" + "fmt" + "strings" + + corev1 "k8s.io/api/core/v1" + apiMeta "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + agenticv1alpha1 "github.com/Clawdlinux/agentic-operator-core/api/v1alpha1" + "github.com/Clawdlinux/agentic-operator-core/internal/admission" + "github.com/Clawdlinux/agentic-operator-core/pkg/governance" +) + +type sandboxObservation struct { + denialMessage string + pods []corev1.Pod + listErr error + hasExecution bool + sameExecution bool +} + +func sandboxCondition(previous *metav1.Condition, observation sandboxObservation, runtimeClass string, generation int64) (*metav1.Condition, bool) { + if admission.IsSandboxDenial(observation.denialMessage) { + return newSandboxCondition(metav1.ConditionFalse, sandboxDenialReason(observation.denialMessage), observation.denialMessage, generation), true + } + if observation.listErr != nil { + return newSandboxCondition(metav1.ConditionUnknown, "PodListFailed", fmt.Sprintf("Sandbox pod observation failed: %v", observation.listErr), generation), true + } + if len(observation.pods) == 0 { + if previous != nil && previous.Status == metav1.ConditionTrue && observation.sameExecution { + return previous, false + } + if !observation.hasExecution { + return nil, false + } + return newSandboxCondition(metav1.ConditionUnknown, "PodsNotObserved", "No workload pods matched the sandbox correlation label.", generation), true + } + + sandboxed := 0 + for _, pod := range observation.pods { + if pod.Spec.RuntimeClassName != nil && *pod.Spec.RuntimeClassName == runtimeClass { + sandboxed++ + } + } + if sandboxed == len(observation.pods) { + return newSandboxCondition(metav1.ConditionTrue, agenticv1alpha1.SandboxEnforcedVerified, fmt.Sprintf("%d/%d workload pods use RuntimeClass %s.", sandboxed, len(observation.pods), runtimeClass), generation), true + } + return newSandboxCondition(metav1.ConditionFalse, agenticv1alpha1.SandboxEnforcedBestEffortUnsandboxed, fmt.Sprintf("%d/%d workload pods use RuntimeClass %s.", sandboxed, len(observation.pods), runtimeClass), generation), true +} + +func newSandboxCondition(status metav1.ConditionStatus, reason, message string, generation int64) *metav1.Condition { + return &metav1.Condition{Type: agenticv1alpha1.ConditionTypeSandboxEnforced, Status: status, ObservedGeneration: generation, Reason: reason, Message: message, LastTransitionTime: metav1.Now()} +} + +func sandboxDenialReason(message string) string { + switch { + case strings.Contains(message, admission.SandboxReadinessRuntimeClassMissing): + return agenticv1alpha1.SandboxEnforcedRuntimeClassMissing + case strings.Contains(message, admission.SandboxReadinessNoReadyNode): + return agenticv1alpha1.SandboxEnforcedNoReadyNode + default: + return "SandboxReadinessCheckFailed" + } +} + +func (r *AgentWorkloadReconciler) observeSandboxStatus(ctx context.Context, workload *agenticv1alpha1.AgentWorkload, denialMessage string) { + previous := apiMeta.FindStatusCondition(workload.Status.Conditions, agenticv1alpha1.ConditionTypeSandboxEnforced) + observation := sandboxObservation{denialMessage: denialMessage} + if workload.Status.ArgoWorkflow != nil { + observation.hasExecution = workload.Status.ArgoWorkflow.Name != "" + observation.sameExecution = workload.Status.SandboxExecutionUID != "" && workload.Status.SandboxExecutionUID == workload.Status.ArgoWorkflow.UID + } + if observation.hasExecution && !admission.IsSandboxDenial(denialMessage) { + pods := &corev1.PodList{} + if err := r.List(ctx, pods, client.MatchingLabels{ + governance.WorkloadUIDLabelKey: string(workload.UID), + governance.ManagedByLabelKey: governance.ManagedByLabelValue, + }); err != nil { + observation.listErr = err + } else { + observation.pods = pods.Items + } + } + + condition, update := sandboxCondition(previous, observation, r.sandboxClass(), workload.Generation) + if !update || condition == nil { + return + } + if previous != nil && previous.Status == condition.Status { + condition.LastTransitionTime = previous.LastTransitionTime + } + workload.Status.Conditions = upsertCondition(workload.Status.Conditions, *condition) + r.recordSandboxWarning(workload, previous, condition) + if workload.Status.ArgoWorkflow != nil && workload.Status.ArgoWorkflow.UID != "" { + workload.Status.SandboxExecutionUID = workload.Status.ArgoWorkflow.UID + } +} + +func (r *AgentWorkloadReconciler) sandboxClass() string { + if class := strings.TrimSpace(r.SandboxClass); class != "" { + return class + } + return admission.DefaultRuntimeClassName +} + +func (r *AgentWorkloadReconciler) recordSandboxWarning(workload *agenticv1alpha1.AgentWorkload, previous, condition *metav1.Condition) { + if r.Recorder == nil || condition.Status != metav1.ConditionFalse { + return + } + if previous != nil && previous.Status == metav1.ConditionFalse { + return + } + r.Recorder.Eventf(workload, nil, corev1.EventTypeWarning, condition.Reason, "SandboxStatusObserved", "%s", condition.Message) +} diff --git a/internal/controller/sandbox_status_test.go b/internal/controller/sandbox_status_test.go new file mode 100644 index 0000000..fa78617 --- /dev/null +++ b/internal/controller/sandbox_status_test.go @@ -0,0 +1,52 @@ +package controller + +import ( + "errors" + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + agenticv1alpha1 "github.com/Clawdlinux/agentic-operator-core/api/v1alpha1" + "github.com/Clawdlinux/agentic-operator-core/internal/admission" +) + +func TestSandboxCondition(t *testing.T) { + gvisor := "gvisor" + previousVerified := &metav1.Condition{Type: agenticv1alpha1.ConditionTypeSandboxEnforced, Status: metav1.ConditionTrue, Reason: agenticv1alpha1.SandboxEnforcedVerified} + tests := []struct { + name string + previous *metav1.Condition + observation sandboxObservation + wantStatus metav1.ConditionStatus + wantReason string + wantChange bool + }{ + {name: "strict runtime class denial", observation: sandboxObservation{denialMessage: "pod adapter execute: create pod: admission webhook denied the request: " + admission.SandboxDenialPrefix + admission.SandboxReadinessRuntimeClassMissing}, wantStatus: metav1.ConditionFalse, wantReason: agenticv1alpha1.SandboxEnforcedRuntimeClassMissing, wantChange: true}, + {name: "strict no ready node denial", observation: sandboxObservation{denialMessage: "admission webhook denied the request: " + admission.SandboxDenialPrefix + admission.SandboxReadinessNoReadyNode}, wantStatus: metav1.ConditionFalse, wantReason: agenticv1alpha1.SandboxEnforcedNoReadyNode, wantChange: true}, + {name: "all observed pods sandboxed", observation: sandboxObservation{pods: []corev1.Pod{{Spec: corev1.PodSpec{RuntimeClassName: &gvisor}}}, hasExecution: true}, wantStatus: metav1.ConditionTrue, wantReason: agenticv1alpha1.SandboxEnforcedVerified, wantChange: true}, + {name: "observed unsandboxed pod", observation: sandboxObservation{pods: []corev1.Pod{{}}, hasExecution: true}, wantStatus: metav1.ConditionFalse, wantReason: agenticv1alpha1.SandboxEnforcedBestEffortUnsandboxed, wantChange: true}, + {name: "no pod before execution", observation: sandboxObservation{}, wantChange: false}, + {name: "no pod after execution", observation: sandboxObservation{hasExecution: true}, wantStatus: metav1.ConditionUnknown, wantReason: "PodsNotObserved", wantChange: true}, + {name: "latches verified without pods", previous: previousVerified, observation: sandboxObservation{hasExecution: true, sameExecution: true}, wantStatus: metav1.ConditionTrue, wantReason: agenticv1alpha1.SandboxEnforcedVerified, wantChange: false}, + {name: "pod list failure", observation: sandboxObservation{listErr: errors.New("unavailable"), hasExecution: true}, wantStatus: metav1.ConditionUnknown, wantReason: "PodListFailed", wantChange: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + condition, changed := sandboxCondition(test.previous, test.observation, gvisor, 4) + if changed != test.wantChange { + t.Fatalf("changed = %t, want %t", changed, test.wantChange) + } + if !test.wantChange { + if condition != test.previous { + t.Fatalf("condition = %#v, want previous %#v", condition, test.previous) + } + return + } + if condition == nil || condition.Status != test.wantStatus || condition.Reason != test.wantReason { + t.Fatalf("condition = %#v, want status=%s reason=%s", condition, test.wantStatus, test.wantReason) + } + }) + } +} diff --git a/pkg/argo/workflow.go b/pkg/argo/workflow.go index 61e8c70..07adff1 100644 --- a/pkg/argo/workflow.go +++ b/pkg/argo/workflow.go @@ -31,6 +31,7 @@ import ( logf "sigs.k8s.io/controller-runtime/pkg/log" agenticv1alpha1 "github.com/Clawdlinux/agentic-operator-core/api/v1alpha1" + "github.com/Clawdlinux/agentic-operator-core/pkg/governance" ) // WorkflowManager encapsulates Argo Workflows interaction logic @@ -220,8 +221,9 @@ func (wm *WorkflowManager) CreateArgoWorkflow( return nil, err } - podLabels := map[string]interface{}{ - "app.kubernetes.io/part-of": "agentic-operator", + podLabels := make(map[string]interface{}) + for key, value := range governance.PodLabels(agentWorkload) { + podLabels[key] = value } if agentWorkload.Spec.Persona != nil && agentWorkload.Spec.Persona.Role != "" { podLabels["agentworkload.clawdlinux.io/role"] = agentWorkload.Spec.Persona.Role diff --git a/pkg/argo/workflow_test.go b/pkg/argo/workflow_test.go index 03206ff..b3f74ce 100644 --- a/pkg/argo/workflow_test.go +++ b/pkg/argo/workflow_test.go @@ -27,6 +27,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/fake" agenticv1alpha1 "github.com/Clawdlinux/agentic-operator-core/api/v1alpha1" + "github.com/Clawdlinux/agentic-operator-core/pkg/governance" ) // TestWorkflowManager_CreateArgoWorkflow verifies that a valid Workflow CR is created @@ -102,8 +103,10 @@ func TestWorkflowManager_CreateArgoWorkflow(t *testing.T) { if err != nil || !found { t.Fatalf("podMetadata.labels missing: found=%v err=%v", found, err) } - if podMetadataLabels["app.kubernetes.io/part-of"] != "agentic-operator" { - t.Errorf("podMetadata part-of label = %q, want agentic-operator", podMetadataLabels["app.kubernetes.io/part-of"]) + for key, value := range governance.PodLabels(workload) { + if podMetadataLabels[key] != value { + t.Errorf("podMetadata label %q = %q, want %q", key, podMetadataLabels[key], value) + } } // Check ownerReference is set diff --git a/pkg/governance/labels.go b/pkg/governance/labels.go new file mode 100644 index 0000000..1e607ad --- /dev/null +++ b/pkg/governance/labels.go @@ -0,0 +1,27 @@ +package governance + +import agenticv1alpha1 "github.com/Clawdlinux/agentic-operator-core/api/v1alpha1" + +const ( + RuntimeSandboxLabelKey = "agentic.clawdlinux.org/runtime-sandbox" + RuntimeSandboxLabelValue = "gvisor" + EgressPartOfLabelKey = "app.kubernetes.io/part-of" + EgressPartOfLabelValue = "agentic-operator" + WorkloadLabelKey = "agentic.clawdlinux.org/workload" + WorkloadUIDLabelKey = "agentic.clawdlinux.org/workload-uid" + WorkloadNamespaceLabelKey = "agentic.clawdlinux.org/workload-namespace" + ManagedByLabelKey = "app.kubernetes.io/managed-by" + ManagedByLabelValue = "agentic-operator" +) + +// PodLabels returns labels propagated to workload pods regardless of runtime. +func PodLabels(workload *agenticv1alpha1.AgentWorkload) map[string]string { + return map[string]string{ + RuntimeSandboxLabelKey: RuntimeSandboxLabelValue, + EgressPartOfLabelKey: EgressPartOfLabelValue, + WorkloadLabelKey: workload.GetName(), + WorkloadUIDLabelKey: string(workload.GetUID()), + WorkloadNamespaceLabelKey: workload.GetNamespace(), + ManagedByLabelKey: ManagedByLabelValue, + } +} diff --git a/pkg/governance/labels_test.go b/pkg/governance/labels_test.go new file mode 100644 index 0000000..1311900 --- /dev/null +++ b/pkg/governance/labels_test.go @@ -0,0 +1,30 @@ +package governance + +import ( + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + + agenticv1alpha1 "github.com/Clawdlinux/agentic-operator-core/api/v1alpha1" +) + +func TestPodLabels(t *testing.T) { + workload := &agenticv1alpha1.AgentWorkload{ + ObjectMeta: metav1.ObjectMeta{Name: "research", Namespace: "tenant-a", UID: types.UID("workload-uid")}, + } + labels := PodLabels(workload) + want := map[string]string{ + RuntimeSandboxLabelKey: RuntimeSandboxLabelValue, + EgressPartOfLabelKey: EgressPartOfLabelValue, + WorkloadLabelKey: "research", + WorkloadUIDLabelKey: "workload-uid", + WorkloadNamespaceLabelKey: "tenant-a", + ManagedByLabelKey: ManagedByLabelValue, + } + for key, value := range want { + if labels[key] != value { + t.Errorf("label %q = %q, want %q", key, labels[key], value) + } + } +} diff --git a/pkg/runtime/adapter.go b/pkg/runtime/adapter.go index fff32c9..84ff7af 100644 --- a/pkg/runtime/adapter.go +++ b/pkg/runtime/adapter.go @@ -44,6 +44,7 @@ type ExecutionStatus struct { Name string // Name of the underlying execution object Namespace string // Namespace of the execution object UID string // UID of the execution object + Message string // Runtime-provided execution status detail Artifacts map[string]string // Step-name to artifact-location map } diff --git a/pkg/runtime/argo_adapter.go b/pkg/runtime/argo_adapter.go index 70f6b0c..89d4cd8 100644 --- a/pkg/runtime/argo_adapter.go +++ b/pkg/runtime/argo_adapter.go @@ -85,6 +85,7 @@ func (a *ArgoWorkflowAdapter) Status(ctx context.Context, workload *agenticv1alp Name: workload.Status.ArgoWorkflow.Name, Namespace: ns, UID: workload.Status.ArgoWorkflow.UID, + Message: wfStatus.Message, }, nil } diff --git a/pkg/runtime/labels.go b/pkg/runtime/labels.go index a8dc42d..fc4e589 100644 --- a/pkg/runtime/labels.go +++ b/pkg/runtime/labels.go @@ -18,7 +18,7 @@ package runtime import ( agenticv1alpha1 "github.com/Clawdlinux/agentic-operator-core/api/v1alpha1" - "github.com/Clawdlinux/agentic-operator-core/internal/admission" + "github.com/Clawdlinux/agentic-operator-core/pkg/governance" ) // GovernanceEgressPartOfKey is the label the default-deny egress NetworkPolicy @@ -31,8 +31,8 @@ import ( // the NetworkPolicy to exist in each pod's namespace; it currently renders // only in the release namespace. const ( - GovernanceEgressPartOfKey = "app.kubernetes.io/part-of" - GovernanceEgressPartOfValue = "agentic-operator" + GovernanceEgressPartOfKey = governance.EgressPartOfLabelKey + GovernanceEgressPartOfValue = governance.EgressPartOfLabelValue ) // governanceLabels returns the pod labels that place a workload's pods under @@ -41,10 +41,5 @@ const ( // selects on part-of. Argo workflow pod labels are set by pkg/argo because // importing this package there would create an import cycle. func governanceLabels(workload *agenticv1alpha1.AgentWorkload) map[string]string { - return map[string]string{ - admission.DefaultRuntimeLabelKey: admission.DefaultRuntimeLabelValue, // gVisor injector - GovernanceEgressPartOfKey: GovernanceEgressPartOfValue, // egress NetworkPolicy - "app.kubernetes.io/managed-by": "agentic-operator", - "agentic.clawdlinux.org/workload": workload.GetName(), - } + return governance.PodLabels(workload) }