diff --git a/api/v1alpha1/server_types.go b/api/v1alpha1/server_types.go
index f4f40b279..381cabb25 100644
--- a/api/v1alpha1/server_types.go
+++ b/api/v1alpha1/server_types.go
@@ -60,6 +60,20 @@ type ServerPortSpec struct {
PortRef *KubernetesNameRef `json:"portRef,omitempty"`
}
+// ServerBootVolumeSpec defines the boot volume for boot-from-volume server creation.
+// When specified, the server boots from this volume instead of an image.
+type ServerBootVolumeSpec struct {
+ // volumeRef is a reference to a Volume object. The volume must be
+ // bootable (created from an image) and available before server creation.
+ // +required
+ VolumeRef KubernetesNameRef `json:"volumeRef,omitempty"`
+
+ // tag is the device tag applied to the volume.
+ // +kubebuilder:validation:MaxLength:=255
+ // +optional
+ Tag *string `json:"tag,omitempty"`
+}
+
// +kubebuilder:validation:MinProperties:=1
type ServerVolumeSpec struct {
// volumeRef is a reference to a Volume object. Server creation will wait for
@@ -122,6 +136,8 @@ type ServerInterfaceStatus struct {
}
// ServerResourceSpec contains the desired state of a server
+// +kubebuilder:validation:XValidation:rule="has(self.imageRef) || has(self.bootVolume)",message="either imageRef or bootVolume must be specified"
+// +kubebuilder:validation:XValidation:rule="!(has(self.imageRef) && has(self.bootVolume))",message="imageRef and bootVolume are mutually exclusive"
type ServerResourceSpec struct {
// name will be the name of the created resource. If not specified, the
// name of the ORC object will be used.
@@ -129,16 +145,23 @@ type ServerResourceSpec struct {
Name *OpenStackName `json:"name,omitempty"`
// imageRef references the image to use for the server instance.
- // NOTE: This is not required in case of boot from volume.
- // +required
+ // This field is required unless bootVolume is specified for boot-from-volume.
+ // +optional
// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="imageRef is immutable"
- ImageRef KubernetesNameRef `json:"imageRef,omitempty"`
+ ImageRef *KubernetesNameRef `json:"imageRef,omitempty"`
// flavorRef references the flavor to use for the server instance.
// +required
// +kubebuilder:validation:XValidation:rule="self == oldSelf",message="flavorRef is immutable"
FlavorRef KubernetesNameRef `json:"flavorRef,omitempty"`
+ // bootVolume specifies a volume to boot from instead of an image.
+ // When specified, imageRef must be omitted. The volume must be
+ // bootable (created from an image using imageRef in the Volume spec).
+ // +optional
+ // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="bootVolume is immutable"
+ BootVolume *ServerBootVolumeSpec `json:"bootVolume,omitempty"`
+
// userData specifies data which will be made available to the server at
// boot time, either via the metadata service or a config drive. It is
// typically read by a configuration service such as cloud-init or ignition.
diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go
index 74fd9dcdf..48bd8d5da 100644
--- a/api/v1alpha1/zz_generated.deepcopy.go
+++ b/api/v1alpha1/zz_generated.deepcopy.go
@@ -3834,6 +3834,26 @@ func (in *Server) DeepCopyObject() runtime.Object {
return nil
}
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *ServerBootVolumeSpec) DeepCopyInto(out *ServerBootVolumeSpec) {
+ *out = *in
+ if in.Tag != nil {
+ in, out := &in.Tag, &out.Tag
+ *out = new(string)
+ **out = **in
+ }
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServerBootVolumeSpec.
+func (in *ServerBootVolumeSpec) DeepCopy() *ServerBootVolumeSpec {
+ if in == nil {
+ return nil
+ }
+ out := new(ServerBootVolumeSpec)
+ in.DeepCopyInto(out)
+ return out
+}
+
// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
func (in *ServerFilter) DeepCopyInto(out *ServerFilter) {
*out = *in
@@ -4252,6 +4272,16 @@ func (in *ServerResourceSpec) DeepCopyInto(out *ServerResourceSpec) {
*out = new(OpenStackName)
**out = **in
}
+ if in.ImageRef != nil {
+ in, out := &in.ImageRef, &out.ImageRef
+ *out = new(KubernetesNameRef)
+ **out = **in
+ }
+ if in.BootVolume != nil {
+ in, out := &in.BootVolume, &out.BootVolume
+ *out = new(ServerBootVolumeSpec)
+ (*in).DeepCopyInto(*out)
+ }
if in.UserData != nil {
in, out := &in.UserData, &out.UserData
*out = new(UserDataSpec)
diff --git a/cmd/models-schema/zz_generated.openapi.go b/cmd/models-schema/zz_generated.openapi.go
index 3b90d275e..3c8bc179a 100644
--- a/cmd/models-schema/zz_generated.openapi.go
+++ b/cmd/models-schema/zz_generated.openapi.go
@@ -160,6 +160,7 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA
"github.com/k-orc/openstack-resource-controller/v2/api/v1alpha1.SecurityGroupSpec": schema_openstack_resource_controller_v2_api_v1alpha1_SecurityGroupSpec(ref),
"github.com/k-orc/openstack-resource-controller/v2/api/v1alpha1.SecurityGroupStatus": schema_openstack_resource_controller_v2_api_v1alpha1_SecurityGroupStatus(ref),
"github.com/k-orc/openstack-resource-controller/v2/api/v1alpha1.Server": schema_openstack_resource_controller_v2_api_v1alpha1_Server(ref),
+ "github.com/k-orc/openstack-resource-controller/v2/api/v1alpha1.ServerBootVolumeSpec": schema_openstack_resource_controller_v2_api_v1alpha1_ServerBootVolumeSpec(ref),
"github.com/k-orc/openstack-resource-controller/v2/api/v1alpha1.ServerFilter": schema_openstack_resource_controller_v2_api_v1alpha1_ServerFilter(ref),
"github.com/k-orc/openstack-resource-controller/v2/api/v1alpha1.ServerGroup": schema_openstack_resource_controller_v2_api_v1alpha1_ServerGroup(ref),
"github.com/k-orc/openstack-resource-controller/v2/api/v1alpha1.ServerGroupFilter": schema_openstack_resource_controller_v2_api_v1alpha1_ServerGroupFilter(ref),
@@ -7464,6 +7465,34 @@ func schema_openstack_resource_controller_v2_api_v1alpha1_Server(ref common.Refe
}
}
+func schema_openstack_resource_controller_v2_api_v1alpha1_ServerBootVolumeSpec(ref common.ReferenceCallback) common.OpenAPIDefinition {
+ return common.OpenAPIDefinition{
+ Schema: spec.Schema{
+ SchemaProps: spec.SchemaProps{
+ Description: "ServerBootVolumeSpec defines the boot volume for boot-from-volume server creation. When specified, the server boots from this volume instead of an image.",
+ Type: []string{"object"},
+ Properties: map[string]spec.Schema{
+ "volumeRef": {
+ SchemaProps: spec.SchemaProps{
+ Description: "volumeRef is a reference to a Volume object. The volume must be bootable (created from an image) and available before server creation.",
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
+ "tag": {
+ SchemaProps: spec.SchemaProps{
+ Description: "tag is the device tag applied to the volume.",
+ Type: []string{"string"},
+ Format: "",
+ },
+ },
+ },
+ Required: []string{"volumeRef"},
+ },
+ },
+ }
+}
+
func schema_openstack_resource_controller_v2_api_v1alpha1_ServerFilter(ref common.ReferenceCallback) common.OpenAPIDefinition {
return common.OpenAPIDefinition{
Schema: spec.Schema{
@@ -8199,7 +8228,7 @@ func schema_openstack_resource_controller_v2_api_v1alpha1_ServerResourceSpec(ref
},
"imageRef": {
SchemaProps: spec.SchemaProps{
- Description: "imageRef references the image to use for the server instance. NOTE: This is not required in case of boot from volume.",
+ Description: "imageRef references the image to use for the server instance. This field is required unless bootVolume is specified for boot-from-volume.",
Type: []string{"string"},
Format: "",
},
@@ -8211,6 +8240,12 @@ func schema_openstack_resource_controller_v2_api_v1alpha1_ServerResourceSpec(ref
Format: "",
},
},
+ "bootVolume": {
+ SchemaProps: spec.SchemaProps{
+ Description: "bootVolume specifies a volume to boot from instead of an image. When specified, imageRef must be omitted. The volume must be bootable (created from an image using imageRef in the Volume spec).",
+ Ref: ref("github.com/k-orc/openstack-resource-controller/v2/api/v1alpha1.ServerBootVolumeSpec"),
+ },
+ },
"userData": {
SchemaProps: spec.SchemaProps{
Description: "userData specifies data which will be made available to the server at boot time, either via the metadata service or a config drive. It is typically read by a configuration service such as cloud-init or ignition.",
@@ -8323,11 +8358,11 @@ func schema_openstack_resource_controller_v2_api_v1alpha1_ServerResourceSpec(ref
},
},
},
- Required: []string{"imageRef", "flavorRef", "ports"},
+ Required: []string{"flavorRef", "ports"},
},
},
Dependencies: []string{
- "github.com/k-orc/openstack-resource-controller/v2/api/v1alpha1.ServerMetadata", "github.com/k-orc/openstack-resource-controller/v2/api/v1alpha1.ServerPortSpec", "github.com/k-orc/openstack-resource-controller/v2/api/v1alpha1.ServerVolumeSpec", "github.com/k-orc/openstack-resource-controller/v2/api/v1alpha1.UserDataSpec"},
+ "github.com/k-orc/openstack-resource-controller/v2/api/v1alpha1.ServerBootVolumeSpec", "github.com/k-orc/openstack-resource-controller/v2/api/v1alpha1.ServerMetadata", "github.com/k-orc/openstack-resource-controller/v2/api/v1alpha1.ServerPortSpec", "github.com/k-orc/openstack-resource-controller/v2/api/v1alpha1.ServerVolumeSpec", "github.com/k-orc/openstack-resource-controller/v2/api/v1alpha1.UserDataSpec"},
}
}
diff --git a/config/crd/bases/openstack.k-orc.cloud_servers.yaml b/config/crd/bases/openstack.k-orc.cloud_servers.yaml
index 2a882bad3..7bb559cb4 100644
--- a/config/crd/bases/openstack.k-orc.cloud_servers.yaml
+++ b/config/crd/bases/openstack.k-orc.cloud_servers.yaml
@@ -202,6 +202,29 @@ spec:
x-kubernetes-validations:
- message: availabilityZone is immutable
rule: self == oldSelf
+ bootVolume:
+ description: |-
+ bootVolume specifies a volume to boot from instead of an image.
+ When specified, imageRef must be omitted. The volume must be
+ bootable (created from an image using imageRef in the Volume spec).
+ properties:
+ tag:
+ description: tag is the device tag applied to the volume.
+ maxLength: 255
+ type: string
+ volumeRef:
+ description: |-
+ volumeRef is a reference to a Volume object. The volume must be
+ bootable (created from an image) and available before server creation.
+ maxLength: 253
+ minLength: 1
+ type: string
+ required:
+ - volumeRef
+ type: object
+ x-kubernetes-validations:
+ - message: bootVolume is immutable
+ rule: self == oldSelf
configDrive:
description: |-
configDrive specifies whether to attach a config drive to the server.
@@ -223,7 +246,7 @@ spec:
imageRef:
description: |-
imageRef references the image to use for the server instance.
- NOTE: This is not required in case of boot from volume.
+ This field is required unless bootVolume is specified for boot-from-volume.
maxLength: 253
minLength: 1
type: string
@@ -354,9 +377,13 @@ spec:
x-kubernetes-list-type: atomic
required:
- flavorRef
- - imageRef
- ports
type: object
+ x-kubernetes-validations:
+ - message: either imageRef or bootVolume must be specified
+ rule: has(self.imageRef) || has(self.bootVolume)
+ - message: imageRef and bootVolume are mutually exclusive
+ rule: '!(has(self.imageRef) && has(self.bootVolume))'
required:
- cloudCredentialsRef
type: object
diff --git a/config/samples/openstack_v1alpha1_server_boot_from_volume.yaml b/config/samples/openstack_v1alpha1_server_boot_from_volume.yaml
new file mode 100644
index 000000000..a63d01abc
--- /dev/null
+++ b/config/samples/openstack_v1alpha1_server_boot_from_volume.yaml
@@ -0,0 +1,25 @@
+# Example of creating a server that boots from a Cinder volume instead of an image.
+# This is the boot-from-volume (BFV) pattern.
+#
+# Prerequisites:
+# - A bootable volume created from an image (see openstack_v1alpha1_volume_bootable.yaml)
+# - Network, subnet, and port resources
+# - A flavor
+---
+apiVersion: openstack.k-orc.cloud/v1alpha1
+kind: Server
+metadata:
+ name: server-boot-from-volume-sample
+spec:
+ cloudCredentialsRef:
+ cloudName: openstack
+ secretName: openstack-clouds
+ managementPolicy: managed
+ resource:
+ # Note: No imageRef - booting from volume instead
+ bootVolume:
+ volumeRef: bootable-volume-sample
+ flavorRef: server-sample
+ ports:
+ - portRef: server-sample
+ availabilityZone: nova
diff --git a/examples/bases/boot-from-volume/kustomization.yaml b/examples/bases/boot-from-volume/kustomization.yaml
new file mode 100644
index 000000000..ce2cb1adc
--- /dev/null
+++ b/examples/bases/boot-from-volume/kustomization.yaml
@@ -0,0 +1,6 @@
+---
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+resources:
+- volume.yaml
+- server.yaml
diff --git a/examples/bases/boot-from-volume/server.yaml b/examples/bases/boot-from-volume/server.yaml
new file mode 100644
index 000000000..129203407
--- /dev/null
+++ b/examples/bases/boot-from-volume/server.yaml
@@ -0,0 +1,18 @@
+---
+# Server that boots from a volume instead of an image
+apiVersion: openstack.k-orc.cloud/v1alpha1
+kind: Server
+metadata:
+ name: server
+spec:
+ cloudCredentialsRef:
+ cloudName: openstack
+ secretName: cloud-config
+ managementPolicy: managed
+ resource:
+ # No imageRef - booting from volume
+ bootVolume:
+ volumeRef: boot-volume
+ flavorRef: flavor
+ ports:
+ - portRef: port
diff --git a/examples/bases/boot-from-volume/volume.yaml b/examples/bases/boot-from-volume/volume.yaml
new file mode 100644
index 000000000..007353870
--- /dev/null
+++ b/examples/bases/boot-from-volume/volume.yaml
@@ -0,0 +1,14 @@
+---
+# Bootable volume created from the cirros image
+apiVersion: openstack.k-orc.cloud/v1alpha1
+kind: Volume
+metadata:
+ name: boot-volume
+spec:
+ cloudCredentialsRef:
+ cloudName: openstack
+ secretName: cloud-config
+ managementPolicy: managed
+ resource:
+ size: 1
+ imageRef: cirros
diff --git a/internal/controllers/server/actuator.go b/internal/controllers/server/actuator.go
index acadd0be1..8f16613a0 100644
--- a/internal/controllers/server/actuator.go
+++ b/internal/controllers/server/actuator.go
@@ -160,15 +160,45 @@ func (actuator serverActuator) CreateResource(ctx context.Context, obj *orcv1alp
reconcileStatus := progress.NewReconcileStatus()
- var image *orcv1alpha1.Image
- {
+ // Determine if we're booting from volume or image
+ bootFromVolume := resource.BootVolume != nil
+
+ var imageID string
+ if !bootFromVolume {
+ // Traditional boot from image
dep, imageReconcileStatus := imageDependency.GetDependency(
ctx, actuator.k8sClient, obj, func(image *orcv1alpha1.Image) bool {
return orcv1alpha1.IsAvailable(image) && image.Status.ID != nil
},
)
reconcileStatus = reconcileStatus.WithReconcileStatus(imageReconcileStatus)
- image = dep
+ if dep != nil && dep.Status.ID != nil {
+ imageID = *dep.Status.ID
+ }
+ }
+
+ // Resolve boot volume for boot-from-volume
+ var blockDevices []servers.BlockDevice
+ if bootFromVolume {
+ bootVolume, bvReconcileStatus := bootVolumeDependency.GetDependency(
+ ctx, actuator.k8sClient, obj, func(volume *orcv1alpha1.Volume) bool {
+ return orcv1alpha1.IsAvailable(volume) && volume.Status.ID != nil
+ },
+ )
+ reconcileStatus = reconcileStatus.WithReconcileStatus(bvReconcileStatus)
+
+ if bootVolume != nil && bootVolume.Status.ID != nil {
+ bd := servers.BlockDevice{
+ SourceType: servers.SourceVolume,
+ DestinationType: servers.DestinationVolume,
+ UUID: *bootVolume.Status.ID,
+ BootIndex: 0, // Always 0 for boot volume
+ }
+ if resource.BootVolume.Tag != nil {
+ bd.Tag = *resource.BootVolume.Tag
+ }
+ blockDevices = append(blockDevices, bd)
+ }
}
flavor, flavorReconcileStatus := dependency.FetchDependency(
@@ -256,7 +286,7 @@ func (actuator serverActuator) CreateResource(ctx context.Context, obj *orcv1alp
serverCreateOpts := servers.CreateOpts{
Name: getResourceName(obj),
- ImageRef: *image.Status.ID,
+ ImageRef: imageID, // Empty string if boot-from-volume
FlavorRef: *flavor.Status.ID,
Networks: portList,
UserData: userData,
@@ -264,6 +294,7 @@ func (actuator serverActuator) CreateResource(ctx context.Context, obj *orcv1alp
Metadata: metadata,
AvailabilityZone: resource.AvailabilityZone,
ConfigDrive: resource.ConfigDrive,
+ BlockDevice: blockDevices, // Boot volume for BFV
}
/* keypairs.CreateOptsExt was merged into servers.CreateOpts in gopher cloud V3
diff --git a/internal/controllers/server/controller.go b/internal/controllers/server/controller.go
index 95ac9f595..c83381a9a 100644
--- a/internal/controllers/server/controller.go
+++ b/internal/controllers/server/controller.go
@@ -73,13 +73,31 @@ var (
"spec.resource.imageRef",
func(server *orcv1alpha1.Server) []string {
resource := server.Spec.Resource
- if resource == nil {
+ if resource == nil || resource.ImageRef == nil {
return nil
}
- return []string{string(resource.ImageRef)}
+ return []string{string(*resource.ImageRef)}
+ },
+ finalizer, externalObjectFieldOwner,
+ )
+
+ // bootVolumeDependency handles the boot volume specified in bootVolume for boot-from-volume.
+ // This volume is attached at server creation time as the root disk.
+ // deletion guard is in place because the server cannot boot without its root volume.
+ // OverrideDependencyName is used to avoid conflict with volumeDependency which also
+ // creates a Volume deletion guard for Server.
+ bootVolumeDependency = dependency.NewDeletionGuardDependency[*orcv1alpha1.ServerList, *orcv1alpha1.Volume](
+ "spec.resource.bootVolume.volumeRef",
+ func(server *orcv1alpha1.Server) []string {
+ resource := server.Spec.Resource
+ if resource == nil || resource.BootVolume == nil {
+ return nil
+ }
+ return []string{string(resource.BootVolume.VolumeRef)}
},
finalizer, externalObjectFieldOwner,
+ dependency.OverrideDependencyName("bootvolume"),
)
portDependency = dependency.NewDeletionGuardDependency[*orcv1alpha1.ServerList, *orcv1alpha1.Port](
@@ -196,6 +214,10 @@ func (c serverReconcilerConstructor) SetupWithManager(ctx context.Context, mgr c
if err != nil {
return err
}
+ bootVolumeWatchEventHandler, err := bootVolumeDependency.WatchEventHandler(log, k8sClient)
+ if err != nil {
+ return err
+ }
builder := ctrl.NewControllerManagedBy(mgr).
WithOptions(options).
@@ -215,6 +237,9 @@ func (c serverReconcilerConstructor) SetupWithManager(ctx context.Context, mgr c
Watches(&orcv1alpha1.Volume{}, volumeWatchEventHandler,
builder.WithPredicates(predicates.NewBecameAvailable(log, &orcv1alpha1.Volume{})),
).
+ Watches(&orcv1alpha1.Volume{}, bootVolumeWatchEventHandler,
+ builder.WithPredicates(predicates.NewBecameAvailable(log, &orcv1alpha1.Volume{})),
+ ).
Watches(&orcv1alpha1.KeyPair{}, keypairWatchEventHandler,
builder.WithPredicates(predicates.NewBecameAvailable(log, &orcv1alpha1.KeyPair{})),
).
@@ -234,6 +259,7 @@ func (c serverReconcilerConstructor) SetupWithManager(ctx context.Context, mgr c
serverGroupDependency.AddToManager(ctx, mgr),
userDataDependency.AddToManager(ctx, mgr),
volumeDependency.AddToManager(ctx, mgr),
+ bootVolumeDependency.AddToManager(ctx, mgr),
keypairDependency.AddToManager(ctx, mgr),
credentialsDependency.AddToManager(ctx, mgr),
credentials.AddCredentialsWatch(log, k8sClient, builder, credentialsDependency),
diff --git a/internal/controllers/server/tests/server-boot-from-volume/00-assert.yaml b/internal/controllers/server/tests/server-boot-from-volume/00-assert.yaml
new file mode 100644
index 000000000..4fa211ad6
--- /dev/null
+++ b/internal/controllers/server/tests/server-boot-from-volume/00-assert.yaml
@@ -0,0 +1,61 @@
+---
+apiVersion: kuttl.dev/v1beta1
+kind: TestAssert
+resourceRefs:
+ - apiVersion: openstack.k-orc.cloud/v1alpha1
+ kind: Server
+ name: server-boot-from-volume
+ ref: server
+ - apiVersion: openstack.k-orc.cloud/v1alpha1
+ kind: Volume
+ name: server-boot-from-volume
+ ref: volume
+ - apiVersion: openstack.k-orc.cloud/v1alpha1
+ kind: Port
+ name: server-boot-from-volume
+ ref: port
+ - apiVersion: openstack.k-orc.cloud/v1alpha1
+ kind: Network
+ name: server-boot-from-volume
+ ref: network
+ - apiVersion: openstack.k-orc.cloud/v1alpha1
+ kind: Subnet
+ name: server-boot-from-volume
+ ref: subnet
+assertAll:
+ - celExpr: "server.status.resource.hostID != ''"
+ - celExpr: "server.status.resource.availabilityZone != ''"
+ # Verify the server booted from volume (imageID may be empty for BFV servers)
+ - celExpr: "port.status.resource.deviceID == server.status.id"
+ - celExpr: "port.status.resource.status == 'ACTIVE'"
+ - celExpr: "size(server.status.resource.interfaces) == 1"
+ - celExpr: "server.status.resource.interfaces[0].portID == port.status.id"
+ - celExpr: "server.status.resource.interfaces[0].netID == network.status.id"
+ - celExpr: "server.status.resource.interfaces[0].macAddr != ''"
+ - celExpr: "server.status.resource.interfaces[0].portState != ''"
+ - celExpr: "size(server.status.resource.interfaces[0].fixedIPs) >= 1"
+ - celExpr: "server.status.resource.interfaces[0].fixedIPs[0].ipAddress != ''"
+ - celExpr: "server.status.resource.interfaces[0].fixedIPs[0].subnetID == subnet.status.id"
+ # Verify volume is bootable
+ - celExpr: "volume.status.resource.bootable == true"
+ # Verify volume is attached to the server
+ - celExpr: "size(volume.status.resource.attachments) == 1"
+ - celExpr: "volume.status.resource.attachments[0].serverID == server.status.id"
+---
+apiVersion: openstack.k-orc.cloud/v1alpha1
+kind: Server
+metadata:
+ name: server-boot-from-volume
+status:
+ resource:
+ name: server-boot-from-volume
+ status: ACTIVE
+---
+apiVersion: openstack.k-orc.cloud/v1alpha1
+kind: Volume
+metadata:
+ name: server-boot-from-volume
+status:
+ resource:
+ bootable: true
+ status: in-use
diff --git a/internal/controllers/server/tests/server-boot-from-volume/00-create-resource.yaml b/internal/controllers/server/tests/server-boot-from-volume/00-create-resource.yaml
new file mode 100644
index 000000000..e14a11c45
--- /dev/null
+++ b/internal/controllers/server/tests/server-boot-from-volume/00-create-resource.yaml
@@ -0,0 +1,30 @@
+apiVersion: openstack.k-orc.cloud/v1alpha1
+kind: Port
+metadata:
+ name: server-boot-from-volume
+spec:
+ cloudCredentialsRef:
+ cloudName: openstack
+ secretName: openstack-clouds
+ managementPolicy: managed
+ resource:
+ networkRef: server-boot-from-volume
+ addresses:
+ - subnetRef: server-boot-from-volume
+---
+apiVersion: openstack.k-orc.cloud/v1alpha1
+kind: Server
+metadata:
+ name: server-boot-from-volume
+spec:
+ cloudCredentialsRef:
+ cloudName: openstack
+ secretName: openstack-clouds
+ managementPolicy: managed
+ resource:
+ # Note: No imageRef - booting from volume!
+ bootVolume:
+ volumeRef: server-boot-from-volume
+ flavorRef: server-boot-from-volume
+ ports:
+ - portRef: server-boot-from-volume
diff --git a/internal/controllers/server/tests/server-boot-from-volume/00-prerequisites.yaml b/internal/controllers/server/tests/server-boot-from-volume/00-prerequisites.yaml
new file mode 100644
index 000000000..a75dc4d64
--- /dev/null
+++ b/internal/controllers/server/tests/server-boot-from-volume/00-prerequisites.yaml
@@ -0,0 +1,63 @@
+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
+ - script: |
+ export E2E_KUTTL_CURRENT_TEST=server-boot-from-volume
+ cat ../templates/create-flavor.tmpl | envsubst | kubectl -n ${NAMESPACE} apply -f -
+---
+apiVersion: openstack.k-orc.cloud/v1alpha1
+kind: Image
+metadata:
+ name: server-boot-from-volume
+spec:
+ cloudCredentialsRef:
+ cloudName: openstack-admin
+ secretName: openstack-clouds
+ managementPolicy: managed
+ resource:
+ content:
+ diskFormat: qcow2
+ download:
+ url: https://github.com/k-orc/openstack-resource-controller/raw/2ddc1857f5e22d2f0df6f5ee033353e4fd907121/internal/controllers/image/testdata/cirros-0.6.3-x86_64-disk.img
+ visibility: public
+---
+apiVersion: openstack.k-orc.cloud/v1alpha1
+kind: Network
+metadata:
+ name: server-boot-from-volume
+spec:
+ cloudCredentialsRef:
+ cloudName: openstack
+ secretName: openstack-clouds
+ managementPolicy: managed
+ resource:
+ name: server-boot-from-volume
+---
+apiVersion: openstack.k-orc.cloud/v1alpha1
+kind: Subnet
+metadata:
+ name: server-boot-from-volume
+spec:
+ cloudCredentialsRef:
+ cloudName: openstack
+ secretName: openstack-clouds
+ managementPolicy: managed
+ resource:
+ networkRef: server-boot-from-volume
+ ipVersion: 4
+ cidr: 192.168.201.0/24
+---
+apiVersion: openstack.k-orc.cloud/v1alpha1
+kind: Volume
+metadata:
+ name: server-boot-from-volume
+spec:
+ cloudCredentialsRef:
+ cloudName: openstack
+ secretName: openstack-clouds
+ managementPolicy: managed
+ resource:
+ size: 1
+ imageRef: server-boot-from-volume
diff --git a/internal/controllers/server/tests/server-boot-from-volume/README.md b/internal/controllers/server/tests/server-boot-from-volume/README.md
new file mode 100644
index 000000000..0a26653ad
--- /dev/null
+++ b/internal/controllers/server/tests/server-boot-from-volume/README.md
@@ -0,0 +1,14 @@
+# Boot from Volume Test
+
+This test creates a server that boots from a Cinder volume instead of an
+image. This is the boot-from-volume (BFV) pattern where:
+
+1. An image is created
+2. A bootable volume is created from that image
+3. A server is created booting from the volume (no imageRef)
+
+The test verifies:
+- Server reaches ACTIVE state
+- Volume is marked as bootable
+- Volume is attached to the server
+- Port is attached to the server
diff --git a/pkg/clients/applyconfiguration/api/v1alpha1/serverbootvolumespec.go b/pkg/clients/applyconfiguration/api/v1alpha1/serverbootvolumespec.go
new file mode 100644
index 000000000..a8456ea36
--- /dev/null
+++ b/pkg/clients/applyconfiguration/api/v1alpha1/serverbootvolumespec.go
@@ -0,0 +1,52 @@
+/*
+Copyright The ORC Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// Code generated by applyconfiguration-gen. DO NOT EDIT.
+
+package v1alpha1
+
+import (
+ apiv1alpha1 "github.com/k-orc/openstack-resource-controller/v2/api/v1alpha1"
+)
+
+// ServerBootVolumeSpecApplyConfiguration represents a declarative configuration of the ServerBootVolumeSpec type for use
+// with apply.
+type ServerBootVolumeSpecApplyConfiguration struct {
+ VolumeRef *apiv1alpha1.KubernetesNameRef `json:"volumeRef,omitempty"`
+ Tag *string `json:"tag,omitempty"`
+}
+
+// ServerBootVolumeSpecApplyConfiguration constructs a declarative configuration of the ServerBootVolumeSpec type for use with
+// apply.
+func ServerBootVolumeSpec() *ServerBootVolumeSpecApplyConfiguration {
+ return &ServerBootVolumeSpecApplyConfiguration{}
+}
+
+// WithVolumeRef sets the VolumeRef 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 VolumeRef field is set to the value of the last call.
+func (b *ServerBootVolumeSpecApplyConfiguration) WithVolumeRef(value apiv1alpha1.KubernetesNameRef) *ServerBootVolumeSpecApplyConfiguration {
+ b.VolumeRef = &value
+ return b
+}
+
+// WithTag sets the Tag 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 Tag field is set to the value of the last call.
+func (b *ServerBootVolumeSpecApplyConfiguration) WithTag(value string) *ServerBootVolumeSpecApplyConfiguration {
+ b.Tag = &value
+ return b
+}
diff --git a/pkg/clients/applyconfiguration/api/v1alpha1/serverresourcespec.go b/pkg/clients/applyconfiguration/api/v1alpha1/serverresourcespec.go
index c3308477a..9348ac5c4 100644
--- a/pkg/clients/applyconfiguration/api/v1alpha1/serverresourcespec.go
+++ b/pkg/clients/applyconfiguration/api/v1alpha1/serverresourcespec.go
@@ -25,18 +25,19 @@ import (
// ServerResourceSpecApplyConfiguration represents a declarative configuration of the ServerResourceSpec type for use
// with apply.
type ServerResourceSpecApplyConfiguration struct {
- Name *apiv1alpha1.OpenStackName `json:"name,omitempty"`
- ImageRef *apiv1alpha1.KubernetesNameRef `json:"imageRef,omitempty"`
- FlavorRef *apiv1alpha1.KubernetesNameRef `json:"flavorRef,omitempty"`
- UserData *UserDataSpecApplyConfiguration `json:"userData,omitempty"`
- Ports []ServerPortSpecApplyConfiguration `json:"ports,omitempty"`
- Volumes []ServerVolumeSpecApplyConfiguration `json:"volumes,omitempty"`
- ServerGroupRef *apiv1alpha1.KubernetesNameRef `json:"serverGroupRef,omitempty"`
- AvailabilityZone *string `json:"availabilityZone,omitempty"`
- KeypairRef *apiv1alpha1.KubernetesNameRef `json:"keypairRef,omitempty"`
- Tags []apiv1alpha1.ServerTag `json:"tags,omitempty"`
- Metadata []ServerMetadataApplyConfiguration `json:"metadata,omitempty"`
- ConfigDrive *bool `json:"configDrive,omitempty"`
+ Name *apiv1alpha1.OpenStackName `json:"name,omitempty"`
+ ImageRef *apiv1alpha1.KubernetesNameRef `json:"imageRef,omitempty"`
+ FlavorRef *apiv1alpha1.KubernetesNameRef `json:"flavorRef,omitempty"`
+ BootVolume *ServerBootVolumeSpecApplyConfiguration `json:"bootVolume,omitempty"`
+ UserData *UserDataSpecApplyConfiguration `json:"userData,omitempty"`
+ Ports []ServerPortSpecApplyConfiguration `json:"ports,omitempty"`
+ Volumes []ServerVolumeSpecApplyConfiguration `json:"volumes,omitempty"`
+ ServerGroupRef *apiv1alpha1.KubernetesNameRef `json:"serverGroupRef,omitempty"`
+ AvailabilityZone *string `json:"availabilityZone,omitempty"`
+ KeypairRef *apiv1alpha1.KubernetesNameRef `json:"keypairRef,omitempty"`
+ Tags []apiv1alpha1.ServerTag `json:"tags,omitempty"`
+ Metadata []ServerMetadataApplyConfiguration `json:"metadata,omitempty"`
+ ConfigDrive *bool `json:"configDrive,omitempty"`
}
// ServerResourceSpecApplyConfiguration constructs a declarative configuration of the ServerResourceSpec type for use with
@@ -69,6 +70,14 @@ func (b *ServerResourceSpecApplyConfiguration) WithFlavorRef(value apiv1alpha1.K
return b
}
+// WithBootVolume sets the BootVolume 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 BootVolume field is set to the value of the last call.
+func (b *ServerResourceSpecApplyConfiguration) WithBootVolume(value *ServerBootVolumeSpecApplyConfiguration) *ServerResourceSpecApplyConfiguration {
+ b.BootVolume = value
+ return b
+}
+
// WithUserData sets the UserData 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 UserData 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..8ea7e71d5 100644
--- a/pkg/clients/applyconfiguration/internal/internal.go
+++ b/pkg/clients/applyconfiguration/internal/internal.go
@@ -2175,6 +2175,15 @@ var schemaYAML = typed.YAMLObject(`types:
type:
namedType: com.github.k-orc.openstack-resource-controller.v2.api.v1alpha1.ServerStatus
default: {}
+- name: com.github.k-orc.openstack-resource-controller.v2.api.v1alpha1.ServerBootVolumeSpec
+ map:
+ fields:
+ - name: tag
+ type:
+ scalar: string
+ - name: volumeRef
+ type:
+ scalar: string
- name: com.github.k-orc.openstack-resource-controller.v2.api.v1alpha1.ServerFilter
map:
fields:
@@ -2391,6 +2400,9 @@ var schemaYAML = typed.YAMLObject(`types:
- name: availabilityZone
type:
scalar: string
+ - name: bootVolume
+ type:
+ namedType: com.github.k-orc.openstack-resource-controller.v2.api.v1alpha1.ServerBootVolumeSpec
- name: configDrive
type:
scalar: boolean
diff --git a/pkg/clients/applyconfiguration/utils.go b/pkg/clients/applyconfiguration/utils.go
index 5a3990951..58334392d 100644
--- a/pkg/clients/applyconfiguration/utils.go
+++ b/pkg/clients/applyconfiguration/utils.go
@@ -266,6 +266,8 @@ func ForKind(kind schema.GroupVersionKind) interface{} {
return &apiv1alpha1.SecurityGroupStatusApplyConfiguration{}
case v1alpha1.SchemeGroupVersion.WithKind("Server"):
return &apiv1alpha1.ServerApplyConfiguration{}
+ case v1alpha1.SchemeGroupVersion.WithKind("ServerBootVolumeSpec"):
+ return &apiv1alpha1.ServerBootVolumeSpecApplyConfiguration{}
case v1alpha1.SchemeGroupVersion.WithKind("ServerFilter"):
return &apiv1alpha1.ServerFilterApplyConfiguration{}
case v1alpha1.SchemeGroupVersion.WithKind("ServerGroup"):
diff --git a/website/docs/crd-reference.md b/website/docs/crd-reference.md
index 32018f962..c9df8368b 100644
--- a/website/docs/crd-reference.md
+++ b/website/docs/crd-reference.md
@@ -1627,6 +1627,7 @@ _Appears in:_
- [RouterResourceSpec](#routerresourcespec)
- [SecurityGroupFilter](#securitygroupfilter)
- [SecurityGroupResourceSpec](#securitygroupresourcespec)
+- [ServerBootVolumeSpec](#serverbootvolumespec)
- [ServerPortSpec](#serverportspec)
- [ServerResourceSpec](#serverresourcespec)
- [ServerVolumeSpec](#servervolumespec)
@@ -3032,6 +3033,24 @@ Server is the Schema for an ORC resource.
| `status` _[ServerStatus](#serverstatus)_ | status defines the observed state of the resource. | | |
+#### ServerBootVolumeSpec
+
+
+
+ServerBootVolumeSpec defines the boot volume for boot-from-volume server creation.
+When specified, the server boots from this volume instead of an image.
+
+
+
+_Appears in:_
+- [ServerResourceSpec](#serverresourcespec)
+
+| Field | Description | Default | Validation |
+| --- | --- | --- | --- |
+| `volumeRef` _[KubernetesNameRef](#kubernetesnameref)_ | volumeRef is a reference to a Volume object. The volume must be
bootable (created from an image) and available before server creation. | | MaxLength: 253
MinLength: 1
|
+| `tag` _string_ | tag is the device tag applied to the volume. | | MaxLength: 255
|
+
+
#### ServerFilter
@@ -3361,8 +3380,9 @@ _Appears in:_
| Field | Description | Default | Validation |
| --- | --- | --- | --- |
| `name` _[OpenStackName](#openstackname)_ | name will be the name of the created resource. If not specified, the
name of the ORC object will be used. | | MaxLength: 255
MinLength: 1
Pattern: `^[^,]+$`
|
-| `imageRef` _[KubernetesNameRef](#kubernetesnameref)_ | imageRef references the image to use for the server instance.
NOTE: This is not required in case of boot from volume. | | MaxLength: 253
MinLength: 1
|
+| `imageRef` _[KubernetesNameRef](#kubernetesnameref)_ | imageRef references the image to use for the server instance.
This field is required unless bootVolume is specified for boot-from-volume. | | MaxLength: 253
MinLength: 1
|
| `flavorRef` _[KubernetesNameRef](#kubernetesnameref)_ | flavorRef references the flavor to use for the server instance. | | MaxLength: 253
MinLength: 1
|
+| `bootVolume` _[ServerBootVolumeSpec](#serverbootvolumespec)_ | bootVolume specifies a volume to boot from instead of an image.
When specified, imageRef must be omitted. The volume must be
bootable (created from an image using imageRef in the Volume spec). | | |
| `userData` _[UserDataSpec](#userdataspec)_ | userData specifies data which will be made available to the server at
boot time, either via the metadata service or a config drive. It is
typically read by a configuration service such as cloud-init or ignition. | | MaxProperties: 1
MinProperties: 1
|
| `ports` _[ServerPortSpec](#serverportspec) array_ | ports defines a list of ports which will be attached to the server. | | MaxItems: 64
MaxProperties: 1
MinProperties: 1
|
| `volumes` _[ServerVolumeSpec](#servervolumespec) array_ | volumes is a list of volumes attached to the server. | | MaxItems: 64
MinProperties: 1
|