diff --git a/go/deployment-operator/Makefile b/go/deployment-operator/Makefile
index eae70ea96a..b8b99c8a55 100644
--- a/go/deployment-operator/Makefile
+++ b/go/deployment-operator/Makefile
@@ -36,6 +36,7 @@ VELERO_CHART_URL := https://github.com/vmware-tanzu/helm-charts/releases/downloa
ENVTEST_K8S_VERSION := 1.35.0
CONTROLLER_GEN_PATHS ?= ./api/...;./internal/...;./pkg/...
+CONTROLLER_GEN_CRD_PATHS ?= ./api/...
PRE = --ensure
@@ -44,7 +45,8 @@ PRE = --ensure
.PHONY: manifests
manifests: TOOL = controller-gen
manifests: --tool ## Generate WebhookConfiguration, ClusterRole and CustomResourceDefinition objects.
- $(CONTROLLER_GEN) rbac:roleName=manager-role crd webhook paths="$(CONTROLLER_GEN_PATHS)" output:crd:artifacts:config=config/crd/bases
+ $(CONTROLLER_GEN) rbac:roleName=manager-role webhook paths="$(CONTROLLER_GEN_PATHS)"
+ $(CONTROLLER_GEN) crd paths="$(CONTROLLER_GEN_CRD_PATHS)" output:crd:artifacts:config=config/crd/bases
@$(MAKE) -s codegen-chart-crds
.PHONY: generate
diff --git a/go/deployment-operator/api/v1alpha1/pluralcrossplanecluster_types.go b/go/deployment-operator/api/v1alpha1/pluralcrossplanecluster_types.go
new file mode 100644
index 0000000000..84b3e2ea79
--- /dev/null
+++ b/go/deployment-operator/api/v1alpha1/pluralcrossplanecluster_types.go
@@ -0,0 +1,152 @@
+package v1alpha1
+
+import (
+ "context"
+ "fmt"
+
+ console "github.com/pluralsh/console/go/client"
+ "github.com/samber/lo"
+ corev1 "k8s.io/api/core/v1"
+ "k8s.io/apimachinery/pkg/api/meta"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ k8sClient "sigs.k8s.io/controller-runtime/pkg/client"
+)
+
+func init() {
+ SchemeBuilder.Register(&PluralCrossplaneClusterList{}, &PluralCrossplaneCluster{})
+}
+
+// PluralCrossplanelusterList contains a list of [PluralCrossplaneCluster]
+// +kubebuilder:object:root=true
+type PluralCrossplaneClusterList struct {
+ metav1.TypeMeta `json:",inline"`
+ metav1.ListMeta `json:"metadata,omitempty"`
+ Items []PluralCrossplaneCluster `json:"items"`
+}
+
+// PluralCrossplaneCluster is the Schema for the Crossplane cluster configuration
+// +kubebuilder:object:root=true
+// +kubebuilder:resource:scope=Namespaced
+// +kubebuilder:subresource:status
+type PluralCrossplaneCluster struct {
+ metav1.TypeMeta `json:",inline"`
+ metav1.ObjectMeta `json:"metadata,omitempty"`
+
+ // Spec of the Crossplane cluster configuration
+ // +kubebuilder:validation:Required
+ Spec CrossplaneConfigurationClusterSpec `json:"spec"`
+
+ // Status of the Crossplane cluster configuration
+ // +kubebuilder:validation:Optional
+ Status Status `json:"status,omitempty"`
+}
+
+type CrossplaneConfigurationClusterSpec struct {
+ // Cluster is a simplified representation of the Console API cluster
+ // object. See [ClusterSpec] for more information.
+ // +kubebuilder:validation:Optional
+ Cluster *ClusterSpec `json:"cluster,omitempty"`
+
+ // TokenSecretRef contains the reference to the secret holding the token to access the Console API
+ // +kubebuilder:validation:Required
+ ConsoleTokenSecretRef corev1.SecretKeySelector `json:"consoleTokenSecretRef"`
+
+ // CrossplaneClusterRef contains the reference to the Crossplane cluster
+ // +kubebuilder:validation:Required
+ CrossplaneClusterRef corev1.ObjectReference `json:"crossplaneClusterRef"`
+
+ // Agent allows configuring agent specific helm chart options.
+ // +kubebuilder:validation:Optional
+ Agent *AgentHelmConfiguration `json:"agent,omitempty"`
+}
+
+func (in *CrossplaneConfigurationClusterSpec) GetAgent() *AgentHelmConfiguration {
+ if in == nil || in.Agent == nil {
+ return &AgentHelmConfiguration{
+ HelmConfiguration: HelmConfiguration{
+ RepoUrl: lo.ToPtr(AgentDefaultRepository),
+ ChartName: lo.ToPtr(AgentDefaultChartName),
+ },
+ }
+ }
+
+ return in.Agent
+}
+
+func (in *PluralCrossplaneCluster) Diff(hasher Hasher) (changed bool, sha string, err error) {
+ currentSha, err := hasher(in.Spec)
+ if err != nil {
+ return false, "", err
+ }
+
+ return !in.Status.IsSHAEqual(currentSha), currentSha, nil
+}
+
+func (in *PluralCrossplaneCluster) SetCondition(condition metav1.Condition) {
+ meta.SetStatusCondition(&in.Status.Conditions, condition)
+}
+
+func (in *PluralCrossplaneCluster) ClusterName() string {
+ if in.Spec.Cluster != nil && in.Spec.Cluster.Handle != nil {
+ return lo.FromPtr(in.Spec.Cluster.Handle)
+ }
+ return in.Name
+}
+
+func (in *PluralCrossplaneCluster) GetConsoleToken(ctx context.Context, c k8sClient.Client) (string, error) {
+ secret := &corev1.Secret{}
+
+ if err := c.Get(
+ ctx,
+ k8sClient.ObjectKey{Name: in.Spec.ConsoleTokenSecretRef.Name, Namespace: in.Namespace},
+ secret,
+ ); err != nil {
+ return "", err
+ }
+
+ token, exists := secret.Data[in.Spec.ConsoleTokenSecretRef.Key]
+ if !exists {
+ return "", fmt.Errorf("secret %s/%s does not contain console token", in.Namespace, in.Spec.ConsoleTokenSecretRef.Name)
+ }
+
+ return string(token), nil
+}
+
+func (in *PluralCrossplaneCluster) Attributes() console.ClusterAttributes {
+ attrs := console.ClusterAttributes{
+ Name: in.Name,
+ }
+
+ if in.Spec.Cluster == nil {
+ return attrs
+ }
+
+ if in.Spec.Cluster.Handle != nil {
+ attrs.Handle = in.Spec.Cluster.Handle
+ }
+
+ if in.Spec.Cluster.Tags != nil {
+ tags := make([]*console.TagAttributes, 0)
+ for name, value := range in.Spec.Cluster.Tags {
+ tags = append(tags, &console.TagAttributes{Name: name, Value: value})
+ }
+ attrs.Tags = tags
+ }
+
+ if in.Spec.Cluster.Metadata != nil {
+ attrs.Metadata = lo.ToPtr(string(in.Spec.Cluster.Metadata.Raw))
+ }
+
+ if in.Spec.Cluster.Bindings != nil {
+ attrs.ReadBindings = PolicyBindings(in.Spec.Cluster.Bindings.Read)
+ attrs.WriteBindings = PolicyBindings(in.Spec.Cluster.Bindings.Write)
+ }
+
+ return attrs
+}
+
+// UpdateAttributes returns the update attributes for this CR by converting
+// the value returned from Attributes().
+func (in *PluralCrossplaneCluster) UpdateAttributes() console.ClusterUpdateAttributes {
+ return ConvertClusterAttributesToUpdate(in.Attributes())
+}
diff --git a/go/deployment-operator/api/v1alpha1/zz_generated.deepcopy.go b/go/deployment-operator/api/v1alpha1/zz_generated.deepcopy.go
index 17e96cff65..ddac12e9e9 100644
--- a/go/deployment-operator/api/v1alpha1/zz_generated.deepcopy.go
+++ b/go/deployment-operator/api/v1alpha1/zz_generated.deepcopy.go
@@ -23,7 +23,7 @@ package v1alpha1
import (
"github.com/pluralsh/console/go/client"
batchv1 "k8s.io/api/batch/v1"
- v1 "k8s.io/api/core/v1"
+ "k8s.io/api/core/v1"
networkingv1 "k8s.io/api/networking/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
@@ -532,11 +532,21 @@ func (in *AgentRuntimeSpec) DeepCopyInto(out *AgentRuntimeSpec) {
*out = new(bool)
**out = **in
}
+ if in.StreamingProxy != nil {
+ in, out := &in.StreamingProxy, &out.StreamingProxy
+ *out = new(bool)
+ **out = **in
+ }
if in.Dind != nil {
in, out := &in.Dind, &out.Dind
*out = new(bool)
**out = **in
}
+ if in.Memory != nil {
+ in, out := &in.Memory, &out.Memory
+ *out = new(bool)
+ **out = **in
+ }
if in.AllowedRepositories != nil {
in, out := &in.AllowedRepositories, &out.AllowedRepositories
*out = make([]string, len(*in))
@@ -1021,6 +1031,33 @@ func (in *CodexConfigRaw) DeepCopy() *CodexConfigRaw {
return out
}
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *CrossplaneConfigurationClusterSpec) DeepCopyInto(out *CrossplaneConfigurationClusterSpec) {
+ *out = *in
+ if in.Cluster != nil {
+ in, out := &in.Cluster, &out.Cluster
+ *out = new(ClusterSpec)
+ (*in).DeepCopyInto(*out)
+ }
+ in.ConsoleTokenSecretRef.DeepCopyInto(&out.ConsoleTokenSecretRef)
+ out.CrossplaneClusterRef = in.CrossplaneClusterRef
+ if in.Agent != nil {
+ in, out := &in.Agent, &out.Agent
+ *out = new(AgentHelmConfiguration)
+ (*in).DeepCopyInto(*out)
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new CrossplaneConfigurationClusterSpec.
+func (in *CrossplaneConfigurationClusterSpec) DeepCopy() *CrossplaneConfigurationClusterSpec {
+ if in == nil {
+ return nil
+ }
+ out := new(CrossplaneConfigurationClusterSpec)
+ in.DeepCopyInto(out)
+ return out
+}
+
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *CustomHealth) DeepCopyInto(out *CustomHealth) {
*out = *in
@@ -1934,6 +1971,65 @@ func (in *PluralCAPIClusterList) DeepCopyObject() runtime.Object {
return nil
}
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *PluralCrossplaneCluster) DeepCopyInto(out *PluralCrossplaneCluster) {
+ *out = *in
+ out.TypeMeta = in.TypeMeta
+ in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
+ in.Spec.DeepCopyInto(&out.Spec)
+ in.Status.DeepCopyInto(&out.Status)
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PluralCrossplaneCluster.
+func (in *PluralCrossplaneCluster) DeepCopy() *PluralCrossplaneCluster {
+ if in == nil {
+ return nil
+ }
+ out := new(PluralCrossplaneCluster)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *PluralCrossplaneCluster) DeepCopyObject() runtime.Object {
+ if c := in.DeepCopy(); c != nil {
+ return c
+ }
+ return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *PluralCrossplaneClusterList) DeepCopyInto(out *PluralCrossplaneClusterList) {
+ *out = *in
+ out.TypeMeta = in.TypeMeta
+ in.ListMeta.DeepCopyInto(&out.ListMeta)
+ if in.Items != nil {
+ in, out := &in.Items, &out.Items
+ *out = make([]PluralCrossplaneCluster, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PluralCrossplaneClusterList.
+func (in *PluralCrossplaneClusterList) DeepCopy() *PluralCrossplaneClusterList {
+ if in == nil {
+ return nil
+ }
+ out := new(PluralCrossplaneClusterList)
+ in.DeepCopyInto(out)
+ return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *PluralCrossplaneClusterList) DeepCopyObject() runtime.Object {
+ if c := in.DeepCopy(); c != nil {
+ return c
+ }
+ return nil
+}
+
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *PodTemplateSpec) DeepCopyInto(out *PodTemplateSpec) {
*out = *in
diff --git a/go/deployment-operator/cmd/agent/kubernetes.go b/go/deployment-operator/cmd/agent/kubernetes.go
index 7c4026e5f9..7604bd660d 100644
--- a/go/deployment-operator/cmd/agent/kubernetes.go
+++ b/go/deployment-operator/cmd/agent/kubernetes.go
@@ -336,6 +336,13 @@ func registerKubeReconcilersOrDie(
}).SetupWithManager(manager); err != nil {
setupLog.Error(err, "unable to create controller", "controller", "PluralCAPIClusterController")
}
+ if err := (&controller.PluralCrossplaneClusterController{
+ Client: manager.GetClient(),
+ Scheme: manager.GetScheme(),
+ ConsoleUrl: consoleURL,
+ }).SetupWithManager(manager); err != nil {
+ setupLog.Error(err, "unable to create controller", "controller", "PluralCrossplaneClusterController")
+ }
if err := (&controller.SentinelRunJobReconciler{
Client: manager.GetClient(),
Scheme: manager.GetScheme(),
diff --git a/go/deployment-operator/cmd/agent/main.go b/go/deployment-operator/cmd/agent/main.go
index 02c0249099..ee61761760 100644
--- a/go/deployment-operator/cmd/agent/main.go
+++ b/go/deployment-operator/cmd/agent/main.go
@@ -34,6 +34,7 @@ import (
clusterv1 "sigs.k8s.io/cluster-api/api/core/v1beta2"
ctrl "sigs.k8s.io/controller-runtime"
+ "github.com/pluralsh/console/go/deployment-operator/internal/crossplane"
"github.com/pluralsh/console/go/deployment-operator/internal/utils"
"github.com/pluralsh/console/go/deployment-operator/pkg/cache"
discoverycache "github.com/pluralsh/console/go/deployment-operator/pkg/cache/discovery"
@@ -66,6 +67,7 @@ func init() {
utilruntime.Must(openshift.AddToScheme(scheme))
utilruntime.Must(fluxcd.AddToScheme(scheme))
utilruntime.Must(clusterv1.AddToScheme(scheme))
+ utilruntime.Must(crossplane.AddToScheme(scheme))
//+kubebuilder:scaffold:scheme
}
diff --git a/go/deployment-operator/config/crd/bases/deployments.plural.sh_agentruns.yaml b/go/deployment-operator/config/crd/bases/deployments.plural.sh_agentruns.yaml
index 0476010126..bff2e3ba57 100644
--- a/go/deployment-operator/config/crd/bases/deployments.plural.sh_agentruns.yaml
+++ b/go/deployment-operator/config/crd/bases/deployments.plural.sh_agentruns.yaml
@@ -44,6 +44,10 @@ spec:
spec:
description: AgentRunSpec defines the desired state of AgentRun
properties:
+ branch:
+ description: Branch is the repository branch the agent should operate
+ on. If omitted, the repository default branch is used.
+ type: string
flowId:
description: FlowID is the flow this agent run is associated with
(optional)
@@ -51,11 +55,13 @@ spec:
language:
description: |-
Language is the programming language used in the agent run.
+
Deprecated: No longer used for image selection. Enable dind on the AgentRuntime instead.
type: string
languageVersion:
description: |-
LanguageVersion is the version of the language to use, if you wish to specify.
+
Deprecated: No longer used for image selection. Enable dind on the AgentRuntime instead.
type: string
mode:
@@ -68,10 +74,6 @@ spec:
description: Repository is the git repository the agent will work
with
type: string
- branch:
- description: Branch is the repository branch the agent should operate
- on. If omitted, the repository default branch is used.
- type: string
runtimeRef:
properties:
name:
diff --git a/go/deployment-operator/config/crd/bases/deployments.plural.sh_agentruntimes.yaml b/go/deployment-operator/config/crd/bases/deployments.plural.sh_agentruntimes.yaml
index 4e60ab57f0..097d5b70c3 100644
--- a/go/deployment-operator/config/crd/bases/deployments.plural.sh_agentruntimes.yaml
+++ b/go/deployment-operator/config/crd/bases/deployments.plural.sh_agentruntimes.yaml
@@ -60,12 +60,6 @@ spec:
- GEMINI: vertex/{model}
- CUSTOM: no automatic prefix; use provider/name explicitly
type: boolean
- streamingProxy:
- description: |-
- StreamingProxy routes OpenAI-compatible LLM requests through the in-pod mcpserver
- streaming proxy before they reach the Console AI proxy (/ext/ai). Only valid when aiProxy
- is enabled. Applies to CODEX and OPENCODE runtimes.
- type: boolean
allowedRepositories:
description: AllowedRepositories the git repositories allowed to be
used with this runtime.
@@ -78,11 +72,6 @@ spec:
babysitting actions (e.g. restarting unhealthy runtimes). When not
provided, a default interval of 1 minute will be used.
type: string
- scmConnection:
- description: |-
- ScmConnection is the name of an ScmConnection in Console to use for git operations on agent runs using this runtime.
- This should match the name of an existing ScmConnection resource or connection created in the Plural UI.
- type: string
bindings:
description: Bindings define the creation permissions for this agent
runtime.
@@ -1268,7 +1257,6 @@ spec:
procMount denotes the type of proc mount to use for the containers.
The default value is Default which uses the container runtime defaults for
readonly paths and masked paths.
- This requires the ProcMountType feature flag to be enabled.
Note that this field cannot be set when spec.os.name is windows.
type: string
readOnlyRootFilesystem:
@@ -1974,11 +1962,12 @@ spec:
tools on the Plural MCP server.
properties:
apiKeySecretRef:
- description: SecretKeySelector selects a key of a Secret.
+ description: ApiKeySecretRef references a Secret containing the
+ Exa API key.
properties:
key:
- description: The key of the secret to select from. Must
- be a valid secret key.
+ description: The key of the secret to select from. Must be
+ a valid secret key.
type: string
name:
default: ""
@@ -1990,20 +1979,20 @@ spec:
More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
type: string
optional:
- description: Specify whether the Secret or its key must
- be defined
+ description: Specify whether the Secret or its key must be
+ defined
type: boolean
required:
- key
type: object
x-kubernetes-map-type: atomic
+ proxyUrl:
+ description: ProxyURL is an HTTP proxy URL used for Exa API requests.
+ type: string
url:
description: URL is the Exa API base URL. Defaults to https://api.exa.ai
when unset.
type: string
- proxyUrl:
- description: ProxyURL is an HTTP proxy URL used for Exa API requests.
- type: string
type: object
git:
description: Git configure commit signing on agent run. When provided,
@@ -2050,6 +2039,17 @@ spec:
Name of this AgentRuntime.
If not provided, the name from AgentRuntime.ObjectMeta will be used.
type: string
+ scmConnection:
+ description: |-
+ ScmConnection is the name of an ScmConnection in Console to use for git operations on agent runs using this runtime.
+ This should match the name of an existing ScmConnection resource or connection created in the Plural UI.
+ type: string
+ streamingProxy:
+ description: |-
+ StreamingProxy routes OpenAI-compatible LLM requests through the in-pod mcpserver
+ sse conversion proxy before they reach the Console AI proxy (/ext/ai). Only valid when aiProxy
+ is enabled. Applies to CODEX and OPENCODE runtimes.
+ type: boolean
targetNamespace:
type: string
template:
@@ -4116,7 +4116,6 @@ spec:
procMount denotes the type of proc mount to use for the containers.
The default value is Default which uses the container runtime defaults for
readonly paths and masked paths.
- This requires the ProcMountType feature flag to be enabled.
Note that this field cannot be set when spec.os.name is windows.
type: string
readOnlyRootFilesystem:
@@ -5687,7 +5686,6 @@ spec:
procMount denotes the type of proc mount to use for the containers.
The default value is Default which uses the container runtime defaults for
readonly paths and masked paths.
- This requires the ProcMountType feature flag to be enabled.
Note that this field cannot be set when spec.os.name is windows.
type: string
readOnlyRootFilesystem:
@@ -6171,7 +6169,6 @@ spec:
When set to false, a new userns is created for the pod. Setting false is useful for
mitigating container breakout vulnerabilities even allowing users to run their
containers as root without actually having root privileges on the host.
- This field is alpha-level and is only honored by servers that enable the UserNamespacesSupport feature.
type: boolean
hostname:
description: |-
@@ -7341,7 +7338,6 @@ spec:
procMount denotes the type of proc mount to use for the containers.
The default value is Default which uses the container runtime defaults for
readonly paths and masked paths.
- This requires the ProcMountType feature flag to be enabled.
Note that this field cannot be set when spec.os.name is windows.
type: string
readOnlyRootFilesystem:
@@ -7910,6 +7906,14 @@ spec:
It adds a name to it that uniquely identifies the ResourceClaim inside the Pod.
Containers that need access to the ResourceClaim reference it with this name.
+
+ When the DRAWorkloadResourceClaims feature gate is enabled and this Pod
+ belongs to a PodGroup, a PodResourceClaim is matched to a
+ PodGroupResourceClaim if all of their fields are equal (Name,
+ ResourceClaimName, and ResourceClaimTemplateName). A matched claim references
+ a single ResourceClaim shared across all Pods in the PodGroup, reserved for
+ the PodGroup in ResourceClaimStatus.ReservedFor rather than for individual
+ Pods.
properties:
name:
description: |-
@@ -7935,6 +7939,16 @@ spec:
generated component, will be used to form a unique name for the
ResourceClaim, which will be recorded in pod.status.resourceClaimStatuses.
+ When the DRAWorkloadResourceClaims feature gate is enabled and the pod
+ belongs to a PodGroup that defines a PodGroupResourceClaim with the same
+ Name and ResourceClaimTemplateName, this PodResourceClaim resolves to the
+ ResourceClaim generated for the PodGroup. All pods in the group that
+ define an equivalent PodResourceClaim matching the
+ PodGroupResourceClaim's Name and ResourceClaimTemplateName share the same
+ generated ResourceClaim. ResourceClaims generated for a PodGroup are
+ owned by the PodGroup and their lifecycles are tied to the PodGroup
+ instead of any individual pod.
+
This field is immutable and no changes will be made to the
corresponding ResourceClaim by the control plane after creating the
ResourceClaim.
@@ -8060,6 +8074,28 @@ spec:
x-kubernetes-list-map-keys:
- name
x-kubernetes-list-type: map
+ schedulingGroup:
+ description: |-
+ SchedulingGroup provides a reference to the immediate scheduling runtime
+ grouping object that this Pod belongs to.
+ This field is used by the scheduler to identify the group and apply the
+ correct group scheduling policies. The association with a group also
+ impacts other lifecycle aspects of a Pod that are relevant in a wider context
+ of scheduling like preemption, resource attachment, etc. If not specified,
+ the Pod is treated as a single unit in all of these aspects.
+ The group object referenced by this field may not exist at the time the
+ Pod is created.
+ This field is immutable, but a group object with the same name may be
+ recreated with different policies. Doing this during pod scheduling
+ may result in the placement not conforming to the expected policies.
+ properties:
+ podGroupName:
+ description: |-
+ PodGroupName specifies the name of the standalone PodGroup object
+ that represents the runtime instance of this group.
+ Must be a DNS subdomain.
+ type: string
+ type: object
securityContext:
description: |-
SecurityContext holds pod-level security attributes and common container settings.
@@ -9488,7 +9524,7 @@ spec:
A failure to resolve or pull the image during pod startup will block containers from starting and may add significant latency. Failures will be retried using normal volume backoff and will be reported on the pod reason and message.
The types of objects that may be mounted by this volume are defined by the container runtime implementation on a host machine and at minimum must include all valid types supported by the container image field.
The OCI object gets mounted in a single directory (spec.containers[*].volumeMounts.mountPath) by merging the manifest layers in the same way as for container images.
- The volume will be mounted read-only (ro) and non-executable files (noexec).
+ The volume will be mounted read-only (ro).
Sub path mounts for containers are not supported (spec.containers[*].volumeMounts.subpath) before 1.33.
The field spec.securityContext.fsGroupChangePolicy has no effect on this volume type.
properties:
@@ -9660,8 +9696,7 @@ spec:
description: |-
portworxVolume represents a portworx volume attached and mounted on kubelets host machine.
Deprecated: PortworxVolume is deprecated. All operations for the in-tree portworxVolume type
- are redirected to the pxd.portworx.com CSI driver when the CSIMigrationPortworx feature-gate
- is on.
+ are redirected to the pxd.portworx.com CSI driver.
properties:
fsType:
description: |-
@@ -10484,42 +10519,6 @@ spec:
x-kubernetes-list-map-keys:
- name
x-kubernetes-list-type: map
- workloadRef:
- description: |-
- WorkloadRef provides a reference to the Workload object that this Pod belongs to.
- This field is used by the scheduler to identify the PodGroup and apply the
- correct group scheduling policies. The Workload object referenced
- by this field may not exist at the time the Pod is created.
- This field is immutable, but a Workload object with the same name
- may be recreated with different policies. Doing this during pod scheduling
- may result in the placement not conforming to the expected policies.
- properties:
- name:
- description: |-
- Name defines the name of the Workload object this Pod belongs to.
- Workload must be in the same namespace as the Pod.
- If it doesn't match any existing Workload, the Pod will remain unschedulable
- until a Workload object is created and observed by the kube-scheduler.
- It must be a DNS subdomain.
- type: string
- podGroup:
- description: |-
- PodGroup is the name of the PodGroup within the Workload that this Pod
- belongs to. If it doesn't match any existing PodGroup within the Workload,
- the Pod will remain unschedulable until the Workload object is recreated
- and observed by the kube-scheduler. It must be a DNS label.
- type: string
- podGroupReplicaKey:
- description: |-
- PodGroupReplicaKey specifies the replica key of the PodGroup to which this
- Pod belongs. It is used to distinguish pods belonging to different replicas
- of the same pod group. The pod group policy is applied separately to each replica.
- When set, it must be a DNS label.
- type: string
- required:
- - name
- - podGroup
- type: object
required:
- containers
type: object
@@ -10541,7 +10540,8 @@ spec:
type: object
x-kubernetes-validations:
- message: streamingProxy requires aiProxy to be enabled
- rule: '!has(self.streamingProxy) || !self.streamingProxy || (has(self.aiProxy) && self.aiProxy)'
+ rule: '!has(self.streamingProxy) || !self.streamingProxy || (has(self.aiProxy)
+ && self.aiProxy)'
status:
properties:
conditions:
diff --git a/go/deployment-operator/config/crd/bases/deployments.plural.sh_pipelinegates.yaml b/go/deployment-operator/config/crd/bases/deployments.plural.sh_pipelinegates.yaml
index fe7adf2948..743638f56e 100644
--- a/go/deployment-operator/config/crd/bases/deployments.plural.sh_pipelinegates.yaml
+++ b/go/deployment-operator/config/crd/bases/deployments.plural.sh_pipelinegates.yaml
@@ -2498,7 +2498,6 @@ spec:
procMount denotes the type of proc mount to use for the containers.
The default value is Default which uses the container runtime defaults for
readonly paths and masked paths.
- This requires the ProcMountType feature flag to be enabled.
Note that this field cannot be set when spec.os.name is windows.
type: string
readOnlyRootFilesystem:
@@ -4103,7 +4102,6 @@ spec:
procMount denotes the type of proc mount to use for the containers.
The default value is Default which uses the container runtime defaults for
readonly paths and masked paths.
- This requires the ProcMountType feature flag to be enabled.
Note that this field cannot be set when spec.os.name is windows.
type: string
readOnlyRootFilesystem:
@@ -4595,7 +4593,6 @@ spec:
When set to false, a new userns is created for the pod. Setting false is useful for
mitigating container breakout vulnerabilities even allowing users to run their
containers as root without actually having root privileges on the host.
- This field is alpha-level and is only honored by servers that enable the UserNamespacesSupport feature.
type: boolean
hostname:
description: |-
@@ -5788,7 +5785,6 @@ spec:
procMount denotes the type of proc mount to use for the containers.
The default value is Default which uses the container runtime defaults for
readonly paths and masked paths.
- This requires the ProcMountType feature flag to be enabled.
Note that this field cannot be set when spec.os.name is windows.
type: string
readOnlyRootFilesystem:
@@ -6365,6 +6361,14 @@ spec:
It adds a name to it that uniquely identifies the ResourceClaim inside the Pod.
Containers that need access to the ResourceClaim reference it with this name.
+
+ When the DRAWorkloadResourceClaims feature gate is enabled and this Pod
+ belongs to a PodGroup, a PodResourceClaim is matched to a
+ PodGroupResourceClaim if all of their fields are equal (Name,
+ ResourceClaimName, and ResourceClaimTemplateName). A matched claim references
+ a single ResourceClaim shared across all Pods in the PodGroup, reserved for
+ the PodGroup in ResourceClaimStatus.ReservedFor rather than for individual
+ Pods.
properties:
name:
description: |-
@@ -6390,6 +6394,16 @@ spec:
generated component, will be used to form a unique name for the
ResourceClaim, which will be recorded in pod.status.resourceClaimStatuses.
+ When the DRAWorkloadResourceClaims feature gate is enabled and the pod
+ belongs to a PodGroup that defines a PodGroupResourceClaim with the same
+ Name and ResourceClaimTemplateName, this PodResourceClaim resolves to the
+ ResourceClaim generated for the PodGroup. All pods in the group that
+ define an equivalent PodResourceClaim matching the
+ PodGroupResourceClaim's Name and ResourceClaimTemplateName share the same
+ generated ResourceClaim. ResourceClaims generated for a PodGroup are
+ owned by the PodGroup and their lifecycles are tied to the PodGroup
+ instead of any individual pod.
+
This field is immutable and no changes will be made to the
corresponding ResourceClaim by the control plane after creating the
ResourceClaim.
@@ -6516,6 +6530,28 @@ spec:
x-kubernetes-list-map-keys:
- name
x-kubernetes-list-type: map
+ schedulingGroup:
+ description: |-
+ SchedulingGroup provides a reference to the immediate scheduling runtime
+ grouping object that this Pod belongs to.
+ This field is used by the scheduler to identify the group and apply the
+ correct group scheduling policies. The association with a group also
+ impacts other lifecycle aspects of a Pod that are relevant in a wider context
+ of scheduling like preemption, resource attachment, etc. If not specified,
+ the Pod is treated as a single unit in all of these aspects.
+ The group object referenced by this field may not exist at the time the
+ Pod is created.
+ This field is immutable, but a group object with the same name may be
+ recreated with different policies. Doing this during pod scheduling
+ may result in the placement not conforming to the expected policies.
+ properties:
+ podGroupName:
+ description: |-
+ PodGroupName specifies the name of the standalone PodGroup object
+ that represents the runtime instance of this group.
+ Must be a DNS subdomain.
+ type: string
+ type: object
securityContext:
description: |-
SecurityContext holds pod-level security attributes and common container settings.
@@ -7960,7 +7996,7 @@ spec:
A failure to resolve or pull the image during pod startup will block containers from starting and may add significant latency. Failures will be retried using normal volume backoff and will be reported on the pod reason and message.
The types of objects that may be mounted by this volume are defined by the container runtime implementation on a host machine and at minimum must include all valid types supported by the container image field.
The OCI object gets mounted in a single directory (spec.containers[*].volumeMounts.mountPath) by merging the manifest layers in the same way as for container images.
- The volume will be mounted read-only (ro) and non-executable files (noexec).
+ The volume will be mounted read-only (ro).
Sub path mounts for containers are not supported (spec.containers[*].volumeMounts.subpath) before 1.33.
The field spec.securityContext.fsGroupChangePolicy has no effect on this volume type.
properties:
@@ -8134,8 +8170,7 @@ spec:
description: |-
portworxVolume represents a portworx volume attached and mounted on kubelets host machine.
Deprecated: PortworxVolume is deprecated. All operations for the in-tree portworxVolume type
- are redirected to the pxd.portworx.com CSI driver when the CSIMigrationPortworx feature-gate
- is on.
+ are redirected to the pxd.portworx.com CSI driver.
properties:
fsType:
description: |-
@@ -8982,42 +9017,6 @@ spec:
x-kubernetes-list-map-keys:
- name
x-kubernetes-list-type: map
- workloadRef:
- description: |-
- WorkloadRef provides a reference to the Workload object that this Pod belongs to.
- This field is used by the scheduler to identify the PodGroup and apply the
- correct group scheduling policies. The Workload object referenced
- by this field may not exist at the time the Pod is created.
- This field is immutable, but a Workload object with the same name
- may be recreated with different policies. Doing this during pod scheduling
- may result in the placement not conforming to the expected policies.
- properties:
- name:
- description: |-
- Name defines the name of the Workload object this Pod belongs to.
- Workload must be in the same namespace as the Pod.
- If it doesn't match any existing Workload, the Pod will remain unschedulable
- until a Workload object is created and observed by the kube-scheduler.
- It must be a DNS subdomain.
- type: string
- podGroup:
- description: |-
- PodGroup is the name of the PodGroup within the Workload that this Pod
- belongs to. If it doesn't match any existing PodGroup within the Workload,
- the Pod will remain unschedulable until the Workload object is recreated
- and observed by the kube-scheduler. It must be a DNS label.
- type: string
- podGroupReplicaKey:
- description: |-
- PodGroupReplicaKey specifies the replica key of the PodGroup to which this
- Pod belongs. It is used to distinguish pods belonging to different replicas
- of the same pod group. The pod group policy is applied separately to each replica.
- When set, it must be a DNS label.
- type: string
- required:
- - name
- - podGroup
- type: object
required:
- containers
type: object
diff --git a/go/deployment-operator/config/crd/bases/deployments.plural.sh_pluralcrossplaneclusters.yaml b/go/deployment-operator/config/crd/bases/deployments.plural.sh_pluralcrossplaneclusters.yaml
new file mode 100644
index 0000000000..b9893de0df
--- /dev/null
+++ b/go/deployment-operator/config/crd/bases/deployments.plural.sh_pluralcrossplaneclusters.yaml
@@ -0,0 +1,319 @@
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+ annotations:
+ controller-gen.kubebuilder.io/version: v0.19.0
+ name: pluralcrossplaneclusters.deployments.plural.sh
+spec:
+ group: deployments.plural.sh
+ names:
+ kind: PluralCrossplaneCluster
+ listKind: PluralCrossplaneClusterList
+ plural: pluralcrossplaneclusters
+ singular: pluralcrossplanecluster
+ scope: Namespaced
+ versions:
+ - name: v1alpha1
+ schema:
+ openAPIV3Schema:
+ description: PluralCrossplaneCluster is the Schema for the Crossplane cluster
+ configuration
+ 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: Spec of the Crossplane cluster configuration
+ properties:
+ agent:
+ description: Agent allows configuring agent specific helm chart options.
+ properties:
+ chartName:
+ description: ChartName is a helm chart name.
+ type: string
+ repoUrl:
+ description: RepoUrl is a url that points to this helm chart.
+ type: string
+ values:
+ description: "Values allows defining arbitrary YAML values to
+ pass to the helm as values.yaml file.\nUse only one of:\n\t-
+ Values\n\t- ValuesSecretRef\n\t- ValuesConfigMapRef"
+ type: object
+ x-kubernetes-preserve-unknown-fields: true
+ valuesConfigMapRef:
+ description: "ValuesConfigMapRef fetches helm values from a config
+ map in this cluster.\nUse only one of:\n\t- Values\n\t- ValuesSecretRef\n\t-
+ ValuesConfigMapRef"
+ properties:
+ key:
+ description: The key to select.
+ type: string
+ name:
+ default: ""
+ description: |-
+ Name of the referent.
+ This field is effectively required, but due to backwards compatibility is
+ allowed to be empty. Instances of this type with an empty value here are
+ almost certainly wrong.
+ More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
+ type: string
+ optional:
+ description: Specify whether the ConfigMap or its key must
+ be defined
+ type: boolean
+ required:
+ - key
+ type: object
+ x-kubernetes-map-type: atomic
+ valuesSecretRef:
+ description: "ValuesSecretRef fetches helm values from a secret
+ in this cluster.\nUse only one of:\n\t- Values\n\t- ValuesSecretRef\n\t-
+ ValuesConfigMapRef"
+ properties:
+ key:
+ description: The key of the secret to select from. Must be
+ a valid secret key.
+ type: string
+ name:
+ default: ""
+ description: |-
+ Name of the referent.
+ This field is effectively required, but due to backwards compatibility is
+ allowed to be empty. Instances of this type with an empty value here are
+ almost certainly wrong.
+ More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
+ type: string
+ optional:
+ description: Specify whether the Secret or its key must be
+ defined
+ type: boolean
+ required:
+ - key
+ type: object
+ x-kubernetes-map-type: atomic
+ type: object
+ cluster:
+ description: |-
+ Cluster is a simplified representation of the Console API cluster
+ object. See [ClusterSpec] for more information.
+ properties:
+ bindings:
+ description: Bindings contain read and write policies of this
+ cluster
+ properties:
+ read:
+ description: Read bindings.
+ items:
+ description: Binding ...
+ properties:
+ UserID:
+ type: string
+ groupID:
+ type: string
+ groupName:
+ type: string
+ id:
+ type: string
+ userEmail:
+ type: string
+ type: object
+ type: array
+ write:
+ description: Write bindings.
+ items:
+ description: Binding ...
+ properties:
+ UserID:
+ type: string
+ groupID:
+ type: string
+ groupName:
+ type: string
+ id:
+ type: string
+ userEmail:
+ type: string
+ type: object
+ type: array
+ type: object
+ handle:
+ description: |-
+ Handle is a short, unique human-readable name used to identify this cluster.
+ Does not necessarily map to the cloud resource name.
+ example: myclusterhandle
+ type: string
+ metadata:
+ description: Metadata for the cluster
+ type: object
+ x-kubernetes-preserve-unknown-fields: true
+ tags:
+ additionalProperties:
+ type: string
+ description: Tags used to filter clusters.
+ type: object
+ type: object
+ consoleTokenSecretRef:
+ description: TokenSecretRef contains the reference to the secret holding
+ the token to access the Console API
+ properties:
+ key:
+ description: The key of the secret to select from. Must be a
+ valid secret key.
+ type: string
+ name:
+ default: ""
+ description: |-
+ Name of the referent.
+ This field is effectively required, but due to backwards compatibility is
+ allowed to be empty. Instances of this type with an empty value here are
+ almost certainly wrong.
+ More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
+ type: string
+ optional:
+ description: Specify whether the Secret or its key must be defined
+ type: boolean
+ required:
+ - key
+ type: object
+ x-kubernetes-map-type: atomic
+ crossplaneClusterRef:
+ description: CrossplaneClusterRef contains the reference to the Crossplane
+ cluster
+ properties:
+ apiVersion:
+ description: API version of the referent.
+ type: string
+ fieldPath:
+ description: |-
+ If referring to a piece of an object instead of an entire object, this string
+ should contain a valid JSON/Go field access statement, such as desiredState.manifest.containers[2].
+ For example, if the object reference is to a container within a pod, this would take on a value like:
+ "spec.containers{name}" (where "name" refers to the name of the container that triggered
+ the event) or if no container name is specified "spec.containers[2]" (container with
+ index 2 in this pod). This syntax is chosen only to have some well-defined way of
+ referencing a part of an object.
+ type: string
+ kind:
+ description: |-
+ Kind of the referent.
+ More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
+ type: string
+ name:
+ description: |-
+ Name of the referent.
+ More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#names
+ type: string
+ namespace:
+ description: |-
+ Namespace of the referent.
+ More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/
+ type: string
+ resourceVersion:
+ description: |-
+ Specific resourceVersion to which this reference is made, if any.
+ More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency
+ type: string
+ uid:
+ description: |-
+ UID of the referent.
+ More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#uids
+ type: string
+ type: object
+ x-kubernetes-map-type: atomic
+ required:
+ - consoleTokenSecretRef
+ - crossplaneClusterRef
+ type: object
+ status:
+ description: Status of the Crossplane cluster configuration
+ properties:
+ conditions:
+ description: Represents the observations of a PrAutomation's current
+ state.
+ 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
+ x-kubernetes-list-map-keys:
+ - type
+ x-kubernetes-list-type: map
+ id:
+ description: ID of the resource in the Console API.
+ type: string
+ sha:
+ description: SHA of last applied configuration.
+ type: string
+ type: object
+ required:
+ - spec
+ type: object
+ served: true
+ storage: true
+ subresources:
+ status: {}
diff --git a/go/deployment-operator/config/samples/pluralCrossplaneCluster.yaml b/go/deployment-operator/config/samples/pluralCrossplaneCluster.yaml
new file mode 100644
index 0000000000..f29d9624dd
--- /dev/null
+++ b/go/deployment-operator/config/samples/pluralCrossplaneCluster.yaml
@@ -0,0 +1,16 @@
+apiVersion: deployments.plural.sh/v1alpha1
+kind: PluralCrossplaneCluster
+metadata:
+ name: crossplane-eks-quickstart
+ namespace: default
+spec:
+ cluster:
+ handle: crossplane-eks-quickstart
+ consoleTokenSecretRef:
+ name: crossplane-eks-quickstart-console-token
+ key: token
+ crossplaneClusterRef:
+ apiVersion: eks.aws.upbound.io/v1beta2
+ kind: Cluster
+ name: cp-eks-small
+ namespace: default
diff --git a/go/deployment-operator/config/samples/pluralCrossplaneCluster_aks.yaml b/go/deployment-operator/config/samples/pluralCrossplaneCluster_aks.yaml
new file mode 100644
index 0000000000..667b060309
--- /dev/null
+++ b/go/deployment-operator/config/samples/pluralCrossplaneCluster_aks.yaml
@@ -0,0 +1,15 @@
+apiVersion: deployments.plural.sh/v1alpha1
+kind: PluralCrossplaneCluster
+metadata:
+ name: crossplane-aks-quickstart
+ namespace: default
+spec:
+ cluster:
+ handle: crossplane-aks-quickstart
+ consoleTokenSecretRef:
+ name: crossplane-aks-quickstart-console-token
+ key: token
+ crossplaneClusterRef:
+ apiVersion: containerservice.azure.upbound.io/v1beta1
+ kind: KubernetesCluster
+ name: cp-aks-small
diff --git a/go/deployment-operator/config/samples/pluralCrossplaneCluster_gke.yaml b/go/deployment-operator/config/samples/pluralCrossplaneCluster_gke.yaml
new file mode 100644
index 0000000000..7249ea7534
--- /dev/null
+++ b/go/deployment-operator/config/samples/pluralCrossplaneCluster_gke.yaml
@@ -0,0 +1,15 @@
+apiVersion: deployments.plural.sh/v1alpha1
+kind: PluralCrossplaneCluster
+metadata:
+ name: crossplane-gke-quickstart
+ namespace: default
+spec:
+ cluster:
+ handle: crossplane-gke-quickstart
+ consoleTokenSecretRef:
+ name: crossplane-gke-quickstart-console-token
+ key: token
+ crossplaneClusterRef:
+ apiVersion: container.gcp.upbound.io/v1beta2
+ kind: Cluster
+ name: cp-gke-small
diff --git a/go/deployment-operator/docs/api.md b/go/deployment-operator/docs/api.md
index 115ff90763..5d8e514e91 100644
--- a/go/deployment-operator/docs/api.md
+++ b/go/deployment-operator/docs/api.md
@@ -19,6 +19,7 @@ Package v1alpha1 contains API Schema definitions for the deployments v1alpha1 AP
- [MetricsAggregate](#metricsaggregate)
- [PipelineGate](#pipelinegate)
- [PluralCAPICluster](#pluralcapicluster)
+- [PluralCrossplaneCluster](#pluralcrossplanecluster)
- [SentinelRunJob](#sentinelrunjob)
- [StackRunJob](#stackrunjob)
- [UpgradeInsights](#upgradeinsights)
@@ -99,6 +100,7 @@ _Appears in:_
_Appears in:_
- [CapiConfigurationClusterSpec](#capiconfigurationclusterspec)
+- [CrossplaneConfigurationClusterSpec](#crossplaneconfigurationclusterspec)
- [HelmSpec](#helmspec)
| Field | Description | Default | Validation |
@@ -475,6 +477,7 @@ _Appears in:_
_Appears in:_
- [CapiConfigurationClusterSpec](#capiconfigurationclusterspec)
+- [CrossplaneConfigurationClusterSpec](#crossplaneconfigurationclusterspec)
- [VirtualClusterSpec](#virtualclusterspec)
| Field | Description | Default | Validation |
@@ -548,6 +551,25 @@ _Appears in:_
| `OpenCost` | |
+#### CrossplaneConfigurationClusterSpec
+
+
+
+
+
+
+
+_Appears in:_
+- [PluralCrossplaneCluster](#pluralcrossplanecluster)
+
+| Field | Description | Default | Validation |
+| --- | --- | --- | --- |
+| `cluster` _[ClusterSpec](#clusterspec)_ | Cluster is a simplified representation of the Console API cluster
object. See [ClusterSpec] for more information. | | Optional: \{\}
|
+| `consoleTokenSecretRef` _[SecretKeySelector](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.29/#secretkeyselector-v1-core)_ | TokenSecretRef contains the reference to the secret holding the token to access the Console API | | Required: \{\}
|
+| `crossplaneClusterRef` _[ObjectReference](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.29/#objectreference-v1-core)_ | CrossplaneClusterRef contains the reference to the Crossplane cluster | | Required: \{\}
|
+| `agent` _[AgentHelmConfiguration](#agenthelmconfiguration)_ | Agent allows configuring agent specific helm chart options. | | Optional: \{\}
|
+
+
#### CustomHealth
@@ -991,6 +1013,24 @@ PluralCAPICluster is the Schema for the CAPI cluster configuration
| `spec` _[CapiConfigurationClusterSpec](#capiconfigurationclusterspec)_ | Spec of the CAPI cluster configuration | | Required: \{\}
|
+#### PluralCrossplaneCluster
+
+
+
+PluralCrossplaneCluster is the Schema for the Crossplane cluster configuration
+
+
+
+
+
+| Field | Description | Default | Validation |
+| --- | --- | --- | --- |
+| `apiVersion` _string_ | `deployments.plural.sh/v1alpha1` | | |
+| `kind` _string_ | `PluralCrossplaneCluster` | | |
+| `metadata` _[ObjectMeta](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.29/#objectmeta-v1-meta)_ | Refer to Kubernetes API documentation for fields of `metadata`. | | |
+| `spec` _[CrossplaneConfigurationClusterSpec](#crossplaneconfigurationclusterspec)_ | Spec of the Crossplane cluster configuration | | Required: \{\}
|
+
+
#### Progress
diff --git a/go/deployment-operator/go.mod b/go/deployment-operator/go.mod
index a82bfa40b1..d091530926 100644
--- a/go/deployment-operator/go.mod
+++ b/go/deployment-operator/go.mod
@@ -29,15 +29,16 @@ require (
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21
github.com/aws/aws-sdk-go-v2/service/eks v1.81.2
github.com/cert-manager/cert-manager v1.19.3
+ github.com/crossplane/crossplane-runtime v1.20.10
github.com/cyphar/filepath-securejoin v0.6.1
github.com/evanphx/json-patch/v5 v5.9.11
github.com/fluxcd/flagger v1.41.0
github.com/fluxcd/helm-controller/api v1.4.3
github.com/gin-gonic/gin v1.12.0
github.com/go-logr/logr v1.4.3
- github.com/go-openapi/jsonpointer v0.22.1
+ github.com/go-openapi/jsonpointer v0.22.5
github.com/gobuffalo/flect v1.0.3
- github.com/google/gnostic-models v0.7.0
+ github.com/google/gnostic-models v0.7.1
github.com/google/go-github/v68 v68.0.0
github.com/grafana/pyroscope-go v1.2.7
github.com/hashicorp/terraform-json v0.26.0
@@ -68,8 +69,8 @@ require (
github.com/vmware-tanzu/velero v1.16.2
github.com/yuin/gopher-lua v1.1.1
gitlab.com/gitlab-org/api/client-go v1.46.0
- golang.org/x/oauth2 v0.35.0
- golang.org/x/time v0.14.0
+ golang.org/x/oauth2 v0.36.0
+ golang.org/x/time v0.15.0
gopkg.in/yaml.v3 v3.0.1
gotest.tools/gotestsum v1.13.0
helm.sh/helm/v3 v3.21.2
@@ -173,7 +174,7 @@ require (
github.com/creack/pty v1.1.24 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dnephin/pflag v1.0.7 // indirect
- github.com/docker/cli v29.2.0+incompatible // indirect
+ github.com/docker/cli v29.4.0+incompatible // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.10.0 // indirect
github.com/emicklei/go-restful/v3 v3.13.0 // indirect
@@ -191,19 +192,19 @@ require (
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-logr/zapr v1.3.0 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
- github.com/go-openapi/jsonreference v0.21.2 // indirect
- github.com/go-openapi/swag v0.24.1 // indirect
- github.com/go-openapi/swag/cmdutils v0.24.0 // indirect
- github.com/go-openapi/swag/conv v0.24.0 // indirect
- github.com/go-openapi/swag/fileutils v0.24.0 // indirect
- github.com/go-openapi/swag/jsonname v0.25.1 // indirect
- github.com/go-openapi/swag/jsonutils v0.24.0 // indirect
- github.com/go-openapi/swag/loading v0.24.0 // indirect
- github.com/go-openapi/swag/mangling v0.24.0 // indirect
- github.com/go-openapi/swag/netutils v0.24.0 // indirect
- github.com/go-openapi/swag/stringutils v0.24.0 // indirect
- github.com/go-openapi/swag/typeutils v0.24.0 // indirect
- github.com/go-openapi/swag/yamlutils v0.24.0 // indirect
+ github.com/go-openapi/jsonreference v0.21.5 // indirect
+ github.com/go-openapi/swag v0.25.5 // indirect
+ github.com/go-openapi/swag/cmdutils v0.25.5 // indirect
+ github.com/go-openapi/swag/conv v0.25.5 // indirect
+ github.com/go-openapi/swag/fileutils v0.25.5 // indirect
+ github.com/go-openapi/swag/jsonname v0.25.5 // indirect
+ github.com/go-openapi/swag/jsonutils v0.25.5 // indirect
+ github.com/go-openapi/swag/loading v0.25.5 // indirect
+ github.com/go-openapi/swag/mangling v0.25.5 // indirect
+ github.com/go-openapi/swag/netutils v0.25.5 // indirect
+ github.com/go-openapi/swag/stringutils v0.25.5 // indirect
+ github.com/go-openapi/swag/typeutils v0.25.5 // indirect
+ github.com/go-openapi/swag/yamlutils v0.25.5 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
@@ -218,7 +219,7 @@ require (
github.com/google/btree v1.1.3 // indirect
github.com/google/cel-go v0.27.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
- github.com/google/go-containerregistry v0.20.6 // indirect
+ github.com/google/go-containerregistry v0.21.2 // indirect
github.com/google/go-querystring v1.2.0 // indirect
github.com/google/pprof v0.0.0-20260115054156-294ebfa9ad83 // indirect
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
@@ -238,9 +239,8 @@ require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/invopop/jsonschema v0.13.0 // indirect
github.com/jmoiron/sqlx v1.4.0 // indirect
- github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
- github.com/klauspost/compress v1.18.4 // indirect
+ github.com/klauspost/compress v1.18.5 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
@@ -297,7 +297,7 @@ require (
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/samber/oops v1.19.0 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
- github.com/secure-systems-lab/go-securesystemslib v0.9.1 // indirect
+ github.com/secure-systems-lab/go-securesystemslib v0.10.0 // indirect
github.com/shirou/gopsutil/v4 v4.26.2 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/sosodev/duration v1.3.1 // indirect
@@ -346,7 +346,7 @@ require (
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.25.0 // indirect
golang.org/x/crypto v0.53.0 // indirect
- golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect
+ golang.org/x/exp v0.0.0-20260112195511-716be5621a96 // indirect
golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 // indirect
golang.org/x/mod v0.36.0 // indirect
golang.org/x/net v0.55.0 // indirect
diff --git a/go/deployment-operator/go.sum b/go/deployment-operator/go.sum
index 2b16c45a66..5fba01723b 100644
--- a/go/deployment-operator/go.sum
+++ b/go/deployment-operator/go.sum
@@ -251,8 +251,8 @@ github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/containerd/platforms v1.0.0-rc.1 h1:83KIq4yy1erSRgOVHNk1HYdPvzdJ5CnsWaRoJX4C41E=
github.com/containerd/platforms v1.0.0-rc.1/go.mod h1:J71L7B+aiM5SdIEqmd9wp6THLVRzJGXfNuWCZCllLA4=
-github.com/containerd/stargz-snapshotter/estargz v0.16.3 h1:7evrXtoh1mSbGj/pfRccTampEyKpjpOnS3CyiV1Ebr8=
-github.com/containerd/stargz-snapshotter/estargz v0.16.3/go.mod h1:uyr4BfYfOj3G9WBVE8cOlQmXAbPN9VEQpBBeJIuOipU=
+github.com/containerd/stargz-snapshotter/estargz v0.18.2 h1:yXkZFYIzz3eoLwlTUZKz2iQ4MrckBxJjkmD16ynUTrw=
+github.com/containerd/stargz-snapshotter/estargz v0.18.2/go.mod h1:XyVU5tcJ3PRpkA9XS2T5us6Eg35yM0214Y+wvrZTBrY=
github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4=
github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec=
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf h1:iW4rZ826su+pqaw19uhpSCzhj44qo35pNgKFGqzDKkU=
@@ -263,6 +263,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:ma
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
+github.com/crossplane/crossplane-runtime v1.20.10 h1:NyfZXaG3hNlD/ZyBRN3kXCSYmZ18gERh3jsZJKCGNjw=
+github.com/crossplane/crossplane-runtime v1.20.10/go.mod h1:EbFC7+3EDUlEmOkDOA0QNwwpen3sfzAUJVquCK9juyY=
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -282,8 +284,8 @@ github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZ
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dnephin/pflag v1.0.7 h1:oxONGlWxhmUct0YzKTgrpQv9AUA1wtPBn7zuSjJqptk=
github.com/dnephin/pflag v1.0.7/go.mod h1:uxE91IoWURlOiTUIA8Mq5ZZkAv3dPUfZNaT80Zm7OQE=
-github.com/docker/cli v29.2.0+incompatible h1:9oBd9+YM7rxjZLfyMGxjraKBKE4/nVyvVfN4qNl9XRM=
-github.com/docker/cli v29.2.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
+github.com/docker/cli v29.4.0+incompatible h1:+IjXULMetlvWJiuSI0Nbor36lcJ5BTcVpUmB21KBoVM=
+github.com/docker/cli v29.4.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk=
github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
github.com/docker/docker-credential-helpers v0.9.5 h1:EFNN8DHvaiK8zVqFA2DT6BjXE0GzfLOZ38ggPTKePkY=
@@ -360,34 +362,40 @@ github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
-github.com/go-openapi/jsonpointer v0.22.1 h1:sHYI1He3b9NqJ4wXLoJDKmUmHkWy/L7rtEo92JUxBNk=
-github.com/go-openapi/jsonpointer v0.22.1/go.mod h1:pQT9OsLkfz1yWoMgYFy4x3U5GY5nUlsOn1qSBH5MkCM=
-github.com/go-openapi/jsonreference v0.21.2 h1:Wxjda4M/BBQllegefXrY/9aq1fxBA8sI5M/lFU6tSWU=
-github.com/go-openapi/jsonreference v0.21.2/go.mod h1:pp3PEjIsJ9CZDGCNOyXIQxsNuroxm8FAJ/+quA0yKzQ=
-github.com/go-openapi/swag v0.24.1 h1:DPdYTZKo6AQCRqzwr/kGkxJzHhpKxZ9i/oX0zag+MF8=
-github.com/go-openapi/swag v0.24.1/go.mod h1:sm8I3lCPlspsBBwUm1t5oZeWZS0s7m/A+Psg0ooRU0A=
-github.com/go-openapi/swag/cmdutils v0.24.0 h1:KlRCffHwXFI6E5MV9n8o8zBRElpY4uK4yWyAMWETo9I=
-github.com/go-openapi/swag/cmdutils v0.24.0/go.mod h1:uxib2FAeQMByyHomTlsP8h1TtPd54Msu2ZDU/H5Vuf8=
-github.com/go-openapi/swag/conv v0.24.0 h1:ejB9+7yogkWly6pnruRX45D1/6J+ZxRu92YFivx54ik=
-github.com/go-openapi/swag/conv v0.24.0/go.mod h1:jbn140mZd7EW2g8a8Y5bwm8/Wy1slLySQQ0ND6DPc2c=
-github.com/go-openapi/swag/fileutils v0.24.0 h1:U9pCpqp4RUytnD689Ek/N1d2N/a//XCeqoH508H5oak=
-github.com/go-openapi/swag/fileutils v0.24.0/go.mod h1:3SCrCSBHyP1/N+3oErQ1gP+OX1GV2QYFSnrTbzwli90=
-github.com/go-openapi/swag/jsonname v0.25.1 h1:Sgx+qbwa4ej6AomWC6pEfXrA6uP2RkaNjA9BR8a1RJU=
-github.com/go-openapi/swag/jsonname v0.25.1/go.mod h1:71Tekow6UOLBD3wS7XhdT98g5J5GR13NOTQ9/6Q11Zo=
-github.com/go-openapi/swag/jsonutils v0.24.0 h1:F1vE1q4pg1xtO3HTyJYRmEuJ4jmIp2iZ30bzW5XgZts=
-github.com/go-openapi/swag/jsonutils v0.24.0/go.mod h1:vBowZtF5Z4DDApIoxcIVfR8v0l9oq5PpYRUuteVu6f0=
-github.com/go-openapi/swag/loading v0.24.0 h1:ln/fWTwJp2Zkj5DdaX4JPiddFC5CHQpvaBKycOlceYc=
-github.com/go-openapi/swag/loading v0.24.0/go.mod h1:gShCN4woKZYIxPxbfbyHgjXAhO61m88tmjy0lp/LkJk=
-github.com/go-openapi/swag/mangling v0.24.0 h1:PGOQpViCOUroIeak/Uj/sjGAq9LADS3mOyjznmHy2pk=
-github.com/go-openapi/swag/mangling v0.24.0/go.mod h1:Jm5Go9LHkycsz0wfoaBDkdc4CkpuSnIEf62brzyCbhc=
-github.com/go-openapi/swag/netutils v0.24.0 h1:Bz02HRjYv8046Ycg/w80q3g9QCWeIqTvlyOjQPDjD8w=
-github.com/go-openapi/swag/netutils v0.24.0/go.mod h1:WRgiHcYTnx+IqfMCtu0hy9oOaPR0HnPbmArSRN1SkZM=
-github.com/go-openapi/swag/stringutils v0.24.0 h1:i4Z/Jawf9EvXOLUbT97O0HbPUja18VdBxeadyAqS1FM=
-github.com/go-openapi/swag/stringutils v0.24.0/go.mod h1:5nUXB4xA0kw2df5PRipZDslPJgJut+NjL7D25zPZ/4w=
-github.com/go-openapi/swag/typeutils v0.24.0 h1:d3szEGzGDf4L2y1gYOSSLeK6h46F+zibnEas2Jm/wIw=
-github.com/go-openapi/swag/typeutils v0.24.0/go.mod h1:q8C3Kmk/vh2VhpCLaoR2MVWOGP8y7Jc8l82qCTd1DYI=
-github.com/go-openapi/swag/yamlutils v0.24.0 h1:bhw4894A7Iw6ne+639hsBNRHg9iZg/ISrOVr+sJGp4c=
-github.com/go-openapi/swag/yamlutils v0.24.0/go.mod h1:DpKv5aYuaGm/sULePoeiG8uwMpZSfReo1HR3Ik0yaG8=
+github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA=
+github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0=
+github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE=
+github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw=
+github.com/go-openapi/swag v0.25.5 h1:pNkwbUEeGwMtcgxDr+2GBPAk4kT+kJ+AaB+TMKAg+TU=
+github.com/go-openapi/swag v0.25.5/go.mod h1:B3RT6l8q7X803JRxa2e59tHOiZlX1t8viplOcs9CwTA=
+github.com/go-openapi/swag/cmdutils v0.25.5 h1:yh5hHrpgsw4NwM9KAEtaDTXILYzdXh/I8Whhx9hKj7c=
+github.com/go-openapi/swag/cmdutils v0.25.5/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0=
+github.com/go-openapi/swag/conv v0.25.5 h1:wAXBYEXJjoKwE5+vc9YHhpQOFj2JYBMF2DUi+tGu97g=
+github.com/go-openapi/swag/conv v0.25.5/go.mod h1:CuJ1eWvh1c4ORKx7unQnFGyvBbNlRKbnRyAvDvzWA4k=
+github.com/go-openapi/swag/fileutils v0.25.5 h1:B6JTdOcs2c0dBIs9HnkyTW+5gC+8NIhVBUwERkFhMWk=
+github.com/go-openapi/swag/fileutils v0.25.5/go.mod h1:V3cT9UdMQIaH4WiTrUc9EPtVA4txS0TOmRURmhGF4kc=
+github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo=
+github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU=
+github.com/go-openapi/swag/jsonutils v0.25.5 h1:XUZF8awQr75MXeC+/iaw5usY/iM7nXPDwdG3Jbl9vYo=
+github.com/go-openapi/swag/jsonutils v0.25.5/go.mod h1:48FXUaz8YsDAA9s5AnaUvAmry1UcLcNVWUjY42XkrN4=
+github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5 h1:SX6sE4FrGb4sEnnxbFL/25yZBb5Hcg1inLeErd86Y1U=
+github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5/go.mod h1:/2KvOTrKWjVA5Xli3DZWdMCZDzz3uV/T7bXwrKWPquo=
+github.com/go-openapi/swag/loading v0.25.5 h1:odQ/umlIZ1ZVRteI6ckSrvP6e2w9UTF5qgNdemJHjuU=
+github.com/go-openapi/swag/loading v0.25.5/go.mod h1:I8A8RaaQ4DApxhPSWLNYWh9NvmX2YKMoB9nwvv6oW6g=
+github.com/go-openapi/swag/mangling v0.25.5 h1:hyrnvbQRS7vKePQPHHDso+k6CGn5ZBs5232UqWZmJZw=
+github.com/go-openapi/swag/mangling v0.25.5/go.mod h1:6hadXM/o312N/h98RwByLg088U61TPGiltQn71Iw0NY=
+github.com/go-openapi/swag/netutils v0.25.5 h1:LZq2Xc2QI8+7838elRAaPCeqJnHODfSyOa7ZGfxDKlU=
+github.com/go-openapi/swag/netutils v0.25.5/go.mod h1:lHbtmj4m57APG/8H7ZcMMSWzNqIQcu0RFiXrPUara14=
+github.com/go-openapi/swag/stringutils v0.25.5 h1:NVkoDOA8YBgtAR/zvCx5rhJKtZF3IzXcDdwOsYzrB6M=
+github.com/go-openapi/swag/stringutils v0.25.5/go.mod h1:PKK8EZdu4QJq8iezt17HM8RXnLAzY7gW0O1KKarrZII=
+github.com/go-openapi/swag/typeutils v0.25.5 h1:EFJ+PCga2HfHGdo8s8VJXEVbeXRCYwzzr9u4rJk7L7E=
+github.com/go-openapi/swag/typeutils v0.25.5/go.mod h1:itmFmScAYE1bSD8C4rS0W+0InZUBrB2xSPbWt6DLGuc=
+github.com/go-openapi/swag/yamlutils v0.25.5 h1:kASCIS+oIeoc55j28T4o8KwlV2S4ZLPT6G0iq2SSbVQ=
+github.com/go-openapi/swag/yamlutils v0.25.5/go.mod h1:Gek1/SjjfbYvM+Iq4QGwa/2lEXde9n2j4a3wI3pNuOQ=
+github.com/go-openapi/testify/enable/yaml/v2 v2.4.0 h1:7SgOMTvJkM8yWrQlU8Jm18VeDPuAvB/xWrdxFJkoFag=
+github.com/go-openapi/testify/enable/yaml/v2 v2.4.0/go.mod h1:14iV8jyyQlinc9StD7w1xVPW3CO3q1Gj04Jy//Kw4VM=
+github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM=
+github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -432,16 +440,16 @@ github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/google/cel-go v0.27.0 h1:e7ih85+4qVrBuqQWTW4FKSqZYokVuc3HnhH5keboFTo=
github.com/google/cel-go v0.27.0/go.mod h1:tTJ11FWqnhw5KKpnWpvW9CJC3Y9GK4EIS0WXnBbebzw=
-github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo=
-github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
+github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c=
+github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
-github.com/google/go-containerregistry v0.20.6 h1:cvWX87UxxLgaH76b4hIvya6Dzz9qHB31qAwjAohdSTU=
-github.com/google/go-containerregistry v0.20.6/go.mod h1:T0x8MuoAoKX/873bkeSfLD2FAkwCDf9/HZgsFJ02E2Y=
+github.com/google/go-containerregistry v0.21.2 h1:vYaMU4nU55JJGFC9JR/s8NZcTjbE9DBBbvusTW9NeS0=
+github.com/google/go-containerregistry v0.21.2/go.mod h1:ctO5aCaewH4AK1AumSF5DPW+0+R+d2FmylMJdp5G7p0=
github.com/google/go-github/v62 v62.0.0 h1:/6mGCaRywZz9MuHyw9gD1CwsbmBX8GWsbFkwMmHdhl4=
github.com/google/go-github/v62 v62.0.0/go.mod h1:EMxeUqGJq2xRu9DYBMwel/mr7kZrzUOfQmmpYrZn2a4=
github.com/google/go-github/v68 v68.0.0 h1:ZW57zeNZiXTdQ16qrDiZ0k6XucrxZ2CGmoTvcCyQG6s=
@@ -530,16 +538,14 @@ github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcI
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
-github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
-github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE=
github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
-github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
-github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
+github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
+github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
@@ -738,8 +744,8 @@ github.com/samber/oops v1.19.0 h1:sfZAwC8MmTXBRRyNc4Z1utuTPBx+hFKF5fJ9DEQRZfw=
github.com/samber/oops v1.19.0/go.mod h1:+f+61dbiMxEMQ8gw/zTxW2pk+YGobaDM4glEHQtPOww=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
-github.com/secure-systems-lab/go-securesystemslib v0.9.1 h1:nZZaNz4DiERIQguNy0cL5qTdn9lR8XKHf4RUyG1Sx3g=
-github.com/secure-systems-lab/go-securesystemslib v0.9.1/go.mod h1:np53YzT0zXGMv6x4iEWc9Z59uR+x+ndLwCLqPYpLXVU=
+github.com/secure-systems-lab/go-securesystemslib v0.10.0 h1:l+H5ErcW0PAehBNrBxoGv1jjNpGYdZ9RcheFkB2WI14=
+github.com/secure-systems-lab/go-securesystemslib v0.10.0/go.mod h1:MRKONWmRoFzPNQ9USRF9i1mc7MvAVvF1LlW8X5VWDvk=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
@@ -822,8 +828,8 @@ github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2W
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
-github.com/vbatts/tar-split v0.12.1 h1:CqKoORW7BUWBe7UL/iqTVvkTBOF8UvOMKOIZykxnnbo=
-github.com/vbatts/tar-split v0.12.1/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA=
+github.com/vbatts/tar-split v0.12.2 h1:w/Y6tjxpeiFMR47yzZPlPj/FcPLpXbTUi/9H7d3CPa4=
+github.com/vbatts/tar-split v0.12.2/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA=
github.com/vektah/gqlparser/v2 v2.5.30 h1:EqLwGAFLIzt1wpx1IPpY67DwUujF1OfzgEyDsLrN6kE=
github.com/vektah/gqlparser/v2 v2.5.30/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo=
github.com/vmihailenco/msgpack v3.3.3+incompatible h1:wapg9xDUZDzGCNFlwc5SqI1rvcciqcxEHac4CYj89xI=
@@ -991,8 +997,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
-golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0=
-golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
+golang.org/x/exp v0.0.0-20260112195511-716be5621a96 h1:Z/6YuSHTLOHfNFdb8zVZomZr7cqNgTJvA8+Qz75D8gU=
+golang.org/x/exp v0.0.0-20260112195511-716be5621a96/go.mod h1:nzimsREAkjBCIEFtHiYkrJyT+2uy9YZJB7H1k68CXZU=
golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 h1:qWFG1Dj7TBjOjOvhEOkmyGPVoquqUKnIU0lEVLp8xyk=
golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358/go.mod h1:4Mzdyp/6jzw9auFDJ3OMF5qksa7UvPnzKqTVGcb04ms=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
@@ -1008,8 +1014,8 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
-golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
-golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
+golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
+golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -1044,8 +1050,8 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
-golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
-golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
+golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
+golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
diff --git a/go/deployment-operator/internal/controller/pluralcrossplanecluster_controller.go b/go/deployment-operator/internal/controller/pluralcrossplanecluster_controller.go
new file mode 100644
index 0000000000..1db9c67a46
--- /dev/null
+++ b/go/deployment-operator/internal/controller/pluralcrossplanecluster_controller.go
@@ -0,0 +1,298 @@
+package controller
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strings"
+
+ "github.com/pluralsh/console/go/deployment-operator/api/v1alpha1"
+ "github.com/pluralsh/console/go/deployment-operator/internal/crossplane"
+ internalerrors "github.com/pluralsh/console/go/deployment-operator/internal/errors"
+ "github.com/pluralsh/console/go/deployment-operator/internal/helm"
+ "github.com/pluralsh/console/go/deployment-operator/internal/utils"
+ "github.com/pluralsh/console/go/deployment-operator/pkg/cache"
+ "github.com/pluralsh/console/go/deployment-operator/pkg/client"
+ "github.com/samber/lo"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+ ctrl "sigs.k8s.io/controller-runtime"
+ k8sClient "sigs.k8s.io/controller-runtime/pkg/client"
+ "sigs.k8s.io/controller-runtime/pkg/controller"
+ "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
+ "sigs.k8s.io/controller-runtime/pkg/log"
+ "sigs.k8s.io/controller-runtime/pkg/reconcile"
+)
+
+const PluralCrossplaneClusterFinalizer = "deployments.plural.sh/plural-crossplane-cluster-protection"
+
+type PluralCrossplaneClusterController struct {
+ k8sClient.Client
+ Scheme *runtime.Scheme
+ ConsoleUrl string
+
+ userGroupCache cache.UserGroupCache
+ consoleClient client.Client
+}
+
+func (in *PluralCrossplaneClusterController) Reconcile(ctx context.Context, req ctrl.Request) (_ reconcile.Result, reterr error) {
+ logger := log.FromContext(ctx)
+
+ pluralCrossplaneCluster := &v1alpha1.PluralCrossplaneCluster{}
+
+ if err := in.Get(ctx, req.NamespacedName, pluralCrossplaneCluster); err != nil {
+ logger.Info("Unable to fetch PluralCrossplaneCluster")
+ return ctrl.Result{}, k8sClient.IgnoreNotFound(err)
+ }
+
+ utils.MarkCondition(pluralCrossplaneCluster.SetCondition, v1alpha1.ReadyConditionType, metav1.ConditionFalse, v1alpha1.ReadyConditionReason, "")
+
+ scope, err := NewDefaultScope(ctx, in.Client, pluralCrossplaneCluster)
+ if err != nil {
+ logger.Error(err, "failed to create scope")
+ utils.MarkCondition(pluralCrossplaneCluster.SetCondition, v1alpha1.ReadyConditionType, metav1.ConditionFalse, v1alpha1.ReadyConditionReason, err.Error())
+ return ctrl.Result{}, err
+ }
+
+ // Always patch object when exiting this function, so we can persist any object changes.
+ defer func() {
+ if err := scope.PatchObject(); err != nil && reterr == nil {
+ reterr = err
+ }
+ }()
+
+ // Handle resource deletion both in Kubernetes cluster and in Console API.
+ result, err := in.addOrRemoveFinalizer(ctx, pluralCrossplaneCluster)
+ if result != nil {
+ return *result, err
+ }
+
+ // Synchronize the console token to make sure it is available
+ consoleToken, err := pluralCrossplaneCluster.GetConsoleToken(ctx, in.Client)
+ if err != nil {
+ if apierrors.IsNotFound(err) {
+ utils.MarkCondition(pluralCrossplaneCluster.SetCondition, v1alpha1.ReadyConditionType, metav1.ConditionFalse, v1alpha1.ReadyConditionReason, "waiting for console token secret")
+ return jitterRequeue(requeueAfter, jitter), nil
+ }
+ logger.Error(err, "failed to get console token from secret")
+ utils.MarkCondition(pluralCrossplaneCluster.SetCondition, v1alpha1.ReadyConditionType, metav1.ConditionFalse, v1alpha1.ReadyConditionReason, err.Error())
+ return ctrl.Result{}, err
+ }
+ consoleToken = strings.TrimSpace(consoleToken)
+
+ changed, sha, err := pluralCrossplaneCluster.Diff(utils.HashObject)
+ if err != nil {
+ logger.Error(err, "unable to calculate PluralCrossplaneCluster SHA")
+ utils.MarkCondition(pluralCrossplaneCluster.SetCondition, v1alpha1.SynchronizedConditionType, metav1.ConditionFalse, v1alpha1.ErrorConditionReason, err.Error())
+ return ctrl.Result{}, err
+ }
+
+ if pluralCrossplaneCluster.Status.HasID() && !changed {
+ // Cluster already synchronized
+ utils.MarkCondition(pluralCrossplaneCluster.SetCondition, v1alpha1.ReadyConditionType, metav1.ConditionTrue, v1alpha1.ReadyConditionReason, "")
+ return ctrl.Result{}, nil
+ }
+
+ crossplaneCluster, err := crossplane.GetCluster(ctx, in.Client, pluralCrossplaneCluster.Spec.CrossplaneClusterRef)
+ if err != nil {
+ if errors.Is(err, crossplane.ErrUnsupportedProvider) {
+ logger.Error(err, "unsupported Crossplane cluster provider")
+ utils.MarkCondition(pluralCrossplaneCluster.SetCondition, v1alpha1.ReadyConditionType, metav1.ConditionFalse, v1alpha1.ReadyConditionReason, err.Error())
+ return jitterRequeue(requeueAfter, jitter), nil
+ }
+ if crossplane.IsClusterNotFound(err) {
+ logger.Info("Crossplane managed cluster not found or CRD not installed yet")
+ utils.MarkCondition(pluralCrossplaneCluster.SetCondition, v1alpha1.ReadyConditionType, metav1.ConditionFalse, v1alpha1.ReadyConditionReason, "waiting for Crossplane cluster to be created")
+ return jitterRequeue(requeueAfter, jitter), nil
+ }
+
+ logger.Error(err, "failed to get Crossplane managed cluster")
+ utils.MarkCondition(pluralCrossplaneCluster.SetCondition, v1alpha1.ReadyConditionType, metav1.ConditionFalse, v1alpha1.ReadyConditionReason, err.Error())
+ return ctrl.Result{}, err
+ }
+ if !crossplane.IsReady(crossplaneCluster) {
+ utils.MarkCondition(pluralCrossplaneCluster.SetCondition, v1alpha1.ReadyConditionType, metav1.ConditionFalse, v1alpha1.ReadyConditionReason, "waiting for Crossplane cluster to be ready")
+ return jitterRequeue(requeueAfter, jitter), nil
+ }
+
+ kubeconfig, err := crossplane.GetKubeconfig(ctx, in.Client, crossplaneCluster, connectionSecretNamespace(pluralCrossplaneCluster))
+ if err != nil {
+ if apierrors.IsNotFound(err) {
+ utils.MarkCondition(pluralCrossplaneCluster.SetCondition, v1alpha1.ReadyConditionType, metav1.ConditionFalse, v1alpha1.ReadyConditionReason, "waiting for Crossplane connection secret")
+ return jitterRequeue(requeueAfter, jitter), nil
+ }
+ logger.Error(err, "failed to read Crossplane connection secret")
+ utils.MarkCondition(pluralCrossplaneCluster.SetCondition, v1alpha1.ReadyConditionType, metav1.ConditionFalse, v1alpha1.ReadyConditionReason, err.Error())
+ return ctrl.Result{}, err
+ }
+
+ if err := in.initConsoleClient(consoleToken); err != nil {
+ logger.Error(err, "Unable to initialize console client")
+ utils.MarkCondition(pluralCrossplaneCluster.SetCondition, v1alpha1.ReadyConditionType, metav1.ConditionFalse, v1alpha1.ReadyConditionReason, err.Error())
+ return ctrl.Result{}, err
+ }
+
+ consoleClusterID, deployToken, err := in.syncConsoleCluster(pluralCrossplaneCluster)
+ if err != nil {
+ logger.Error(err, "failed to sync console cluster")
+ utils.MarkCondition(pluralCrossplaneCluster.SetCondition, v1alpha1.ReadyConditionType, metav1.ConditionFalse, v1alpha1.ReadyConditionReason, err.Error())
+ if apierrors.IsNotFound(err) {
+ return jitterRequeue(requeueAfter, jitter), nil
+ }
+ return ctrl.Result{}, err
+ }
+
+ if err = in.deployAgent(pluralCrossplaneCluster, kubeconfig, deployToken); err != nil {
+ if !strings.Contains(err.Error(), "another operation (install/upgrade/rollback) is in progress") {
+ logger.Error(err, "failed to deploy agent")
+ return reconcile.Result{}, err
+ }
+ }
+
+ pluralCrossplaneCluster.Status.SHA = lo.ToPtr(sha)
+ pluralCrossplaneCluster.Status.ID = lo.ToPtr(consoleClusterID)
+ utils.MarkCondition(pluralCrossplaneCluster.SetCondition, v1alpha1.ReadyConditionType, metav1.ConditionTrue, v1alpha1.ReadyConditionReason, "")
+
+ return ctrl.Result{}, nil
+}
+
+func (in *PluralCrossplaneClusterController) deployAgent(cluster *v1alpha1.PluralCrossplaneCluster, kubeconfig []byte, deployToken string) error {
+ url, err := sanitizeURL(in.ConsoleUrl)
+ if err != nil {
+ return err
+ }
+ values := map[string]any{
+ "secrets": map[string]string{
+ "deployToken": deployToken,
+ },
+ "consoleUrl": fmt.Sprintf("%s/ext/gql", url),
+ }
+
+ deployer, err := helm.New(
+ helm.WithReleaseName(v1alpha1.AgentDefaultReleaseName),
+ helm.WithReleaseNamespace(v1alpha1.AgentDefaultNamespace),
+ helm.WithRepository(cluster.Spec.GetAgent().GetRepoUrl()),
+ helm.WithChartName(cluster.Spec.GetAgent().GetChartName()),
+ helm.WithKubeconfig(string(kubeconfig)),
+ helm.WithValues(values),
+ )
+ if err != nil {
+ return err
+ }
+
+ return deployer.Upgrade(true)
+}
+
+func (in *PluralCrossplaneClusterController) syncConsoleCluster(cluster *v1alpha1.PluralCrossplaneCluster) (id, token string, err error) {
+ err = in.ensureCluster(cluster)
+ if err != nil {
+ return
+ }
+ existingConsoleCluster, err := in.consoleClient.GetClusterByHandle(cluster.ClusterName())
+ if err != nil {
+ if apierrors.IsNotFound(err) {
+ newConsoleCluster, err := in.consoleClient.CreateCluster(cluster.Attributes())
+ if err != nil {
+ return "", "", err
+ }
+ if newConsoleCluster.CreateCluster.DeployToken == nil {
+ return "", "", fmt.Errorf("could not fetch deploy token from cluster")
+ }
+ return newConsoleCluster.CreateCluster.ID, lo.FromPtr(newConsoleCluster.CreateCluster.DeployToken), nil
+ }
+ return
+ }
+ id = existingConsoleCluster.ID
+ token, err = in.consoleClient.GetDeployToken(&id, nil)
+ if err != nil {
+ return
+ }
+ err = in.consoleClient.UpdateCluster(id, cluster.UpdateAttributes())
+
+ return
+}
+
+func (in *PluralCrossplaneClusterController) ensureCluster(cluster *v1alpha1.PluralCrossplaneCluster) error {
+ if cluster.Spec.Cluster == nil || cluster.Spec.Cluster.Bindings == nil {
+ return nil
+ }
+
+ bindings, req, err := ensureBindings(cluster.Spec.Cluster.Bindings.Read, in.userGroupCache)
+ if err != nil {
+ return err
+ }
+
+ cluster.Spec.Cluster.Bindings.Read = bindings
+
+ bindings, req2, err := ensureBindings(cluster.Spec.Cluster.Bindings.Write, in.userGroupCache)
+ if err != nil {
+ return err
+ }
+
+ cluster.Spec.Cluster.Bindings.Write = bindings
+
+ if req || req2 {
+ return apierrors.NewNotFound(schema.GroupResource{}, "bindings not yet resolved")
+ }
+
+ return nil
+}
+
+func connectionSecretNamespace(cluster *v1alpha1.PluralCrossplaneCluster) string {
+ if ns := cluster.Spec.CrossplaneClusterRef.Namespace; ns != "" {
+ return ns
+ }
+
+ return cluster.Namespace
+}
+
+func (in *PluralCrossplaneClusterController) SetupWithManager(mgr ctrl.Manager) error {
+ return ctrl.NewControllerManagedBy(mgr).
+ WithOptions(controller.Options{MaxConcurrentReconciles: 1}).
+ For(&v1alpha1.PluralCrossplaneCluster{}).
+ Complete(in)
+}
+
+func (in *PluralCrossplaneClusterController) addOrRemoveFinalizer(ctx context.Context, cluster *v1alpha1.PluralCrossplaneCluster) (*ctrl.Result, error) {
+ if cluster.GetDeletionTimestamp().IsZero() {
+ if !controllerutil.ContainsFinalizer(cluster, PluralCrossplaneClusterFinalizer) {
+ controllerutil.AddFinalizer(cluster, PluralCrossplaneClusterFinalizer)
+ }
+ return nil, nil
+ }
+ if !cluster.Status.HasID() {
+ controllerutil.RemoveFinalizer(cluster, PluralCrossplaneClusterFinalizer)
+ return nil, nil
+ }
+ if in.consoleClient == nil {
+ consoleToken, err := cluster.GetConsoleToken(ctx, in.Client)
+ if err != nil {
+ return nil, err
+ }
+ consoleToken = strings.TrimSpace(consoleToken)
+ if err := in.initConsoleClient(consoleToken); err != nil {
+ return nil, err
+ }
+ }
+ err := in.consoleClient.DetachCluster(cluster.Status.GetID())
+ if err != nil && !internalerrors.IsNotFound(err) {
+ return nil, err
+ }
+
+ controllerutil.RemoveFinalizer(cluster, PluralCrossplaneClusterFinalizer)
+ return nil, nil
+}
+
+func (in *PluralCrossplaneClusterController) initConsoleClient(consoleToken string) error {
+ if in.consoleClient == nil {
+ url, err := sanitizeURL(in.ConsoleUrl)
+ if err != nil {
+ return err
+ }
+ in.consoleClient = client.New(fmt.Sprintf("%s/gql", url), consoleToken)
+ in.userGroupCache = cache.NewUserGroupCache(in.consoleClient)
+ }
+ return nil
+}
diff --git a/go/deployment-operator/internal/crossplane/aws_cluster_auth.go b/go/deployment-operator/internal/crossplane/aws_cluster_auth.go
new file mode 100644
index 0000000000..a5657b792d
--- /dev/null
+++ b/go/deployment-operator/internal/crossplane/aws_cluster_auth.go
@@ -0,0 +1,86 @@
+package crossplane
+
+import (
+ "context"
+
+ xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1"
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+ k8sClient "sigs.k8s.io/controller-runtime/pkg/client"
+)
+
+var awsEKSUpboundClusterAuthGVK = schema.GroupVersion{Group: "eks.aws.upbound.io", Version: "v1beta1"}.WithKind("ClusterAuth")
+
+func hydrateAWSClusterConnectionSpecFromClusterAuth(ctx context.Context, c k8sClient.Client, cluster *AWSCluster, gv schema.GroupVersion) error {
+ if cluster == nil || HasConnectionSecretConfig(cluster) {
+ return nil
+ }
+
+ ref, ok, err := clusterAuthConnectionSecretRef(ctx, c, gv, cluster.GetName(), cluster.GetNamespace())
+ if err != nil {
+ return err
+ }
+ if !ok {
+ return nil
+ }
+
+ cluster.Spec.WriteConnectionSecretToReference = ref
+ return nil
+}
+
+func clusterAuthConnectionSecretRef(ctx context.Context, c k8sClient.Client, gv schema.GroupVersion, clusterName, namespace string) (*xpv1.SecretReference, bool, error) {
+ authGV := gv
+ if authGV.Group == awsEKSUpboundClusterV1Beta2GVK.Group {
+ authGV.Version = awsEKSUpboundClusterAuthGVK.Version
+ }
+
+ list := &unstructured.UnstructuredList{}
+ list.SetGroupVersionKind(authGV.WithKind("ClusterAuthList"))
+
+ listOpts := make([]k8sClient.ListOption, 0, 1)
+ if namespace != "" {
+ listOpts = append(listOpts, k8sClient.InNamespace(namespace))
+ }
+ if err := c.List(ctx, list, listOpts...); err != nil {
+ return nil, false, err
+ }
+
+ for i := range list.Items {
+ item := &list.Items[i]
+ if !clusterAuthTargetsCluster(item, clusterName) {
+ continue
+ }
+
+ name, found, err := unstructured.NestedString(item.Object, "spec", "writeConnectionSecretToRef", "name")
+ if err != nil || !found || name == "" {
+ continue
+ }
+
+ return &xpv1.SecretReference{Name: name, Namespace: namespace}, true, nil
+ }
+
+ return nil, false, nil
+}
+
+func clusterAuthTargetsCluster(obj *unstructured.Unstructured, clusterName string) bool {
+ if obj == nil {
+ return false
+ }
+
+ forProvider, found, err := unstructured.NestedMap(obj.Object, "spec", "forProvider")
+ if err != nil || !found {
+ return false
+ }
+
+ if name, ok := forProvider["clusterName"].(string); ok && name == clusterName {
+ return true
+ }
+
+ if ref, ok := forProvider["clusterNameRef"].(map[string]interface{}); ok {
+ if name, ok := ref["name"].(string); ok && name == clusterName {
+ return true
+ }
+ }
+
+ return false
+}
diff --git a/go/deployment-operator/internal/crossplane/aws_cluster_auth_test.go b/go/deployment-operator/internal/crossplane/aws_cluster_auth_test.go
new file mode 100644
index 0000000000..d3aa30c833
--- /dev/null
+++ b/go/deployment-operator/internal/crossplane/aws_cluster_auth_test.go
@@ -0,0 +1,26 @@
+package crossplane
+
+import (
+ "testing"
+
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+)
+
+func TestClusterAuthTargetsCluster(t *testing.T) {
+ obj := &unstructured.Unstructured{Object: map[string]interface{}{
+ "spec": map[string]interface{}{
+ "forProvider": map[string]interface{}{
+ "clusterNameRef": map[string]interface{}{
+ "name": "cp-eks-small",
+ },
+ },
+ },
+ }}
+
+ if !clusterAuthTargetsCluster(obj, "cp-eks-small") {
+ t.Fatal("expected cluster auth to target cluster")
+ }
+ if clusterAuthTargetsCluster(obj, "other") {
+ t.Fatal("expected cluster auth not to target other cluster")
+ }
+}
diff --git a/go/deployment-operator/internal/crossplane/aws_eks.go b/go/deployment-operator/internal/crossplane/aws_eks.go
new file mode 100644
index 0000000000..92ddcaecbc
--- /dev/null
+++ b/go/deployment-operator/internal/crossplane/aws_eks.go
@@ -0,0 +1,182 @@
+package crossplane
+
+import (
+ "context"
+
+ xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1"
+ corev1 "k8s.io/api/core/v1"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+ k8sClient "sigs.k8s.io/controller-runtime/pkg/client"
+)
+
+var (
+ awsEKSClusterGVK = schema.GroupVersion{Group: "eks.aws.crossplane.io", Version: "v1beta1"}.WithKind("Cluster")
+ awsEKSUpboundClusterGVK = schema.GroupVersion{Group: "eks.aws.upbound.io", Version: "v1beta1"}.WithKind("Cluster")
+ awsEKSUpboundClusterV1Beta2GVK = schema.GroupVersion{Group: "eks.aws.upbound.io", Version: "v1beta2"}.WithKind("Cluster")
+ awsEKSClusterListGVK = awsEKSClusterGVK.GroupVersion().WithKind("ClusterList")
+ awsEKSUpboundClusterListGVK = awsEKSUpboundClusterGVK.GroupVersion().WithKind("ClusterList")
+ awsEKSUpboundClusterV1Beta2ListGVK = awsEKSUpboundClusterV1Beta2GVK.GroupVersion().WithKind("ClusterList")
+)
+
+func isAWSEKSClusterGVK(gvk schema.GroupVersionKind) bool {
+ if gvk.Kind != "Cluster" {
+ return false
+ }
+
+ switch gvk.Group {
+ case awsEKSClusterGVK.Group:
+ return gvk.Version == awsEKSClusterGVK.Version
+ case awsEKSUpboundClusterGVK.Group:
+ return gvk.Version == awsEKSUpboundClusterGVK.Version || gvk.Version == awsEKSUpboundClusterV1Beta2GVK.Version
+ default:
+ return false
+ }
+}
+
+func getAWSCluster(ctx context.Context, c k8sClient.Client, ref corev1.ObjectReference) (*AWSCluster, error) {
+ gvk, err := clusterRefGVK(ref)
+ if err != nil {
+ return nil, err
+ }
+
+ raw := &unstructured.Unstructured{}
+ raw.SetGroupVersionKind(gvk)
+ if err := c.Get(ctx, k8sClient.ObjectKey{Name: ref.Name}, raw); err != nil {
+ return nil, err
+ }
+
+ cluster := &AWSCluster{}
+ if err := runtime.DefaultUnstructuredConverter.FromUnstructured(raw.Object, cluster); err != nil {
+ return nil, err
+ }
+ cluster.SetGroupVersionKind(raw.GroupVersionKind())
+ if err := hydrateAWSClusterConnectionSpecFromClusterAuth(ctx, c, cluster, raw.GroupVersionKind().GroupVersion()); err != nil {
+ return nil, err
+ }
+
+ return cluster, nil
+}
+
+// AWSCluster mirrors github.com/crossplane-contrib/provider-aws/apis/eks/v1beta1 Cluster.
+// Only fields required for readiness and connection secret resolution are modeled.
+//
+// +kubebuilder:object:generate=false
+type AWSCluster struct {
+ metav1.TypeMeta `json:",inline"`
+ metav1.ObjectMeta `json:"metadata,omitempty"`
+
+ Spec AWSClusterSpec `json:"spec"`
+ Status AWSClusterStatus `json:"status,omitempty"`
+}
+
+type AWSClusterSpec struct {
+ xpv1.ResourceSpec `json:",inline"`
+ ForProvider AWSClusterForProvider `json:"forProvider,omitempty"`
+}
+
+type AWSClusterForProvider struct {
+ Region string `json:"region,omitempty"`
+}
+
+type AWSClusterStatus struct {
+ xpv1.ResourceStatus `json:",inline"`
+ AtProvider AWSClusterAtProvider `json:"atProvider,omitempty"`
+}
+
+type AWSClusterAtProvider struct {
+ Endpoint string `json:"endpoint,omitempty"`
+ Region string `json:"region,omitempty"`
+ ID string `json:"id,omitempty"`
+ CertificateAuthority []AWSClusterCertificateAuthority `json:"certificateAuthority,omitempty"`
+}
+
+type AWSClusterCertificateAuthority struct {
+ Data string `json:"data,omitempty"`
+}
+
+// AWSClusterList contains a list of AWSCluster.
+//
+// +kubebuilder:object:generate=false
+type AWSClusterList struct {
+ metav1.TypeMeta `json:",inline"`
+ metav1.ListMeta `json:"metadata,omitempty"`
+ Items []AWSCluster `json:"items"`
+}
+
+func (in *AWSCluster) GetWriteConnectionSecretToReference() *xpv1.SecretReference {
+ if in == nil {
+ return nil
+ }
+
+ return in.Spec.WriteConnectionSecretToReference
+}
+
+func (in *AWSCluster) GetPublishConnectionDetailsTo() *xpv1.PublishConnectionDetailsTo {
+ if in == nil {
+ return nil
+ }
+
+ return in.Spec.PublishConnectionDetailsTo
+}
+
+func (in *AWSCluster) GetCondition(ct xpv1.ConditionType) xpv1.Condition {
+ if in == nil {
+ return xpv1.Condition{}
+ }
+
+ return in.Status.GetCondition(ct)
+}
+
+func (in *AWSCluster) DeepCopyObject() runtime.Object {
+ if in == nil {
+ return nil
+ }
+
+ out := &AWSCluster{}
+ in.DeepCopyInto(out)
+ return out
+}
+
+func (in *AWSCluster) DeepCopyInto(out *AWSCluster) {
+ *out = *in
+ out.TypeMeta = in.TypeMeta
+ in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
+ out.Spec = in.Spec
+ out.Status = in.Status
+}
+
+func (in *AWSClusterList) DeepCopyObject() runtime.Object {
+ if in == nil {
+ return nil
+ }
+
+ out := &AWSClusterList{}
+ in.DeepCopyInto(out)
+ return out
+}
+
+func (in *AWSClusterList) DeepCopyInto(out *AWSClusterList) {
+ *out = *in
+ out.TypeMeta = in.TypeMeta
+ in.ListMeta.DeepCopyInto(&out.ListMeta)
+ if in.Items != nil {
+ in, out := &in.Items, &out.Items
+ *out = make([]AWSCluster, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
+}
+
+func (in *AWSClusterList) DeepCopy() *AWSClusterList {
+ if in == nil {
+ return nil
+ }
+
+ out := &AWSClusterList{}
+ in.DeepCopyInto(out)
+ return out
+}
diff --git a/go/deployment-operator/internal/crossplane/azure_aks.go b/go/deployment-operator/internal/crossplane/azure_aks.go
new file mode 100644
index 0000000000..69c9ad906f
--- /dev/null
+++ b/go/deployment-operator/internal/crossplane/azure_aks.go
@@ -0,0 +1,150 @@
+package crossplane
+
+import (
+ "context"
+
+ xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+ k8sClient "sigs.k8s.io/controller-runtime/pkg/client"
+
+ corev1 "k8s.io/api/core/v1"
+)
+
+var (
+ azureAKSClusterGVK = schema.GroupVersion{Group: "containerservice.azure.upbound.io", Version: "v1beta1"}.WithKind("KubernetesCluster")
+ azureAKSClusterV2GVK = schema.GroupVersion{Group: "containerservice.azure.upbound.io", Version: "v1beta2"}.WithKind("KubernetesCluster")
+ azureAKSClusterListGVK = azureAKSClusterGVK.GroupVersion().WithKind("KubernetesClusterList")
+ azureAKSClusterV2ListGVK = azureAKSClusterV2GVK.GroupVersion().WithKind("KubernetesClusterList")
+)
+
+func isAzureAKSClusterGVK(gvk schema.GroupVersionKind) bool {
+ if gvk.Kind != "KubernetesCluster" {
+ return false
+ }
+ if gvk.Group != azureAKSClusterGVK.Group {
+ return false
+ }
+ return gvk.Version == azureAKSClusterGVK.Version || gvk.Version == azureAKSClusterV2GVK.Version
+}
+
+func getAzureAKSCluster(ctx context.Context, c k8sClient.Client, ref corev1.ObjectReference) (*AzureAKSCluster, error) {
+ gvk, err := clusterRefGVK(ref)
+ if err != nil {
+ return nil, err
+ }
+
+ raw := &unstructured.Unstructured{}
+ raw.SetGroupVersionKind(gvk)
+ if err := c.Get(ctx, k8sClient.ObjectKey{Name: ref.Name}, raw); err != nil {
+ return nil, err
+ }
+
+ cluster := &AzureAKSCluster{}
+ if err := runtime.DefaultUnstructuredConverter.FromUnstructured(raw.Object, cluster); err != nil {
+ return nil, err
+ }
+ cluster.SetGroupVersionKind(raw.GroupVersionKind())
+
+ return cluster, nil
+}
+
+// AzureAKSCluster mirrors containerservice.azure.upbound.io KubernetesCluster.
+// Only fields required for readiness and connection secret resolution are modeled.
+//
+// +kubebuilder:object:generate=false
+type AzureAKSCluster struct {
+ metav1.TypeMeta `json:",inline"`
+ metav1.ObjectMeta `json:"metadata,omitempty"`
+
+ Spec AzureAKSClusterSpec `json:"spec"`
+ Status AzureAKSClusterStatus `json:"status,omitempty"`
+}
+
+type AzureAKSClusterSpec struct {
+ xpv1.ResourceSpec `json:",inline"`
+}
+
+type AzureAKSClusterStatus struct {
+ xpv1.ResourceStatus `json:",inline"`
+}
+
+// AzureAKSClusterList contains a list of AzureAKSCluster.
+//
+// +kubebuilder:object:generate=false
+type AzureAKSClusterList struct {
+ metav1.TypeMeta `json:",inline"`
+ metav1.ListMeta `json:"metadata,omitempty"`
+ Items []AzureAKSCluster `json:"items"`
+}
+
+func (in *AzureAKSCluster) GetWriteConnectionSecretToReference() *xpv1.SecretReference {
+ if in == nil {
+ return nil
+ }
+ return in.Spec.WriteConnectionSecretToReference
+}
+
+func (in *AzureAKSCluster) GetPublishConnectionDetailsTo() *xpv1.PublishConnectionDetailsTo {
+ if in == nil {
+ return nil
+ }
+ return in.Spec.PublishConnectionDetailsTo
+}
+
+func (in *AzureAKSCluster) GetCondition(ct xpv1.ConditionType) xpv1.Condition {
+ if in == nil {
+ return xpv1.Condition{}
+ }
+ return in.Status.GetCondition(ct)
+}
+
+func (in *AzureAKSCluster) DeepCopyObject() runtime.Object {
+ if in == nil {
+ return nil
+ }
+ out := &AzureAKSCluster{}
+ in.DeepCopyInto(out)
+ return out
+}
+
+func (in *AzureAKSCluster) DeepCopyInto(out *AzureAKSCluster) {
+ *out = *in
+ out.TypeMeta = in.TypeMeta
+ in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
+ out.Spec = in.Spec
+ out.Status = in.Status
+}
+
+func (in *AzureAKSClusterList) DeepCopyObject() runtime.Object {
+ if in == nil {
+ return nil
+ }
+ out := &AzureAKSClusterList{}
+ in.DeepCopyInto(out)
+ return out
+}
+
+func (in *AzureAKSClusterList) DeepCopyInto(out *AzureAKSClusterList) {
+ *out = *in
+ out.TypeMeta = in.TypeMeta
+ in.ListMeta.DeepCopyInto(&out.ListMeta)
+ if in.Items != nil {
+ in, out := &in.Items, &out.Items
+ *out = make([]AzureAKSCluster, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
+}
+
+func (in *AzureAKSClusterList) DeepCopy() *AzureAKSClusterList {
+ if in == nil {
+ return nil
+ }
+ out := &AzureAKSClusterList{}
+ in.DeepCopyInto(out)
+ return out
+}
diff --git a/go/deployment-operator/internal/crossplane/cluster.go b/go/deployment-operator/internal/crossplane/cluster.go
new file mode 100644
index 0000000000..1a401c38ec
--- /dev/null
+++ b/go/deployment-operator/internal/crossplane/cluster.go
@@ -0,0 +1,118 @@
+package crossplane
+
+import (
+ "context"
+ "fmt"
+
+ xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1"
+ corev1 "k8s.io/api/core/v1"
+ apierrors "k8s.io/apimachinery/pkg/api/errors"
+ "k8s.io/apimachinery/pkg/api/meta"
+ k8sClient "sigs.k8s.io/controller-runtime/pkg/client"
+)
+
+var kubeconfigSecretKeys = []string{
+ "kubeconfig",
+ "kubeconfig.json",
+ "attribute.kubeconfig",
+}
+
+// ManagedCluster is implemented by Crossplane provider Cluster managed resources.
+type ManagedCluster interface {
+ k8sClient.Object
+ GetWriteConnectionSecretToReference() *xpv1.SecretReference
+ GetPublishConnectionDetailsTo() *xpv1.PublishConnectionDetailsTo
+ GetCondition(ct xpv1.ConditionType) xpv1.Condition
+}
+
+// IsReady reports whether the managed cluster Ready condition is True.
+func IsReady(cluster ManagedCluster) bool {
+ return cluster.GetCondition(xpv1.TypeReady).Status == corev1.ConditionTrue
+}
+
+// HasConnectionSecretConfig reports whether the managed resource publishes connection details to a secret.
+func HasConnectionSecretConfig(cluster ManagedCluster) bool {
+ if cluster == nil {
+ return false
+ }
+
+ if ref := cluster.GetWriteConnectionSecretToReference(); ref != nil && ref.Name != "" {
+ return true
+ }
+
+ if publishRef := cluster.GetPublishConnectionDetailsTo(); publishRef != nil && publishRef.Name != "" {
+ return true
+ }
+
+ return false
+}
+
+// ConnectionSecretRef resolves the connection secret target from a managed cluster.
+func ConnectionSecretRef(cluster ManagedCluster, defaultNamespace string) (namespace, name string, err error) {
+ ref := cluster.GetWriteConnectionSecretToReference()
+ if ref != nil && ref.Name != "" {
+ namespace = ref.Namespace
+ if namespace == "" {
+ namespace = cluster.GetNamespace()
+ }
+ if namespace == "" {
+ namespace = defaultNamespace
+ }
+ if namespace == "" {
+ return "", "", fmt.Errorf("spec.writeConnectionSecretToRef.namespace is required for cluster-scoped managed resources")
+ }
+
+ return namespace, ref.Name, nil
+ }
+
+ publishRef := cluster.GetPublishConnectionDetailsTo()
+ if publishRef != nil && publishRef.Name != "" {
+ namespace = cluster.GetNamespace()
+ if namespace == "" {
+ namespace = defaultNamespace
+ }
+ if namespace == "" {
+ return "", "", fmt.Errorf("publishConnectionDetailsTo requires a target namespace for cluster-scoped managed resources")
+ }
+
+ return namespace, publishRef.Name, nil
+ }
+
+ return "", "", fmt.Errorf("spec.writeConnectionSecretToRef or spec.publishConnectionDetailsTo is not set")
+}
+
+// KubeconfigFromConnectionSecret extracts kubeconfig bytes from a Crossplane connection secret.
+func KubeconfigFromConnectionSecret(secret *corev1.Secret) ([]byte, error) {
+ for _, key := range kubeconfigSecretKeys {
+ if data, ok := secret.Data[key]; ok && len(data) > 0 {
+ return data, nil
+ }
+ }
+
+ return nil, fmt.Errorf("connection secret %s/%s does not contain kubeconfig", secret.Namespace, secret.Name)
+}
+
+// GetKubeconfig loads kubeconfig bytes from the connection secret referenced by the Cluster.
+func GetKubeconfig(ctx context.Context, c k8sClient.Client, cluster ManagedCluster, defaultNamespace string) ([]byte, error) {
+ namespace, name, err := ConnectionSecretRef(cluster, defaultNamespace)
+ if err != nil {
+ return nil, err
+ }
+
+ return GetKubeconfigFromSecret(ctx, c, namespace, name)
+}
+
+// GetKubeconfigFromSecret loads kubeconfig bytes from an existing secret.
+func GetKubeconfigFromSecret(ctx context.Context, c k8sClient.Client, namespace, name string) ([]byte, error) {
+ secret := &corev1.Secret{}
+ if err := c.Get(ctx, k8sClient.ObjectKey{Namespace: namespace, Name: name}, secret); err != nil {
+ return nil, err
+ }
+
+ return KubeconfigFromConnectionSecret(secret)
+}
+
+// IsClusterNotFound reports whether the error indicates the managed cluster CRD or object is missing.
+func IsClusterNotFound(err error) bool {
+ return apierrors.IsNotFound(err) || meta.IsNoMatchError(err)
+}
diff --git a/go/deployment-operator/internal/crossplane/cluster_test.go b/go/deployment-operator/internal/crossplane/cluster_test.go
new file mode 100644
index 0000000000..ed719c3afc
--- /dev/null
+++ b/go/deployment-operator/internal/crossplane/cluster_test.go
@@ -0,0 +1,141 @@
+package crossplane
+
+import (
+ "errors"
+ "testing"
+
+ xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1"
+ corev1 "k8s.io/api/core/v1"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+func TestIsReady(t *testing.T) {
+ cluster := &AWSCluster{}
+ cluster.Status.SetConditions(xpv1.Available())
+
+ if !IsReady(cluster) {
+ t.Fatal("expected managed cluster to be ready")
+ }
+}
+
+func TestConnectionSecretRef(t *testing.T) {
+ cluster := &AWSCluster{
+ ObjectMeta: metav1.ObjectMeta{
+ Namespace: "infra",
+ },
+ Spec: AWSClusterSpec{
+ ResourceSpec: xpv1.ResourceSpec{
+ WriteConnectionSecretToReference: &xpv1.SecretReference{
+ Name: "eks-kubeconfig",
+ Namespace: "secrets",
+ },
+ },
+ },
+ }
+
+ namespace, name, err := ConnectionSecretRef(cluster, "")
+ if err != nil {
+ t.Fatalf("ConnectionSecretRef() error = %v", err)
+ }
+ if namespace != "secrets" || name != "eks-kubeconfig" {
+ t.Fatalf("got namespace=%q name=%q", namespace, name)
+ }
+}
+
+func TestConnectionSecretRefDefaultsNamespace(t *testing.T) {
+ cluster := &AWSCluster{
+ ObjectMeta: metav1.ObjectMeta{
+ Namespace: "infra",
+ },
+ Spec: AWSClusterSpec{
+ ResourceSpec: xpv1.ResourceSpec{
+ WriteConnectionSecretToReference: &xpv1.SecretReference{
+ Name: "eks-kubeconfig",
+ },
+ },
+ },
+ }
+
+ namespace, name, err := ConnectionSecretRef(cluster, "")
+ if err != nil {
+ t.Fatalf("ConnectionSecretRef() error = %v", err)
+ }
+ if namespace != "infra" || name != "eks-kubeconfig" {
+ t.Fatalf("got namespace=%q name=%q", namespace, name)
+ }
+}
+
+func TestConnectionSecretRefClusterScopedUsesDefaultNamespace(t *testing.T) {
+ cluster := &AWSCluster{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "cp-eks-small",
+ },
+ Spec: AWSClusterSpec{
+ ResourceSpec: xpv1.ResourceSpec{
+ WriteConnectionSecretToReference: &xpv1.SecretReference{
+ Name: "cp-eks-small-kubeconfig",
+ },
+ },
+ },
+ }
+
+ namespace, name, err := ConnectionSecretRef(cluster, "default")
+ if err != nil {
+ t.Fatalf("ConnectionSecretRef() error = %v", err)
+ }
+ if namespace != "default" || name != "cp-eks-small-kubeconfig" {
+ t.Fatalf("got namespace=%q name=%q", namespace, name)
+ }
+}
+
+func TestConnectionSecretRefPublishConnectionDetailsTo(t *testing.T) {
+ cluster := &AWSCluster{
+ ObjectMeta: metav1.ObjectMeta{
+ Name: "cp-eks-small",
+ },
+ Spec: AWSClusterSpec{
+ ResourceSpec: xpv1.ResourceSpec{
+ PublishConnectionDetailsTo: &xpv1.PublishConnectionDetailsTo{
+ Name: "cp-eks-small-kubeconfig",
+ },
+ },
+ },
+ }
+
+ namespace, name, err := ConnectionSecretRef(cluster, "default")
+ if err != nil {
+ t.Fatalf("ConnectionSecretRef() error = %v", err)
+ }
+ if namespace != "default" || name != "cp-eks-small-kubeconfig" {
+ t.Fatalf("got namespace=%q name=%q", namespace, name)
+ }
+}
+
+func TestKubeconfigFromConnectionSecret(t *testing.T) {
+ secret := &corev1.Secret{
+ ObjectMeta: metav1.ObjectMeta{Name: "conn", Namespace: "infra"},
+ Data: map[string][]byte{
+ "kubeconfig": []byte("apiVersion: v1\n"),
+ },
+ }
+
+ kubeconfig, err := KubeconfigFromConnectionSecret(secret)
+ if err != nil {
+ t.Fatalf("KubeconfigFromConnectionSecret() error = %v", err)
+ }
+ if string(kubeconfig) != "apiVersion: v1\n" {
+ t.Fatalf("unexpected kubeconfig: %q", string(kubeconfig))
+ }
+}
+
+func TestGetClusterUnsupportedProvider(t *testing.T) {
+ _, err := GetCluster(t.Context(), nil, corev1.ObjectReference{
+ APIVersion: "container.gcp.crossplane.io/v1beta1",
+ Kind: "Cluster",
+ Name: "gke",
+ Namespace: "infra",
+ })
+ if !errors.Is(err, ErrUnsupportedProvider) {
+ t.Fatalf("expected ErrUnsupportedProvider, got %v", err)
+ }
+}
diff --git a/go/deployment-operator/internal/crossplane/gke_cluster.go b/go/deployment-operator/internal/crossplane/gke_cluster.go
new file mode 100644
index 0000000000..5e9c75dbee
--- /dev/null
+++ b/go/deployment-operator/internal/crossplane/gke_cluster.go
@@ -0,0 +1,149 @@
+package crossplane
+
+import (
+ "context"
+
+ xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1"
+ corev1 "k8s.io/api/core/v1"
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
+ "k8s.io/apimachinery/pkg/runtime"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+ k8sClient "sigs.k8s.io/controller-runtime/pkg/client"
+)
+
+var (
+ gkeClusterGVK = schema.GroupVersion{Group: "container.gcp.upbound.io", Version: "v1beta1"}.WithKind("Cluster")
+ gkeClusterV2GVK = schema.GroupVersion{Group: "container.gcp.upbound.io", Version: "v1beta2"}.WithKind("Cluster")
+ gkeClusterListGVK = gkeClusterGVK.GroupVersion().WithKind("ClusterList")
+ gkeClusterV2ListGVK = gkeClusterV2GVK.GroupVersion().WithKind("ClusterList")
+)
+
+func isGKEClusterGVK(gvk schema.GroupVersionKind) bool {
+ if gvk.Kind != "Cluster" {
+ return false
+ }
+ if gvk.Group != gkeClusterGVK.Group {
+ return false
+ }
+ return gvk.Version == gkeClusterGVK.Version || gvk.Version == gkeClusterV2GVK.Version
+}
+
+func getGKECluster(ctx context.Context, c k8sClient.Client, ref corev1.ObjectReference) (*GKECluster, error) {
+ gvk, err := clusterRefGVK(ref)
+ if err != nil {
+ return nil, err
+ }
+
+ raw := &unstructured.Unstructured{}
+ raw.SetGroupVersionKind(gvk)
+ if err := c.Get(ctx, k8sClient.ObjectKey{Name: ref.Name}, raw); err != nil {
+ return nil, err
+ }
+
+ cluster := &GKECluster{}
+ if err := runtime.DefaultUnstructuredConverter.FromUnstructured(raw.Object, cluster); err != nil {
+ return nil, err
+ }
+ cluster.SetGroupVersionKind(raw.GroupVersionKind())
+
+ return cluster, nil
+}
+
+// GKECluster mirrors container.gcp.upbound.io Cluster.
+// Only fields required for readiness and connection secret resolution are modeled.
+//
+// +kubebuilder:object:generate=false
+type GKECluster struct {
+ metav1.TypeMeta `json:",inline"`
+ metav1.ObjectMeta `json:"metadata,omitempty"`
+
+ Spec GKEClusterSpec `json:"spec"`
+ Status GKEClusterStatus `json:"status,omitempty"`
+}
+
+type GKEClusterSpec struct {
+ xpv1.ResourceSpec `json:",inline"`
+}
+
+type GKEClusterStatus struct {
+ xpv1.ResourceStatus `json:",inline"`
+}
+
+// GKEClusterList contains a list of GKECluster.
+//
+// +kubebuilder:object:generate=false
+type GKEClusterList struct {
+ metav1.TypeMeta `json:",inline"`
+ metav1.ListMeta `json:"metadata,omitempty"`
+ Items []GKECluster `json:"items"`
+}
+
+func (in *GKECluster) GetWriteConnectionSecretToReference() *xpv1.SecretReference {
+ if in == nil {
+ return nil
+ }
+ return in.Spec.WriteConnectionSecretToReference
+}
+
+func (in *GKECluster) GetPublishConnectionDetailsTo() *xpv1.PublishConnectionDetailsTo {
+ if in == nil {
+ return nil
+ }
+ return in.Spec.PublishConnectionDetailsTo
+}
+
+func (in *GKECluster) GetCondition(ct xpv1.ConditionType) xpv1.Condition {
+ if in == nil {
+ return xpv1.Condition{}
+ }
+ return in.Status.GetCondition(ct)
+}
+
+func (in *GKECluster) DeepCopyObject() runtime.Object {
+ if in == nil {
+ return nil
+ }
+ out := &GKECluster{}
+ in.DeepCopyInto(out)
+ return out
+}
+
+func (in *GKECluster) DeepCopyInto(out *GKECluster) {
+ *out = *in
+ out.TypeMeta = in.TypeMeta
+ in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
+ out.Spec = in.Spec
+ out.Status = in.Status
+}
+
+func (in *GKEClusterList) DeepCopyObject() runtime.Object {
+ if in == nil {
+ return nil
+ }
+ out := &GKEClusterList{}
+ in.DeepCopyInto(out)
+ return out
+}
+
+func (in *GKEClusterList) DeepCopyInto(out *GKEClusterList) {
+ *out = *in
+ out.TypeMeta = in.TypeMeta
+ in.ListMeta.DeepCopyInto(&out.ListMeta)
+ if in.Items != nil {
+ in, out := &in.Items, &out.Items
+ *out = make([]GKECluster, len(*in))
+ for i := range *in {
+ (*in)[i].DeepCopyInto(&(*out)[i])
+ }
+ }
+}
+
+func (in *GKEClusterList) DeepCopy() *GKEClusterList {
+ if in == nil {
+ return nil
+ }
+ out := &GKEClusterList{}
+ in.DeepCopyInto(out)
+ return out
+}
diff --git a/go/deployment-operator/internal/crossplane/provider.go b/go/deployment-operator/internal/crossplane/provider.go
new file mode 100644
index 0000000000..2ac0422d58
--- /dev/null
+++ b/go/deployment-operator/internal/crossplane/provider.go
@@ -0,0 +1,53 @@
+package crossplane
+
+import (
+ "context"
+ "errors"
+ "fmt"
+
+ corev1 "k8s.io/api/core/v1"
+ "k8s.io/apimachinery/pkg/runtime/schema"
+ k8sClient "sigs.k8s.io/controller-runtime/pkg/client"
+)
+
+var ErrUnsupportedProvider = errors.New("unsupported crossplane cluster provider")
+
+// Supported Crossplane Cluster API groups:
+// - AWS EKS: eks.aws.crossplane.io/v1beta1 and eks.aws.upbound.io/v1beta1/v1beta2 Cluster
+// - Azure AKS: containerservice.azure.upbound.io/v1beta1/v1beta2 KubernetesCluster
+// - GKE: container.gcp.upbound.io/v1beta1/v1beta2 Cluster
+
+// GetCluster fetches the Crossplane managed Cluster referenced by ref.
+func GetCluster(ctx context.Context, c k8sClient.Client, ref corev1.ObjectReference) (ManagedCluster, error) {
+ gvk, err := clusterRefGVK(ref)
+ if err != nil {
+ return nil, err
+ }
+
+ switch {
+ case isAWSEKSClusterGVK(gvk):
+ return getAWSCluster(ctx, c, ref)
+ case isAzureAKSClusterGVK(gvk):
+ return getAzureAKSCluster(ctx, c, ref)
+ case isGKEClusterGVK(gvk):
+ return getGKECluster(ctx, c, ref)
+ default:
+ return nil, fmt.Errorf("%w: %s", ErrUnsupportedProvider, gvk.String())
+ }
+}
+
+func clusterRefGVK(ref corev1.ObjectReference) (schema.GroupVersionKind, error) {
+ if ref.Name == "" {
+ return schema.GroupVersionKind{}, fmt.Errorf("crossplane cluster ref name is required")
+ }
+ if ref.APIVersion == "" || ref.Kind == "" {
+ return schema.GroupVersionKind{}, fmt.Errorf("crossplane cluster ref apiVersion and kind are required")
+ }
+
+ gv, err := schema.ParseGroupVersion(ref.APIVersion)
+ if err != nil {
+ return schema.GroupVersionKind{}, fmt.Errorf("crossplane cluster ref apiVersion: %w", err)
+ }
+
+ return gv.WithKind(ref.Kind), nil
+}
diff --git a/go/deployment-operator/internal/crossplane/scheme.go b/go/deployment-operator/internal/crossplane/scheme.go
new file mode 100644
index 0000000000..fd88c23f3b
--- /dev/null
+++ b/go/deployment-operator/internal/crossplane/scheme.go
@@ -0,0 +1,41 @@
+package crossplane
+
+import (
+ metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+ "k8s.io/apimachinery/pkg/runtime"
+)
+
+var (
+ // SchemeBuilder registers Crossplane cluster types used by the deployment operator.
+ SchemeBuilder = runtime.NewSchemeBuilder(addKnownTypes)
+ AddToScheme = SchemeBuilder.AddToScheme
+)
+
+func addKnownTypes(scheme *runtime.Scheme) error {
+ // The Go type is AWSCluster but the CRD kind is Cluster.
+ scheme.AddKnownTypeWithName(awsEKSClusterGVK, &AWSCluster{})
+ scheme.AddKnownTypeWithName(awsEKSClusterListGVK, &AWSClusterList{})
+ scheme.AddKnownTypeWithName(awsEKSUpboundClusterGVK, &AWSCluster{})
+ scheme.AddKnownTypeWithName(awsEKSUpboundClusterListGVK, &AWSClusterList{})
+ scheme.AddKnownTypeWithName(awsEKSUpboundClusterV1Beta2GVK, &AWSCluster{})
+ scheme.AddKnownTypeWithName(awsEKSUpboundClusterV1Beta2ListGVK, &AWSClusterList{})
+ metav1.AddToGroupVersion(scheme, awsEKSClusterGVK.GroupVersion())
+ metav1.AddToGroupVersion(scheme, awsEKSUpboundClusterGVK.GroupVersion())
+ metav1.AddToGroupVersion(scheme, awsEKSUpboundClusterV1Beta2GVK.GroupVersion())
+
+ // The Go type is AzureAKSCluster but the CRD kind is KubernetesCluster.
+ scheme.AddKnownTypeWithName(azureAKSClusterGVK, &AzureAKSCluster{})
+ scheme.AddKnownTypeWithName(azureAKSClusterListGVK, &AzureAKSClusterList{})
+ scheme.AddKnownTypeWithName(azureAKSClusterV2GVK, &AzureAKSCluster{})
+ scheme.AddKnownTypeWithName(azureAKSClusterV2ListGVK, &AzureAKSClusterList{})
+ metav1.AddToGroupVersion(scheme, azureAKSClusterGVK.GroupVersion())
+ metav1.AddToGroupVersion(scheme, azureAKSClusterV2GVK.GroupVersion())
+ // The Go type is GKECluster but the CRD kind is Cluster.
+ scheme.AddKnownTypeWithName(gkeClusterGVK, &GKECluster{})
+ scheme.AddKnownTypeWithName(gkeClusterListGVK, &GKEClusterList{})
+ scheme.AddKnownTypeWithName(gkeClusterV2GVK, &GKECluster{})
+ scheme.AddKnownTypeWithName(gkeClusterV2ListGVK, &GKEClusterList{})
+ metav1.AddToGroupVersion(scheme, gkeClusterGVK.GroupVersion())
+ metav1.AddToGroupVersion(scheme, gkeClusterV2GVK.GroupVersion())
+ return nil
+}
diff --git a/go/deployment-operator/internal/crossplane/scheme_test.go b/go/deployment-operator/internal/crossplane/scheme_test.go
new file mode 100644
index 0000000000..470231eb9a
--- /dev/null
+++ b/go/deployment-operator/internal/crossplane/scheme_test.go
@@ -0,0 +1,49 @@
+package crossplane
+
+import (
+ "testing"
+
+ "k8s.io/apimachinery/pkg/runtime"
+ "sigs.k8s.io/controller-runtime/pkg/client/apiutil"
+)
+
+func TestAddToSchemeRegistersClusterList(t *testing.T) {
+ scheme := runtime.NewScheme()
+ if err := AddToScheme(scheme); err != nil {
+ t.Fatalf("AddToScheme() error = %v", err)
+ }
+
+ for _, gvk := range []struct {
+ name string
+ gvk struct {
+ group, version, kind string
+ }
+ }{
+ {
+ name: "crossplane-contrib",
+ gvk: struct{ group, version, kind string }{
+ group: "eks.aws.crossplane.io", version: "v1beta1", kind: "ClusterList",
+ },
+ },
+ {
+ name: "upbound",
+ gvk: struct{ group, version, kind string }{
+ group: "eks.aws.upbound.io", version: "v1beta1", kind: "ClusterList",
+ },
+ },
+ } {
+ t.Run(gvk.name, func(t *testing.T) {
+ list := &AWSClusterList{}
+ list.APIVersion = gvk.gvk.group + "/" + gvk.gvk.version
+ list.Kind = gvk.gvk.kind
+
+ got, err := apiutil.GVKForObject(list, scheme)
+ if err != nil {
+ t.Fatalf("GVKForObject() error = %v", err)
+ }
+ if got.Group != gvk.gvk.group || got.Version != gvk.gvk.version || got.Kind != gvk.gvk.kind {
+ t.Fatalf("got GVK %s, want %s/%s %s", got, gvk.gvk.group, gvk.gvk.version, gvk.gvk.kind)
+ }
+ })
+ }
+}