Skip to content

Commit

Permalink
add support for stackscript based bootstrapping (#211)
Browse files Browse the repository at this point in the history
* add support for stackscript based bootstrapping
  • Loading branch information
eljohnson92 committed Apr 3, 2024
1 parent b78416f commit e3566e4
Show file tree
Hide file tree
Showing 16 changed files with 400 additions and 83 deletions.
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ gosec: ## Run gosec against code.

.PHONY: lint
lint: ## Run lint against code.
docker run --rm -w /workdir -v $(PWD):/workdir golangci/golangci-lint:v1.56.1 golangci-lint run -c .golangci.yml
docker run --rm -w /workdir -v $(PWD):/workdir golangci/golangci-lint:v1.57.2 golangci-lint run -c .golangci.yml

.PHONY: nilcheck
nilcheck: nilaway ## Run nil check against code.
Expand Down
7 changes: 0 additions & 7 deletions api/v1alpha1/linodemachine_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,6 @@ type LinodeMachineSpec struct {
// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable"
AuthorizedUsers []string `json:"authorizedUsers,omitempty"`
// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable"
StackScriptID int `json:"stackscriptId,omitempty"`
// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable"
StackScriptData map[string]string `json:"stackscriptData,omitempty"`
// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable"
BackupID int `json:"backupId,omitempty"`
// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable"
Image string `json:"image,omitempty"`
Expand All @@ -67,9 +63,6 @@ type LinodeMachineSpec struct {
// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable"
Tags []string `json:"tags,omitempty"`
// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable"
// +optional
Metadata *InstanceMetadataOptions `json:"metadata,omitempty"`
// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="Value is immutable"
FirewallID int `json:"firewallId,omitempty"`

// CredentialsRef is a reference to a Secret that contains the credentials
Expand Down
12 changes: 0 additions & 12 deletions api/v1alpha1/zz_generated.deepcopy.go

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

4 changes: 4 additions & 0 deletions cloud/scope/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ type LinodeInstanceClient interface {
CreateInstanceDisk(ctx context.Context, linodeID int, opts linodego.InstanceDiskCreateOptions) (*linodego.InstanceDisk, error)
GetInstance(ctx context.Context, linodeID int) (*linodego.Instance, error)
DeleteInstance(ctx context.Context, linodeID int) error
GetRegion(ctx context.Context, regionID string) (*linodego.Region, error)
GetImage(ctx context.Context, imageID string) (*linodego.Image, error)
CreateStackscript(ctx context.Context, opts linodego.StackscriptCreateOptions) (*linodego.Stackscript, error)
ListStackscripts(ctx context.Context, opts *linodego.ListOptions) ([]linodego.Stackscript, error)
WaitForInstanceDiskStatus(ctx context.Context, instanceID int, diskID int, status linodego.DiskStatus, timeoutSeconds int) (*linodego.InstanceDisk, error)
}

Expand Down
21 changes: 21 additions & 0 deletions cloud/services/stackscript.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
#!/bin/sh
# <UDF name="instancedata" label="instance-data contents(base64 encoded" />
# <UDF name="userdata" label="user-data file contents (base64 encoded)" />

cat > /etc/cloud/cloud.cfg.d/100_none.cfg <<EOF
datasource_list: [ "None"]
datasource:
None:
metadata:
id: $LINODE_ID
$(echo "${INSTANCEDATA}" | base64 -d | sed "s/^/ /")
userdata_raw: |
$(echo "${USERDATA}" | base64 -d | sed "s/^/ /")
EOF

cloud-init clean
cloud-init -f /etc/cloud/cloud.cfg.d/100_none.cfg init --local
cloud-init -f /etc/cloud/cloud.cfg.d/100_none.cfg init
cloud-init -f /etc/cloud/cloud.cfg.d/100_none.cfg modules --mode=config
cloud-init -f /etc/cloud/cloud.cfg.d/100_none.cfg modules --mode=final
50 changes: 50 additions & 0 deletions cloud/services/stackscripts.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package services

import (
"context"
"fmt"
"net/http"

"github.com/linode/linodego"

"github.com/linode/cluster-api-provider-linode/cloud/scope"
"github.com/linode/cluster-api-provider-linode/util"
"github.com/linode/cluster-api-provider-linode/version"

_ "embed"
)

//go:embed stackscript.sh
var stackscriptTemplate string

func EnsureStackscript(ctx context.Context, machineScope *scope.MachineScope) (int, error) {
stackscriptName := fmt.Sprintf("CAPL-%s", version.GetVersion())
listFilter := util.Filter{
ID: nil,
Label: stackscriptName,
Tags: []string{},
}
filter, err := listFilter.String()
if err != nil {
return 0, err
}
stackscripts, err := machineScope.LinodeClient.ListStackscripts(ctx, &linodego.ListOptions{Filter: filter})
if util.IgnoreLinodeAPIError(err, http.StatusNotFound) != nil {
return 0, fmt.Errorf("failed to get stackscript with label %s: %w", stackscriptName, err)
}
if stackscripts != nil {
return stackscripts[0].ID, nil
}
stackscriptCreateOptions := linodego.StackscriptCreateOptions{
Label: fmt.Sprintf("CAPL-%s", version.GetVersion()),
Description: fmt.Sprintf("Stackscript for creating CAPL clusters with CAPL controller version %s", version.GetVersion()),
Script: stackscriptTemplate,
Images: []string{"any/all"},
}
stackscript, err := machineScope.LinodeClient.CreateStackscript(ctx, stackscriptCreateOptions)
if err != nil {
return 0, fmt.Errorf("failed to create StackScript: %w", err)
}

return stackscript.ID, nil
}
115 changes: 115 additions & 0 deletions cloud/services/stackscripts_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package services

import (
"context"
"fmt"
"testing"

"github.com/linode/linodego"
"github.com/stretchr/testify/assert"
"go.uber.org/mock/gomock"

"github.com/linode/cluster-api-provider-linode/cloud/scope"
"github.com/linode/cluster-api-provider-linode/mock"
)

func TestEnsureStackscripts(t *testing.T) {
t.Parallel()

tests := []struct {
name string
machineScope *scope.MachineScope
want int
expectedError error
expects func(client *mock.MockLinodeMachineClient)
}{
{
name: "Success - Successfully get existing StackScript",
machineScope: &scope.MachineScope{},
want: 1234,
expects: func(mockClient *mock.MockLinodeMachineClient) {
mockClient.EXPECT().ListStackscripts(gomock.Any(), &linodego.ListOptions{Filter: "{\"label\":\"CAPL-dev\"}"}).Return([]linodego.Stackscript{{
Label: "CAPI Test 1",
ID: 1234,
}}, nil)
},
},
{
name: "Error - failed get existing StackScript",
machineScope: &scope.MachineScope{},
expects: func(mockClient *mock.MockLinodeMachineClient) {
mockClient.EXPECT().ListStackscripts(gomock.Any(), gomock.Any()).Return(nil, fmt.Errorf("failed to get StackScript"))
},
expectedError: fmt.Errorf("failed to get StackScript"),
},
{
name: "Success - Successfully created StackScript",
machineScope: &scope.MachineScope{},
want: 56345,
expects: func(mockClient *mock.MockLinodeMachineClient) {
mockClient.EXPECT().ListStackscripts(gomock.Any(), gomock.Any()).Return(nil, nil)
mockClient.EXPECT().CreateStackscript(gomock.Any(), linodego.StackscriptCreateOptions{
Label: "CAPL-dev",
Description: "Stackscript for creating CAPL clusters with CAPL controller version dev",
Script: `#!/bin/sh
# <UDF name="instancedata" label="instance-data contents(base64 encoded" />
# <UDF name="userdata" label="user-data file contents (base64 encoded)" />
cat > /etc/cloud/cloud.cfg.d/100_none.cfg <<EOF
datasource_list: [ "None"]
datasource:
None:
metadata:
id: $LINODE_ID
$(echo "${INSTANCEDATA}" | base64 -d | sed "s/^/ /")
userdata_raw: |
$(echo "${USERDATA}" | base64 -d | sed "s/^/ /")
EOF
cloud-init clean
cloud-init -f /etc/cloud/cloud.cfg.d/100_none.cfg init --local
cloud-init -f /etc/cloud/cloud.cfg.d/100_none.cfg init
cloud-init -f /etc/cloud/cloud.cfg.d/100_none.cfg modules --mode=config
cloud-init -f /etc/cloud/cloud.cfg.d/100_none.cfg modules --mode=final
`,
Images: []string{"any/all"},
}).Return(&linodego.Stackscript{
Label: "CAPI Test 1",
ID: 56345,
}, nil)
},
},
{
name: "Error - failed create StackScript",
machineScope: &scope.MachineScope{},
expects: func(mockClient *mock.MockLinodeMachineClient) {
mockClient.EXPECT().ListStackscripts(gomock.Any(), gomock.Any()).Return(nil, nil)
mockClient.EXPECT().CreateStackscript(gomock.Any(), gomock.Any()).Return(nil, fmt.Errorf("failed to create StackScript"))
},
expectedError: fmt.Errorf("failed to create StackScript"),
},
}
for _, tt := range tests {
testcase := tt
t.Run(testcase.name, func(t *testing.T) {
t.Parallel()

ctrl := gomock.NewController(t)
defer ctrl.Finish()

mockClient := mock.NewMockLinodeMachineClient(ctrl)

testcase.machineScope.LinodeClient = mockClient

testcase.expects(mockClient)

got, err := EnsureStackscript(context.Background(), testcase.machineScope)
if testcase.expectedError != nil {
assert.ErrorContains(t, err, testcase.expectedError.Error())
} else {
assert.Equal(t, testcase.want, got)
}
})
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -161,16 +161,6 @@ spec:
x-kubernetes-validations:
- message: Value is immutable
rule: self == oldSelf
metadata:
description: InstanceMetadataOptions defines metadata of instance
properties:
userData:
description: UserData expects a Base64-encoded string
type: string
type: object
x-kubernetes-validations:
- message: Value is immutable
rule: self == oldSelf
privateIp:
type: boolean
x-kubernetes-validations:
Expand All @@ -190,18 +180,6 @@ spec:
x-kubernetes-validations:
- message: Value is immutable
rule: self == oldSelf
stackscriptData:
additionalProperties:
type: string
type: object
x-kubernetes-validations:
- message: Value is immutable
rule: self == oldSelf
stackscriptId:
type: integer
x-kubernetes-validations:
- message: Value is immutable
rule: self == oldSelf
tags:
items:
type: string
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,16 +150,6 @@ spec:
x-kubernetes-validations:
- message: Value is immutable
rule: self == oldSelf
metadata:
description: InstanceMetadataOptions defines metadata of instance
properties:
userData:
description: UserData expects a Base64-encoded string
type: string
type: object
x-kubernetes-validations:
- message: Value is immutable
rule: self == oldSelf
privateIp:
type: boolean
x-kubernetes-validations:
Expand All @@ -179,18 +169,6 @@ spec:
x-kubernetes-validations:
- message: Value is immutable
rule: self == oldSelf
stackscriptData:
additionalProperties:
type: string
type: object
x-kubernetes-validations:
- message: Value is immutable
rule: self == oldSelf
stackscriptId:
type: integer
x-kubernetes-validations:
- message: Value is immutable
rule: self == oldSelf
tags:
items:
type: string
Expand Down
35 changes: 33 additions & 2 deletions controller/linodemachine_controller_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"encoding/gob"
"errors"
"fmt"
"slices"
"sort"

"github.com/go-logr/logr"
Expand All @@ -38,6 +39,7 @@ import (

infrav1alpha1 "github.com/linode/cluster-api-provider-linode/api/v1alpha1"
"github.com/linode/cluster-api-provider-linode/cloud/scope"
"github.com/linode/cluster-api-provider-linode/cloud/services"
"github.com/linode/cluster-api-provider-linode/util"
"github.com/linode/cluster-api-provider-linode/util/reconciler"
)
Expand Down Expand Up @@ -74,8 +76,37 @@ func (r *LinodeMachineReconciler) newCreateConfig(ctx context.Context, machineSc

return nil, err
}
createConfig.Metadata = &linodego.InstanceMetadataOptions{
UserData: b64.StdEncoding.EncodeToString(bootstrapData),

region, err := machineScope.LinodeClient.GetRegion(ctx, machineScope.LinodeMachine.Spec.Region)
if err != nil {
return nil, err
}
regionMetadataSupport := slices.Contains(region.Capabilities, "Metadata")
image, err := machineScope.LinodeClient.GetImage(ctx, machineScope.LinodeMachine.Spec.Image)
if err != nil {
return nil, err
}
imageMetadataSupport := slices.Contains(image.Capabilities, "cloud-init")
if imageMetadataSupport && regionMetadataSupport {
createConfig.Metadata = &linodego.InstanceMetadataOptions{
UserData: b64.StdEncoding.EncodeToString(bootstrapData),
}
} else {
logger.Info(fmt.Sprintf("using StackScripts for bootstrapping. imageMetadataSupport: %t, regionMetadataSupport: %t",
imageMetadataSupport, regionMetadataSupport))
capiStackScriptID, err := services.EnsureStackscript(ctx, machineScope)
if err != nil {
return nil, err
}
createConfig.StackScriptID = capiStackScriptID
// ###### WARNING, currently label, region and type are supported as cloud-init variables, any changes ######
// any changes to this could be potentially backwards incompatible and should be noted through a backwards incompatible version update #####
instanceData := fmt.Sprintf("label: %s\nregion: %s\ntype: %s", machineScope.LinodeMachine.Name, machineScope.LinodeMachine.Spec.Region, machineScope.LinodeMachine.Spec.Type)
// ###### WARNING ######
createConfig.StackScriptData = map[string]string{
"instancedata": b64.StdEncoding.EncodeToString([]byte(instanceData)),
"userdata": b64.StdEncoding.EncodeToString(bootstrapData),
}
}

if createConfig.Tags == nil {
Expand Down
Loading

0 comments on commit e3566e4

Please sign in to comment.