Skip to content

Commit

Permalink
[improvement] add cloud-init boostrap via cluster object store (#586)
Browse files Browse the repository at this point in the history
* linodeobjectstoragekey: add more secret format template values

Adds .BucketName and .S3Endpoint as templateable values to the generated Secret
format of the  LinodeObjectStorageKey resource.

* clients: add AWS SDK clients

* hack: add cloud-init bootstrap via cluster object store

* templates: add cluster object store configuration

* docs: add cluster object store
  • Loading branch information
cbang-akamai authored Dec 12, 2024
1 parent db232c7 commit 3792726
Show file tree
Hide file tree
Showing 32 changed files with 1,430 additions and 82 deletions.
20 changes: 20 additions & 0 deletions api/v1alpha2/linodecluster_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ type LinodeClusterSpec struct {
// NodeBalancerFirewallRef is a reference to a NodeBalancer Firewall object. This makes the linode use the specified NodeBalancer Firewall.
NodeBalancerFirewallRef *corev1.ObjectReference `json:"nodeBalancerFirewallRef,omitempty"`

// ObjectStore defines a supporting Object Storage bucket for cluster operations. This is currently used for
// bootstrapping (e.g. Cloud-init).
// +optional
ObjectStore *ObjectStore `json:"objectStore,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
Expand Down Expand Up @@ -173,6 +178,21 @@ type LinodeNBPortConfig struct {
NodeBalancerConfigID *int `json:"nodeBalancerConfigID,omitempty"`
}

// ObjectStore defines a supporting Object Storage bucket for cluster operations. This is currently used for
// bootstrapping (e.g. Cloud-init).
type ObjectStore struct {
// PresignedURLDuration defines the duration for which presigned URLs are valid.
//
// This is used to generate presigned URLs for S3 Bucket objects, which are used by
// control-plane and worker nodes to fetch bootstrap data.
//
// +optional
PresignedURLDuration *metav1.Duration `json:"presignedURLDuration,omitempty"`

// CredentialsRef is a reference to a Secret that contains the credentials to use for accessing the Cluster Object Store.
CredentialsRef corev1.SecretReference `json:"credentialsRef,omitempty"`
}

// +kubebuilder:object:root=true

// LinodeClusterList contains a list of LinodeCluster
Expand Down
3 changes: 1 addition & 2 deletions api/v1alpha2/linodeobjectstoragekey_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,8 @@ type GeneratedSecret struct {
// +optional
Type corev1.SecretType `json:"type,omitempty"`
// How to format the data stored in the generated Secret.
// It supports Go template syntax and interpolating the following values: .AccessKey, .SecretKey.
// It supports Go template syntax and interpolating the following values: .AccessKey, .SecretKey .BucketName .BucketEndpoint .S3Endpoint
// If no format is supplied then a generic one is used containing the values specified.
// When SecretType is set to addons.cluster.x-k8s.io/resource-set, a .BucketEndpoint value is also available pointing to the location of the first bucket specified in BucketAccess.
// +optional
Format map[string]string `json:"format,omitempty"`
}
Expand Down
27 changes: 27 additions & 0 deletions api/v1alpha2/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions clients/clients.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import (
"context"

"github.com/akamai/AkamaiOPEN-edgegrid-golang/v8/pkg/dns"
awssigner "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/go-resty/resty/v2"
"github.com/linode/linodego"
"sigs.k8s.io/controller-runtime/pkg/client"
Expand Down Expand Up @@ -123,6 +125,16 @@ type K8sClient interface {
client.Client
}

type S3Client interface {
DeleteObject(ctx context.Context, params *s3.DeleteObjectInput, optFns ...func(*s3.Options)) (*s3.DeleteObjectOutput, error)
PutObject(ctx context.Context, params *s3.PutObjectInput, optFns ...func(*s3.Options)) (*s3.PutObjectOutput, error)
HeadObject(ctx context.Context, params *s3.HeadObjectInput, optFns ...func(*s3.Options)) (*s3.HeadObjectOutput, error)
}

type S3PresignClient interface {
PresignGetObject(ctx context.Context, params *s3.GetObjectInput, optFns ...func(*s3.PresignOptions)) (*awssigner.PresignedHTTPRequest, error)
}

type LinodeTokenClient interface {
SetToken(token string) *linodego.Client
}
49 changes: 49 additions & 0 deletions cloud/scope/common.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,16 @@ import (
"github.com/akamai/AkamaiOPEN-edgegrid-golang/v8/pkg/dns"
"github.com/akamai/AkamaiOPEN-edgegrid-golang/v8/pkg/edgegrid"
"github.com/akamai/AkamaiOPEN-edgegrid-golang/v8/pkg/session"
"github.com/aws/aws-sdk-go-v2/aws"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/linode/linodego"
corev1 "k8s.io/api/core/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"

infrav1alpha2 "github.com/linode/cluster-api-provider-linode/api/v1alpha2"
"github.com/linode/cluster-api-provider-linode/observability/wrappers/linodeclient"
"github.com/linode/cluster-api-provider-linode/version"

Expand All @@ -33,6 +38,9 @@ const (

// MaxBodySize is the max payload size for Akamai edge dns client requests
maxBody = 131072

// defaultObjectStorageSignedUrlExpiry is the default expiration for Object Storage signed URls
defaultObjectStorageSignedUrlExpiry = 15 * time.Minute
)

type Option struct {
Expand Down Expand Up @@ -104,6 +112,47 @@ func CreateLinodeClient(config ClientConfig, opts ...Option) (LinodeClient, erro
), nil
}

func CreateS3Clients(ctx context.Context, crClient K8sClient, cluster infrav1alpha2.LinodeCluster) (S3Client, S3PresignClient, error) {
var (
configOpts = []func(*awsconfig.LoadOptions) error{
awsconfig.WithRegion("auto"),
}

clientOpts = []func(*s3.Options){}
)

// If we have a cluster object store bucket, get its configuration.
if cluster.Spec.ObjectStore != nil {
secret, err := getCredentials(ctx, crClient, cluster.Spec.ObjectStore.CredentialsRef, cluster.GetNamespace())
if err == nil {
var (
access_key = string(secret.Data["access_key"])
secret_key = string(secret.Data["secret_key"])
s3_endpoint = string(secret.Data["s3_endpoint"])
)

configOpts = append(configOpts, awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(access_key, secret_key, "")))
clientOpts = append(clientOpts, func(opts *s3.Options) {
opts.BaseEndpoint = aws.String(s3_endpoint)
})
}
}

config, err := awsconfig.LoadDefaultConfig(ctx, configOpts...)
if err != nil {
return nil, nil, fmt.Errorf("load s3 config: %w", err)
}

var (
s3Client = s3.NewFromConfig(config, clientOpts...)
s3PresignClient = s3.NewPresignClient(s3Client, func(opts *s3.PresignOptions) {
opts.Expires = defaultObjectStorageSignedUrlExpiry
})
)

return s3Client, s3PresignClient, nil
}

func setUpEdgeDNSInterface() (dnsInterface dns.DNS, err error) {
edgeRCConfig := edgegrid.Config{
Host: os.Getenv("AKAMAI_HOST"),
Expand Down
59 changes: 41 additions & 18 deletions cloud/scope/machine.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,16 @@ type MachineScopeParams struct {
}

type MachineScope struct {
Client K8sClient
PatchHelper *patch.Helper
Cluster *clusterv1.Cluster
Machine *clusterv1.Machine
TokenHash string
LinodeClient LinodeClient
LinodeCluster *infrav1alpha2.LinodeCluster
LinodeMachine *infrav1alpha2.LinodeMachine
Client K8sClient
S3Client S3Client
S3PresignClient S3PresignClient
PatchHelper *patch.Helper
Cluster *clusterv1.Cluster
Machine *clusterv1.Machine
TokenHash string
LinodeClient LinodeClient
LinodeCluster *infrav1alpha2.LinodeCluster
LinodeMachine *infrav1alpha2.LinodeMachine
}

func validateMachineScopeParams(params MachineScopeParams) error {
Expand Down Expand Up @@ -62,20 +64,28 @@ func NewMachineScope(ctx context.Context, linodeClientConfig ClientConfig, param
if err != nil {
return nil, fmt.Errorf("failed to create linode client: %w", err)
}

s3client, s3PresignClient, err := CreateS3Clients(ctx, params.Client, *params.LinodeCluster)
if err != nil {
return nil, fmt.Errorf("create s3 clients: %w", err)
}

helper, err := patch.NewHelper(params.LinodeMachine, params.Client)
if err != nil {
return nil, fmt.Errorf("failed to init patch helper: %w", err)
}

return &MachineScope{
Client: params.Client,
PatchHelper: helper,
Cluster: params.Cluster,
Machine: params.Machine,
TokenHash: GetHash(linodeClientConfig.Token),
LinodeClient: linodeClient,
LinodeCluster: params.LinodeCluster,
LinodeMachine: params.LinodeMachine,
Client: params.Client,
S3Client: s3client,
S3PresignClient: s3PresignClient,
PatchHelper: helper,
Cluster: params.Cluster,
Machine: params.Machine,
TokenHash: GetHash(linodeClientConfig.Token),
LinodeClient: linodeClient,
LinodeCluster: params.LinodeCluster,
LinodeMachine: params.LinodeMachine,
}, nil
}

Expand All @@ -102,7 +112,7 @@ func (s *MachineScope) AddFinalizer(ctx context.Context) error {
// GetBootstrapData returns the bootstrap data from the secret in the Machine's bootstrap.dataSecretName.
func (m *MachineScope) GetBootstrapData(ctx context.Context) ([]byte, error) {
if m.Machine.Spec.Bootstrap.DataSecretName == nil {
return []byte{}, fmt.Errorf(
return nil, fmt.Errorf(
"bootstrap data secret is nil for LinodeMachine %s/%s",
m.LinodeMachine.Namespace,
m.LinodeMachine.Name,
Expand All @@ -112,7 +122,7 @@ func (m *MachineScope) GetBootstrapData(ctx context.Context) ([]byte, error) {
secret := &corev1.Secret{}
key := types.NamespacedName{Namespace: m.LinodeMachine.Namespace, Name: *m.Machine.Spec.Bootstrap.DataSecretName}
if err := m.Client.Get(ctx, key, secret); err != nil {
return []byte{}, fmt.Errorf(
return nil, fmt.Errorf(
"failed to retrieve bootstrap data secret for LinodeMachine %s/%s",
m.LinodeMachine.Namespace,
m.LinodeMachine.Name,
Expand All @@ -131,6 +141,19 @@ func (m *MachineScope) GetBootstrapData(ctx context.Context) ([]byte, error) {
return value, nil
}

func (m *MachineScope) GetBucketName(ctx context.Context) (string, error) {
if m.LinodeCluster.Spec.ObjectStore == nil {
return "", errors.New("no cluster object store")
}

name, err := getCredentialDataFromRef(ctx, m.Client, m.LinodeCluster.Spec.ObjectStore.CredentialsRef, m.LinodeCluster.GetNamespace(), "bucket_name")
if err != nil {
return "", fmt.Errorf("get bucket name: %w", err)
}

return string(name), nil
}

func (s *MachineScope) AddCredentialsRefFinalizer(ctx context.Context) error {
// Only add the finalizer if the machine has an override for the credentials reference
if s.LinodeMachine.Spec.CredentialsRef == nil {
Expand Down
29 changes: 29 additions & 0 deletions cloud/scope/machine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,35 @@ func TestNewMachineScope(t *testing.T) {
require.NoError(t, err)
assert.NotNil(t, mScope)
})),
Path(
Call("cluster object store used", func(ctx context.Context, mck Mock) {
mck.K8sClient.EXPECT().Get(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).DoAndReturn(func(ctx context.Context, key client.ObjectKey, obj *corev1.Secret, opts ...client.GetOption) error {
secret := corev1.Secret{Data: map[string][]byte{
"bucket_name": []byte("fake"),
"s3_endpoint": []byte("fake"),
"access_key": []byte("fake"),
"secret_key": []byte("fake"),
}}
*obj = secret
return nil
})
}),
Result("success", func(ctx context.Context, mck Mock) {
mScope, err := NewMachineScope(ctx, ClientConfig{Token: "apiToken"}, MachineScopeParams{
Client: mck.K8sClient,
Cluster: &clusterv1.Cluster{},
Machine: &clusterv1.Machine{},
LinodeCluster: &infrav1alpha2.LinodeCluster{
Spec: infrav1alpha2.LinodeClusterSpec{
ObjectStore: &infrav1alpha2.ObjectStore{
CredentialsRef: corev1.SecretReference{Name: "fake"},
},
}},
LinodeMachine: &infrav1alpha2.LinodeMachine{},
})
require.NoError(t, err)
assert.NotNil(t, mScope)
})),
),
)
}
Expand Down
20 changes: 12 additions & 8 deletions cloud/scope/object_storage_key.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"context"
"errors"
"fmt"
"strings"
"text/template"

"github.com/go-logr/logr"
Expand Down Expand Up @@ -103,26 +104,29 @@ func (s *ObjectStorageKeyScope) GenerateKeySecret(ctx context.Context, key *lino
"SecretKey": key.SecretKey,
}

// If the desired secret is of ClusterResourceSet type, encapsulate the secret.
// Bucket details are retrieved from the first referenced LinodeObjectStorageBucket in the access key.
if s.Key.Spec.GeneratedSecret.Type == clusteraddonsv1.ClusterResourceSetSecretType {
if len(s.Key.Spec.GeneratedSecret.Format) == 0 {
secretStringData = map[string]string{
"access_key": key.AccessKey,
"secret_key": key.SecretKey,
}
} else {
// This should never run since the CRD has a validation marker to ensure bucketAccess has at least one item.
if len(s.Key.Spec.BucketAccess) == 0 {
return nil, fmt.Errorf("unable to generate %s; spec.bucketAccess must not be empty", clusteraddonsv1.ClusterResourceSetSecretType)
}

// Bucket details are retrieved from the first referenced LinodeObjectStorageBucket in the access key.
bucketRef := s.Key.Spec.BucketAccess[0]
bucket, err := s.LinodeClient.GetObjectStorageBucket(ctx, bucketRef.Region, bucketRef.BucketName)
if err != nil {
return nil, fmt.Errorf("unable to generate %s; failed to get bucket: %w", clusteraddonsv1.ClusterResourceSetSecretType, err)
}

tmplData["BucketName"] = bucket.Label
tmplData["BucketEndpoint"] = bucket.Hostname
} else if len(s.Key.Spec.GeneratedSecret.Format) == 0 {
secretStringData = map[string]string{
"access_key": key.AccessKey,
"secret_key": key.SecretKey,
}
// Cluster URL (S3 endpoint)
// https://techdocs.akamai.com/cloud-computing/docs/access-buckets-and-files-through-urls#cluster-url-s3-endpoint
tmplData["S3Endpoint"] = "https://" + strings.TrimPrefix(bucket.Hostname, bucket.Label+".")
}

for key, tmpl := range s.Key.Spec.GeneratedSecret.Format {
Expand Down
Loading

0 comments on commit 3792726

Please sign in to comment.