Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ generate-mock:
mockgen -source=pkg/cryptsetup-client/cryptsetup_client.go -destination=mocks/mock_cryptsetupclient.go -package=mocks
mockgen -source=internal/driver/metadata.go -destination=mocks/mock_metadata.go -package=mocks
mockgen -source=pkg/hwinfo/hwinfo.go -destination=mocks/mock_hwinfo.go -package=mocks
mockgen -source=pkg/filesystem-stats/filesystem_stats.go -destination=mocks/mock_filesystemstatter.go -package=mocks

.PHONY: test
test:
Expand All @@ -173,6 +174,10 @@ e2e-test:
csi-sanity-test:
KUBECONFIG=$(KUBECONFIG) ./tests/csi-sanity/run-tests.sh

.PHONY: sanity-test
sanity-test:
go test ./tests/sanity -v

.PHONY: upstream-e2e-tests
upstream-e2e-tests:
OS=$(OS) ARCH=$(ARCH_SHORT) K8S_VERSION=$(K8S_VERSION) KUBECONFIG=$(KUBECONFIG) ./tests/upstream-e2e/run-tests.sh
Expand Down
8 changes: 8 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ require (
github.com/google/uuid v1.6.0
github.com/ianschenck/envflag v0.0.0-20140720210342-9111d830d133
github.com/jaypipes/ghw v0.21.1
github.com/kubernetes-csi/csi-test/v5 v5.4.0
github.com/linode/go-metadata v0.2.3
github.com/linode/linodego v1.62.0
github.com/martinjungblut/go-cryptsetup v0.0.0-20220520180014-fd0874fd07a6
Expand Down Expand Up @@ -47,9 +48,12 @@ require (
github.com/go-openapi/jsonreference v0.20.2 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/go-resty/resty/v2 v2.17.0 // indirect
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/google/gnostic-models v0.7.0 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect
github.com/jaypipes/pcidb v1.1.1 // indirect
github.com/josharian/intern v1.0.0 // indirect
Expand All @@ -59,6 +63,8 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/onsi/ginkgo/v2 v2.22.0 // indirect
github.com/onsi/gomega v1.36.1 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
Expand All @@ -77,11 +83,13 @@ require (
golang.org/x/term v0.38.0 // indirect
golang.org/x/text v0.32.0 // indirect
golang.org/x/time v0.12.0 // indirect
golang.org/x/tools v0.39.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
gopkg.in/inf.v0 v0.9.1 // indirect
gopkg.in/ini.v1 v1.66.6 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
howett.net/plist v1.0.2-0.20250314012144-ee69052608d9 // indirect
k8s.io/kube-openapi v0.0.0-20250710124328-f3f2b991d03b // indirect
Expand Down
16 changes: 10 additions & 6 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@ github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo=
github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad h1:a6HEuzUHeKH6hwfN/ZoQgRgVIWFJljSWa/zetS2WTvg=
github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg=
Expand Down Expand Up @@ -78,6 +78,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kubernetes-csi/csi-test/v5 v5.4.0 h1:u5DgYNIreSNO2+u4Nq2Wpl+bbakRSjNyxZHmDTAqnYA=
github.com/kubernetes-csi/csi-test/v5 v5.4.0/go.mod h1:anAJKFUb/SdHhIHECgSKxC5LSiLzib+1I6mrWF5Hve8=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/linode/go-metadata v0.2.3 h1:tGTVXJdVYI2e50jljW81C1Anmux7NfVX0MC6CgiJTyc=
Expand All @@ -98,10 +100,10 @@ github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFd
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/onsi/ginkgo/v2 v2.21.0 h1:7rg/4f3rB88pb5obDgNZrNHrQ4e6WpjonchcpuBRnZM=
github.com/onsi/ginkgo/v2 v2.21.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4=
github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=
github.com/onsi/ginkgo/v2 v2.22.0 h1:Yed107/8DjTr0lKCNt7Dn8yQ6ybuDRQoMGrNFKzMfHg=
github.com/onsi/ginkgo/v2 v2.22.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
github.com/onsi/gomega v1.36.1 h1:bJDPBO7ibjxcbHMgSCoo4Yj18UWbKDlLwX1x9sybDcw=
github.com/onsi/gomega v1.36.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
Expand Down Expand Up @@ -236,6 +238,8 @@ gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
gopkg.in/ini.v1 v1.66.6 h1:LATuAqN/shcYAOkv3wl2L4rkaKqkcgTBQjOyYDvcPKI=
gopkg.in/ini.v1 v1.66.6/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
Expand Down
75 changes: 64 additions & 11 deletions internal/driver/controllerserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,22 @@ type ControllerServer struct {
csi.UnimplementedControllerServer
}

// checkPublishCompatibility verifies if a re-publish request is compatible with existing publish.
// Uses volume tags to track readonly state. If a volume was published as readonly, it will have
// a tag "csi-readonly-<nodeID>". If no such tag exists, the volume is assumed to be read-write.
// Returns nil if compatible, or an AlreadyExists error if incompatible.
func checkPublishCompatibility(volume *linodego.Volume, linodeID int, readonly bool) error {
wasReadOnly := volumeHasReadOnlyTag(volume, linodeID)

if wasReadOnly != readonly {
return status.Errorf(codes.AlreadyExists,
"volume %d already published to node %d with readonly=%v, cannot re-publish with readonly=%v",
volume.ID, linodeID, wasReadOnly, readonly)
}

return nil
}

// NewControllerServer instantiates a new RPC service that implements the
// CSI [Controller Service RPC] endpoints.
//
Expand Down Expand Up @@ -189,28 +205,35 @@ func (cs *ControllerServer) ControllerPublishVolume(ctx context.Context, req *cs
}

// Check if the volume exists and is valid.
// If the volume is already attached to the specified instance, it returns its device path.
devicePath, err := cs.getAndValidateVolume(ctx, volumeID, instance)
volume, err := cs.getAndValidateVolume(ctx, volumeID, instance)
if err != nil {
observability.RecordMetrics(observability.ControllerPublishVolumeTotal, observability.ControllerPublishVolumeDuration, observability.Failed, functionStartTime)
return resp, err
}
// If devicePath is not empty, the volume is already attached
if devicePath != "" {
observability.RecordMetrics(observability.ControllerPublishVolumeTotal, observability.ControllerPublishVolumeDuration, observability.Failed, functionStartTime)

readonly := req.GetReadonly()

// If volume is already attached to this instance, handle idempotency
if volume.LinodeID != nil && *volume.LinodeID == instance.ID {
if err := checkPublishCompatibility(volume, linodeID, readonly); err != nil {
observability.RecordMetrics(observability.ControllerPublishVolumeTotal, observability.ControllerPublishVolumeDuration, observability.Failed, functionStartTime)
return resp, err
}
observability.RecordMetrics(observability.ControllerPublishVolumeTotal, observability.ControllerPublishVolumeDuration, observability.Completed, functionStartTime)
log.V(2).Info("Volume already attached (idempotent)", "volume_id", volumeID, "device_path", volume.FilesystemPath)
return &csi.ControllerPublishVolumeResponse{
PublishContext: map[string]string{
devicePathKey: devicePath,
},
PublishContext: map[string]string{devicePathKey: volume.FilesystemPath},
}, nil
}

// Check if the instance can accommodate the volume attachment
if capErr := cs.checkAttachmentCapacity(ctx, instance); capErr != nil {
log.V(2).Info("Cannot attach volume: capacity limit reached", "volume_id", volumeID, "node_id", linodeID, "error", capErr)
observability.RecordMetrics(observability.ControllerPublishVolumeTotal, observability.ControllerPublishVolumeDuration, observability.Failed, functionStartTime)
return resp, capErr
}

log.V(4).Info("Attaching volume to instance", "volume_id", volumeID, "node_id", linodeID)
// Attach the volume to the specified instance
if attachErr := cs.attachVolume(ctx, volumeID, linodeID); attachErr != nil {
observability.RecordMetrics(observability.ControllerPublishVolumeTotal, observability.ControllerPublishVolumeDuration, observability.Failed, functionStartTime)
Expand All @@ -219,12 +242,19 @@ func (cs *ControllerServer) ControllerPublishVolume(ctx context.Context, req *cs

log.V(4).Info("Waiting for volume to attach", "volume_id", volumeID)
// Wait for the volume to be successfully attached to the instance
volume, err := cs.client.WaitForVolumeLinodeID(ctx, volumeID, &linodeID, waitTimeout())
volume, err = cs.client.WaitForVolumeLinodeID(ctx, volumeID, &linodeID, waitTimeout())
if err != nil {
observability.RecordMetrics(observability.ControllerPublishVolumeTotal, observability.ControllerPublishVolumeDuration, observability.Failed, functionStartTime)
return resp, err
}

// Ensure the readonly tag state is correct:
// - If publishing as readonly: add the tag
// - If publishing as read-write: remove any stale readonly tag (from a previous failed unpublish)
if err := cs.syncReadOnlyTag(ctx, volumeID, volume, linodeID, readonly); err != nil {
log.V(2).Info("Failed to sync readonly tag on volume", "volume_id", volumeID, "error", err)
}

// Record function completion
observability.RecordMetrics(observability.ControllerPublishVolumeTotal, observability.ControllerPublishVolumeDuration, observability.Completed, functionStartTime)

Expand Down Expand Up @@ -273,8 +303,11 @@ func (cs *ControllerServer) ControllerUnpublishVolume(ctx context.Context, req *
return &csi.ControllerUnpublishVolumeResponse{}, errInternal("get volume %d: %v", volumeID, err)
}

// Parse nodeID early so we can use it for tag cleanup later
var linodeID int
if req.GetNodeId() != "" {
linodeID, statusErr := linodevolumes.NodeIdAsInt("ControllerUnpublishVolume", req)
var statusErr error
linodeID, statusErr = linodevolumes.NodeIdAsInt("ControllerUnpublishVolume", req)
if statusErr != nil {
observability.RecordMetrics(observability.ControllerUnpublishVolumeTotal, observability.ControllerUnpublishVolumeDuration, observability.Failed, functionStartTime)
return &csi.ControllerUnpublishVolumeResponse{}, statusErr
Expand All @@ -299,11 +332,19 @@ func (cs *ControllerServer) ControllerUnpublishVolume(ctx context.Context, req *
}

log.V(4).Info("Waiting for volume to detach")
if _, err := cs.client.WaitForVolumeLinodeID(ctx, volumeID, nil, waitTimeout()); err != nil {
volume, err = cs.client.WaitForVolumeLinodeID(ctx, volumeID, nil, waitTimeout())
if err != nil {
observability.RecordMetrics(observability.ControllerUnpublishVolumeTotal, observability.ControllerUnpublishVolumeDuration, observability.Failed, functionStartTime)
return &csi.ControllerUnpublishVolumeResponse{}, errInternal("wait for volume %d to detach: %v", volumeID, err)
}

// Remove readonly tag if nodeID was provided
if linodeID != 0 {
if err := cs.syncReadOnlyTag(ctx, volumeID, volume, linodeID, false); err != nil {
log.V(2).Info("Failed to remove readonly tag from volume", "volume_id", volumeID, "error", err)
}
}

// Record function completion
observability.RecordMetrics(observability.ControllerUnpublishVolumeTotal, observability.ControllerUnpublishVolumeDuration, observability.Completed, functionStartTime)

Expand Down Expand Up @@ -565,3 +606,15 @@ func getVolumeResponse(volume *linodego.Volume) (csiVolume *csi.Volume, publishe

return
}

// syncReadOnlyTag ensures the readonly tag state is correct for a volume.
// If readonly is true, adds the tag; otherwise removes it if present.
// Returns nil if no update was needed or the update succeeded.
func (cs *ControllerServer) syncReadOnlyTag(ctx context.Context, volumeID int, volume *linodego.Volume, linodeID int, readonly bool) error {
newTags, updated := setReadOnlyTag(volume.Tags, linodeID, readonly)
if !updated {
return nil
}
_, err := cs.client.UpdateVolume(ctx, volumeID, linodego.VolumeUpdateOptions{Tags: &newTags})
return err
}
60 changes: 46 additions & 14 deletions internal/driver/controllerserver_helper.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"slices"
"strings"
"time"

Expand Down Expand Up @@ -65,6 +66,11 @@ func cloneTimeout() int {
}

const (
// VolumeReadOnlyTagPrefix is the prefix used for tagging volumes published as read-only.
// Format: "csi-readonly-<nodeID>" (e.g., "csi-readonly-12345678")
// Tag length: 13 + up to 10 digits = max 23 chars (within 3-50 limit)
VolumeReadOnlyTagPrefix = "csi-readonly-"

// VolumeTags is the parameter key used for passing a comma-separated list
// of tags to the Linode API.
VolumeTags = Name + "/volumeTags"
Expand Down Expand Up @@ -178,15 +184,17 @@ func (cs *ControllerServer) getContentSourceVolume(ctx context.Context, contentS
// Parse the volume ID from the content source
volKey, err = linodevolumes.ParseLinodeVolumeKey(sourceVolume.GetVolumeId())
if err != nil {
return nil, errInternal("parse volume info from content source: %v", err)
return nil, errNotFound("parse volume info from content source: %v", err)
}
if volKey == nil {
return nil, errInternal("processed *LinodeVolumeKey is nil") // Throw an internal error if the processed LinodeVolumeKey is nil
}

// Retrieve the volume data using the parsed volume ID
volumeData, err := cs.client.GetVolume(ctx, volKey.VolumeID)
if err != nil {
if linodego.IsNotFound(err) {
return nil, errVolumeNotFound(volKey.VolumeID)
} else if err != nil {
return nil, errInternal("get volume %d: %v", volKey.VolumeID, err)
}
if volumeData == nil {
Expand Down Expand Up @@ -678,15 +686,13 @@ func (cs *ControllerServer) validateControllerPublishVolumeRequest(ctx context.C
// getAndValidateVolume retrieves the volume by its ID and run checks.
//
// It performs the following checks:
// 1. If the volume is found and already attached to the specified Linode instance,
// it returns the device path of the volume.
// 2. If the volume is not found, it returns an error indicating that the volume does not exist.
// 3. If the volume is attached to a different instance, it returns an error indicating
// 1. If the volume is not found, it returns an error indicating that the volume does not exist.
// 2. If the volume is attached to a different instance, it returns an error indicating
// that the volume is already attached elsewhere.
//
// Additionally, it checks if the volume and instance are in the same region based on
// the provided volume context. If they are not in the same region, it returns an internal error.
func (cs *ControllerServer) getAndValidateVolume(ctx context.Context, volumeID int, instance *linodego.Instance) (string, error) {
// Returns the volume object. Caller should check volume.LinodeID to determine if already attached
// to the target instance, and use volume.FilesystemPath for the device path.
func (cs *ControllerServer) getAndValidateVolume(ctx context.Context, volumeID int, instance *linodego.Instance) (*linodego.Volume, error) {
log, ctx := logger.GetLogger(ctx)
log.V(4).Info("Entering getAndValidateVolume()", "volumeID", volumeID, "linodeID", instance.ID)
defer log.V(4).Info("Exiting getAndValidateVolume()")
Expand All @@ -697,21 +703,44 @@ func (cs *ControllerServer) getAndValidateVolume(ctx context.Context, volumeID i

volume, err := cs.client.GetVolume(ctx, volumeID)
if linodego.IsNotFound(err) {
return "", errVolumeNotFound(volumeID)
return nil, errVolumeNotFound(volumeID)
} else if err != nil {
return "", errInternal("get volume %d: %v", volumeID, err)
return nil, errInternal("get volume %d: %v", volumeID, err)
}

if volume.LinodeID != nil {
if *volume.LinodeID == instance.ID {
log.V(4).Info("Volume already attached to instance", "volume_id", volume.ID, "node_id", *volume.LinodeID, "device_path", volume.FilesystemPath)
return volume.FilesystemPath, nil
return volume, nil
}
return "", errVolumeAttached(volumeID, *volume.LinodeID)
return nil, errVolumeAttached(volumeID, *volume.LinodeID)
}

log.V(4).Info("Volume validated and is not attached to instance", "volume_id", volume.ID, "node_id", instance.ID)
return "", nil
return volume, nil
}

// setReadOnlyTag updates the volume tags to reflect the readonly state for the given node.
// Returns the updated tags and whether an update is needed.
func setReadOnlyTag(existingTags []string, nodeID int, readonly bool) ([]string, bool) {
tag := fmt.Sprintf("%s%d", VolumeReadOnlyTagPrefix, nodeID)
idx := slices.Index(existingTags, tag)
hasTag := idx != -1

if readonly == hasTag {
return existingTags, false // already in desired state
}

if readonly {
return append(existingTags, tag), true
}
return slices.Delete(existingTags, idx, idx+1), true
}

// volumeHasReadOnlyTag checks if the volume has a read-only tag for the given node.
func volumeHasReadOnlyTag(volume *linodego.Volume, nodeID int) bool {
tag := fmt.Sprintf("%s%d", VolumeReadOnlyTagPrefix, nodeID)
return slices.Contains(volume.Tags, tag)
}

// getInstance retrieves the Linode instance by its ID. If the
Expand All @@ -734,6 +763,9 @@ func (cs *ControllerServer) getInstance(ctx context.Context, linodeID int) (*lin
// If any other error occurs, return an internal error.
return nil, errInternal("get linode instance %d: %v", linodeID, err)
}
if instance == nil {
return nil, errInstanceNotFound(linodeID)
}

log.V(4).Info("Instance retrieved", "instance", instance)
return instance, nil
Expand Down
Loading
Loading