diff --git a/api/v1alpha1/linodecluster_types.go b/api/v1alpha1/linodecluster_types.go index 44aeb7c79..a5424cbec 100644 --- a/api/v1alpha1/linodecluster_types.go +++ b/api/v1alpha1/linodecluster_types.go @@ -40,6 +40,11 @@ type LinodeClusterSpec struct { // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable" // +optional VPCRef *corev1.ObjectReference `json:"vpcRef,omitempty"` + + // CredentialsRef is a reference to a Secret that contains the credentials to use for provisioning this cluster. If not + // supplied then the credentials of the controller will be used. + // +optional + CredentialsRef *corev1.SecretReference `json:"credentialsRef,omitempty"` } // LinodeClusterStatus defines the observed state of LinodeCluster diff --git a/api/v1alpha1/linodevpc_types.go b/api/v1alpha1/linodevpc_types.go index 61c4072e8..7079f83df 100644 --- a/api/v1alpha1/linodevpc_types.go +++ b/api/v1alpha1/linodevpc_types.go @@ -17,6 +17,7 @@ limitations under the License. package v1alpha1 import ( + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1" ) @@ -41,6 +42,11 @@ type LinodeVPCSpec struct { // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable" // +optional Subnets []VPCSubnetCreateOptions `json:"subnets,omitempty"` + + // CredentialsRef is a reference to a Secret that contains the credentials to use for provisioning this VPC. If not + // supplied then the credentials of the controller will be used. + // +optional + CredentialsRef *corev1.SecretReference `json:"credentialsRef,omitempty"` } // VPCSubnetCreateOptions defines subnet options diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index bca2950ca..fad390c65 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -142,6 +142,11 @@ func (in *LinodeClusterSpec) DeepCopyInto(out *LinodeClusterSpec) { *out = new(v1.ObjectReference) **out = **in } + if in.CredentialsRef != nil { + in, out := &in.CredentialsRef, &out.CredentialsRef + *out = new(v1.SecretReference) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LinodeClusterSpec. @@ -728,6 +733,11 @@ func (in *LinodeVPCSpec) DeepCopyInto(out *LinodeVPCSpec) { *out = make([]VPCSubnetCreateOptions, len(*in)) copy(*out, *in) } + if in.CredentialsRef != nil { + in, out := &in.CredentialsRef, &out.CredentialsRef + *out = new(v1.SecretReference) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new LinodeVPCSpec. diff --git a/cloud/scope/cluster.go b/cloud/scope/cluster.go index 90d102882..05a1e6b45 100644 --- a/cloud/scope/cluster.go +++ b/cloud/scope/cluster.go @@ -50,11 +50,19 @@ func validateClusterScopeParams(params ClusterScopeParams) error { // NewClusterScope creates a new Scope from the supplied parameters. // This is meant to be called for each reconcile iteration. -func NewClusterScope(apiKey string, params ClusterScopeParams) (*ClusterScope, error) { +func NewClusterScope(ctx context.Context, apiKey string, params ClusterScopeParams) (*ClusterScope, error) { if err := validateClusterScopeParams(params); err != nil { return nil, err } + // Override the controller credentials with ones from the Cluster's Secret reference (if supplied). + if params.LinodeCluster.Spec.CredentialsRef != nil { + data, err := getCredentialDataFromRef(ctx, params.Client, *params.LinodeCluster.Spec.CredentialsRef, params.LinodeCluster.GetNamespace()) + if err != nil { + return nil, fmt.Errorf("credentials from secret ref: %w", err) + } + apiKey = string(data) + } linodeClient := createLinodeClient(apiKey) helper, err := patch.NewHelper(params.LinodeCluster, params.Client) diff --git a/cloud/scope/common.go b/cloud/scope/common.go index 6d954555b..b4675d6db 100644 --- a/cloud/scope/common.go +++ b/cloud/scope/common.go @@ -28,20 +28,24 @@ func createLinodeClient(apiKey string) *linodego.Client { return &linodeClient } -func getCredentialDataFromRef(ctx context.Context, crClient client.Client, credentialsRef *corev1.SecretReference) ([]byte, error) { - secretRefName := client.ObjectKey{ +func getCredentialDataFromRef(ctx context.Context, crClient client.Client, credentialsRef corev1.SecretReference, defaultNamespace string) ([]byte, error) { + secretRef := client.ObjectKey{ Name: credentialsRef.Name, Namespace: credentialsRef.Namespace, } + if secretRef.Namespace == "" { + secretRef.Namespace = defaultNamespace + } var credSecret corev1.Secret - if err := crClient.Get(ctx, secretRefName, &credSecret); err != nil { - return nil, fmt.Errorf("failed to retrieve configured credentials secret %s: %w", secretRefName.String(), err) + if err := crClient.Get(ctx, secretRef, &credSecret); err != nil { + return nil, fmt.Errorf("get credentials secret %s/%s: %w", secretRef.Namespace, secretRef.Name, err) } + // TODO: This key is hard-coded (for now) to match the externally-managed `manager-credentials` Secret. rawData, ok := credSecret.Data["apiToken"] if !ok { - return nil, fmt.Errorf("credentials secret %s is missing an apiToken key", secretRefName.String()) + return nil, fmt.Errorf("no apiToken key in credentials secret %s/%s", secretRef.Namespace, secretRef.Name) } return rawData, nil diff --git a/cloud/scope/machine.go b/cloud/scope/machine.go index 6a3bcd47d..05d57f85a 100644 --- a/cloud/scope/machine.go +++ b/cloud/scope/machine.go @@ -52,11 +52,19 @@ func validateMachineScopeParams(params MachineScopeParams) error { return nil } -func NewMachineScope(apiKey string, params MachineScopeParams) (*MachineScope, error) { +func NewMachineScope(ctx context.Context, apiKey string, params MachineScopeParams) (*MachineScope, error) { if err := validateMachineScopeParams(params); err != nil { return nil, err } + // Override the controller credentials with ones from the Cluster's Secret reference (if supplied). + if params.LinodeCluster.Spec.CredentialsRef != nil { + data, err := getCredentialDataFromRef(ctx, params.Client, *params.LinodeCluster.Spec.CredentialsRef, params.LinodeCluster.GetNamespace()) + if err != nil { + return nil, fmt.Errorf("credentials from cluster secret ref: %w", err) + } + apiKey = string(data) + } linodeClient := createLinodeClient(apiKey) helper, err := patch.NewHelper(params.LinodeMachine, params.Client) diff --git a/cloud/scope/object_storage_bucket.go b/cloud/scope/object_storage_bucket.go index f6ab1c2ce..cbcac59e4 100644 --- a/cloud/scope/object_storage_bucket.go +++ b/cloud/scope/object_storage_bucket.go @@ -52,11 +52,7 @@ func NewObjectStorageBucketScope(ctx context.Context, apiKey string, params Obje // Override the controller credentials with ones from the Cluster's Secret reference (if supplied). if params.Object.Spec.CredentialsRef != nil { - credRef := *params.Object.Spec.CredentialsRef - if credRef.Namespace == "" { - credRef.Namespace = params.Object.Namespace - } - data, err := getCredentialDataFromRef(ctx, params.Client, &credRef) + data, err := getCredentialDataFromRef(ctx, params.Client, *params.Object.Spec.CredentialsRef, params.Object.GetNamespace()) if err != nil { return nil, fmt.Errorf("credentials from cluster secret ref: %w", err) } diff --git a/cloud/scope/vpc.go b/cloud/scope/vpc.go index 0ffa39108..5d3d1e32f 100644 --- a/cloud/scope/vpc.go +++ b/cloud/scope/vpc.go @@ -54,11 +54,19 @@ func validateVPCScopeParams(params VPCScopeParams) error { // NewVPCScope creates a new Scope from the supplied parameters. // This is meant to be called for each reconcile iteration. -func NewVPCScope(apiKey string, params VPCScopeParams) (*VPCScope, error) { +func NewVPCScope(ctx context.Context, apiKey string, params VPCScopeParams) (*VPCScope, error) { if err := validateVPCScopeParams(params); err != nil { return nil, err } + // Override the controller credentials with ones from the VPC's Secret reference (if supplied). + if params.LinodeVPC.Spec.CredentialsRef != nil { + data, err := getCredentialDataFromRef(ctx, params.Client, *params.LinodeVPC.Spec.CredentialsRef, params.LinodeVPC.GetNamespace()) + if err != nil { + return nil, fmt.Errorf("credentials from secret ref: %w", err) + } + apiKey = string(data) + } linodeClient := createLinodeClient(apiKey) helper, err := patch.NewHelper(params.LinodeVPC, params.Client) diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_linodeclusters.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_linodeclusters.yaml index 91869d51d..e0fd9764b 100644 --- a/config/crd/bases/infrastructure.cluster.x-k8s.io_linodeclusters.yaml +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_linodeclusters.yaml @@ -73,6 +73,21 @@ spec: - host - port type: object + credentialsRef: + description: |- + CredentialsRef is a reference to a Secret that contains the credentials to use for provisioning this cluster. If not + supplied then the credentials of the controller will be used. + properties: + name: + description: name is unique within a namespace to reference a + secret resource. + type: string + namespace: + description: namespace defines the space within which the secret + name must be unique. + type: string + type: object + x-kubernetes-map-type: atomic network: description: NetworkSpec encapsulates all things related to Linode network. diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_linodeclustertemplates.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_linodeclustertemplates.yaml index eed496511..b494d89c9 100644 --- a/config/crd/bases/infrastructure.cluster.x-k8s.io_linodeclustertemplates.yaml +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_linodeclustertemplates.yaml @@ -67,6 +67,21 @@ spec: - host - port type: object + credentialsRef: + description: |- + CredentialsRef is a reference to a Secret that contains the credentials to use for provisioning this cluster. If not + supplied then the credentials of the controller will be used. + properties: + name: + description: name is unique within a namespace to reference + a secret resource. + type: string + namespace: + description: namespace defines the space within which + the secret name must be unique. + type: string + type: object + x-kubernetes-map-type: atomic network: description: NetworkSpec encapsulates all things related to Linode network. diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_linodevpcs.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_linodevpcs.yaml index ac94d5f9b..ece7ff9a2 100644 --- a/config/crd/bases/infrastructure.cluster.x-k8s.io_linodevpcs.yaml +++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_linodevpcs.yaml @@ -39,6 +39,21 @@ spec: spec: description: LinodeVPCSpec defines the desired state of LinodeVPC properties: + credentialsRef: + description: |- + CredentialsRef is a reference to a Secret that contains the credentials to use for provisioning this VPC. If not + supplied then the credentials of the controller will be used. + properties: + name: + description: name is unique within a namespace to reference a + secret resource. + type: string + namespace: + description: namespace defines the space within which the secret + name must be unique. + type: string + type: object + x-kubernetes-map-type: atomic description: type: string label: diff --git a/controller/linodecluster_controller.go b/controller/linodecluster_controller.go index 897a4829d..998917755 100644 --- a/controller/linodecluster_controller.go +++ b/controller/linodecluster_controller.go @@ -95,6 +95,7 @@ func (r *LinodeClusterReconciler) Reconcile(ctx context.Context, req ctrl.Reques } // Create the cluster scope. clusterScope, err := scope.NewClusterScope( + ctx, r.LinodeApiKey, scope.ClusterScopeParams{ Client: r.Client, diff --git a/controller/linodemachine_controller.go b/controller/linodemachine_controller.go index 75465d3fe..cdbb97b7f 100644 --- a/controller/linodemachine_controller.go +++ b/controller/linodemachine_controller.go @@ -168,6 +168,7 @@ func (r *LinodeMachineReconciler) Reconcile(ctx context.Context, req ctrl.Reques linodeCluster := &infrav1alpha1.LinodeCluster{} machineScope, err := scope.NewMachineScope( + ctx, r.LinodeApiKey, scope.MachineScopeParams{ Client: r.Client, @@ -184,6 +185,7 @@ func (r *LinodeMachineReconciler) Reconcile(ctx context.Context, req ctrl.Reques } clusterScope, err := scope.NewClusterScope( + ctx, r.LinodeApiKey, scope.ClusterScopeParams{ Client: r.Client, diff --git a/controller/linodevpc_controller.go b/controller/linodevpc_controller.go index 84d1b3834..771c95ae5 100644 --- a/controller/linodevpc_controller.go +++ b/controller/linodevpc_controller.go @@ -85,6 +85,7 @@ func (r *LinodeVPCReconciler) Reconcile(ctx context.Context, req ctrl.Request) ( } vpcScope, err := scope.NewVPCScope( + ctx, r.LinodeApiKey, scope.VPCScopeParams{ Client: r.Client, diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index a1cb0d91c..fbf5abe64 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -11,6 +11,7 @@ - [k3s](./topics/flavors/k3s.md) - [rke2](./topics/flavors/rke2.md) - [Etcd](./topics/etcd.md) + - [Multi-Tenancy](./topics/multi-tenancy.md) - [Development](./developers/development.md) - [Releasing](./developers/releasing.md) - [Reference](./reference/reference.md) diff --git a/docs/src/developers/development.md b/docs/src/developers/development.md index 7cbea2614..b6b29487b 100644 --- a/docs/src/developers/development.md +++ b/docs/src/developers/development.md @@ -147,12 +147,13 @@ providers: Here is a list of required configuration parameters: ```sh -# Cluster settings +## Cluster settings export CLUSTER_NAME=capl-cluster export KUBERNETES_VERSION=v1.29.1 -# Linode settings +## Linode settings export LINODE_REGION=us-ord +# Multi-tenancy: This may be changed for each cluster to deploy to different Linode accounts. export LINODE_TOKEN= export LINODE_CONTROL_PLANE_MACHINE_TYPE=g6-standard-2 export LINODE_MACHINE_TYPE=g6-standard-2 diff --git a/docs/src/topics/multi-tenancy.md b/docs/src/topics/multi-tenancy.md new file mode 100644 index 000000000..290dcec96 --- /dev/null +++ b/docs/src/topics/multi-tenancy.md @@ -0,0 +1,51 @@ +# Multi-Tenancy + +CAPL can manage multi-tenant workload clusters across Linode accounts. Custom resources may reference an optional Secret +containing their Linode credentials (i.e. API token) to be used for the deployment of Linode resources (e.g. Linodes, +VPCs, NodeBalancers, etc.) associated with the cluster. + +The following example shows a basic credentials Secret: + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: linode-credentials +stringData: + apiToken: +``` + +```admonish warning +The Linode API token data must be put in a key named `apiToken`! +``` + +Which may be optionally consumed by one or more custom resource objects: + +```yaml +# Example: LinodeCluster +apiVersion: infrastructure.cluster.x-k8s.io/v1alpha1 +kind: LinodeCluster +metadata: + name: test-cluster +spec: + credentialsRef: + name: linode-credentials + ... +--- +# Example: LinodeVPC +apiVersion: infrastructure.cluster.x-k8s.io/v1alpha1 +kind: LinodeVPC +metadata: + name: test-vpc +spec: + credentialsRef: + name: linode-credentials + ... +``` + +Secrets from other namespaces by additionally specifying an optional +`.spec.credentialsRef.namespace` value. + +```admonish warning +If `.spec.credentialsRef` is set for a LinodeCluster, it should also be set for adjacent resources (e.g. LinodeVPC). +``` diff --git a/templates/flavors/base/linodeCluster.yaml b/templates/flavors/base/linodeCluster.yaml index 9c637f598..3f5956877 100644 --- a/templates/flavors/base/linodeCluster.yaml +++ b/templates/flavors/base/linodeCluster.yaml @@ -5,3 +5,5 @@ metadata: name: ${CLUSTER_NAME} spec: region: ${LINODE_REGION} + credentialsRef: + name: ${CLUSTER_NAME}-credentials diff --git a/templates/flavors/base/secret.yaml b/templates/flavors/base/secret.yaml new file mode 100644 index 000000000..cadc5e564 --- /dev/null +++ b/templates/flavors/base/secret.yaml @@ -0,0 +1,7 @@ +--- +apiVersion: v1 +kind: Secret +metadata: + name: ${CLUSTER_NAME}-credentials +stringData: + apiToken: ${LINODE_TOKEN}