From 625ac6046ebe494560620e5934dfbb548cc75a75 Mon Sep 17 00:00:00 2001 From: eshulman2 Date: Wed, 31 Dec 2025 15:05:41 +0200 Subject: [PATCH] Add boot-from-volume support to server controller Add support for booting servers from Cinder volumes instead of images. This enables the boot-from-volume (BFV) pattern where a bootable volume (created from an image) is used as the root disk. Design decisions: 1. Boot volume vs data volumes separation: - Only the boot volume (bootVolume field) is attached at server creation time via Nova's block device mapping - Additional data volumes continue to use the existing dynamic attachment mechanism (spec.resource.volumes) which attaches volumes after server creation - This separation allows data volumes to remain mutable (add/remove after server creation) while the boot volume is immutable - Avoids duplicating volume attachment logic between creation-time and runtime mechanisms 2. No deleteOnTermination option: - Deliberately not exposing Nova's delete_on_termination flag - If enabled, Nova would delete the underlying OpenStack volume when the server is deleted, but the ORC Volume resource would remain as an orphan - The orphaned Volume resource would then attempt to recreate the volume, leading to unexpected behavior - Users who want the volume deleted should delete both Server and Volume resources, maintaining consistent ORC resource lifecycle management API Changes: - Add ServerBootVolumeSpec type with volumeRef and optional tag fields - Add bootVolume field to ServerResourceSpec (mutually exclusive with imageRef) - Make imageRef optional (pointer) with CEL validation Controller Changes: - Add bootVolumeDependency with deletion guard and unique controller name - Handle boot-from-volume in CreateResource by building BlockDevice list Tests & Examples: - Add kuttl test for server boot-from-volume scenario - Add config/samples/openstack_v1alpha1_server_boot_from_volume.yaml - Add examples/bases/boot-from-volume/ with volume and server examples assisted-by: claude --- api/v1alpha1/server_types.go | 29 ++++++++- api/v1alpha1/zz_generated.deepcopy.go | 30 +++++++++ cmd/models-schema/zz_generated.openapi.go | 41 +++++++++++- .../bases/openstack.k-orc.cloud_servers.yaml | 31 ++++++++- ...tack_v1alpha1_server_boot_from_volume.yaml | 25 ++++++++ .../bases/boot-from-volume/kustomization.yaml | 6 ++ examples/bases/boot-from-volume/server.yaml | 18 ++++++ examples/bases/boot-from-volume/volume.yaml | 14 +++++ internal/controllers/server/actuator.go | 39 ++++++++++-- internal/controllers/server/controller.go | 30 ++++++++- .../server-boot-from-volume/00-assert.yaml | 61 ++++++++++++++++++ .../00-create-resource.yaml | 30 +++++++++ .../00-prerequisites.yaml | 63 +++++++++++++++++++ .../tests/server-boot-from-volume/README.md | 14 +++++ .../api/v1alpha1/serverbootvolumespec.go | 52 +++++++++++++++ .../api/v1alpha1/serverresourcespec.go | 33 ++++++---- .../applyconfiguration/internal/internal.go | 12 ++++ pkg/clients/applyconfiguration/utils.go | 2 + website/docs/crd-reference.md | 22 ++++++- 19 files changed, 525 insertions(+), 27 deletions(-) create mode 100644 config/samples/openstack_v1alpha1_server_boot_from_volume.yaml create mode 100644 examples/bases/boot-from-volume/kustomization.yaml create mode 100644 examples/bases/boot-from-volume/server.yaml create mode 100644 examples/bases/boot-from-volume/volume.yaml create mode 100644 internal/controllers/server/tests/server-boot-from-volume/00-assert.yaml create mode 100644 internal/controllers/server/tests/server-boot-from-volume/00-create-resource.yaml create mode 100644 internal/controllers/server/tests/server-boot-from-volume/00-prerequisites.yaml create mode 100644 internal/controllers/server/tests/server-boot-from-volume/README.md create mode 100644 pkg/clients/applyconfiguration/api/v1alpha1/serverbootvolumespec.go 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
|