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
2 changes: 1 addition & 1 deletion cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -296,8 +296,8 @@
if err := (&controller.LinodeVPCReconciler{
Client: mgr.GetClient(),
Recorder: mgr.GetEventRecorderFor("LinodeVPCReconciler"),
WatchFilterValue: flags.clusterWatchFilter,
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
217 changes: 166 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,132 @@
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

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 387 in internal/controller/linodevpc_controller.go

View check run for this annotation

Codecov / codecov/patch

internal/controller/linodevpc_controller.go#L379-L387

Added lines #L379 - L387 were not covered by tests
}

if vpc == nil {
return nil
}

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

View check run for this annotation

Codecov / codecov/patch

internal/controller/linodevpc_controller.go#L391-L392

Added lines #L391 - L392 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 411 in internal/controller/linodevpc_controller.go

View check run for this annotation

Codecov / codecov/patch

internal/controller/linodevpc_controller.go#L407-L411

Added lines #L407 - L411 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 419 in internal/controller/linodevpc_controller.go

View check run for this annotation

Codecov / codecov/patch

internal/controller/linodevpc_controller.go#L413-L419

Added lines #L413 - L419 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 432 in internal/controller/linodevpc_controller.go

View check run for this annotation

Codecov / codecov/patch

internal/controller/linodevpc_controller.go#L426-L432

Added lines #L426 - L432 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 444 in internal/controller/linodevpc_controller.go

View check run for this annotation

Codecov / codecov/patch

internal/controller/linodevpc_controller.go#L442-L444

Added lines #L442 - L444 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 450 in internal/controller/linodevpc_controller.go

View check run for this annotation

Codecov / codecov/patch

internal/controller/linodevpc_controller.go#L449-L450

Added lines #L449 - L450 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 461 in internal/controller/linodevpc_controller.go

View check run for this annotation

Codecov / codecov/patch

internal/controller/linodevpc_controller.go#L459-L461

Added lines #L459 - L461 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 486 in internal/controller/linodevpc_controller.go

View check run for this annotation

Codecov / codecov/patch

internal/controller/linodevpc_controller.go#L485-L486

Added lines #L485 - L486 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