Skip to content

Commit f60ac2e

Browse files
Add capi-auth-token file to control plane machines (#115)
1 parent eab4b8b commit f60ac2e

File tree

9 files changed

+241
-0
lines changed

9 files changed

+241
-0
lines changed

Dockerfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ RUN go mod download
1515
COPY main.go main.go
1616
COPY apis/ apis/
1717
COPY controllers/ controllers/
18+
COPY pkg/ pkg/
1819

1920
# Build
2021
RUN CGO_ENABLED=0 GOOS=linux GOARCH=$arch go build -a -ldflags '-s -w' -o manager main.go

controllers/cloudinit/cloudinit.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,14 @@ package cloudinit
1919
import (
2020
"bytes"
2121
"fmt"
22+
"path/filepath"
2223
"text/template"
2324
)
2425

26+
var (
27+
CAPIAuthTokenPath = filepath.Join("/capi", "etc", "token")
28+
)
29+
2530
// File is a file that cloud-init will create.
2631
type File struct {
2732
// Content of the file to create.

controllers/cloudinit/controlplane_init.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ import (
2727

2828
// ControlPlaneInitInput defines the context needed to generate a controlplane instance to init a cluster.
2929
type ControlPlaneInitInput struct {
30+
// AuthToken will be used for authenticating CAPI-only requests to the cluster-agent.
31+
AuthToken string
3032
// CAKey is the PEM-encoded key of the cluster CA certificate.
3133
CAKey string
3234
// CACert is the PEM-encoded cert of the cluster CA certificate.
@@ -131,6 +133,7 @@ func NewInitControlPlane(input *ControlPlaneInitInput) (*CloudConfig, error) {
131133
cloudConfig.WriteFiles,
132134
File{Content: input.CAKey, Path: filepath.Join("/var", "tmp", "ca.key"), Permissions: "0600", Owner: "root:root"},
133135
File{Content: input.CACert, Path: filepath.Join("/var", "tmp", "ca.crt"), Permissions: "0600", Owner: "root:root"},
136+
File{Content: input.AuthToken, Path: CAPIAuthTokenPath, Permissions: "0600", Owner: "root:root"},
134137
)
135138
cloudConfig.WriteFiles = append(cloudConfig.WriteFiles, input.ExtraWriteFiles...)
136139

controllers/cloudinit/controlplane_init_test.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@ func TestControlPlaneInit(t *testing.T) {
2828
t.Run("Simple", func(t *testing.T) {
2929
g := NewWithT(t)
3030

31+
authToken := "capi-auth-token"
3132
cloudConfig, err := cloudinit.NewInitControlPlane(&cloudinit.ControlPlaneInitInput{
33+
AuthToken: authToken,
3234
CAKey: `CA KEY DATA`,
3335
CACert: `CA CERT DATA`,
3436
ControlPlaneEndpoint: "k8s.my-domain.com",
@@ -75,6 +77,12 @@ func TestControlPlaneInit(t *testing.T) {
7577
Permissions: "0600",
7678
Owner: "root:root",
7779
},
80+
cloudinit.File{
81+
Content: authToken,
82+
Path: cloudinit.CAPIAuthTokenPath,
83+
Permissions: "0600",
84+
Owner: "root:root",
85+
},
7886
))
7987

8088
_, err = cloudinit.GenerateCloudConfig(cloudConfig)

controllers/cloudinit/controlplane_join.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ import (
2727

2828
// ControlPlaneJoinInput defines the context needed to generate a controlplane instance to join a cluster.
2929
type ControlPlaneJoinInput struct {
30+
// AuthToken will be used for authenticating CAPI-only requests to the cluster-agent.
31+
AuthToken string
3032
// ControlPlaneEndpoint is the control plane endpoint of the cluster.
3133
ControlPlaneEndpoint string
3234
// Token is the token that will be used for joining other nodes to the cluster.
@@ -109,6 +111,12 @@ func NewJoinControlPlane(input *ControlPlaneJoinInput) (*CloudConfig, error) {
109111
}
110112

111113
cloudConfig := NewBaseCloudConfig()
114+
cloudConfig.WriteFiles = append(cloudConfig.WriteFiles, File{
115+
Content: input.AuthToken,
116+
Path: CAPIAuthTokenPath,
117+
Permissions: "0600",
118+
Owner: "root:root",
119+
})
112120
cloudConfig.WriteFiles = append(cloudConfig.WriteFiles, input.ExtraWriteFiles...)
113121
if args := input.ExtraKubeletArgs; len(args) > 0 {
114122
cloudConfig.WriteFiles = append(cloudConfig.WriteFiles, File{

controllers/cloudinit/controlplane_join_test.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@ func TestControlPlaneJoin(t *testing.T) {
2828
t.Run("Simple", func(t *testing.T) {
2929
g := NewWithT(t)
3030

31+
authToken := "capi-auth-token"
3132
cloudConfig, err := cloudinit.NewJoinControlPlane(&cloudinit.ControlPlaneJoinInput{
33+
AuthToken: authToken,
3234
ControlPlaneEndpoint: "k8s.my-domain.com",
3335
KubernetesVersion: "v1.25.2",
3436
ClusterAgentPort: "30000",
@@ -60,6 +62,15 @@ func TestControlPlaneJoin(t *testing.T) {
6062
`microk8s add-node --token-ttl 10000 --token "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"`,
6163
}))
6264

65+
g.Expect(cloudConfig.WriteFiles).To(ContainElements(
66+
cloudinit.File{
67+
Content: authToken,
68+
Path: cloudinit.CAPIAuthTokenPath,
69+
Permissions: "0600",
70+
Owner: "root:root",
71+
},
72+
))
73+
6374
_, err = cloudinit.GenerateCloudConfig(cloudConfig)
6475
g.Expect(err).ToNot(HaveOccurred())
6576
})

controllers/microk8sconfig_controller.go

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,8 @@ import (
5959
"sigs.k8s.io/controller-runtime/pkg/handler"
6060
"sigs.k8s.io/controller-runtime/pkg/log"
6161
"sigs.k8s.io/controller-runtime/pkg/source"
62+
63+
tokenpkg "github.com/canonical/cluster-api-bootstrap-provider-microk8s/pkg/token"
6264
)
6365

6466
type InitLocker interface {
@@ -213,6 +215,10 @@ func (r *MicroK8sConfigReconciler) Reconcile(ctx context.Context, req ctrl.Reque
213215
return ctrl.Result{}, nil
214216
}
215217

218+
if err = tokenpkg.Reconcile(ctx, r.Client, util.ObjectKey(scope.Cluster)); err != nil {
219+
return ctrl.Result{}, fmt.Errorf("failed to reconcile token: %w", err)
220+
}
221+
216222
// Note: can't use IsFalse here because we need to handle the absence of the condition as well as false.
217223
if !conditions.IsTrue(cluster, clusterv1.ControlPlaneInitializedCondition) {
218224
log.Info("Cluster control plane is not initialized, waiting")
@@ -296,7 +302,13 @@ func (r *MicroK8sConfigReconciler) handleClusterNotInitialized(ctx context.Conte
296302
portOfDqlite = remappedDqlitePort
297303
}
298304

305+
authToken, err := tokenpkg.Lookup(ctx, r.Client, util.ObjectKey(scope.Cluster))
306+
if err != nil {
307+
return ctrl.Result{}, fmt.Errorf("failed to lookup auth token: %w", err)
308+
}
309+
299310
controlPlaneInput := &cloudinit.ControlPlaneInitInput{
311+
AuthToken: authToken,
300312
CACert: *cert,
301313
CAKey: *key,
302314
ControlPlaneEndpoint: scope.Cluster.Spec.ControlPlaneEndpoint.Host,
@@ -403,7 +415,13 @@ func (r *MicroK8sConfigReconciler) handleJoiningControlPlaneNode(ctx context.Con
403415
return ctrl.Result{RequeueAfter: 30 * time.Second}, nil
404416
}
405417

418+
authToken, err := tokenpkg.Lookup(ctx, r.Client, util.ObjectKey(scope.Cluster))
419+
if err != nil {
420+
return ctrl.Result{}, fmt.Errorf("failed to lookup auth token: %w", err)
421+
}
422+
406423
controlPlaneInput := &cloudinit.ControlPlaneJoinInput{
424+
AuthToken: authToken,
407425
ControlPlaneEndpoint: scope.Cluster.Spec.ControlPlaneEndpoint.Host,
408426
Token: token,
409427
TokenTTL: microk8sConfig.Spec.InitConfiguration.JoinTokenTTLInSecs,

pkg/token/token.go

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package token
2+
3+
import (
4+
"context"
5+
cryptorand "crypto/rand"
6+
"encoding/base64"
7+
"fmt"
8+
9+
corev1 "k8s.io/api/core/v1"
10+
apierrors "k8s.io/apimachinery/pkg/api/errors"
11+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
12+
clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1"
13+
"sigs.k8s.io/controller-runtime/pkg/client"
14+
)
15+
16+
const (
17+
AuthTokenNameSuffix = "capi-auth-token"
18+
)
19+
20+
// Reconcile ensures that a token secret exists for the given cluster.
21+
func Reconcile(ctx context.Context, c client.Client, clusterKey client.ObjectKey) error {
22+
if _, err := getSecret(ctx, c, clusterKey); err != nil {
23+
if apierrors.IsNotFound(err) {
24+
if _, err := generateAndStore(ctx, c, clusterKey); err != nil {
25+
return fmt.Errorf("failed to generate and store token: %w", err)
26+
}
27+
return nil
28+
}
29+
}
30+
31+
return nil
32+
}
33+
34+
// Lookup retrieves the token for the given cluster.
35+
func Lookup(ctx context.Context, c client.Client, clusterKey client.ObjectKey) (string, error) {
36+
secret, err := getSecret(ctx, c, clusterKey)
37+
if err != nil {
38+
return "", fmt.Errorf("failed to get secret: %w", err)
39+
}
40+
41+
v, ok := secret.Data["token"]
42+
if !ok {
43+
return "", fmt.Errorf("token not found in secret")
44+
}
45+
46+
return string(v), nil
47+
}
48+
49+
// authTokenName returns the name of the auth-token secret, computed by convention using the name of the cluster.
50+
func authTokenName(clusterName string) string {
51+
return fmt.Sprintf("%s-%s", clusterName, AuthTokenNameSuffix)
52+
}
53+
54+
// getSecret retrieves the token secret for the given cluster.
55+
func getSecret(ctx context.Context, c client.Client, clusterKey client.ObjectKey) (*corev1.Secret, error) {
56+
s := &corev1.Secret{}
57+
key := client.ObjectKey{
58+
Name: authTokenName(clusterKey.Name),
59+
Namespace: clusterKey.Namespace,
60+
}
61+
if err := c.Get(ctx, key, s); err != nil {
62+
return nil, fmt.Errorf("failed to get secret: %w", err)
63+
}
64+
65+
return s, nil
66+
}
67+
68+
// generateAndStore generates a new token and stores it in a secret.
69+
func generateAndStore(ctx context.Context, c client.Client, clusterKey client.ObjectKey) (*corev1.Secret, error) {
70+
token, err := randomB64(16)
71+
if err != nil {
72+
return nil, fmt.Errorf("failed to generate token: %w", err)
73+
}
74+
75+
secret := &corev1.Secret{
76+
ObjectMeta: metav1.ObjectMeta{
77+
Namespace: clusterKey.Namespace,
78+
Name: authTokenName(clusterKey.Name),
79+
},
80+
Data: map[string][]byte{
81+
"token": []byte(token),
82+
},
83+
Type: clusterv1.ClusterSecretType,
84+
}
85+
86+
if err := c.Create(ctx, secret); err != nil {
87+
return nil, fmt.Errorf("failed to create secret: %w", err)
88+
}
89+
90+
return secret, nil
91+
}
92+
93+
// randomB64 generates a random base64 string of n bytes.
94+
func randomB64(n int) (string, error) {
95+
b := make([]byte, n)
96+
_, err := cryptorand.Read(b)
97+
if err != nil {
98+
return "", fmt.Errorf("failed to read random bytes: %w", err)
99+
}
100+
return base64.StdEncoding.EncodeToString(b), nil
101+
}

pkg/token/token_test.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package token_test
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"testing"
7+
8+
. "github.com/onsi/gomega"
9+
corev1 "k8s.io/api/core/v1"
10+
v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
11+
"sigs.k8s.io/controller-runtime/pkg/client"
12+
"sigs.k8s.io/controller-runtime/pkg/client/fake"
13+
14+
"github.com/canonical/cluster-api-bootstrap-provider-microk8s/pkg/token"
15+
)
16+
17+
func TestReconcile(t *testing.T) {
18+
t.Run("SecretAvailableSucceeds", func(t *testing.T) {
19+
namespace := "test-namespace"
20+
clusterName := "test-cluster"
21+
secret := &corev1.Secret{
22+
ObjectMeta: v1.ObjectMeta{
23+
Name: fmt.Sprintf("%s-%s", clusterName, token.AuthTokenNameSuffix),
24+
Namespace: namespace,
25+
},
26+
}
27+
c := fake.NewClientBuilder().WithObjects(secret).Build()
28+
29+
g := NewWithT(t)
30+
31+
g.Expect(token.Reconcile(context.Background(), c, client.ObjectKey{Name: clusterName, Namespace: namespace})).To(Succeed())
32+
})
33+
34+
t.Run("SecretNotFoundGenerates", func(t *testing.T) {
35+
namespace := "test-namespace"
36+
clusterName := "test-cluster"
37+
c := fake.NewClientBuilder().Build()
38+
39+
g := NewWithT(t)
40+
41+
g.Expect(token.Reconcile(context.Background(), c, client.ObjectKey{Name: clusterName, Namespace: namespace})).To(Succeed())
42+
43+
s := &corev1.Secret{}
44+
key := client.ObjectKey{
45+
Name: fmt.Sprintf("%s-%s", clusterName, token.AuthTokenNameSuffix),
46+
Namespace: namespace,
47+
}
48+
g.Expect(c.Get(context.Background(), key, s)).To(Succeed())
49+
g.Expect(s.ObjectMeta.Name).To(Equal(fmt.Sprintf("%s-%s", clusterName, token.AuthTokenNameSuffix)))
50+
g.Expect(s.ObjectMeta.Namespace).To(Equal(namespace))
51+
g.Expect(string(s.Data["token"])).ToNot(BeEmpty())
52+
})
53+
54+
t.Run("LookupFailsIfNoSecret", func(t *testing.T) {
55+
namespace := "test-namespace"
56+
clusterName := "test-cluster"
57+
c := fake.NewClientBuilder().Build()
58+
59+
g := NewWithT(t)
60+
61+
_, err := token.Lookup(context.Background(), c, client.ObjectKey{Name: clusterName, Namespace: namespace})
62+
g.Expect(err).To(HaveOccurred())
63+
})
64+
65+
t.Run("LookupSucceedsIfSecretExists", func(t *testing.T) {
66+
namespace := "test-namespace"
67+
clusterName := "test-cluster"
68+
expToken := "test-token"
69+
secret := &corev1.Secret{
70+
ObjectMeta: v1.ObjectMeta{
71+
Name: fmt.Sprintf("%s-%s", clusterName, token.AuthTokenNameSuffix),
72+
Namespace: namespace,
73+
},
74+
Data: map[string][]byte{
75+
"token": []byte(expToken),
76+
},
77+
}
78+
c := fake.NewClientBuilder().WithObjects(secret).Build()
79+
80+
g := NewWithT(t)
81+
82+
token, err := token.Lookup(context.Background(), c, client.ObjectKey{Name: clusterName, Namespace: namespace})
83+
g.Expect(err).ToNot(HaveOccurred())
84+
g.Expect(token).To(Equal(expToken))
85+
})
86+
}

0 commit comments

Comments
 (0)