Skip to content
Merged
13 changes: 13 additions & 0 deletions api/v1alpha2/linodevpc_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,14 @@ type LinodeVPCSpec struct {
// +optional
Subnets []VPCSubnetCreateOptions `json:"subnets,omitempty"`

// Retain allows you to keep the VPC after the LinodeVPC object is deleted.
// This is useful if you want to use an existing VPC that was not created by this controller.
// If set to true, the controller will not delete the VPC resource in Linode.
// Defaults to false.
// +optional
// +kubebuilder:default=false
Retain bool `json:"retain,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
Expand All @@ -55,6 +63,11 @@ type VPCSubnetCreateOptions struct {
// SubnetID is subnet id for the subnet
// +optional
SubnetID int `json:"subnetID,omitempty"`
// Retain allows you to keep the Subnet after the LinodeVPC object is deleted.
// This is only applicable when the parent VPC has RetainVPC set to true.
// +optional
// +kubebuilder:default=false
Retain bool `json:"retain,omitempty"`
}

// LinodeVPCStatus defines the observed state of LinodeVPC
Expand Down
2 changes: 2 additions & 0 deletions clients/clients.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ type LinodeVPCClient interface {
ListVPCs(ctx context.Context, opts *linodego.ListOptions) ([]linodego.VPC, error)
CreateVPC(ctx context.Context, opts linodego.VPCCreateOptions) (*linodego.VPC, error)
DeleteVPC(ctx context.Context, vpcID int) error
CreateVPCSubnet(ctx context.Context, opts linodego.VPCSubnetCreateOptions, vpcID int) (*linodego.VPCSubnet, error)
DeleteVPCSubnet(ctx context.Context, vpcID, subnetID int) error
}

// LinodeNodeBalancerClient defines the methods that interact with Linode's Node Balancer service.
Expand Down
4 changes: 2 additions & 2 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -295,9 +295,9 @@
// LinodeVPC Controller
if err := (&controller.LinodeVPCReconciler{
Client: mgr.GetClient(),
Recorder: mgr.GetEventRecorderFor("LinodeVPCReconciler"),
WatchFilterValue: flags.clusterWatchFilter,
Recorder: mgr.GetEventRecorderFor("linodevpc-controller"),

Check warning on line 298 in cmd/main.go

View check run for this annotation

Codecov / codecov/patch

cmd/main.go#L298

Added line #L298 was not covered by tests
LinodeClientConfig: linodeClientConfig,
WatchFilterValue: flags.clusterWatchFilter,

Check warning on line 300 in cmd/main.go

View check run for this annotation

Codecov / codecov/patch

cmd/main.go#L300

Added line #L300 was not covered by tests
}).SetupWithManager(mgr, crcontroller.Options{MaxConcurrentReconciles: flags.linodeVPCConcurrency}); err != nil {
setupLog.Error(err, "unable to create controller", "controller", "LinodeVPC")
os.Exit(1)
Expand Down
14 changes: 14 additions & 0 deletions config/crd/bases/infrastructure.cluster.x-k8s.io_linodevpcs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,14 @@ spec:
x-kubernetes-validations:
- message: Value is immutable
rule: self == oldSelf
retain:
default: false
description: |-
Retain allows you to keep the VPC after the LinodeVPC object is deleted.
This is useful if you want to use an existing VPC that was not created by this controller.
If set to true, the controller will not delete the VPC resource in Linode.
Defaults to false.
type: boolean
subnets:
items:
description: VPCSubnetCreateOptions defines subnet options
Expand All @@ -82,6 +90,12 @@ spec:
maxLength: 63
minLength: 3
type: string
retain:
default: false
description: |-
Retain allows you to keep the Subnet after the LinodeVPC object is deleted.
This is only applicable when the parent VPC has RetainVPC set to true.
type: boolean
subnetID:
description: SubnetID is subnet id for the subnet
type: integer
Expand Down
2 changes: 2 additions & 0 deletions docs/src/reference/out.md
Original file line number Diff line number Diff line change
Expand Up @@ -1079,6 +1079,7 @@ _Appears in:_
| `description` _string_ | | | |
| `region` _string_ | | | |
| `subnets` _[VPCSubnetCreateOptions](#vpcsubnetcreateoptions) array_ | | | |
| `retain` _boolean_ | Retain allows you to keep the VPC after the LinodeVPC object is deleted.<br />This is useful if you want to use an existing VPC that was not created by this controller.<br />If set to true, the controller will not delete the VPC resource in Linode.<br />Defaults to false. | false | |
| `credentialsRef` _[SecretReference](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.32/#secretreference-v1-core)_ | CredentialsRef is a reference to a Secret that contains the credentials to use for provisioning this VPC. If not<br />supplied then the credentials of the controller will be used. | | |


Expand Down Expand Up @@ -1237,5 +1238,6 @@ _Appears in:_
| `label` _string_ | | | MaxLength: 63 <br />MinLength: 3 <br /> |
| `ipv4` _string_ | | | |
| `subnetID` _integer_ | SubnetID is subnet id for the subnet | | |
| `retain` _boolean_ | Retain allows you to keep the Subnet after the LinodeVPC object is deleted.<br />This is only applicable when the parent VPC has RetainVPC set to true. | false | |


32 changes: 32 additions & 0 deletions docs/src/topics/vpc.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,38 @@ spec:

Reference to LinodeVPC object is added to LinodeCluster object which then uses the specified VPC to provision resources.

## Lifecycle Management and Adopting Existing VPCs

The provider offers flexible lifecycle management, allowing you to adopt existing VPCs and control whether resources are deleted when their corresponding Kubernetes objects are removed.

### Adopting an Existing VPC
You can instruct the controller to use a pre-existing VPC by specifying its ID in the `LinodeVPCSpec`. The controller will "adopt" this VPC and manage its subnets without creating a new one.

```yaml
---
apiVersion: infrastructure.cluster.x-k8s.io/v1alpha2
kind: LinodeVPC
metadata:
name: my-adopted-vpc
spec:
vpcID: 12345
region: us-sea
# subnets can be defined and will be created within the adopted VPC
subnets:
- label: my-new-subnet-in-adopted-vpc
ipv4: 10.0.3.0/24
```

### Retaining Resources on Deletion
By default, the controller deletes VPCs and subnets from your Linode account when you delete the `LinodeVPC` Kubernetes object. You can prevent this using the `retain` flag.

- **`spec.retain`**: When set to `true` on the `LinodeVPC`, the VPC itself will not be deleted from Linode. This is the default and recommended behavior when adopting an existing VPC.
- **`spec.subnets[].retain`**: When the parent VPC is retained, you can use this flag to control individual subnets. If `retain` is `false` (the default), the subnet will be deleted.

```admonish warning title="Safety Check for Attached Linodes"
The controller includes a critical safety feature: it will **not** delete a subnet if it has any active Linode instances attached to it. The operation will be paused and retried, preventing resource orphaning.
```

### Additional Configuration
By default, the VPC will use the subnet with the `default` label for deploying clusters. To modify this behavior, set the `SUBNET_NAME` environment variable to match the label of the subnet to be used. Make sure the subnet is set up in the LinodeVPC manifest.

Expand Down
218 changes: 167 additions & 51 deletions internal/controller/linodevpc_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
"time"

"github.com/go-logr/logr"
"github.com/linode/linodego"
corev1 "k8s.io/api/core/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
Expand Down Expand Up @@ -289,85 +290,73 @@
return nil
}

//nolint:nestif,gocognit // As simple as possible.
func (r *LinodeVPCReconciler) reconcileDelete(ctx context.Context, logger logr.Logger, vpcScope *scope.VPCScope) (ctrl.Result, error) {
logger.Info("deleting VPC")

if vpcScope.LinodeVPC.Spec.VPCID != nil {
vpc, err := vpcScope.LinodeClient.GetVPC(ctx, *vpcScope.LinodeVPC.Spec.VPCID)
if util.IgnoreLinodeAPIError(err, http.StatusNotFound) != nil {
logger.Error(err, "Failed to fetch VPC")

if vpcScope.LinodeVPC.ObjectMeta.DeletionTimestamp.Add(reconciler.DefaultTimeout(r.ReconcileTimeout, reconciler.DefaultVPCControllerReconcileTimeout)).After(time.Now()) {
logger.Info("re-queuing VPC deletion")

return ctrl.Result{RequeueAfter: reconciler.DefaultVPCControllerReconcileDelay}, nil
}
if vpcScope.LinodeVPC.Spec.Retain {
return r.handleRetainedVPC(ctx, logger, vpcScope)
}

return ctrl.Result{}, err
if err := r.deleteVPCResources(ctx, logger, vpcScope); err != nil {
if errors.Is(err, util.ErrReconcileAgain) {
return ctrl.Result{RequeueAfter: reconciler.DefaultVPCControllerReconcileDelay}, nil
}
return ctrl.Result{}, err
}

if vpc != nil {
for i := range vpc.Subnets {
if len(vpc.Subnets[i].Linodes) == 0 {
continue
}

logger.Info("VPC subnets still has node(s) attached")

if vpc.Updated.Add(reconciler.DefaultTimeout(r.ReconcileTimeout, reconciler.DefaultVPCControllerWaitForHasNodesTimeout)).After(time.Now()) {
logger.Info("VPC has node(s) attached, re-queuing VPC deletion")

return ctrl.Result{RequeueAfter: reconciler.DefaultVPCControllerReconcileDelay}, nil
}
conditions.Set(vpcScope.LinodeVPC, metav1.Condition{
Type: string(clusterv1.ReadyCondition),
Status: metav1.ConditionFalse,
Reason: string(clusterv1.DeletedReason),
Message: "VPC deleted",
})
r.Recorder.Event(vpcScope.LinodeVPC, corev1.EventTypeNormal, clusterv1.DeletedReason, "VPC has cleaned up")

conditions.Set(vpcScope.LinodeVPC, metav1.Condition{
Type: string(clusterv1.ReadyCondition),
Status: metav1.ConditionFalse,
Reason: string(clusterv1.DeletionFailedReason),
Message: "skipped due to node(s) attached",
})
vpcScope.LinodeVPC.Spec.VPCID = nil

return ctrl.Result{}, errors.New("will not delete VPC with node(s) attached")
}
if err := vpcScope.RemoveCredentialsRefFinalizer(ctx); err != nil {
logger.Error(err, "Failed to update credentials secret")
if vpcScope.LinodeVPC.ObjectMeta.DeletionTimestamp.Add(reconciler.DefaultTimeout(r.ReconcileTimeout, reconciler.DefaultVPCControllerReconcileTimeout)).After(time.Now()) {
logger.Info("re-queuing VPC deletion")
return ctrl.Result{RequeueAfter: reconciler.DefaultVPCControllerReconcileDelay}, nil
}
return ctrl.Result{}, err

Check warning on line 323 in internal/controller/linodevpc_controller.go

View check run for this annotation

Codecov / codecov/patch

internal/controller/linodevpc_controller.go#L318-L323

Added lines #L318 - L323 were not covered by tests
}

err = vpcScope.LinodeClient.DeleteVPC(ctx, *vpcScope.LinodeVPC.Spec.VPCID)
if util.IgnoreLinodeAPIError(err, http.StatusNotFound) != nil {
logger.Error(err, "Failed to delete VPC")
controllerutil.RemoveFinalizer(vpcScope.LinodeVPC, infrav1alpha2.VPCFinalizer)
// TODO: remove this check and removal later
if controllerutil.ContainsFinalizer(vpcScope.LinodeVPC, infrav1alpha2.GroupVersion.String()) {
controllerutil.RemoveFinalizer(vpcScope.LinodeVPC, infrav1alpha2.GroupVersion.String())
}

Check warning on line 330 in internal/controller/linodevpc_controller.go

View check run for this annotation

Codecov / codecov/patch

internal/controller/linodevpc_controller.go#L329-L330

Added lines #L329 - L330 were not covered by tests

if vpcScope.LinodeVPC.ObjectMeta.DeletionTimestamp.Add(reconciler.DefaultTimeout(r.ReconcileTimeout, reconciler.DefaultVPCControllerReconcileTimeout)).After(time.Now()) {
logger.Info("re-queuing VPC deletion")
return ctrl.Result{}, nil
}

return ctrl.Result{RequeueAfter: reconciler.DefaultVPCControllerReconcileDelay}, nil
}
func (r *LinodeVPCReconciler) handleRetainedVPC(ctx context.Context, logger logr.Logger, vpcScope *scope.VPCScope) (ctrl.Result, error) {
logger.Info("VPC has retain flag, skipping VPC deletion")

return ctrl.Result{}, err
}
if err := r.handleRetainedSubnets(ctx, logger, vpcScope); err != nil {
if errors.Is(err, util.ErrReconcileAgain) {
return ctrl.Result{RequeueAfter: reconciler.DefaultVPCControllerReconcileDelay}, nil

Check warning on line 340 in internal/controller/linodevpc_controller.go

View check run for this annotation

Codecov / codecov/patch

internal/controller/linodevpc_controller.go#L339-L340

Added lines #L339 - L340 were not covered by tests
}
} else {
logger.Info("VPC ID is missing, nothing to do")
return ctrl.Result{}, err

Check warning on line 342 in internal/controller/linodevpc_controller.go

View check run for this annotation

Codecov / codecov/patch

internal/controller/linodevpc_controller.go#L342

Added line #L342 was not covered by tests
}

conditions.Set(vpcScope.LinodeVPC, metav1.Condition{
Type: string(clusterv1.ReadyCondition),
Status: metav1.ConditionFalse,
Reason: string(clusterv1.DeletedReason),
Message: "VPC deleted",
Message: "VPC retained as requested, associated cloud resource was not deleted.",
})

r.Recorder.Event(vpcScope.LinodeVPC, corev1.EventTypeNormal, clusterv1.DeletedReason, "VPC has cleaned up")

vpcScope.LinodeVPC.Spec.VPCID = nil
r.Recorder.Event(vpcScope.LinodeVPC, corev1.EventTypeNormal, "VPCResourceRetained", "VPC retained as requested, associated cloud resource was not deleted.")

if err := vpcScope.RemoveCredentialsRefFinalizer(ctx); err != nil {
logger.Error(err, "Failed to update credentials secret")

if vpcScope.LinodeVPC.ObjectMeta.DeletionTimestamp.Add(reconciler.DefaultTimeout(r.ReconcileTimeout, reconciler.DefaultVPCControllerReconcileTimeout)).After(time.Now()) {
logger.Info("re-queuing VPC deletion")

return ctrl.Result{RequeueAfter: reconciler.DefaultVPCControllerReconcileDelay}, nil
}

return ctrl.Result{}, err
}

Expand All @@ -380,6 +369,133 @@
return ctrl.Result{}, nil
}

func (r *LinodeVPCReconciler) handleRetainedSubnets(ctx context.Context, logger logr.Logger, vpcScope *scope.VPCScope) error {
if vpcScope.LinodeVPC.Spec.VPCID == nil {
return nil
}

Check warning on line 375 in internal/controller/linodevpc_controller.go

View check run for this annotation

Codecov / codecov/patch

internal/controller/linodevpc_controller.go#L374-L375

Added lines #L374 - L375 were not covered by tests

logger.Info("subnet deletion enabled, checking subnets")
vpc, err := vpcScope.LinodeClient.GetVPC(ctx, *vpcScope.LinodeVPC.Spec.VPCID)
if err != nil {
if util.IgnoreLinodeAPIError(err, http.StatusNotFound) == nil {
return nil
}
logger.Error(err, "Failed to fetch VPC for subnet deletion")
if vpcScope.LinodeVPC.ObjectMeta.DeletionTimestamp.Add(reconciler.DefaultTimeout(r.ReconcileTimeout, reconciler.DefaultVPCControllerReconcileTimeout)).After(time.Now()) {
logger.Info("re-queuing VPC deletion due to fetch error for subnet deletion")
return util.ErrReconcileAgain
}
return err

Check warning on line 388 in internal/controller/linodevpc_controller.go

View check run for this annotation

Codecov / codecov/patch

internal/controller/linodevpc_controller.go#L380-L388

Added lines #L380 - L388 were not covered by tests
}

if vpc == nil {
return nil
}

Check warning on line 393 in internal/controller/linodevpc_controller.go

View check run for this annotation

Codecov / codecov/patch

internal/controller/linodevpc_controller.go#L392-L393

Added lines #L392 - L393 were not covered by tests

// index the subnets by ID for quick lookup
apiSubnets := make(map[int]linodego.VPCSubnet)
for _, s := range vpc.Subnets {
apiSubnets[s.ID] = s
}

for _, subnet := range vpcScope.LinodeVPC.Spec.Subnets {
if subnet.Retain {
continue
}

if apiSubnet, ok := apiSubnets[subnet.SubnetID]; ok {
if len(apiSubnet.Linodes) > 0 {
logger.Info("subnet still has node(s) attached", "subnetID", subnet.SubnetID)
if vpcScope.LinodeVPC.ObjectMeta.DeletionTimestamp.Add(reconciler.DefaultTimeout(r.ReconcileTimeout, reconciler.DefaultVPCControllerWaitForHasNodesTimeout)).After(time.Now()) {
logger.Info("re-queuing VPC deletion due to attached nodes in subnet")
return util.ErrReconcileAgain
}

Check warning on line 412 in internal/controller/linodevpc_controller.go

View check run for this annotation

Codecov / codecov/patch

internal/controller/linodevpc_controller.go#L408-L412

Added lines #L408 - L412 were not covered by tests

conditions.Set(vpcScope.LinodeVPC, metav1.Condition{
Type: string(clusterv1.ReadyCondition),
Status: metav1.ConditionFalse,
Reason: string(clusterv1.DeletionFailedReason),
Message: fmt.Sprintf("will not delete subnet %d with node(s) attached", subnet.SubnetID),
})
return fmt.Errorf("will not delete subnet %d with node(s) attached", subnet.SubnetID)

Check warning on line 420 in internal/controller/linodevpc_controller.go

View check run for this annotation

Codecov / codecov/patch

internal/controller/linodevpc_controller.go#L414-L420

Added lines #L414 - L420 were not covered by tests
}
}

logger.Info("deleting subnet", "subnetID", subnet.SubnetID)
deleteErr := vpcScope.LinodeClient.DeleteVPCSubnet(ctx, *vpcScope.LinodeVPC.Spec.VPCID, subnet.SubnetID)
if deleteErr != nil {
if err := util.IgnoreLinodeAPIError(deleteErr, http.StatusNotFound); err != nil {
logger.Error(err, "Failed to delete subnet")
if vpcScope.LinodeVPC.ObjectMeta.DeletionTimestamp.Add(reconciler.DefaultTimeout(r.ReconcileTimeout, reconciler.DefaultVPCControllerReconcileTimeout)).After(time.Now()) {
logger.Info("re-queuing VPC deletion due to subnet delete error")
return util.ErrReconcileAgain
}
return err

Check warning on line 433 in internal/controller/linodevpc_controller.go

View check run for this annotation

Codecov / codecov/patch

internal/controller/linodevpc_controller.go#L427-L433

Added lines #L427 - L433 were not covered by tests
}
}
}

return nil
}

func (r *LinodeVPCReconciler) deleteVPCResources(ctx context.Context, logger logr.Logger, vpcScope *scope.VPCScope) error {
if vpcScope.LinodeVPC.Spec.VPCID == nil {
logger.Info("VPC ID is missing, nothing to do")
return nil
}

Check warning on line 445 in internal/controller/linodevpc_controller.go

View check run for this annotation

Codecov / codecov/patch

internal/controller/linodevpc_controller.go#L443-L445

Added lines #L443 - L445 were not covered by tests

vpc, err := vpcScope.LinodeClient.GetVPC(ctx, *vpcScope.LinodeVPC.Spec.VPCID)
if err != nil {
if util.IgnoreLinodeAPIError(err, http.StatusNotFound) == nil {
return nil
}

Check warning on line 451 in internal/controller/linodevpc_controller.go

View check run for this annotation

Codecov / codecov/patch

internal/controller/linodevpc_controller.go#L450-L451

Added lines #L450 - L451 were not covered by tests
logger.Error(err, "Failed to fetch VPC")
if vpcScope.LinodeVPC.ObjectMeta.DeletionTimestamp.Add(reconciler.DefaultTimeout(r.ReconcileTimeout, reconciler.DefaultVPCControllerReconcileTimeout)).After(time.Now()) {
logger.Info("re-queuing VPC deletion due to fetch error")
return util.ErrReconcileAgain
}
return err
}
if vpc == nil {
logger.Info("VPC not found, nothing to do")
return nil
}

Check warning on line 462 in internal/controller/linodevpc_controller.go

View check run for this annotation

Codecov / codecov/patch

internal/controller/linodevpc_controller.go#L460-L462

Added lines #L460 - L462 were not covered by tests

for i := range vpc.Subnets {
if len(vpc.Subnets[i].Linodes) == 0 {
continue
}

logger.Info("VPC subnets still has node(s) attached")
if vpc.Updated.Add(reconciler.DefaultTimeout(r.ReconcileTimeout, reconciler.DefaultVPCControllerWaitForHasNodesTimeout)).After(time.Now()) {
logger.Info("VPC has node(s) attached, re-queuing VPC deletion")
return util.ErrReconcileAgain
}

conditions.Set(vpcScope.LinodeVPC, metav1.Condition{
Type: string(clusterv1.ReadyCondition),
Status: metav1.ConditionFalse,
Reason: string(clusterv1.DeletionFailedReason),
Message: "skipped due to node(s) attached",
})
return errors.New("will not delete VPC with node(s) attached")
}

if err := vpcScope.LinodeClient.DeleteVPC(ctx, *vpcScope.LinodeVPC.Spec.VPCID); err != nil {
if util.IgnoreLinodeAPIError(err, http.StatusNotFound) == nil {
return nil
}

Check warning on line 487 in internal/controller/linodevpc_controller.go

View check run for this annotation

Codecov / codecov/patch

internal/controller/linodevpc_controller.go#L486-L487

Added lines #L486 - L487 were not covered by tests
logger.Error(err, "Failed to delete VPC")
if vpcScope.LinodeVPC.ObjectMeta.DeletionTimestamp.Add(reconciler.DefaultTimeout(r.ReconcileTimeout, reconciler.DefaultVPCControllerReconcileTimeout)).After(time.Now()) {
logger.Info("re-queuing VPC deletion due to delete error")
return util.ErrReconcileAgain
}
return err
}

return nil
}

// SetupWithManager sets up the controller with the Manager.
//
//nolint:dupl // this is same as Placement Group, worth making generic later.
Expand Down
Loading
Loading