diff --git a/Dockerfile b/Dockerfile index 85f0f13..871d3ec 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,22 +1,24 @@ -# Dockerfile — Wrapper operator (distroless). +# Dockerfile — Dispatcher operator (distroless). # -# Wrapper is a long-running Deployment in seam-system. It manages ClusterPack -# compilation and delivery. Distroless: zero attack surface. INV-022. -# wrapper-schema.md §3. +# Dispatcher is a long-running Deployment in seam-system. It manages pack +# delivery and lifecycle. Distroless: zero attack surface. INV-022. +# dispatcher-schema.md §3. FROM golang:1.25 AS builder WORKDIR /build -COPY wrapper/ . +COPY dispatcher/ . COPY conductor/ ../conductor/ -COPY seam-core/ ../seam-core/ +COPY seam/ ../seam/ +COPY seam-sdk/ ../seam-sdk/ +COPY conductor-sdk/ ../conductor-sdk/ RUN CGO_ENABLED=0 GOOS=linux go build \ -trimpath \ -ldflags="-s -w" \ - -o /bin/wrapper \ + -o /bin/dispatcher \ ./cmd/wrapper FROM gcr.io/distroless/base:nonroot -COPY --from=builder /bin/wrapper /usr/local/bin/wrapper +COPY --from=builder /bin/dispatcher /usr/local/bin/dispatcher USER 65532:65532 -ENTRYPOINT ["/usr/local/bin/wrapper"] +ENTRYPOINT ["/usr/local/bin/dispatcher"] diff --git a/Makefile b/Makefile index 3073378..5a2c824 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ CONTROLLER_GEN ?= $(shell which controller-gen 2>/dev/null || echo $(HOME)/go/bin/controller-gen) IMAGE_REGISTRY ?= 10.20.0.1:5000/ontai-dev -IMAGE_NAME := wrapper +IMAGE_NAME := dispatcher TAG ?= dev build: diff --git a/api/seam/v1alpha1/packinstalled_types.go b/api/seam/v1alpha1/packinstalled_types.go index ce0060f..ed1987a 100644 --- a/api/seam/v1alpha1/packinstalled_types.go +++ b/api/seam/v1alpha1/packinstalled_types.go @@ -85,6 +85,22 @@ type PackInstalledSpec struct { // HelmVersion is the Helm SDK version used to render the pack. Carried from PackDelivery. // +optional HelmVersion string `json:"helmVersion,omitempty"` + + // RemediationPolicyRef is an optional reference to a RemediationPolicy CR + // (conductor.ontai.dev) in the same namespace. When absent, Conductor Watchdog + // applies the platform defaults (threshold=3, per-reason default strategies, + // MaxAttempts=3, 5m window). + // +optional + RemediationPolicyRef *RemediationPolicyRefSpec `json:"remediationPolicyRef,omitempty"` +} + +// RemediationPolicyRefSpec is a name+namespace reference to a RemediationPolicy CR. +type RemediationPolicyRefSpec struct { + // Name is the RemediationPolicy CR name. + Name string `json:"name"` + + // Namespace is the namespace of the RemediationPolicy CR. + Namespace string `json:"namespace"` } // PackInstalledStatus is the observed state of a PackInstalled. diff --git a/api/seam/v1alpha1/packlog_types.go b/api/seam/v1alpha1/packlog_types.go index b6799a1..71eccf6 100644 --- a/api/seam/v1alpha1/packlog_types.go +++ b/api/seam/v1alpha1/packlog_types.go @@ -181,12 +181,33 @@ type PackLogSpec struct { WorkloadDigest string `json:"workloadDigest,omitempty"` } +// RemediationAttemptRecord tracks one remediation attempt series for a specific +// FailureReason. Written by the management Conductor after each remediation Job +// completes. The management Conductor is the sole writer; tenant conductor +// observes failure counts but does not write here. +type RemediationAttemptRecord struct { + // FailureReason is the seam-sdk FailureReason value this record tracks. + // +kubebuilder:validation:Enum=CrashLoopBackOff;OOMKilled;ImagePullBackOff;FailedMount;MultiAttachError + FailureReason string `json:"failureReason"` + + // AttemptCount is the total number of remediation Jobs submitted for this reason. + AttemptCount int32 `json:"attemptCount"` + + // LastAttemptAt is the time the most recent remediation Job was submitted. + // +optional + LastAttemptAt *metav1.Time `json:"lastAttemptAt,omitempty"` +} + // PackLogStatus is the observed state of a PackLog. -// Currently empty; reserved for future controller-set conditions. type PackLogStatus struct { // ObservedGeneration is the last generation processed by any consumer. // +optional ObservedGeneration int64 `json:"observedGeneration,omitempty"` + + // RemediationAttempts records the Conductor Watchdog remediation attempt history + // per FailureReason. Written by management Conductor exec mode after each Job. + // +optional + RemediationAttempts []RemediationAttemptRecord `json:"remediationAttempts,omitempty"` } // +kubebuilder:object:root=true diff --git a/api/seam/v1alpha1/zz_generated.deepcopy.go b/api/seam/v1alpha1/zz_generated.deepcopy.go index e965598..0ffefd0 100644 --- a/api/seam/v1alpha1/zz_generated.deepcopy.go +++ b/api/seam/v1alpha1/zz_generated.deepcopy.go @@ -388,6 +388,11 @@ func (in *PackInstalledSpec) DeepCopyInto(out *PackInstalledSpec) { *out = new(DependencyPolicy) **out = **in } + if in.RemediationPolicyRef != nil { + in, out := &in.RemediationPolicyRef, &out.RemediationPolicyRef + *out = new(RemediationPolicyRefSpec) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PackInstalledSpec. @@ -437,7 +442,7 @@ func (in *PackLog) DeepCopyInto(out *PackLog) { out.TypeMeta = in.TypeMeta in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) in.Spec.DeepCopyInto(&out.Spec) - out.Status = in.Status + in.Status.DeepCopyInto(&out.Status) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PackLog. @@ -583,6 +588,13 @@ func (in *PackLogSpec) DeepCopy() *PackLogSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *PackLogStatus) DeepCopyInto(out *PackLogStatus) { *out = *in + if in.RemediationAttempts != nil { + in, out := &in.RemediationAttempts, &out.RemediationAttempts + *out = make([]RemediationAttemptRecord, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PackLogStatus. @@ -767,3 +779,37 @@ func (in *PackRegistryRef) DeepCopy() *PackRegistryRef { in.DeepCopyInto(out) return out } + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RemediationAttemptRecord) DeepCopyInto(out *RemediationAttemptRecord) { + *out = *in + if in.LastAttemptAt != nil { + in, out := &in.LastAttemptAt, &out.LastAttemptAt + *out = (*in).DeepCopy() + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RemediationAttemptRecord. +func (in *RemediationAttemptRecord) DeepCopy() *RemediationAttemptRecord { + if in == nil { + return nil + } + out := new(RemediationAttemptRecord) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RemediationPolicyRefSpec) DeepCopyInto(out *RemediationPolicyRefSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RemediationPolicyRefSpec. +func (in *RemediationPolicyRefSpec) DeepCopy() *RemediationPolicyRefSpec { + if in == nil { + return nil + } + out := new(RemediationPolicyRefSpec) + in.DeepCopyInto(out) + return out +} diff --git a/cmd/wrapper/main.go b/cmd/wrapper/main.go index 1063d3d..a1d5d71 100644 --- a/cmd/wrapper/main.go +++ b/cmd/wrapper/main.go @@ -7,6 +7,7 @@ package main import ( + "context" "flag" "os" @@ -14,6 +15,7 @@ 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/client" "sigs.k8s.io/controller-runtime/pkg/healthz" "sigs.k8s.io/controller-runtime/pkg/log/zap" metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" @@ -21,6 +23,7 @@ import ( seamv1alpha1 "github.com/ontai-dev/seam/api/v1alpha1" dispatcherv1alpha1 "github.com/ontai-dev/dispatcher/api/seam/v1alpha1" "github.com/ontai-dev/dispatcher/internal/controller" + "github.com/ontai-dev/dispatcher/internal/identity" ) var scheme = runtime.NewScheme() @@ -60,14 +63,25 @@ func main() { ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) setupLog := ctrl.Log.WithName("setup") - // CI-INV-004: leader election required. Lease name: wrapper-leader. + cfg := ctrl.GetConfigOrDie() + startupClient, err := client.New(cfg, client.Options{Scheme: scheme}) + if err != nil { + setupLog.Error(err, "unable to create startup client") + os.Exit(1) + } + if err := identity.EnsureSeamMembership(context.Background(), startupClient); err != nil { + setupLog.Error(err, "unable to ensure SeamMembership") + os.Exit(1) + } + + // CI-INV-004: leader election required. Lease name: dispatcher-leader. // Lease namespace: seam-system (canonical operator namespace). - mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ + mgr, err := ctrl.NewManager(cfg, ctrl.Options{ Scheme: scheme, Metrics: metricsserver.Options{BindAddress: metricsAddr}, HealthProbeBindAddress: healthProbeAddr, LeaderElection: enableLeaderElection, - LeaderElectionID: "wrapper-leader", + LeaderElectionID: "dispatcher-leader", LeaderElectionNamespace: "seam-system", }) if err != nil { diff --git a/config/crd/bases/seam.ontai.dev_packdeliveries.yaml b/config/crd/bases/seam.ontai.dev_packdeliveries.yaml index fe8bb07..2b3e2a1 100644 --- a/config/crd/bases/seam.ontai.dev_packdeliveries.yaml +++ b/config/crd/bases/seam.ontai.dev_packdeliveries.yaml @@ -4,8 +4,11 @@ kind: CustomResourceDefinition metadata: annotations: controller-gen.kubebuilder.io/version: v0.16.1 +<<<<<<<< HEAD:config/crd/seam.ontai.dev_packdeliveries.yaml +======== labels: infrastructure.ontai.dev/lineage-root: "true" +>>>>>>>> origin/main:config/crd/bases/seam.ontai.dev_packdeliveries.yaml name: packdeliveries.seam.ontai.dev spec: group: seam.ontai.dev diff --git a/config/crd/seam.ontai.dev_packexecutions.yaml b/config/crd/seam.ontai.dev_packexecutions.yaml new file mode 100644 index 0000000..4f158e8 --- /dev/null +++ b/config/crd/seam.ontai.dev_packexecutions.yaml @@ -0,0 +1,251 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.1 + name: packexecutions.seam.ontai.dev +spec: + group: seam.ontai.dev + names: + kind: PackExecution + listKind: PackExecutionList + plural: packexecutions + shortNames: + - pe + singular: packexecution + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.packDeliveryRef.name + name: Pack + type: string + - jsonPath: .spec.targetClusterRef + name: Target + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + PackExecution is the dispatcher CRD for a runtime pack delivery request. + wrapper-schema.md §3. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: |- + PackExecutionSpec defines the desired state of a PackExecution. + wrapper-schema.md §3. + properties: + admissionProfileRef: + description: AdmissionProfileRef is the name of the RBACProfile governing + this execution. + type: string + chartName: + description: ChartName is the Helm chart name. Carried from PackDelivery. + type: string + chartURL: + description: ChartURL is the Helm chart repository URL. Carried from + PackDelivery. + type: string + chartVersion: + description: ChartVersion is the Helm chart version for this execution. + Carried from PackDelivery. + type: string + helmVersion: + description: HelmVersion is the Helm SDK version used to render the + pack. Carried from PackDelivery. + type: string + lineage: + description: Lineage is the sealed causal chain record for this root + declaration. Immutable after creation. + properties: + creatingOperator: + description: |- + CreatingOperator identifies the Seam Operator that created this object. + This is a structured identity carrying the operator name and its deployed + version at creation time. + properties: + name: + description: |- + Name is the canonical name of the Seam Operator (e.g., platform, guardian, + wrapper, conductor). + type: string + version: + description: |- + Version is the deployed version of the operator at the time the object was + created (e.g., v1.26.5-r3). This allows audit tooling to correlate objects + with the operator version that produced them. + type: string + required: + - name + - version + type: object + creationRationale: + description: |- + CreationRationale is the reason this object was created, drawn from the + Seam Core controlled vocabulary defined in rationale.go. It is not a + free-text field. + enum: + - ClusterProvision + - ClusterDecommission + - SecurityEnforcement + - PackExecution + - VirtualizationFulfillment + - ConductorAssignment + - VortexBinding + type: string + rootGenerationAtCreation: + description: |- + RootGenerationAtCreation is the metadata.generation of the root declaration + at the time this object was created. Together with RootUID, it provides a + complete temporal anchor for the derivation record. + format: int64 + type: integer + rootKind: + description: |- + RootKind is the kind of the root declaration that caused this object to + exist (e.g., TalosCluster, PackExecution, RBACPolicy). + type: string + rootName: + description: RootName is the name of the root declaration. + type: string + rootNamespace: + description: RootNamespace is the namespace of the root declaration. + type: string + rootUID: + description: |- + RootUID is the UID of the root declaration at the time this object was + created. Used to verify that no root declaration replacement has occurred. + type: string + required: + - creatingOperator + - creationRationale + - rootGenerationAtCreation + - rootKind + - rootName + - rootNamespace + - rootUID + type: object + packDeliveryRef: + description: PackDeliveryRef identifies the PackDelivery to deploy. + properties: + name: + description: Name is the name of the PackDelivery CR. + minLength: 1 + type: string + version: + description: Version is the expected version of the PackDelivery. + Guards against name reuse. + minLength: 1 + type: string + required: + - name + - version + type: object + targetClusterRef: + description: TargetClusterRef is the name of the target cluster to + deliver the pack to. + type: string + required: + - packDeliveryRef + - targetClusterRef + type: object + status: + description: PackExecutionStatus is the observed state of a PackExecution. + properties: + conditions: + description: Conditions is the list of status conditions for this + PackExecution. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + jobName: + description: JobName is the name of the pack-deploy Kueue Job submitted + for this execution. + type: string + observedGeneration: + description: ObservedGeneration is the generation most recently reconciled. + format: int64 + type: integer + operationResultRef: + description: |- + OperationResultRef is the name of the PackLog CR written after + successful pack-deploy Job completion. + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/seam.ontai.dev_packlogs.yaml b/config/crd/seam.ontai.dev_packlogs.yaml new file mode 100644 index 0000000..e4fd58d --- /dev/null +++ b/config/crd/seam.ontai.dev_packlogs.yaml @@ -0,0 +1,318 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.1 + name: packlogs.seam.ontai.dev +spec: + group: seam.ontai.dev + names: + kind: PackLog + listKind: PackLogList + plural: packlogs + shortNames: + - pl + singular: packlog + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.capability + name: Capability + type: string + - jsonPath: .spec.status + name: Status + type: string + - jsonPath: .spec.targetClusterRef + name: Cluster + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + PackLog is the immutable result record written by the Conductor + execute-mode Job after a pack-deploy capability completes. One PackLog + per PackExecution, created in namespace seam-tenant-{clusterName}. + seam-core-schema.md §8, Decision 11. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: |- + PackLogSpec is the complete result document written by the + Conductor execute-mode Job before exit. Written by conductor; read by dispatcher. + seam-core-schema.md §8, Decision 11. + properties: + artifacts: + description: Artifacts is the list of artifacts produced by this execution. + items: + description: |- + PackLogArtifact is a structured reference to an artifact produced + by a pack-deploy execution. Never contains raw artifact content. + properties: + checksum: + description: 'Checksum is the content-addressed checksum. Format: + sha256:.' + type: string + kind: + description: 'Kind declares the artifact type. One of: ConfigMap, + Secret, OCIImage, S3Object.' + enum: + - ConfigMap + - Secret + - OCIImage + - S3Object + type: string + name: + description: Name is a logical identifier for this artifact. + type: string + reference: + description: Reference is the fully qualified reference for + the artifact kind. + type: string + required: + - kind + - name + - reference + type: object + type: array + capability: + description: Capability is the name of the Conductor capability that + produced this result. + type: string + completedAt: + description: CompletedAt is the time the capability execution finished. + format: date-time + type: string + deployedResources: + description: |- + DeployedResources is the list of Kubernetes resources applied during this + execution. Populated by pack-deploy on success. Used by PackInstalledReconciler + for deletion cleanup. + items: + description: |- + PackLogDeployedResource records a single Kubernetes resource applied + during a pack-deploy execution. + properties: + apiVersion: + description: APIVersion is the Kubernetes apiVersion (e.g., + apps/v1, v1). + type: string + kind: + description: Kind is the Kubernetes resource Kind (e.g., Deployment, + Namespace). + type: string + name: + description: Name is the resource name. + type: string + namespace: + description: Namespace is the resource namespace. Empty for + cluster-scoped resources. + type: string + required: + - apiVersion + - kind + - name + type: object + type: array + failureReason: + description: FailureReason is populated when Status is Failed. Nil + on success. + properties: + category: + description: Category classifies the failure domain. + enum: + - ValidationFailure + - CapabilityUnavailable + - ExecutionFailure + - ExternalDependencyFailure + - InvariantViolation + - LicenseViolation + - StorageUnavailable + type: string + failedStep: + description: FailedStep is the name of the step that failed. Empty + for single-step capabilities. + type: string + reason: + description: Reason is a human-readable description of the specific + failure. + type: string + required: + - category + - reason + type: object + packDeliveryRef: + description: PackDeliveryRef is the name of the PackDelivery CR that + was deployed. + type: string + packDeliveryVersion: + description: |- + PackDeliveryVersion is the PackDelivery spec.version deployed in this operation. + Populated by the Conductor executor at write time. Rollback anchor. + seam-core-schema.md §7.8. + type: string + packExecutionRef: + description: PackExecutionRef is the name of the PackExecution CR + that triggered this operation. + type: string + phase: + description: Phase identifies the RunnerConfig phase this result belongs + to. + type: string + previousRevisionRef: + description: |- + PreviousRevisionRef is the name of the PackLog CR that was superseded when + this revision was written. Absent for revision 1 (no predecessor). + type: string + rbacDigest: + description: |- + RBACDigest is the OCI digest of the RBAC layer deployed in this operation. + Copied from PackDelivery.spec.rbacDigest at deploy time. Rollback anchor. + type: string + revision: + description: |- + Revision is the monotonically increasing revision counter for this pack operation + sequence. Incremented each time a new result supersedes the previous one. + format: int64 + type: integer + startedAt: + description: StartedAt is the time the capability execution began. + format: date-time + type: string + status: + allOf: + - enum: + - Succeeded + - Failed + - enum: + - Succeeded + - Failed + description: Status is the terminal status of the capability execution. + type: string + steps: + description: Steps contains individual step results for multi-step + capabilities. + items: + description: |- + PackLogStepResult is the execution result for one step within a + multi-step capability. + properties: + completedAt: + description: CompletedAt is the time this step finished execution. + format: date-time + type: string + message: + description: Message provides additional context about the step + outcome. + type: string + name: + description: Name is the step identifier within the capability. + type: string + startedAt: + description: StartedAt is the time this step began execution. + format: date-time + type: string + status: + allOf: + - enum: + - Succeeded + - Failed + - enum: + - Succeeded + - Failed + description: Status is the terminal status of this step. + type: string + required: + - name + - status + type: object + type: array + talosClusterOperationResultRef: + description: |- + TalosClusterOperationResultRef is reserved for future cross-reference to a + TalosCluster-scoped OperationResult. Stub field; not populated by any current + controller. + type: string + targetClusterRef: + description: TargetClusterRef is the name of the target cluster this + operation ran against. + type: string + workloadDigest: + description: |- + WorkloadDigest is the OCI digest of the workload layer deployed in this operation. + Copied from PackDelivery.spec.workloadDigest at deploy time. Rollback anchor. + type: string + required: + - capability + - revision + - status + type: object + status: + description: PackLogStatus is the observed state of a PackLog. + properties: + observedGeneration: + description: ObservedGeneration is the last generation processed by + any consumer. + format: int64 + type: integer + remediationAttempts: + description: |- + RemediationAttempts records the Conductor Watchdog remediation attempt history + per FailureReason. Written by management Conductor exec mode after each Job. + items: + description: |- + RemediationAttemptRecord tracks one remediation attempt series for a specific + FailureReason. Written by the management Conductor after each remediation Job + completes. The management Conductor is the sole writer; tenant conductor + observes failure counts but does not write here. + properties: + attemptCount: + description: AttemptCount is the total number of remediation + Jobs submitted for this reason. + format: int32 + type: integer + failureReason: + description: FailureReason is the seam-sdk FailureReason value + this record tracks. + enum: + - CrashLoopBackOff + - OOMKilled + - ImagePullBackOff + - FailedMount + - MultiAttachError + type: string + lastAttemptAt: + description: LastAttemptAt is the time the most recent remediation + Job was submitted. + format: date-time + type: string + required: + - attemptCount + - failureReason + type: object + type: array + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/seam.ontai.dev_packreceipts.yaml b/config/crd/seam.ontai.dev_packreceipts.yaml new file mode 100644 index 0000000..dc65fb1 --- /dev/null +++ b/config/crd/seam.ontai.dev_packreceipts.yaml @@ -0,0 +1,222 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.1 + name: packreceipts.seam.ontai.dev +spec: + group: seam.ontai.dev + names: + kind: PackReceipt + listKind: PackReceiptList + plural: packreceipts + shortNames: + - pr + singular: packreceipt + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .spec.packDeliveryRef + name: Pack + type: string + - jsonPath: .status.verified + name: Verified + type: boolean + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: |- + PackReceipt is the dispatcher CRD for pack delivery acknowledgement on a tenant cluster. + Written by conductor agent after signature verification. INV-026. + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: |- + PackReceiptSpec defines the desired state of a PackReceipt. + Written by the packinstalled pull loop on the tenant cluster conductor after + Ed25519 signature verification. INV-026. conductor-schema.md. + properties: + chartName: + description: ChartName is the Helm chart name. Carried from PackDelivery. + type: string + chartURL: + description: ChartURL is the Helm chart repository URL. Carried from + PackDelivery. + type: string + chartVersion: + description: ChartVersion is the Helm chart version. Carried from + PackDelivery. + type: string + deployedResources: + description: |- + DeployedResources is the inventory of Kubernetes resources applied to the tenant cluster + during the pack-deploy Job. Conductor role=tenant uses this list to detect drift by + verifying each resource still exists with the expected state. + CLUSTERPACK-BL-VERSION-CLEANUP, conductor-schema.md. + items: + description: |- + PackReceiptDeployedResource records a single Kubernetes resource that was applied + to the tenant cluster as part of a pack-deploy Job. Used by conductor role=tenant + to detect drift between declared and actual cluster state. + CLUSTERPACK-BL-VERSION-CLEANUP. conductor-schema.md. + properties: + apiVersion: + description: APIVersion is the full API version string (e.g., + "apps/v1"). + type: string + kind: + description: Kind is the resource kind (e.g., "Deployment"). + type: string + name: + description: Name is the resource name. + type: string + namespace: + description: Namespace is the namespace the resource was applied + to. Empty for cluster-scoped resources. + type: string + required: + - apiVersion + - kind + - name + type: object + type: array + helmVersion: + description: HelmVersion is the Helm SDK version. Carried from PackDelivery. + type: string + packDeliveryRef: + description: PackDeliveryRef is the name of the PackDelivery CR this + receipt acknowledges. + type: string + packInstalledRef: + description: PackInstalledRef is the name of the PackInstalled CR + this receipt acknowledges. + type: string + rbacDigest: + description: RBACDigest is the OCI digest of the RBAC layer. Carried + from PackDelivery for audit. + type: string + signatureRef: + description: |- + SignatureRef is the name of the signed artifact Secret on the management cluster + (seam-pack-signed-{cluster}-{packInstalled}) from which this receipt was derived. + type: string + targetClusterRef: + description: TargetClusterRef is the name of the cluster this receipt + was generated on. + type: string + workloadDigest: + description: WorkloadDigest is the OCI digest of the workload layer. + Carried from PackDelivery. + type: string + required: + - packDeliveryRef + - targetClusterRef + type: object + status: + description: |- + PackReceiptStatus is the observed state of a PackReceipt. + Written by the packinstalled pull loop after signature verification. INV-026. + properties: + conditions: + description: Conditions is the list of status conditions for this + PackReceipt. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + observedGeneration: + description: ObservedGeneration is the generation most recently reconciled. + format: int64 + type: integer + signature: + description: |- + Signature is the base64-encoded Ed25519 signature from the signed artifact Secret. + Stored for auditability and idempotency checking. INV-026. + type: string + verificationFailedReason: + description: |- + VerificationFailedReason is set when Verified=false and describes the + specific verification failure (e.g., "Ed25519 signature verification failed (INV-026)"). + type: string + verified: + description: |- + Verified indicates whether the Ed25519 signature on the PackInstalled artifact + was successfully verified against the management cluster's public key. INV-026. + type: boolean + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/internal/controller/clusterpack_reconciler.go b/internal/controller/clusterpack_reconciler.go index 3f83904..2201dc3 100644 --- a/internal/controller/clusterpack_reconciler.go +++ b/internal/controller/clusterpack_reconciler.go @@ -3,7 +3,6 @@ package controller import ( "context" "fmt" - "strings" "time" batchv1 "k8s.io/api/batch/v1" @@ -25,6 +24,8 @@ import ( seamcorev1alpha1 "github.com/ontai-dev/seam/api/v1alpha1" dispatcherv1alpha1 "github.com/ontai-dev/dispatcher/api/seam/v1alpha1" "github.com/ontai-dev/seam/pkg/conditions" + "github.com/ontai-dev/seam/pkg/lineage" + "github.com/ontai-dev/seam/pkg/namespaces" ) // packSignatureAnnotation is the annotation key set by the conductor signing loop @@ -36,7 +37,7 @@ const packSignatureAnnotation = "ontai.dev/pack-signature" // clusterPackFinalizer is added to every ClusterPack on first reconcile. // On deletion it triggers cleanup of all PackInstances and RunnerConfigs // derived from this ClusterPack before the object is removed from etcd. -const clusterPackFinalizer = "infrastructure.ontai.dev/clusterpack-cleanup" +const clusterPackFinalizer = "seam.ontai.dev/clusterpack-cleanup" // ClusterPackReconciler watches ClusterPack CRs and manages their signing lifecycle // and immutability enforcement. @@ -110,7 +111,7 @@ func (r *ClusterPackReconciler) Reconcile(ctx context.Context, req ctrl.Request) // r.Client.Patch() after the status patch setup would overwrite the in-memory // object with the stored state, losing any status mutations made before the call. // CI-INV-002: ClusterPack spec is immutable after creation. - const specSnapshotAnnotation = "infrastructure.ontai.dev/spec-checksum-snapshot" + const specSnapshotAnnotation = "seam.ontai.dev/spec-checksum-snapshot" currentChecksum := cp.Spec.Checksum + "|" + cp.Spec.RegistryRef.URL + "|" + cp.Spec.RegistryRef.Digest + "|" + cp.Spec.Version if _, ok := cp.Annotations[specSnapshotAnnotation]; !ok { metaPatch := client.MergeFrom(cp.DeepCopy()) @@ -233,7 +234,7 @@ func (r *ClusterPackReconciler) Reconcile(ctx context.Context, req ctrl.Request) // with current version already exists (delivery complete) or PackExecution already // exists (delivery in progress). wrapper-schema.md §9 delivery chain. for _, clusterName := range cp.Spec.TargetClusters { - tenantNS := "seam-tenant-" + clusterName + tenantNS := namespaces.Tenant(clusterName) peName := cp.Name + "-" + clusterName // If PackInstance exists with current version, delivery is already complete — skip. @@ -271,9 +272,9 @@ func (r *ClusterPackReconciler) Reconcile(ctx context.Context, req ctrl.Request) Name: peName, Namespace: tenantNS, Labels: map[string]string{ - "infrastructure.ontai.dev/pack": cp.Name, - "infrastructure.ontai.dev/pack-version": cp.Spec.Version, - "infrastructure.ontai.dev/cluster": clusterName, + "seam.ontai.dev/pack": cp.Name, + "seam.ontai.dev/pack-version": cp.Spec.Version, + "seam.ontai.dev/cluster": clusterName, }, }, Spec: dispatcherv1alpha1.PackExecutionSpec{ @@ -285,6 +286,9 @@ func (r *ClusterPackReconciler) Reconcile(ctx context.Context, req ctrl.Request) AdmissionProfileRef: cp.Name, }, } + // Wire descendant lineage: DescendantReconciler appends this PackExecution to the + // PackDelivery's LineageRecord descendantRegistry. seam-core-schema.md §3. + lineage.SetDescendantLabels(newPE, lineage.IndexName("PackDelivery", cp.Name), tenantNS, "dispatcher", lineage.PackExecution, cp.GetAnnotations()[lineage.AnnotationDeclaringPrincipal]) if err := r.Client.Create(ctx, newPE); err != nil && !apierrors.IsAlreadyExists(err) { return ctrl.Result{}, fmt.Errorf("create PackExecution %s/%s: %w", tenantNS, peName, err) } @@ -355,7 +359,7 @@ func (r *ClusterPackReconciler) handleRollback(ctx context.Context, cp *dispatch // Patch spec back to target version + digests, clear rollbackToRevision, // and remove the spec-snapshot annotation so the immutability check re-records // the rolled-back state on the next reconcile pass. - const specSnapshotAnnotation = "infrastructure.ontai.dev/spec-checksum-snapshot" + const specSnapshotAnnotation = "seam.ontai.dev/spec-checksum-snapshot" patch := client.MergeFrom(cp.DeepCopy()) cp.Spec.Version = targetVersion cp.Spec.RBACDigest = targetRBACDigest @@ -435,7 +439,7 @@ func (r *ClusterPackReconciler) handleClusterPackDeletion(ctx context.Context, c // 2.5. Delete DriftSignals for each target cluster. // Convention: DriftSignal name = "drift-{cp.Name}", namespace = "seam-tenant-{clusterName}". for _, clusterName := range cp.Spec.TargetClusters { - tenantNS := "seam-tenant-" + clusterName + tenantNS := namespaces.Tenant(clusterName) signalName := "drift-" + cp.Name signal := &seamcorev1alpha1.DriftSignal{ ObjectMeta: metav1.ObjectMeta{Name: signalName, Namespace: tenantNS}, @@ -571,8 +575,8 @@ func (r *ClusterPackReconciler) MapPackInstanceToClusterPack( // WS2: Delete the corresponding PackExecution so the ClusterPackReconciler // creates a fresh one on the next reconcile pass. ns := pi.GetNamespace() - clusterName := strings.TrimPrefix(ns, "seam-tenant-") - if clusterName != ns { + clusterName := namespaces.ClusterNameFromTenant(ns) + if clusterName != "" { peName := cpName + "-" + clusterName pe := &dispatcherv1alpha1.PackExecution{} if getErr := r.Client.Get(ctx, client.ObjectKey{Name: peName, Namespace: ns}, pe); getErr == nil { diff --git a/internal/controller/packexecution_reconciler.go b/internal/controller/packexecution_reconciler.go index 81b7d76..2b32d0d 100644 --- a/internal/controller/packexecution_reconciler.go +++ b/internal/controller/packexecution_reconciler.go @@ -28,6 +28,7 @@ import ( dispatcherv1alpha1 "github.com/ontai-dev/dispatcher/api/seam/v1alpha1" "github.com/ontai-dev/seam/pkg/conditions" "github.com/ontai-dev/seam/pkg/lineage" + "github.com/ontai-dev/seam/pkg/namespaces" ) const ( @@ -60,10 +61,10 @@ const ( // RBACProfile gates have not yet cleared. gateRequeueInterval = 30 * time.Second - // wrapperRunnerServiceAccount is the ServiceAccount used by pack-deploy Jobs. + // dispatcherRunnerServiceAccount is the ServiceAccount used by pack-deploy Jobs. // The ServiceAccount must exist in the Job's namespace (seam-tenant-{clusterRef}). - // wrapper-design.md §4. - wrapperRunnerServiceAccount = "wrapper-runner" + // dispatcher-design.md §4. + dispatcherRunnerServiceAccount = "dispatcher-runner" // conductorImageEnvVar is the env var the operator reads for the conductor image. // Defaults to the local dev registry if not set. @@ -218,7 +219,8 @@ func (r *PackExecutionReconciler) Reconcile(ctx context.Context, req ctrl.Reques pe.Generation, ) - // Gate 1: Signature gate. + // Gate 1: Signature gate. PackDelivery lives in the same namespace as the PackExecution + // (seam-tenant-{clusterRef}). All day2 operation CRs are scoped to the tenant namespace. cp := &dispatcherv1alpha1.PackDelivery{} cpKey := client.ObjectKey{Name: pe.Spec.PackDeliveryRef.Name, Namespace: pe.Namespace} if err := r.Client.Get(ctx, cpKey, cp); err != nil { @@ -376,7 +378,7 @@ func (r *PackExecutionReconciler) Reconcile(ctx context.Context, req ctrl.Reques ) // Gate 5: DispatcherRunnerRBAC gate. - // SubjectAccessReview verifies wrapper-runner SA has required permissions + // SubjectAccessReview verifies dispatcher-runner SA has required permissions // before submitting the Job. Catches stale or missing RBAC before runtime failure. rbacCheckFn := r.isDispatcherRunnerRBACReady if r.RBACChecker != nil { @@ -384,7 +386,7 @@ func (r *PackExecutionReconciler) Reconcile(ctx context.Context, req ctrl.Reques } rbacSARReady, rbacSARDenyReason, err := rbacCheckFn(ctx, pe) if err != nil { - return ctrl.Result{}, fmt.Errorf("failed to check wrapper-runner RBAC: %w", err) + return ctrl.Result{}, fmt.Errorf("failed to check dispatcher-runner RBAC: %w", err) } if !rbacSARReady { conditions.SetCondition( @@ -412,7 +414,7 @@ func (r *PackExecutionReconciler) Reconcile(ctx context.Context, req ctrl.Reques conditions.ConditionTypeDispatcherRunnerRBACNotReady, metav1.ConditionFalse, conditions.ReasonDispatcherRunnerRBACNotReady, - "wrapper-runner has required RBAC permissions.", + "dispatcher-runner has required RBAC permissions.", pe.Generation, ) @@ -576,7 +578,7 @@ func (r *PackExecutionReconciler) Reconcile(ctx context.Context, req ctrl.Reques piBaseName = pe.Spec.PackDeliveryRef.Name } piName := piBaseName + "-" + pe.Spec.TargetClusterRef - piNamespace := "seam-tenant-" + pe.Spec.TargetClusterRef + piNamespace := namespaces.Tenant(pe.Spec.TargetClusterRef) // Determine upgradeDirection by comparing existing PackInstance version. upgradeDir := packUpgradeDirection(ctx, r.Client, piName, piNamespace, cp.Spec.Version) @@ -586,8 +588,8 @@ func (r *PackExecutionReconciler) Reconcile(ctx context.Context, req ctrl.Reques Name: piName, Namespace: piNamespace, Labels: map[string]string{ - "infrastructure.ontai.dev/pack": pe.Spec.PackDeliveryRef.Name, - "infrastructure.ontai.dev/cluster": pe.Spec.TargetClusterRef, + "seam.ontai.dev/pack": pe.Spec.PackDeliveryRef.Name, + "seam.ontai.dev/cluster": pe.Spec.TargetClusterRef, }, OwnerReferences: []metav1.OwnerReference{{ APIVersion: dispatcherv1alpha1.GroupVersion.String(), @@ -606,7 +608,7 @@ func (r *PackExecutionReconciler) Reconcile(ctx context.Context, req ctrl.Reques } // Wire descendant lineage so the DescendantReconciler can append this // PackInstance to the PackExecution's ILI. seam-core-schema.md §3. - lineage.SetDescendantLabels(pi, lineage.IndexName("PackExecution", pe.Name), pe.Namespace, "wrapper", lineage.PackExecution, pe.GetAnnotations()[lineage.AnnotationDeclaringPrincipal]) + lineage.SetDescendantLabels(pi, lineage.IndexName("PackDelivery", pe.Spec.PackDeliveryRef.Name), pe.Namespace, "dispatcher", lineage.PackExecution, pe.GetAnnotations()[lineage.AnnotationDeclaringPrincipal]) if err := r.Client.Create(ctx, pi); err != nil { if !apierrors.IsAlreadyExists(err) { return ctrl.Result{}, fmt.Errorf("failed to create PackInstance %s: %w", piName, err) @@ -688,7 +690,19 @@ func (r *PackExecutionReconciler) Reconcile(ctx context.Context, req ctrl.Reques // Step J — Submit pack-deploy Job via Kueue. Direct Job creation without // Kueue admission is an invariant violation. wrapper-design.md §4. - job := r.buildPackDeployJob(pe, cp, jobName) + // Read the RunnerConfig to get the executor image (conductor-exec:v...). + // Falls back to conductorImageDefault if RunnerConfig absent or image empty. + runnerImage := conductorImageDefault + { + rcObj := &unstructured.Unstructured{} + rcObj.SetGroupVersionKind(schema.GroupVersionKind{Group: "seam.ontai.dev", Version: "v1alpha1", Kind: "RunnerConfig"}) + if getErr := r.Client.Get(ctx, types.NamespacedName{Name: pe.Spec.TargetClusterRef, Namespace: namespaces.OntSystem}, rcObj); getErr == nil { + if img, found, _ := unstructured.NestedString(rcObj.Object, "spec", "runnerImage"); found && img != "" { + runnerImage = img + } + } + } + job := r.buildPackDeployJob(pe, cp, jobName, runnerImage) if err := r.Client.Create(ctx, job); err != nil { return ctrl.Result{}, fmt.Errorf("failed to create pack-deploy Job %s: %w", jobName, err) } @@ -710,37 +724,41 @@ func (r *PackExecutionReconciler) Reconcile(ctx context.Context, req ctrl.Reques // isPermissionSnapshotCurrent reads the PermissionSnapshot for the target cluster // via unstructured to avoid importing guardian types. Returns true if the snapshot -// has a Fresh=True condition. PermissionSnapshots live exclusively in seam-system -// and are named "snapshot-{clusterRef}". guardian-schema.md §PS condition vocabulary. +// has a Fresh=True condition. PermissionSnapshots live exclusively in seam-system. +// Naming: management cluster uses "snapshot-management"; all other clusters use +// "snapshot-{clusterRef}". Tries clusterRef first then falls back to "management" +// so the management cluster self-import case (ccs-mgmt targeting itself) works. +// guardian-schema.md §PS condition vocabulary. G-BL-SNAPSHOT-ALIAS. // Returns false (not error) if the snapshot is not found or not fresh. func (r *PackExecutionReconciler) isPermissionSnapshotCurrent(ctx context.Context, pe *dispatcherv1alpha1.PackExecution) (bool, error) { - ps := &unstructured.Unstructured{} - ps.SetGroupVersionKind(schema.GroupVersionKind{ + psGVK := schema.GroupVersionKind{ Group: "guardian.ontai.dev", Version: "v1alpha1", Kind: "PermissionSnapshot", - }) - psKey := types.NamespacedName{ - Name: "snapshot-" + pe.Spec.TargetClusterRef, - Namespace: "seam-system", } - if err := r.Client.Get(ctx, psKey, ps); err != nil { - if apierrors.IsNotFound(err) { - return false, nil + // Try snapshot-{clusterRef} first; fall back to snapshot-management for + // the management cluster whose snapshot name is role-derived, not cluster-derived. + for _, name := range []string{"snapshot-" + pe.Spec.TargetClusterRef, "snapshot-management"} { + ps := &unstructured.Unstructured{} + ps.SetGroupVersionKind(psGVK) + if err := r.Client.Get(ctx, types.NamespacedName{Name: name, Namespace: namespaces.SeamSystem}, ps); err != nil { + if apierrors.IsNotFound(err) { + continue + } + return false, err } - return false, err - } - conditions, found, err := unstructured.NestedSlice(ps.Object, "status", "conditions") - if err != nil || !found { - return false, nil - } - for _, raw := range conditions { - cond, ok := raw.(map[string]interface{}) - if !ok { + conditions, found, err := unstructured.NestedSlice(ps.Object, "status", "conditions") + if err != nil || !found { continue } - if cond["type"] == "Fresh" && cond["status"] == "True" { - return true, nil + for _, raw := range conditions { + cond, ok := raw.(map[string]interface{}) + if !ok { + continue + } + if cond["type"] == "Fresh" && cond["status"] == "True" { + return true, nil + } } } return false, nil @@ -761,7 +779,7 @@ func (r *PackExecutionReconciler) isRBACProfileProvisioned(ctx context.Context, }) rpKey := types.NamespacedName{ Name: pe.Spec.AdmissionProfileRef, - Namespace: "seam-tenant-" + pe.Spec.TargetClusterRef, + Namespace: namespaces.Tenant(pe.Spec.TargetClusterRef), } if err := r.Client.Get(ctx, rpKey, rp); err != nil { if apierrors.IsNotFound(err) { @@ -780,10 +798,10 @@ func (r *PackExecutionReconciler) isRBACProfileProvisioned(ctx context.Context, // isConductorReadyForCluster determines whether the Conductor agent for the // target cluster is live and ready to accept Jobs. It performs two lookups: // -// 1. InfrastructureTalosCluster namespace (Fix 1): tries seam-tenant-{clusterRef} -// first; if not found, falls back to seam-system. The management cluster -// InfrastructureTalosCluster lives in seam-system, tenant cluster in -// seam-tenant-{name}. If absent in both namespaces, cluster not yet registered. +// 1. TalosCluster namespace: tries seam-tenant-{clusterRef} first; if not found, +// falls back to seam-system. The management cluster TalosCluster lives in +// seam-system, tenant cluster in seam-tenant-{name}. +// If absent in both namespaces, cluster not yet registered. // // 2. RunnerConfig readiness (Fix 2): looks up the RunnerConfig named // {targetClusterRef} in ont-system and checks status.capabilities. A @@ -799,14 +817,14 @@ func (r *PackExecutionReconciler) isRBACProfileProvisioned(ctx context.Context, func (r *PackExecutionReconciler) isConductorReadyForCluster(ctx context.Context, pe *dispatcherv1alpha1.PackExecution) (bool, error) { clusterRef := pe.Spec.TargetClusterRef tcGVK := schema.GroupVersionKind{ - Group: "infrastructure.ontai.dev", + Group: "seam.ontai.dev", Version: "v1alpha1", - Kind: "InfrastructureTalosCluster", + Kind: "TalosCluster", } // Fix 1: try seam-tenant-{clusterRef} then fall back to seam-system. tcFound := false - for _, ns := range []string{"seam-tenant-" + clusterRef, "seam-system"} { + for _, ns := range []string{namespaces.Tenant(clusterRef), namespaces.SeamSystem} { tc := &unstructured.Unstructured{} tc.SetGroupVersionKind(tcGVK) if err := r.Client.Get(ctx, types.NamespacedName{Name: clusterRef, Namespace: ns}, tc); err != nil { @@ -828,11 +846,11 @@ func (r *PackExecutionReconciler) isConductorReadyForCluster(ctx context.Context // correct signal that Conductor is live. conductor-schema.md §5, §10 step 3. rc := &unstructured.Unstructured{} rc.SetGroupVersionKind(schema.GroupVersionKind{ - Group: "infrastructure.ontai.dev", + Group: "seam.ontai.dev", Version: "v1alpha1", - Kind: "InfrastructureRunnerConfig", + Kind: "RunnerConfig", }) - if err := r.Client.Get(ctx, types.NamespacedName{Name: clusterRef, Namespace: "ont-system"}, rc); err != nil { + if err := r.Client.Get(ctx, types.NamespacedName{Name: clusterRef, Namespace: namespaces.OntSystem}, rc); err != nil { if apierrors.IsNotFound(err) { return false, nil } @@ -847,27 +865,27 @@ func (r *PackExecutionReconciler) isConductorReadyForCluster(ctx context.Context } // isDispatcherRunnerRBACReady performs SubjectAccessReview checks to verify that -// the wrapper-runner ServiceAccount in pe.Namespace has the required RBAC +// the dispatcher-runner ServiceAccount in pe.Namespace has the required RBAC // permissions before a Kueue Job is submitted. Returns (true, "", nil) when // all permissions are granted. Returns (false, denyReason, nil) when any // permission is denied. Returns (false, "", err) on SAR API failure. // // This is gate 5 of the PackExecution gate check. It catches stale or missing -// RBAC before the Job pod runs and fails with a Forbidden error. The three -// checks mirror the permissions declared in the compiler-generated -// wrapper-runner Role (05-post-bootstrap/wrapper-runner.yaml). +// RBAC before the Job pod runs and fails with a Forbidden error. The checks +// mirror the permissions declared in the compiler-generated +// dispatcher-runner Role (05-post-bootstrap/dispatcher-runner.yaml). func (r *PackExecutionReconciler) isDispatcherRunnerRBACReady(ctx context.Context, pe *dispatcherv1alpha1.PackExecution) (bool, string, error) { - saUser := "system:serviceaccount:" + pe.Namespace + ":wrapper-runner" + saUser := "system:serviceaccount:" + pe.Namespace + ":dispatcher-runner" checks := []struct { verb string group string resource string }{ - {"list", "infrastructure.ontai.dev", "infrastructurepackexecutions"}, - {"get", "infrastructure.ontai.dev", "infrastructurerunnerconfigs"}, - {"create", "infrastructure.ontai.dev", "packoperationresults"}, - {"delete", "infrastructure.ontai.dev", "packoperationresults"}, + {"list", "seam.ontai.dev", "packexecutions"}, + {"get", "seam.ontai.dev", "runnerconfigs"}, + {"create", "seam.ontai.dev", "packlogs"}, + {"delete", "seam.ontai.dev", "packlogs"}, } for _, chk := range checks { @@ -888,7 +906,7 @@ func (r *PackExecutionReconciler) isDispatcherRunnerRBACReady(ctx context.Contex if !sar.Status.Allowed { reason := sar.Status.Reason if reason == "" { - reason = fmt.Sprintf("wrapper-runner cannot %s %s in %s: permission denied", chk.verb, chk.resource, chk.group) + reason = fmt.Sprintf("dispatcher-runner cannot %s %s in %s: permission denied", chk.verb, chk.resource, chk.group) } return false, reason, nil } @@ -896,6 +914,17 @@ func (r *PackExecutionReconciler) isDispatcherRunnerRBACReady(ctx context.Contex return true, "", nil } +// packInstalledName returns the deterministic PackInstalled CR name for the given +// PackExecution and its owning PackDelivery. Matches the piName formula in the +// completion handler above. Used to stamp PACK_INSTALLED_NAME into the pack-deploy Job. +func packInstalledName(pe *dispatcherv1alpha1.PackExecution, cp *dispatcherv1alpha1.PackDelivery) string { + base := cp.Spec.BasePackName + if base == "" { + base = pe.Spec.PackDeliveryRef.Name + } + return base + "-" + pe.Spec.TargetClusterRef +} + // buildPackDeployJob constructs the pack-deploy Job spec for Kueue admission. // The Job carries the kueue.x-k8s.io/queue-name label — without this label, // the Job will not be admitted. wrapper-design.md §4. @@ -903,9 +932,9 @@ func (r *PackExecutionReconciler) buildPackDeployJob( pe *dispatcherv1alpha1.PackExecution, cp *dispatcherv1alpha1.PackDelivery, jobName string, + conductorImage string, ) *batchv1.Job { ttl := packDeployJobTTL - conductorImage := conductorImageDefault // resultCMName is the packExecutionRef passed to the Conductor Job via // OPERATION_RESULT_CM. Conductor uses this value as the label key for @@ -919,8 +948,8 @@ func (r *PackExecutionReconciler) buildPackDeployJob( Namespace: pe.Namespace, Labels: map[string]string{ kueueQueueLabel: packDeployQueue, - "app.kubernetes.io/part-of": "wrapper", - "infrastructure.ontai.dev/pe-name": pe.Name, + "app.kubernetes.io/part-of": "dispatcher", + "seam.ontai.dev/pe-name": pe.Name, }, OwnerReferences: []metav1.OwnerReference{ { @@ -939,7 +968,7 @@ func (r *PackExecutionReconciler) buildPackDeployJob( BackoffLimit: int32Ptr(0), Template: corev1.PodTemplateSpec{ Spec: corev1.PodSpec{ - ServiceAccountName: wrapperRunnerServiceAccount, + ServiceAccountName: dispatcherRunnerServiceAccount, RestartPolicy: corev1.RestartPolicyNever, SecurityContext: &corev1.PodSecurityContext{ RunAsNonRoot: boolPtr(true), @@ -970,11 +999,13 @@ func (r *PackExecutionReconciler) buildPackDeployJob( {Name: "PACK_REGISTRY_REF", Value: cp.Spec.RegistryRef.URL + "@" + cp.Spec.RegistryRef.Digest}, {Name: "PACK_CHECKSUM", Value: cp.Spec.Checksum}, {Name: "PACK_SIGNATURE", Value: cp.Status.PackSignature}, + {Name: "PACK_INSTALLED_NAME", Value: packInstalledName(pe, cp)}, }, VolumeMounts: []corev1.VolumeMount{ { Name: "kubeconfig", MountPath: "/var/run/secrets/kubeconfig", + SubPath: "value", ReadOnly: true, }, }, @@ -1037,9 +1068,9 @@ func (r *PackExecutionReconciler) SetupWithManager(mgr ctrl.Manager) error { }) rcObj := &unstructured.Unstructured{} rcObj.SetGroupVersionKind(schema.GroupVersionKind{ - Group: "infrastructure.ontai.dev", + Group: "seam.ontai.dev", Version: "v1alpha1", - Kind: "InfrastructureRunnerConfig", + Kind: "RunnerConfig", }) return ctrl.NewControllerManagedBy(mgr). For(&dispatcherv1alpha1.PackExecution{}, builder.WithPredicates(predicate.GenerationChangedPredicate{})). @@ -1063,7 +1094,7 @@ func (r *PackExecutionReconciler) mapSnapshotToPackExecutions( // Not a snapshot-{cluster} name pattern — ignore. return nil } - ns := "seam-tenant-" + clusterRef + ns := namespaces.Tenant(clusterRef) peList := &dispatcherv1alpha1.PackExecutionList{} if err := r.Client.List(ctx, peList, client.InNamespace(ns)); err != nil { return nil @@ -1153,11 +1184,11 @@ func (r *PackExecutionReconciler) mapRunnerConfigToPackExecutions( obj client.Object, ) []reconcile.Request { // RunnerConfig name == cluster name; lives in ont-system. - if obj.GetNamespace() != "ont-system" { + if obj.GetNamespace() != namespaces.OntSystem { return nil } clusterRef := obj.GetName() - ns := "seam-tenant-" + clusterRef + ns := namespaces.Tenant(clusterRef) peList := &dispatcherv1alpha1.PackExecutionList{} if err := r.Client.List(ctx, peList, client.InNamespace(ns)); err != nil { return nil diff --git a/internal/controller/packinstance_reconciler.go b/internal/controller/packinstance_reconciler.go index 58a24b3..b1250c7 100644 --- a/internal/controller/packinstance_reconciler.go +++ b/internal/controller/packinstance_reconciler.go @@ -22,6 +22,7 @@ import ( dispatcherv1alpha1 "github.com/ontai-dev/dispatcher/api/seam/v1alpha1" "github.com/ontai-dev/seam/pkg/conditions" + "github.com/ontai-dev/seam/pkg/namespaces" ) // driftCheckInterval is how often the reconciler re-reads PackReceipt drift status @@ -32,7 +33,7 @@ const driftCheckInterval = 60 * time.Second // non-empty DeployedResources list. The deletion handler removes all deployed // resources from the target cluster before allowing the PackInstance to be deleted. // INV-006: no Jobs on the delete path. wrapper-schema.md §3, Decision 11. -const workloadCleanupFinalizer = "infrastructure.ontai.dev/workload-cleanup" +const workloadCleanupFinalizer = "seam.ontai.dev/workload-cleanup" // PackInstanceReconciler watches PackInstance CRs and reflects drift state from // the target cluster conductor's PackReceipt. @@ -430,7 +431,7 @@ func (r *PackInstanceReconciler) cleanupDeployedResources(ctx context.Context, p targetCluster := pi.Spec.TargetClusterRef kubeconfigSecretName := "seam-mc-" + targetCluster + "-kubeconfig" - kubeconfigNamespace := "seam-tenant-" + targetCluster + kubeconfigNamespace := namespaces.Tenant(targetCluster) kubeconfigSecret := &corev1.Secret{} if err := r.Client.Get(ctx, client.ObjectKey{Name: kubeconfigSecretName, Namespace: kubeconfigNamespace}, kubeconfigSecret); err != nil { diff --git a/internal/identity/identity.go b/internal/identity/identity.go new file mode 100644 index 0000000..72008a3 --- /dev/null +++ b/internal/identity/identity.go @@ -0,0 +1,65 @@ +package identity + +import ( + "context" + + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client" + + seamv1alpha1 "github.com/ontai-dev/seam/api/v1alpha1" + "github.com/ontai-dev/seam-sdk/conditions" + "github.com/ontai-dev/seam-sdk/labels" + "github.com/ontai-dev/seam-sdk/operator" + "github.com/ontai-dev/seam/pkg/namespaces" +) + +// SeamIdentity implements operator.SeamOperator for the dispatcher operator. +type SeamIdentity struct{} + +var _ operator.SeamOperator = (*SeamIdentity)(nil) + +func (s *SeamIdentity) OperatorName() string { return "dispatcher" } +func (s *SeamIdentity) MembershipCRName() string { return "seam-dispatcher" } +func (s *SeamIdentity) ReadyConditionType() string { return conditions.ConditionReady } +func (s *SeamIdentity) Domain() string { return "seam.ontai.dev" } +func (s *SeamIdentity) Subdomain() string { return "pack" } +func (s *SeamIdentity) ConditionTypes() []string { + return []string{ + conditions.ConditionReady, + conditions.ConditionSeamMembershipProvisioned, + conditions.ConditionRBACProfileActive, + conditions.ConditionReconciling, + conditions.ConditionDegraded, + } +} +func (s *SeamIdentity) LineageLabelSchema() map[string]string { + return map[string]string{ + labels.LabelManagedBy: "dispatcher", + labels.LabelRootDeclarationKind: "", + labels.LabelRootDeclarationName: "", + labels.LabelRootDeclarationNamespace: "", + } +} + +// EnsureSeamMembership creates the SeamMembership CR for the dispatcher operator +// in seam-system. Idempotent: AlreadyExists is not an error. +func EnsureSeamMembership(ctx context.Context, c client.Client) error { + id := &SeamIdentity{} + sm := &seamv1alpha1.SeamMembership{ + ObjectMeta: metav1.ObjectMeta{ + Name: id.MembershipCRName(), + Namespace: namespaces.SeamSystem, + }, + Spec: seamv1alpha1.SeamMembershipSpec{ + AppIdentityRef: id.OperatorName(), + DomainIdentityRef: id.OperatorName(), + PrincipalRef: "system:serviceaccount:" + namespaces.SeamSystem + ":" + id.OperatorName(), + Tier: "infrastructure", + }, + } + if err := c.Create(ctx, sm); err != nil && !k8serrors.IsAlreadyExists(err) { + return err + } + return nil +} diff --git a/internal/identity/identity_test.go b/internal/identity/identity_test.go new file mode 100644 index 0000000..6a5d772 --- /dev/null +++ b/internal/identity/identity_test.go @@ -0,0 +1,96 @@ +package identity_test + +import ( + "context" + "testing" + + k8sruntime "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + seamv1alpha1 "github.com/ontai-dev/seam/api/v1alpha1" + "github.com/ontai-dev/dispatcher/internal/identity" + "github.com/ontai-dev/seam-sdk/conditions" + "github.com/ontai-dev/seam-sdk/operator" + "github.com/ontai-dev/seam/pkg/namespaces" +) + +var _ operator.SeamOperator = (*identity.SeamIdentity)(nil) + +func newScheme(t *testing.T) *k8sruntime.Scheme { + t.Helper() + s := k8sruntime.NewScheme() + if err := seamv1alpha1.AddToScheme(s); err != nil { + t.Fatalf("AddToScheme: %v", err) + } + return s +} + +func TestSeamIdentity_Values(t *testing.T) { + id := &identity.SeamIdentity{} + if got := id.OperatorName(); got != "dispatcher" { + t.Errorf("OperatorName() = %q, want %q", got, "dispatcher") + } + if got := id.MembershipCRName(); got != "seam-dispatcher" { + t.Errorf("MembershipCRName() = %q, want %q", got, "seam-dispatcher") + } + if got := id.ReadyConditionType(); got != conditions.ConditionReady { + t.Errorf("ReadyConditionType() = %q, want %q", got, conditions.ConditionReady) + } + if got := id.Domain(); got != "seam.ontai.dev" { + t.Errorf("Domain() = %q, want %q", got, "seam.ontai.dev") + } + if got := id.Subdomain(); got != "pack" { + t.Errorf("Subdomain() = %q, want %q", got, "pack") + } +} + +func TestSeamIdentity_ConditionTypes_ContainsReady(t *testing.T) { + id := &identity.SeamIdentity{} + for _, ct := range id.ConditionTypes() { + if ct == conditions.ConditionReady { + return + } + } + t.Error("ConditionTypes() does not include conditions.ConditionReady") +} + +func TestSeamIdentity_LineageLabelSchema_HasManagedBy(t *testing.T) { + id := &identity.SeamIdentity{} + schema := id.LineageLabelSchema() + v, ok := schema["seam.ontai.dev/managed-by"] + if !ok { + t.Fatal("LineageLabelSchema() missing seam.ontai.dev/managed-by") + } + if v != "dispatcher" { + t.Errorf("seam.ontai.dev/managed-by = %q, want %q", v, "dispatcher") + } +} + +func TestEnsureSeamMembership_Creates(t *testing.T) { + c := fake.NewClientBuilder().WithScheme(newScheme(t)).Build() + if err := identity.EnsureSeamMembership(context.Background(), c); err != nil { + t.Fatalf("EnsureSeamMembership: %v", err) + } + sm := &seamv1alpha1.SeamMembership{} + key := types.NamespacedName{Name: "seam-dispatcher", Namespace: namespaces.SeamSystem} + if err := c.Get(context.Background(), key, sm); err != nil { + t.Fatalf("Get SeamMembership: %v", err) + } + if sm.Spec.AppIdentityRef != "dispatcher" { + t.Errorf("AppIdentityRef = %q, want %q", sm.Spec.AppIdentityRef, "dispatcher") + } + if sm.Spec.Tier != "infrastructure" { + t.Errorf("Tier = %q, want %q", sm.Spec.Tier, "infrastructure") + } +} + +func TestEnsureSeamMembership_Idempotent(t *testing.T) { + c := fake.NewClientBuilder().WithScheme(newScheme(t)).Build() + if err := identity.EnsureSeamMembership(context.Background(), c); err != nil { + t.Fatalf("first call: %v", err) + } + if err := identity.EnsureSeamMembership(context.Background(), c); err != nil { + t.Fatalf("second call (idempotency): %v", err) + } +} diff --git a/test/unit/clusterpack_reconciler_test.go b/test/unit/clusterpack_reconciler_test.go index 7617eff..952c38b 100644 --- a/test/unit/clusterpack_reconciler_test.go +++ b/test/unit/clusterpack_reconciler_test.go @@ -41,7 +41,7 @@ func newClusterPack(name, namespace, version string) *dispatcherv1alpha1.PackDel ObjectMeta: metav1.ObjectMeta{ Name: name, Namespace: namespace, - Finalizers: []string{"infrastructure.ontai.dev/clusterpack-cleanup"}, + Finalizers: []string{"seam.ontai.dev/clusterpack-cleanup"}, }, Spec: dispatcherv1alpha1.PackDeliverySpec{ Version: version, @@ -116,7 +116,7 @@ func TestClusterPackReconciler_SignedTransitionsToAvailable(t *testing.T) { cp := newClusterPack("signed-pack", "infra-system", "v1.0.0") cp.Annotations = map[string]string{ "ontai.dev/pack-signature": "base64sig==", - "infrastructure.ontai.dev/spec-checksum-snapshot": cp.Spec.Checksum + "|" + cp.Spec.RegistryRef.URL + "|" + cp.Spec.RegistryRef.Digest + "|" + cp.Spec.Version, + "seam.ontai.dev/spec-checksum-snapshot": cp.Spec.Checksum + "|" + cp.Spec.RegistryRef.URL + "|" + cp.Spec.RegistryRef.Digest + "|" + cp.Spec.Version, } fakeClient := fake.NewClientBuilder().WithScheme(s).WithObjects(cp). WithStatusSubresource(&dispatcherv1alpha1.PackDelivery{}).Build() @@ -192,7 +192,7 @@ func TestClusterPackReconciler_ImmutabilityViolation(t *testing.T) { cp := newClusterPack("immutable-pack", "infra-system", "v1.0.0") // Pre-set the snapshot annotation with a different checksum to simulate mutation. cp.Annotations = map[string]string{ - "infrastructure.ontai.dev/spec-checksum-snapshot": "sha256:different|old-url|old-digest|v0.9.0", + "seam.ontai.dev/spec-checksum-snapshot": "sha256:different|old-url|old-digest|v0.9.0", } fakeClient := fake.NewClientBuilder().WithScheme(s).WithObjects(cp). WithStatusSubresource(&dispatcherv1alpha1.PackDelivery{}).Build() @@ -292,7 +292,7 @@ func TestClusterPackReconciler_PackExecutionDeletedRecreatesPE(t *testing.T) { cp.Status.PackSignature = "base64sig==" cp.Annotations = map[string]string{ "ontai.dev/pack-signature": "base64sig==", - "infrastructure.ontai.dev/spec-checksum-snapshot": cp.Spec.Checksum + "|" + + "seam.ontai.dev/spec-checksum-snapshot": cp.Spec.Checksum + "|" + cp.Spec.RegistryRef.URL + "|" + cp.Spec.RegistryRef.Digest + "|" + cp.Spec.Version, } @@ -352,7 +352,7 @@ func TestClusterPackReconciler_RevokedNoRequeue(t *testing.T) { cp := newClusterPack("revoked-pack", "infra-system", "v1.0.0") // Pre-set revoked condition and snapshot. cp.Annotations = map[string]string{ - "infrastructure.ontai.dev/spec-checksum-snapshot": cp.Spec.Checksum + "|" + cp.Spec.RegistryRef.URL + "|" + cp.Spec.RegistryRef.Digest + "|" + cp.Spec.Version, + "seam.ontai.dev/spec-checksum-snapshot": cp.Spec.Checksum + "|" + cp.Spec.RegistryRef.URL + "|" + cp.Spec.RegistryRef.Digest + "|" + cp.Spec.Version, } cp.Status.Conditions = []metav1.Condition{ { diff --git a/test/unit/controller/helpers_test.go b/test/unit/controller/helpers_test.go index 80c9907..ea9af84 100644 --- a/test/unit/controller/helpers_test.go +++ b/test/unit/controller/helpers_test.go @@ -114,19 +114,16 @@ func newPE(name, cpName, cpVersion string, cpUID types.UID, clusterRef, profileR func newRunnerConfig(clusterRef string, capCount int) *unstructured.Unstructured { rc := &unstructured.Unstructured{} rc.SetGroupVersionKind(schema.GroupVersionKind{ - Group: "infrastructure.ontai.dev", + Group: "seam.ontai.dev", Version: "v1alpha1", - Kind: "InfrastructureRunnerConfig", + Kind: "RunnerConfig", }) rc.SetName(clusterRef) rc.SetNamespace("ont-system") if capCount > 0 { caps := make([]interface{}, capCount) for i := 0; i < capCount; i++ { - caps[i] = map[string]interface{}{ - "name": "pack-deploy", - "version": "v1.0.0", - } + caps[i] = map[string]interface{}{"name": "pack-deploy"} } _ = unstructured.SetNestedSlice(rc.Object, caps, "status", "capabilities") } @@ -139,9 +136,9 @@ func newRunnerConfig(clusterRef string, capCount int) *unstructured.Unstructured func newTalosCluster(clusterRef string, conductorReady bool) *unstructured.Unstructured { tc := &unstructured.Unstructured{} tc.SetGroupVersionKind(schema.GroupVersionKind{ - Group: "infrastructure.ontai.dev", + Group: "seam.ontai.dev", Version: "v1alpha1", - Kind: "InfrastructureTalosCluster", + Kind: "TalosCluster", }) tc.SetName(clusterRef) tc.SetNamespace("seam-tenant-" + clusterRef) diff --git a/test/unit/controller/kueue_job_scheduling_test.go b/test/unit/controller/kueue_job_scheduling_test.go index c753670..aa6de12 100644 --- a/test/unit/controller/kueue_job_scheduling_test.go +++ b/test/unit/controller/kueue_job_scheduling_test.go @@ -73,12 +73,12 @@ func TestJobSubmission_KueueQueueLabel(t *testing.T) { } // Part-of label. - if v := job.Labels["app.kubernetes.io/part-of"]; v != "wrapper" { + if v := job.Labels["app.kubernetes.io/part-of"]; v != "dispatcher" { t.Errorf("app.kubernetes.io/part-of=%q, want wrapper", v) } // PE name label — used for filtering. - if v := job.Labels["infrastructure.ontai.dev/pe-name"]; v != pe.Name { + if v := job.Labels["seam.ontai.dev/pe-name"]; v != pe.Name { t.Errorf("infrastructure.ontai.dev/pe-name=%q, want %q", v, pe.Name) } } diff --git a/test/unit/controller/packinstance_lifecycle_test.go b/test/unit/controller/packinstance_lifecycle_test.go index 0693de2..52aaa22 100644 --- a/test/unit/controller/packinstance_lifecycle_test.go +++ b/test/unit/controller/packinstance_lifecycle_test.go @@ -183,6 +183,52 @@ func TestOwnershipChain_TalosClusterExists(t *testing.T) { } } +// TestPackInstalled_LineageLabelUsesPackDeliveryName verifies that when a PackInstalled +// is created after Job success, the infrastructure.ontai.dev/root-ili label is set to +// "packdelivery-{cpName}" (not "packexecution-{peName}"). This ensures the DescendantReconciler +// can find the correct LineageRecord by its deterministic IndexName. TC-MC-13/TC-MC-23. +func TestPackInstalled_LineageLabelUsesPackDeliveryName(t *testing.T) { + const ( + peName = "pe-lineage-label" + cpName = "nginx" + cpVersion = "v1.0.0" + clusterRef = "ccs-mgmt" + profileRef = "profile-lineage" + ) + + fakeClient, pe := allGatesSetup(t, peName, cpName, cpVersion, clusterRef, profileRef) + ctx := context.Background() + + job := newJob(packDeployJobName(peName), "infra-system", 1, 0, pe) + por := newOperationResultPOR(peName, "infra-system") + if err := fakeClient.Create(ctx, job); err != nil { + t.Fatalf("create Job: %v", err) + } + if err := fakeClient.Create(ctx, por); err != nil { + t.Fatalf("create PackOperationResult: %v", err) + } + + r := &controller.PackExecutionReconciler{ + Client: fakeClient, + Scheme: buildTestScheme(t), + Recorder: clientevents.NewFakeRecorder(32), + RBACChecker: rbacAllowedStub, + } + reconcilePackExecution(t, r, peName, "infra-system") + + piName := cpName + "-" + clusterRef + pi := &dispatcherv1alpha1.PackInstalled{} + if err := fakeClient.Get(ctx, types.NamespacedName{Name: piName, Namespace: "seam-tenant-" + clusterRef}, pi); err != nil { + t.Fatalf("PackInstalled %q not created: %v", piName, err) + } + + wantILI := "packdelivery-" + cpName + gotILI := pi.GetLabels()["infrastructure.ontai.dev/root-ili"] + if gotILI != wantILI { + t.Errorf("PackInstalled root-ili label = %q, want %q (must reference PackDelivery LineageRecord, not PackExecution)", gotILI, wantILI) + } +} + // TestWaitingForCluster_TalosClusterAbsent verifies that when the target TalosCluster // does not exist, the PackExecutionReconciler sets Waiting=True with // ReasonAwaitingConductorReady, requeues, and creates neither a Job nor a PackInstance. diff --git a/test/unit/controller/rollback_test.go b/test/unit/controller/rollback_test.go index 22c2160..ce47ef8 100644 --- a/test/unit/controller/rollback_test.go +++ b/test/unit/controller/rollback_test.go @@ -50,7 +50,7 @@ func buildRollbackCP(name, version, namespace string, targetRevision int64, rbac cp.Spec.WorkloadDigest = workloadDigest cp.Spec.TargetClusters = []string{"ccs-dev"} cp.Spec.RollbackToRevision = targetRevision - cp.Annotations["infrastructure.ontai.dev/spec-checksum-snapshot"] = "stale-checksum" + cp.Annotations["seam.ontai.dev/spec-checksum-snapshot"] = "stale-checksum" return cp } @@ -114,7 +114,7 @@ func TestClusterPackReconciler_Rollback_OneStep(t *testing.T) { if updated.Spec.RollbackToRevision != 0 { t.Errorf("spec.rollbackToRevision=%d, want 0 (cleared)", updated.Spec.RollbackToRevision) } - if _, ok := updated.Annotations["infrastructure.ontai.dev/spec-checksum-snapshot"]; ok { + if _, ok := updated.Annotations["seam.ontai.dev/spec-checksum-snapshot"]; ok { t.Error("spec-checksum-snapshot annotation should be removed after rollback") } } diff --git a/test/unit/packexecution_reconciler_test.go b/test/unit/packexecution_reconciler_test.go index 68a9d5b..c631c63 100644 --- a/test/unit/packexecution_reconciler_test.go +++ b/test/unit/packexecution_reconciler_test.go @@ -121,19 +121,16 @@ func newRBACProfile(name, namespace string, provisioned bool) *unstructured.Unst func newRunnerConfig(clusterName string, capCount int) *unstructured.Unstructured { rc := &unstructured.Unstructured{} rc.SetGroupVersionKind(schema.GroupVersionKind{ - Group: "infrastructure.ontai.dev", + Group: "seam.ontai.dev", Version: "v1alpha1", - Kind: "InfrastructureRunnerConfig", + Kind: "RunnerConfig", }) rc.SetName(clusterName) rc.SetNamespace("ont-system") if capCount > 0 { caps := make([]interface{}, capCount) for i := 0; i < capCount; i++ { - caps[i] = map[string]interface{}{ - "name": "pack-deploy", - "version": "v1.0.0", - } + caps[i] = map[string]interface{}{"name": "pack-deploy"} } _ = unstructured.SetNestedSlice(rc.Object, caps, "status", "capabilities") } @@ -146,9 +143,9 @@ func newRunnerConfig(clusterName string, capCount int) *unstructured.Unstructure func newTalosClusterWithConductorReady(clusterName string, conductorReady bool) *unstructured.Unstructured { tc := &unstructured.Unstructured{} tc.SetGroupVersionKind(schema.GroupVersionKind{ - Group: "infrastructure.ontai.dev", + Group: "seam.ontai.dev", Version: "v1alpha1", - Kind: "InfrastructureTalosCluster", + Kind: "TalosCluster", }) tc.SetName(clusterName) tc.SetNamespace("seam-tenant-" + clusterName) @@ -741,16 +738,16 @@ func TestPackExecutionReconciler_Gate0_RunnerConfigCapabilitiesAppear(t *testing rcKey := types.NamespacedName{Name: "ccs-test", Namespace: "ont-system"} rcLive := &unstructured.Unstructured{} rcLive.SetGroupVersionKind(schema.GroupVersionKind{ - Group: "infrastructure.ontai.dev", + Group: "seam.ontai.dev", Version: "v1alpha1", - Kind: "InfrastructureRunnerConfig", + Kind: "RunnerConfig", }) if err := cl.Get(context.Background(), rcKey, rcLive); err != nil { t.Fatalf("get RunnerConfig: %v", err) } // Mutate the live object in-place and use regular Update (RunnerConfig is not in // WithStatusSubresource so the fake client stores the full object including status). - caps := []interface{}{map[string]interface{}{"name": "pack-deploy", "version": "v1.0.0"}} + caps := []interface{}{map[string]interface{}{"name": "pack-deploy"}} if err := unstructured.SetNestedSlice(rcLive.Object, caps, "status", "capabilities"); err != nil { t.Fatalf("set capabilities on rcLive: %v", err) } @@ -759,7 +756,7 @@ func TestPackExecutionReconciler_Gate0_RunnerConfigCapabilitiesAppear(t *testing } // Verify capabilities stored before proceeding. rcCheck := &unstructured.Unstructured{} - rcCheck.SetGroupVersionKind(schema.GroupVersionKind{Group: "infrastructure.ontai.dev", Version: "v1alpha1", Kind: "InfrastructureRunnerConfig"}) + rcCheck.SetGroupVersionKind(schema.GroupVersionKind{Group: "seam.ontai.dev", Version: "v1alpha1", Kind: "RunnerConfig"}) if err := cl.Get(context.Background(), rcKey, rcCheck); err != nil { t.Fatalf("get RunnerConfig after update: %v", err) } diff --git a/test/unit/role_independence_test.go b/test/unit/role_independence_test.go index f0b359d..8ca5aee 100644 --- a/test/unit/role_independence_test.go +++ b/test/unit/role_independence_test.go @@ -129,7 +129,7 @@ func TestRoleIndependence_PackExecution_ManagementCluster_JobNamespace(t *testin // The Job must carry the management cluster reference in its labels. job := jobList.Items[0] - if got := job.Labels["infrastructure.ontai.dev/pe-name"]; got != pe.Name { + if got := job.Labels["seam.ontai.dev/pe-name"]; got != pe.Name { t.Errorf("Job label pe-name = %q, want %q", got, pe.Name) } } @@ -148,7 +148,7 @@ func TestRoleIndependence_PackExecution_ManagementCluster_PackInstance(t *testin peNS = "infra-system" ) s := newPackExecutionScheme(t) - cp := newSignedClusterPack("cilium", peNS, "v1.16.0") + cp := newSignedClusterPack("cilium", "infra-system", "v1.16.0") pe := newPackExecution(peName, peNS, "cilium", "v1.16.0", clusterRef, "cilium") ps := newPermissionSnapshot("snapshot-"+clusterRef, "seam-system", true) profile := newRBACProfile("cilium", tenantNS, true) @@ -248,7 +248,7 @@ func TestRoleIndependence_ClusterPack_SignaturePathUnchanged(t *testing.T) { ObjectMeta: metav1.ObjectMeta{ Name: "cilium", Namespace: "infra-system", - Finalizers: []string{"infrastructure.ontai.dev/clusterpack-cleanup"}, + Finalizers: []string{"seam.ontai.dev/clusterpack-cleanup"}, Annotations: map[string]string{ "ontai.dev/pack-signature": "mgmt-conductor-sig==", },