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 093e63451..0d27a2467 100644
--- a/api/v1alpha1/zz_generated.deepcopy.go
+++ b/api/v1alpha1/zz_generated.deepcopy.go
@@ -5175,6 +5175,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 8eab33c2d..a2635d7c8 100644
--- a/cmd/models-schema/zz_generated.openapi.go
+++ b/cmd/models-schema/zz_generated.openapi.go
@@ -9953,6 +9953,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"},
},
@@ -10084,6 +10091,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..c1ec4335c 100644
--- a/internal/controllers/volume/actuator.go
+++ b/internal/controllers/volume/actuator.go
@@ -165,6 +165,20 @@ func (actuator volumeActuator) CreateResource(ctx context.Context, obj orcObject
}
}
+ // Resolve image dependency for bootable volumes
+ var imageID string
+ if resource.ImageRef != nil {
+ image, imageDepRS := imageDependency.GetDependency(
+ ctx, actuator.k8sClient, obj, func(dep *orcv1alpha1.Image) bool {
+ return orcv1alpha1.IsAvailable(dep) && dep.Status.ID != nil
+ },
+ )
+ reconcileStatus = reconcileStatus.WithReconcileStatus(imageDepRS)
+ if image != nil {
+ imageID = ptr.Deref(image.Status.ID, "")
+ }
+ }
+
if needsReschedule, _ := reconcileStatus.NeedsReschedule(); needsReschedule {
return nil, reconcileStatus
}
@@ -181,6 +195,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..531efbf3e 100644
--- a/internal/controllers/volume/controller.go
+++ b/internal/controllers/volume/controller.go
@@ -74,6 +74,18 @@ var volumetypeDependency = dependency.NewDeletionGuardDependency[*orcv1alpha1.Vo
finalizer, externalObjectFieldOwner,
)
+var imageDependency = dependency.NewDeletionGuardDependency[*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)}
+ },
+ finalizer, externalObjectFieldOwner,
+)
+
// 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 +221,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 +241,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/internal/controllers/volume/tests/volume-dependency/00-assert.yaml b/internal/controllers/volume/tests/volume-dependency/00-assert.yaml
index 92782c001..7461a8dab 100644
--- a/internal/controllers/volume/tests/volume-dependency/00-assert.yaml
+++ b/internal/controllers/volume/tests/volume-dependency/00-assert.yaml
@@ -28,3 +28,18 @@ status:
message: Waiting for VolumeType/volume-dependency to be created
status: "True"
reason: Progressing
+---
+apiVersion: openstack.k-orc.cloud/v1alpha1
+kind: Volume
+metadata:
+ name: volume-dependency-no-image
+status:
+ conditions:
+ - type: Available
+ message: Waiting for Image/volume-dependency-missing-image to be created
+ status: "False"
+ reason: Progressing
+ - type: Progressing
+ message: Waiting for Image/volume-dependency-missing-image to be created
+ status: "True"
+ reason: Progressing
diff --git a/internal/controllers/volume/tests/volume-dependency/00-create-resources-missing-deps.yaml b/internal/controllers/volume/tests/volume-dependency/00-create-resources-missing-deps.yaml
index ac339b291..71224d5ff 100644
--- a/internal/controllers/volume/tests/volume-dependency/00-create-resources-missing-deps.yaml
+++ b/internal/controllers/volume/tests/volume-dependency/00-create-resources-missing-deps.yaml
@@ -23,3 +23,16 @@ spec:
managementPolicy: managed
resource:
size: 1
+---
+apiVersion: openstack.k-orc.cloud/v1alpha1
+kind: Volume
+metadata:
+ name: volume-dependency-no-image
+spec:
+ cloudCredentialsRef:
+ cloudName: openstack
+ secretName: openstack-clouds
+ managementPolicy: managed
+ resource:
+ size: 1
+ imageRef: volume-dependency-missing-image
diff --git a/pkg/clients/applyconfiguration/api/v1alpha1/volumeresourcespec.go b/pkg/clients/applyconfiguration/api/v1alpha1/volumeresourcespec.go
index 7386c2714..e345fc165 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 d3113778e..c1ec97e00 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 5b5cb5142..171b136b2 100644
--- a/pkg/clients/applyconfiguration/internal/internal.go
+++ b/pkg/clients/applyconfiguration/internal/internal.go
@@ -2953,6 +2953,9 @@ var schemaYAML = typed.YAMLObject(`types:
- name: description
type:
scalar: string
+ - name: imageRef
+ type:
+ scalar: string
- name: metadata
type:
list:
@@ -3001,6 +3004,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 23b3b2403..fdf952480 100644
--- a/website/docs/crd-reference.md
+++ b/website/docs/crd-reference.md
@@ -3930,6 +3930,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
@@ -3958,6 +3959,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
|