diff --git a/.markdownlinkcheck.json b/.markdownlinkcheck.json index 9c14d740..8687e528 100644 --- a/.markdownlinkcheck.json +++ b/.markdownlinkcheck.json @@ -1,6 +1,8 @@ { "ignorePatterns": [ { "pattern": "^https://www.linode.com" }, + { "pattern": "^https://www.techdocs.akamai.com" }, + { "pattern": "^https://techdocs.akamai.com" }, { "pattern": "^https://linode.com" }, { "pattern": "^http://localhost" } ], diff --git a/controller/linodemachine_controller_helpers.go b/controller/linodemachine_controller_helpers.go index 5cee4c49..d73321d4 100644 --- a/controller/linodemachine_controller_helpers.go +++ b/controller/linodemachine_controller_helpers.go @@ -111,15 +111,16 @@ func newCreateConfig(ctx context.Context, machineScope *scope.MachineScope, logg // if vpc, attach additional interface as eth0 to linode if machineScope.LinodeCluster.Spec.VPCRef != nil { - iface, err := getVPCInterfaceConfig(ctx, machineScope, logger) + iface, err := getVPCInterfaceConfig(ctx, machineScope, createConfig.Interfaces, logger) if err != nil { logger.Error(err, "Failed to get VPC interface config") return nil, err } - - // add VPC interface as first interface - createConfig.Interfaces = slices.Insert(createConfig.Interfaces, 0, *iface) + if iface != nil { + // add VPC interface as first interface + createConfig.Interfaces = slices.Insert(createConfig.Interfaces, 0, *iface) + } } if machineScope.LinodeMachine.Spec.PlacementGroupRef != nil { @@ -333,7 +334,7 @@ func getFirewallID(ctx context.Context, machineScope *scope.MachineScope, logger return *linodeFirewall.Spec.FirewallID, nil } -func getVPCInterfaceConfig(ctx context.Context, machineScope *scope.MachineScope, logger logr.Logger) (*linodego.InstanceConfigInterfaceCreateOptions, error) { +func getVPCInterfaceConfig(ctx context.Context, machineScope *scope.MachineScope, interfaces []linodego.InstanceConfigInterfaceCreateOptions, logger logr.Logger) (*linodego.InstanceConfigInterfaceCreateOptions, error) { name := machineScope.LinodeCluster.Spec.VPCRef.Name namespace := machineScope.LinodeCluster.Spec.VPCRef.Namespace if namespace == "" { @@ -381,6 +382,12 @@ func getVPCInterfaceConfig(ctx context.Context, machineScope *scope.MachineScope }) subnetID = vpc.Subnets[0].ID + for i, netInterface := range interfaces { + if netInterface.Purpose == linodego.InterfacePurposeVPC { + interfaces[i].SubnetID = &subnetID + return nil, nil //nolint:nilnil // it is important we don't return an interface if a VPC interface already exists + } + } return &linodego.InstanceConfigInterfaceCreateOptions{ Purpose: linodego.InterfacePurposeVPC, diff --git a/controller/linodemachine_controller_test.go b/controller/linodemachine_controller_test.go index bc201121..13998d1b 100644 --- a/controller/linodemachine_controller_test.go +++ b/controller/linodemachine_controller_test.go @@ -1566,3 +1566,271 @@ var _ = Describe("machine in PlacementGroup", Label("machine", "placementGroup") Expect(createOpts.FirewallID).To(Equal(2)) }) }) + +var _ = Describe("machine in VPC", Label("machine", "VPC"), Ordered, func() { + var machine clusterv1.Machine + var secret corev1.Secret + var lvpcReconciler *LinodeVPCReconciler + var linodeVPC infrav1alpha2.LinodeVPC + + var mockCtrl *gomock.Controller + var testLogs *bytes.Buffer + var logger logr.Logger + + cluster := clusterv1.Cluster{ + ObjectMeta: metav1.ObjectMeta{ + Name: "mock", + Namespace: defaultNamespace, + }, + } + + linodeCluster := infrav1alpha2.LinodeCluster{ + Spec: infrav1alpha2.LinodeClusterSpec{ + Region: "us-ord", + Network: infrav1alpha2.NetworkSpec{ + LoadBalancerType: "dns", + DNSRootDomain: "lkedevs.net", + DNSUniqueIdentifier: "abc123", + DNSTTLSec: 30, + }, + VPCRef: &corev1.ObjectReference{ + Namespace: "default", + Kind: "LinodeVPC", + Name: "test-cluster", + }, + }, + } + + recorder := record.NewFakeRecorder(10) + + BeforeEach(func(ctx SpecContext) { + secret = corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "bootstrap-secret", + Namespace: defaultNamespace, + }, + Data: map[string][]byte{ + "value": []byte("userdata"), + }, + } + Expect(k8sClient.Create(ctx, &secret)).To(Succeed()) + + machine = clusterv1.Machine{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: defaultNamespace, + Labels: make(map[string]string), + }, + Spec: clusterv1.MachineSpec{ + Bootstrap: clusterv1.Bootstrap{ + DataSecretName: ptr.To("bootstrap-secret"), + }, + }, + } + + linodeVPC = infrav1alpha2.LinodeVPC{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-cluster", + Namespace: defaultNamespace, + UID: "5123122", + }, + Spec: infrav1alpha2.LinodeVPCSpec{ + VPCID: ptr.To(1), + Region: "us-ord", + Subnets: []infrav1alpha2.VPCSubnetCreateOptions{}, + }, + Status: infrav1alpha2.LinodeVPCStatus{ + Ready: true, + }, + } + Expect(k8sClient.Create(ctx, &linodeVPC)).To(Succeed()) + + lvpcReconciler = &LinodeVPCReconciler{ + Recorder: recorder, + Client: k8sClient, + } + + mockCtrl = gomock.NewController(GinkgoT()) + testLogs = &bytes.Buffer{} + logger = zap.New( + zap.WriteTo(GinkgoWriter), + zap.WriteTo(testLogs), + zap.UseDevMode(true), + ) + }) + + AfterEach(func(ctx SpecContext) { + Expect(k8sClient.Delete(ctx, &secret)).To(Succeed()) + var currentVPC infrav1alpha2.LinodeVPC + Expect(k8sClient.Get(ctx, client.ObjectKeyFromObject(&linodeVPC), ¤tVPC)).To(Succeed()) + currentVPC.Finalizers = nil + + Expect(k8sClient.Update(ctx, ¤tVPC)).To(Succeed()) + + Expect(k8sClient.Delete(ctx, ¤tVPC)).To(Succeed()) + + mockCtrl.Finish() + for len(recorder.Events) > 0 { + <-recorder.Events + } + }) + + It("creates a instance with vpc", func(ctx SpecContext) { + linodeMachine := infrav1alpha2.LinodeMachine{ + ObjectMeta: metav1.ObjectMeta{ + Name: "mock", + Namespace: defaultNamespace, + UID: "12345", + }, + Spec: infrav1alpha2.LinodeMachineSpec{ + ProviderID: ptr.To("linode://0"), + Type: "g6-nanode-1", + Interfaces: []infrav1alpha2.InstanceConfigInterfaceCreateOptions{ + { + Primary: true, + }, + }, + }, + } + mockLinodeClient := mock.NewMockLinodeClient(mockCtrl) + getRegion := mockLinodeClient.EXPECT(). + GetRegion(ctx, gomock.Any()). + Return(&linodego.Region{Capabilities: []string{linodego.CapabilityMetadata, infrav1alpha2.LinodePlacementGroupCapability}}, nil) + mockLinodeClient.EXPECT(). + GetImage(ctx, gomock.Any()). + After(getRegion). + Return(&linodego.Image{Capabilities: []string{"cloud-init"}}, nil) + mockLinodeClient.EXPECT(). + ListVPCs(ctx, gomock.Any()). + Return([]linodego.VPC{}, nil) + mockLinodeClient.EXPECT(). + CreateVPC(ctx, gomock.Any()). + Return(&linodego.VPC{ID: 1}, nil) + mockLinodeClient.EXPECT(). + GetVPC(ctx, gomock.Any()). + Return(&linodego.VPC{ID: 1, Subnets: []linodego.VPCSubnet{{ + ID: 1, + Label: "test", + IPv4: "10.0.0.0/24", + }}}, nil) + helper, err := patch.NewHelper(&linodeVPC, k8sClient) + Expect(err).NotTo(HaveOccurred()) + + _, err = lvpcReconciler.reconcile(ctx, logger, &scope.VPCScope{ + PatchHelper: helper, + Client: k8sClient, + LinodeClient: mockLinodeClient, + LinodeVPC: &linodeVPC, + }) + + Expect(err).NotTo(HaveOccurred()) + + mScope := scope.MachineScope{ + Client: k8sClient, + LinodeClient: mockLinodeClient, + Cluster: &cluster, + Machine: &machine, + LinodeCluster: &linodeCluster, + LinodeMachine: &linodeMachine, + } + + patchHelper, err := patch.NewHelper(mScope.LinodeMachine, k8sClient) + Expect(err).NotTo(HaveOccurred()) + mScope.PatchHelper = patchHelper + + createOpts, err := newCreateConfig(ctx, &mScope, logger) + Expect(err).NotTo(HaveOccurred()) + Expect(createOpts).NotTo(BeNil()) + Expect(createOpts.Interfaces).To(Equal([]linodego.InstanceConfigInterfaceCreateOptions{ + { + Purpose: linodego.InterfacePurposeVPC, + Primary: true, + SubnetID: ptr.To(1), + IPv4: &linodego.VPCIPv4{NAT1To1: ptr.To("any")}, + }, + { + Primary: true, + }})) + }) + It("creates a instance with pre defined vpc interface", func(ctx SpecContext) { + linodeMachine := infrav1alpha2.LinodeMachine{ + ObjectMeta: metav1.ObjectMeta{ + Name: "mock", + Namespace: defaultNamespace, + UID: "12345", + }, + Spec: infrav1alpha2.LinodeMachineSpec{ + ProviderID: ptr.To("linode://0"), + Type: "g6-nanode-1", + Interfaces: []infrav1alpha2.InstanceConfigInterfaceCreateOptions{ + { + Purpose: linodego.InterfacePurposeVPC, + Primary: false, + }, + { + Purpose: linodego.InterfacePurposePublic, + Primary: true, + }, + }, + }, + } + mockLinodeClient := mock.NewMockLinodeClient(mockCtrl) + getRegion := mockLinodeClient.EXPECT(). + GetRegion(ctx, gomock.Any()). + Return(&linodego.Region{Capabilities: []string{linodego.CapabilityMetadata, infrav1alpha2.LinodePlacementGroupCapability}}, nil) + mockLinodeClient.EXPECT(). + GetImage(ctx, gomock.Any()). + After(getRegion). + Return(&linodego.Image{Capabilities: []string{"cloud-init"}}, nil) + mockLinodeClient.EXPECT(). + ListVPCs(ctx, gomock.Any()). + Return([]linodego.VPC{}, nil) + mockLinodeClient.EXPECT(). + CreateVPC(ctx, gomock.Any()). + Return(&linodego.VPC{ID: 1}, nil) + mockLinodeClient.EXPECT(). + GetVPC(ctx, gomock.Any()). + Return(&linodego.VPC{ID: 1, Subnets: []linodego.VPCSubnet{{ + ID: 1, + Label: "test", + IPv4: "10.0.0.0/24", + }}}, nil) + helper, err := patch.NewHelper(&linodeVPC, k8sClient) + Expect(err).NotTo(HaveOccurred()) + + _, err = lvpcReconciler.reconcile(ctx, logger, &scope.VPCScope{ + PatchHelper: helper, + Client: k8sClient, + LinodeClient: mockLinodeClient, + LinodeVPC: &linodeVPC, + }) + + Expect(err).NotTo(HaveOccurred()) + + mScope := scope.MachineScope{ + Client: k8sClient, + LinodeClient: mockLinodeClient, + Cluster: &cluster, + Machine: &machine, + LinodeCluster: &linodeCluster, + LinodeMachine: &linodeMachine, + } + + patchHelper, err := patch.NewHelper(mScope.LinodeMachine, k8sClient) + Expect(err).NotTo(HaveOccurred()) + mScope.PatchHelper = patchHelper + + createOpts, err := newCreateConfig(ctx, &mScope, logger) + Expect(err).NotTo(HaveOccurred()) + Expect(createOpts).NotTo(BeNil()) + Expect(createOpts.Interfaces).To(Equal([]linodego.InstanceConfigInterfaceCreateOptions{ + { + Purpose: linodego.InterfacePurposeVPC, + Primary: false, + SubnetID: ptr.To(1), + }, + { + Purpose: linodego.InterfacePurposePublic, + Primary: true, + }})) + }) +}) diff --git a/docs/src/topics/vpc.md b/docs/src/topics/vpc.md index 119952c2..f0b368e0 100644 --- a/docs/src/topics/vpc.md +++ b/docs/src/topics/vpc.md @@ -15,6 +15,32 @@ Key facts about VPC network configuration: 4. [Kubernetes host-scope IPAM mode](https://docs.cilium.io/en/stable/network/concepts/ipam/kubernetes/) is used to assign pod CIDRs to nodes. We run [linode CCM](https://github.com/linode/linode-cloud-controller-manager) with [route-controller enabled](https://github.com/linode/linode-cloud-controller-manager?tab=readme-ov-file#routes) which automatically adds/updates routes within VPC when pod cidrs are added/updated by k8s. This enables pod-to-pod traffic to be routable within the VPC. 5. kube-proxy is disabled by default. + +## Configuring the VPC interface +In order to configure the VPC interface beyond the default above, an explicit interface can be configured in the `LinodeMachineTemplate`. +When the `LinodeMachine` controller find an interface with `purpose: vpc` it will automatically inject the `SubnetID` from the +`VPCRef`. + +_Example template where the VPC interface is not the primary interface_ +```yaml +--- +apiVersion: infrastructure.cluster.x-k8s.io/v1alpha2 +kind: LinodeMachineTemplate +metadata: + name: test-cluster-md-0 + namespace: default +spec: + template: + spec: + region: "us-mia" + type: "g6-standard-4" + image: linode/ubuntu22.04 + interfaces: + - purpose: vpc + primary: false + - purpose: public + primary: true +``` ## How VPC is provisioned A VPC is tied to a region. CAPL generates LinodeVPC manifest which contains the VPC name, region and subnet information. By defult, VPC name is set to cluster name but can be overwritten by specifying relevant environment variable.