From c88f7b87808ff85c37336bffc749ec28304bea03 Mon Sep 17 00:00:00 2001 From: eshulman2 Date: Wed, 31 Dec 2025 14:15:10 +0200 Subject: [PATCH] Add imageRef to volume controller Enables creating bootable volumes from images by adding an imageRef field to the Volume spec. When specified, the volume is created with the image baked in, making it suitable for boot-from-volume scenarios. Changes: - Add imageRef field to VolumeResourceSpec - Add bootable and imageID fields to VolumeResourceStatus - Add image dependency with deletion guard - Add kuttl tests for bootable volume creation assisted-by: claude --- api/v1alpha1/volume_types.go | 12 +++++ api/v1alpha1/zz_generated.deepcopy.go | 5 ++ cmd/models-schema/zz_generated.openapi.go | 14 ++++++ .../bases/openstack.k-orc.cloud_volumes.yaml | 16 +++++++ .../openstack_v1alpha1_volume_bootable.yaml | 29 +++++++++++ internal/controllers/volume/actuator.go | 13 +++++ internal/controllers/volume/controller.go | 22 +++++++++ internal/controllers/volume/status.go | 7 +++ .../volume-create-bootable/00-assert.yaml | 48 +++++++++++++++++++ .../00-create-resource.yaml | 28 +++++++++++ .../volume-create-bootable/00-secret.yaml | 5 ++ .../api/v1alpha1/volumeresourcespec.go | 9 ++++ .../api/v1alpha1/volumeresourcestatus.go | 9 ++++ .../applyconfiguration/internal/internal.go | 6 +++ website/docs/crd-reference.md | 2 + 15 files changed, 225 insertions(+) create mode 100644 config/samples/openstack_v1alpha1_volume_bootable.yaml create mode 100644 internal/controllers/volume/tests/volume-create-bootable/00-assert.yaml create mode 100644 internal/controllers/volume/tests/volume-create-bootable/00-create-resource.yaml create mode 100644 internal/controllers/volume/tests/volume-create-bootable/00-secret.yaml diff --git a/api/v1alpha1/volume_types.go b/api/v1alpha1/volume_types.go index f50f83daa..49e2f3d06 100644 --- a/api/v1alpha1/volume_types.go +++ b/api/v1alpha1/volume_types.go @@ -56,6 +56,13 @@ type VolumeResourceSpec struct { // +listType=atomic // +optional Metadata []VolumeMetadata `json:"metadata,omitempty"` + + // imageRef is a reference to an ORC Image. If specified, creates a + // bootable volume from this image. The volume size must be >= the + // image's min_disk requirement. + // +optional + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="imageRef is immutable" + ImageRef *KubernetesNameRef `json:"imageRef,omitempty"` } // VolumeFilter defines an existing resource by its properties @@ -176,6 +183,11 @@ type VolumeResourceStatus struct { // +optional Bootable *bool `json:"bootable,omitempty"` + // imageID is the ID of the image this volume was created from, if any. + // +kubebuilder:validation:MaxLength=1024 + // +optional + ImageID string `json:"imageID,omitempty"` + // encrypted denotes if the volume is encrypted. // +optional Encrypted *bool `json:"encrypted,omitempty"` diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 74fd9dcdf..1024ea46a 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -5220,6 +5220,11 @@ func (in *VolumeResourceSpec) DeepCopyInto(out *VolumeResourceSpec) { *out = make([]VolumeMetadata, len(*in)) copy(*out, *in) } + if in.ImageRef != nil { + in, out := &in.ImageRef, &out.ImageRef + *out = new(KubernetesNameRef) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VolumeResourceSpec. diff --git a/cmd/models-schema/zz_generated.openapi.go b/cmd/models-schema/zz_generated.openapi.go index 3b90d275e..42f004b03 100644 --- a/cmd/models-schema/zz_generated.openapi.go +++ b/cmd/models-schema/zz_generated.openapi.go @@ -10090,6 +10090,13 @@ func schema_openstack_resource_controller_v2_api_v1alpha1_VolumeResourceSpec(ref }, }, }, + "imageRef": { + SchemaProps: spec.SchemaProps{ + Description: "imageRef is a reference to an ORC Image. If specified, creates a bootable volume from this image. The volume size must be >= the image's min_disk requirement.", + Type: []string{"string"}, + Format: "", + }, + }, }, Required: []string{"size"}, }, @@ -10221,6 +10228,13 @@ func schema_openstack_resource_controller_v2_api_v1alpha1_VolumeResourceStatus(r Format: "", }, }, + "imageID": { + SchemaProps: spec.SchemaProps{ + Description: "imageID is the ID of the image this volume was created from, if any.", + Type: []string{"string"}, + Format: "", + }, + }, "encrypted": { SchemaProps: spec.SchemaProps{ Description: "encrypted denotes if the volume is encrypted.", diff --git a/config/crd/bases/openstack.k-orc.cloud_volumes.yaml b/config/crd/bases/openstack.k-orc.cloud_volumes.yaml index aca503047..eeaf10a8b 100644 --- a/config/crd/bases/openstack.k-orc.cloud_volumes.yaml +++ b/config/crd/bases/openstack.k-orc.cloud_volumes.yaml @@ -173,6 +173,17 @@ spec: maxLength: 255 minLength: 1 type: string + imageRef: + description: |- + imageRef is a reference to an ORC Image. If specified, creates a + bootable volume from this image. The volume size must be >= the + image's min_disk requirement. + maxLength: 253 + minLength: 1 + type: string + x-kubernetes-validations: + - message: imageRef is immutable + rule: self == oldSelf metadata: description: |- metadata key and value pairs to be associated with the volume. @@ -389,6 +400,11 @@ spec: description: host is the identifier of the host holding the volume. maxLength: 1024 type: string + imageID: + description: imageID is the ID of the image this volume was created + from, if any. + maxLength: 1024 + type: string metadata: description: metadata key and value pairs to be associated with the volume. diff --git a/config/samples/openstack_v1alpha1_volume_bootable.yaml b/config/samples/openstack_v1alpha1_volume_bootable.yaml new file mode 100644 index 000000000..a1f80ba0c --- /dev/null +++ b/config/samples/openstack_v1alpha1_volume_bootable.yaml @@ -0,0 +1,29 @@ +--- +# Example of creating a bootable volume from an image. +# The volume can then be used as a boot device for a server. +apiVersion: openstack.k-orc.cloud/v1alpha1 +kind: Image +metadata: + name: ubuntu-2404 +spec: + cloudCredentialsRef: + cloudName: openstack + secretName: openstack-clouds + import: + filter: + name: ubuntu-24.04-server +--- +apiVersion: openstack.k-orc.cloud/v1alpha1 +kind: Volume +metadata: + name: bootable-volume-sample +spec: + cloudCredentialsRef: + cloudName: openstack + secretName: openstack-clouds + managementPolicy: managed + resource: + description: Bootable volume created from Ubuntu image + size: 50 + imageRef: ubuntu-2404 + volumeTypeRef: fast-ssd diff --git a/internal/controllers/volume/actuator.go b/internal/controllers/volume/actuator.go index 4e1e238f9..2fbde2b44 100644 --- a/internal/controllers/volume/actuator.go +++ b/internal/controllers/volume/actuator.go @@ -32,6 +32,7 @@ import ( "github.com/k-orc/openstack-resource-controller/v2/internal/controllers/generic/progress" "github.com/k-orc/openstack-resource-controller/v2/internal/logging" "github.com/k-orc/openstack-resource-controller/v2/internal/osclients" + "github.com/k-orc/openstack-resource-controller/v2/internal/util/dependency" orcerrors "github.com/k-orc/openstack-resource-controller/v2/internal/util/errors" ) @@ -165,6 +166,17 @@ func (actuator volumeActuator) CreateResource(ctx context.Context, obj orcObject } } + // Resolve image dependency for bootable volumes + image, imageDepRS := dependency.FetchDependency( + ctx, actuator.k8sClient, obj.Namespace, + resource.ImageRef, "Image", + func(dep *orcv1alpha1.Image) bool { + return orcv1alpha1.IsAvailable(dep) && dep.Status.ID != nil + }, + ) + reconcileStatus = reconcileStatus.WithReconcileStatus(imageDepRS) + imageID := ptr.Deref(image.Status.ID, "") + if needsReschedule, _ := reconcileStatus.NeedsReschedule(); needsReschedule { return nil, reconcileStatus } @@ -181,6 +193,7 @@ func (actuator volumeActuator) CreateResource(ctx context.Context, obj orcObject Metadata: metadata, VolumeType: volumetypeID, AvailabilityZone: resource.AvailabilityZone, + ImageID: imageID, } osResource, err := actuator.osClient.CreateVolume(ctx, createOpts) diff --git a/internal/controllers/volume/controller.go b/internal/controllers/volume/controller.go index 276c4236c..fb64c2c75 100644 --- a/internal/controllers/volume/controller.go +++ b/internal/controllers/volume/controller.go @@ -74,6 +74,19 @@ var volumetypeDependency = dependency.NewDeletionGuardDependency[*orcv1alpha1.Vo finalizer, externalObjectFieldOwner, ) +// No deletion guard for image, because images can be safely deleted while +// referenced by a volume +var imageDependency = dependency.NewDependency[*orcv1alpha1.VolumeList, *orcv1alpha1.Image]( + "spec.resource.imageRef", + func(volume *orcv1alpha1.Volume) []string { + resource := volume.Spec.Resource + if resource == nil || resource.ImageRef == nil { + return nil + } + return []string{string(*resource.ImageRef)} + }, +) + // serverToVolumeMapFunc creates a mapping function that reconciles volumes when: // - a volume ID appears in server status but the volume doesn't have attachment info for that server // - a volume has attachment info for a server, but the server no longer lists that volume @@ -209,11 +222,19 @@ func (c volumeReconcilerConstructor) SetupWithManager(ctx context.Context, mgr c return err } + imageWatchEventHandler, err := imageDependency.WatchEventHandler(log, k8sClient) + if err != nil { + return err + } + builder := ctrl.NewControllerManagedBy(mgr). WithOptions(options). Watches(&orcv1alpha1.VolumeType{}, volumetypeWatchEventHandler, builder.WithPredicates(predicates.NewBecameAvailable(log, &orcv1alpha1.VolumeType{})), ). + Watches(&orcv1alpha1.Image{}, imageWatchEventHandler, + builder.WithPredicates(predicates.NewBecameAvailable(log, &orcv1alpha1.Image{})), + ). Watches(&orcv1alpha1.Server{}, handler.EnqueueRequestsFromMapFunc(serverToVolumeMapFunc(ctx, k8sClient)), builder.WithPredicates(predicates.NewServerVolumesChanged(log)), ). @@ -221,6 +242,7 @@ func (c volumeReconcilerConstructor) SetupWithManager(ctx context.Context, mgr c if err := errors.Join( volumetypeDependency.AddToManager(ctx, mgr), + imageDependency.AddToManager(ctx, mgr), credentialsDependency.AddToManager(ctx, mgr), credentials.AddCredentialsWatch(log, mgr.GetClient(), builder, credentialsDependency), ); err != nil { diff --git a/internal/controllers/volume/status.go b/internal/controllers/volume/status.go index 064ef7575..96de129be 100644 --- a/internal/controllers/volume/status.go +++ b/internal/controllers/volume/status.go @@ -92,6 +92,13 @@ func (volumeStatusWriter) ApplyResourceStatus(log logr.Logger, osResource *osRes } } + // Extract image ID from volume_image_metadata if present. + // When a volume is created from an image, OpenStack stores the source + // image ID in the volume's metadata under "image_id". + if imageID, ok := osResource.VolumeImageMetadata["image_id"]; ok { + resourceStatus.WithImageID(imageID) + } + for k, v := range osResource.Metadata { resourceStatus.WithMetadata(orcapplyconfigv1alpha1.VolumeMetadataStatus(). WithName(k). diff --git a/internal/controllers/volume/tests/volume-create-bootable/00-assert.yaml b/internal/controllers/volume/tests/volume-create-bootable/00-assert.yaml new file mode 100644 index 000000000..c9444c53c --- /dev/null +++ b/internal/controllers/volume/tests/volume-create-bootable/00-assert.yaml @@ -0,0 +1,48 @@ +--- +apiVersion: openstack.k-orc.cloud/v1alpha1 +kind: Image +metadata: + name: volume-create-bootable-image +status: + resource: + status: active +--- +apiVersion: openstack.k-orc.cloud/v1alpha1 +kind: Volume +metadata: + name: volume-create-bootable +status: + resource: + name: volume-create-bootable + size: 1 + status: available + bootable: true + encrypted: false + multiattach: false + conditions: + - type: Available + status: "True" + reason: Success + - type: Progressing + status: "False" + reason: Success +--- +apiVersion: kuttl.dev/v1beta1 +kind: TestAssert +resourceRefs: + - apiVersion: openstack.k-orc.cloud/v1alpha1 + kind: Volume + name: volume-create-bootable + ref: volume + - apiVersion: openstack.k-orc.cloud/v1alpha1 + kind: Image + name: volume-create-bootable-image + ref: image +assertAll: + - celExpr: "volume.status.id != ''" + - celExpr: "volume.status.resource.tenantID != ''" + - celExpr: "volume.status.resource.userID != ''" + - celExpr: "volume.status.resource.volumeType != ''" + - celExpr: "volume.status.resource.createdAt != ''" + - celExpr: "volume.status.resource.updatedAt != ''" + - celExpr: "volume.status.resource.imageID == image.status.id" diff --git a/internal/controllers/volume/tests/volume-create-bootable/00-create-resource.yaml b/internal/controllers/volume/tests/volume-create-bootable/00-create-resource.yaml new file mode 100644 index 000000000..7a20ca9d6 --- /dev/null +++ b/internal/controllers/volume/tests/volume-create-bootable/00-create-resource.yaml @@ -0,0 +1,28 @@ +--- +apiVersion: openstack.k-orc.cloud/v1alpha1 +kind: Image +metadata: + name: volume-create-bootable-image +spec: + cloudCredentialsRef: + cloudName: openstack + secretName: openstack-clouds + managementPolicy: managed + resource: + content: + diskFormat: raw + download: + url: https://github.com/k-orc/openstack-resource-controller/raw/690b760f49dfb61b173755e91cb51ed42472c7f3/internal/controllers/image/testdata/raw.img +--- +apiVersion: openstack.k-orc.cloud/v1alpha1 +kind: Volume +metadata: + name: volume-create-bootable +spec: + cloudCredentialsRef: + cloudName: openstack + secretName: openstack-clouds + managementPolicy: managed + resource: + size: 1 + imageRef: volume-create-bootable-image diff --git a/internal/controllers/volume/tests/volume-create-bootable/00-secret.yaml b/internal/controllers/volume/tests/volume-create-bootable/00-secret.yaml new file mode 100644 index 000000000..f0fb63e85 --- /dev/null +++ b/internal/controllers/volume/tests/volume-create-bootable/00-secret.yaml @@ -0,0 +1,5 @@ +apiVersion: kuttl.dev/v1beta1 +kind: TestStep +commands: + - command: kubectl create secret generic openstack-clouds --from-file=clouds.yaml=${E2E_KUTTL_OSCLOUDS} ${E2E_KUTTL_CACERT_OPT} + namespaced: true diff --git a/pkg/clients/applyconfiguration/api/v1alpha1/volumeresourcespec.go b/pkg/clients/applyconfiguration/api/v1alpha1/volumeresourcespec.go index efa5c19ca..d6c7f2f04 100644 --- a/pkg/clients/applyconfiguration/api/v1alpha1/volumeresourcespec.go +++ b/pkg/clients/applyconfiguration/api/v1alpha1/volumeresourcespec.go @@ -31,6 +31,7 @@ type VolumeResourceSpecApplyConfiguration struct { VolumeTypeRef *apiv1alpha1.KubernetesNameRef `json:"volumeTypeRef,omitempty"` AvailabilityZone *string `json:"availabilityZone,omitempty"` Metadata []VolumeMetadataApplyConfiguration `json:"metadata,omitempty"` + ImageRef *apiv1alpha1.KubernetesNameRef `json:"imageRef,omitempty"` } // VolumeResourceSpecApplyConfiguration constructs a declarative configuration of the VolumeResourceSpec type for use with @@ -91,3 +92,11 @@ func (b *VolumeResourceSpecApplyConfiguration) WithMetadata(values ...*VolumeMet } return b } + +// WithImageRef sets the ImageRef field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the ImageRef field is set to the value of the last call. +func (b *VolumeResourceSpecApplyConfiguration) WithImageRef(value apiv1alpha1.KubernetesNameRef) *VolumeResourceSpecApplyConfiguration { + b.ImageRef = &value + return b +} diff --git a/pkg/clients/applyconfiguration/api/v1alpha1/volumeresourcestatus.go b/pkg/clients/applyconfiguration/api/v1alpha1/volumeresourcestatus.go index 1ac93544c..a9eb7c404 100644 --- a/pkg/clients/applyconfiguration/api/v1alpha1/volumeresourcestatus.go +++ b/pkg/clients/applyconfiguration/api/v1alpha1/volumeresourcestatus.go @@ -38,6 +38,7 @@ type VolumeResourceStatusApplyConfiguration struct { Metadata []VolumeMetadataStatusApplyConfiguration `json:"metadata,omitempty"` UserID *string `json:"userID,omitempty"` Bootable *bool `json:"bootable,omitempty"` + ImageID *string `json:"imageID,omitempty"` Encrypted *bool `json:"encrypted,omitempty"` ReplicationStatus *string `json:"replicationStatus,omitempty"` ConsistencyGroupID *string `json:"consistencyGroupID,omitempty"` @@ -168,6 +169,14 @@ func (b *VolumeResourceStatusApplyConfiguration) WithBootable(value bool) *Volum return b } +// WithImageID sets the ImageID field in the declarative configuration to the given value +// and returns the receiver, so that objects can be built by chaining "With" function invocations. +// If called multiple times, the ImageID field is set to the value of the last call. +func (b *VolumeResourceStatusApplyConfiguration) WithImageID(value string) *VolumeResourceStatusApplyConfiguration { + b.ImageID = &value + return b +} + // WithEncrypted sets the Encrypted field in the declarative configuration to the given value // and returns the receiver, so that objects can be built by chaining "With" function invocations. // If called multiple times, the Encrypted field is set to the value of the last call. diff --git a/pkg/clients/applyconfiguration/internal/internal.go b/pkg/clients/applyconfiguration/internal/internal.go index abaeca27f..dfb83d47f 100644 --- a/pkg/clients/applyconfiguration/internal/internal.go +++ b/pkg/clients/applyconfiguration/internal/internal.go @@ -3001,6 +3001,9 @@ var schemaYAML = typed.YAMLObject(`types: - name: description type: scalar: string + - name: imageRef + type: + scalar: string - name: metadata type: list: @@ -3049,6 +3052,9 @@ var schemaYAML = typed.YAMLObject(`types: - name: host type: scalar: string + - name: imageID + type: + scalar: string - name: metadata type: list: diff --git a/website/docs/crd-reference.md b/website/docs/crd-reference.md index 32018f962..f1664a185 100644 --- a/website/docs/crd-reference.md +++ b/website/docs/crd-reference.md @@ -3972,6 +3972,7 @@ _Appears in:_ | `volumeTypeRef` _[KubernetesNameRef](#kubernetesnameref)_ | volumeTypeRef is a reference to the ORC VolumeType which this resource is associated with. | | MaxLength: 253
MinLength: 1
| | `availabilityZone` _string_ | availabilityZone is the availability zone in which to create the volume. | | MaxLength: 255
| | `metadata` _[VolumeMetadata](#volumemetadata) array_ | Refer to Kubernetes API documentation for fields of `metadata`. | | MaxItems: 64
| +| `imageRef` _[KubernetesNameRef](#kubernetesnameref)_ | imageRef is a reference to an ORC Image. If specified, creates a
bootable volume from this image. The volume size must be >= the
image's min_disk requirement. | | MaxLength: 253
MinLength: 1
| #### VolumeResourceStatus @@ -4000,6 +4001,7 @@ _Appears in:_ | `metadata` _[VolumeMetadataStatus](#volumemetadatastatus) array_ | Refer to Kubernetes API documentation for fields of `metadata`. | | MaxItems: 64
| | `userID` _string_ | userID is the ID of the user who created the volume. | | MaxLength: 1024
| | `bootable` _boolean_ | bootable indicates whether this is a bootable volume. | | | +| `imageID` _string_ | imageID is the ID of the image this volume was created from, if any. | | MaxLength: 1024
| | `encrypted` _boolean_ | encrypted denotes if the volume is encrypted. | | | | `replicationStatus` _string_ | replicationStatus is the status of replication. | | MaxLength: 1024
| | `consistencyGroupID` _string_ | consistencyGroupID is the consistency group ID. | | MaxLength: 1024
|