From ac5caf7f6ac3d3b3fae18b11d0c3fbfb2ddd25ba Mon Sep 17 00:00:00 2001 From: eshulman2 Date: Tue, 30 Dec 2025 18:30:26 +0200 Subject: [PATCH 1/5] Add metadata to server controller Add metadata field to server controller allowing setting metadata on servers created by orc --- api/v1alpha1/server_types.go | 40 ++++++++ api/v1alpha1/zz_generated.deepcopy.go | 40 ++++++++ cmd/models-schema/zz_generated.openapi.go | 99 ++++++++++++++++++- .../bases/openstack.k-orc.cloud_servers.yaml | 43 ++++++++ config/samples/openstack_v1alpha1_server.yaml | 5 + internal/controllers/server/actuator.go | 40 ++++++++ internal/controllers/server/status.go | 9 ++ .../tests/server-create-full/00-assert.yaml | 5 + .../00-create-resource.yaml | 5 + .../server/tests/server-update/00-assert.yaml | 1 + .../server/tests/server-update/01-assert.yaml | 5 + .../server-update/01-updated-resource.yaml | 5 + .../server/tests/server-update/02-assert.yaml | 1 + internal/osclients/compute.go | 9 ++ internal/osclients/mock/compute.go | 15 +++ .../api/v1alpha1/servermetadata.go | 48 +++++++++ .../api/v1alpha1/servermetadatastatus.go | 48 +++++++++ .../api/v1alpha1/serverresourcespec.go | 14 +++ .../api/v1alpha1/serverresourcestatus.go | 14 +++ .../applyconfiguration/internal/internal.go | 30 ++++++ pkg/clients/applyconfiguration/utils.go | 4 + website/docs/crd-reference.md | 36 +++++++ 22 files changed, 514 insertions(+), 2 deletions(-) create mode 100644 pkg/clients/applyconfiguration/api/v1alpha1/servermetadata.go create mode 100644 pkg/clients/applyconfiguration/api/v1alpha1/servermetadatastatus.go diff --git a/api/v1alpha1/server_types.go b/api/v1alpha1/server_types.go index 7fefa5f38..72471e9b0 100644 --- a/api/v1alpha1/server_types.go +++ b/api/v1alpha1/server_types.go @@ -181,6 +181,27 @@ type ServerResourceSpec struct { // +listType=set // +optional Tags []ServerTag `json:"tags,omitempty"` + + // metadata is a list of metadata key-value pairs which will be set on the server. + // +kubebuilder:validation:MaxItems:=128 + // +listType=atomic + // +optional + Metadata []ServerMetadata `json:"metadata,omitempty"` +} + +// ServerMetadata represents a key-value pair for server metadata. +type ServerMetadata struct { + // key is the metadata key. + // +kubebuilder:validation:MinLength:=1 + // +kubebuilder:validation:MaxLength:=255 + // +required + Key string `json:"key,omitempty"` + + // value is the metadata value. + // +kubebuilder:validation:MaxLength:=255 + // +kubebuilder:validation:MinLength:=1 + // +required + Value string `json:"value,omitempty"` } // +kubebuilder:validation:MinProperties:=1 @@ -261,4 +282,23 @@ type ServerResourceStatus struct { // +listType=atomic // +optional Tags []string `json:"tags,omitempty"` + + // metadata is the list of metadata key-value pairs on the resource. + // +kubebuilder:validation:MaxItems:=128 + // +listType=atomic + // +optional + Metadata []ServerMetadataStatus `json:"metadata,omitempty"` +} + +// ServerMetadataStatus represents a key-value pair for server metadata in status. +type ServerMetadataStatus struct { + // key is the metadata key. + // +kubebuilder:validation:MaxLength:=255 + // +optional + Key string `json:"key,omitempty"` + + // value is the metadata value. + // +kubebuilder:validation:MaxLength:=255 + // +optional + Value string `json:"value,omitempty"` } diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 093e63451..2cf6387c0 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -4194,6 +4194,36 @@ func (in *ServerList) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServerMetadata) DeepCopyInto(out *ServerMetadata) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServerMetadata. +func (in *ServerMetadata) DeepCopy() *ServerMetadata { + if in == nil { + return nil + } + out := new(ServerMetadata) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServerMetadataStatus) DeepCopyInto(out *ServerMetadataStatus) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServerMetadataStatus. +func (in *ServerMetadataStatus) DeepCopy() *ServerMetadataStatus { + if in == nil { + return nil + } + out := new(ServerMetadataStatus) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ServerPortSpec) DeepCopyInto(out *ServerPortSpec) { *out = *in @@ -4256,6 +4286,11 @@ func (in *ServerResourceSpec) DeepCopyInto(out *ServerResourceSpec) { *out = make([]ServerTag, len(*in)) copy(*out, *in) } + if in.Metadata != nil { + in, out := &in.Metadata, &out.Metadata + *out = make([]ServerMetadata, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServerResourceSpec. @@ -4293,6 +4328,11 @@ func (in *ServerResourceStatus) DeepCopyInto(out *ServerResourceStatus) { *out = make([]string, len(*in)) copy(*out, *in) } + if in.Metadata != nil { + in, out := &in.Metadata, &out.Metadata + *out = make([]ServerMetadataStatus, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServerResourceStatus. diff --git a/cmd/models-schema/zz_generated.openapi.go b/cmd/models-schema/zz_generated.openapi.go index 8eab33c2d..493105f59 100644 --- a/cmd/models-schema/zz_generated.openapi.go +++ b/cmd/models-schema/zz_generated.openapi.go @@ -175,6 +175,8 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA "github.com/k-orc/openstack-resource-controller/v2/api/v1alpha1.ServerInterfaceFixedIP": schema_openstack_resource_controller_v2_api_v1alpha1_ServerInterfaceFixedIP(ref), "github.com/k-orc/openstack-resource-controller/v2/api/v1alpha1.ServerInterfaceStatus": schema_openstack_resource_controller_v2_api_v1alpha1_ServerInterfaceStatus(ref), "github.com/k-orc/openstack-resource-controller/v2/api/v1alpha1.ServerList": schema_openstack_resource_controller_v2_api_v1alpha1_ServerList(ref), + "github.com/k-orc/openstack-resource-controller/v2/api/v1alpha1.ServerMetadata": schema_openstack_resource_controller_v2_api_v1alpha1_ServerMetadata(ref), + "github.com/k-orc/openstack-resource-controller/v2/api/v1alpha1.ServerMetadataStatus": schema_openstack_resource_controller_v2_api_v1alpha1_ServerMetadataStatus(ref), "github.com/k-orc/openstack-resource-controller/v2/api/v1alpha1.ServerPortSpec": schema_openstack_resource_controller_v2_api_v1alpha1_ServerPortSpec(ref), "github.com/k-orc/openstack-resource-controller/v2/api/v1alpha1.ServerResourceSpec": schema_openstack_resource_controller_v2_api_v1alpha1_ServerResourceSpec(ref), "github.com/k-orc/openstack-resource-controller/v2/api/v1alpha1.ServerResourceStatus": schema_openstack_resource_controller_v2_api_v1alpha1_ServerResourceStatus(ref), @@ -8079,6 +8081,61 @@ func schema_openstack_resource_controller_v2_api_v1alpha1_ServerList(ref common. } } +func schema_openstack_resource_controller_v2_api_v1alpha1_ServerMetadata(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "ServerMetadata represents a key-value pair for server metadata.", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "key": { + SchemaProps: spec.SchemaProps{ + Description: "key is the metadata key.", + Type: []string{"string"}, + Format: "", + }, + }, + "value": { + SchemaProps: spec.SchemaProps{ + Description: "value is the metadata value.", + Type: []string{"string"}, + Format: "", + }, + }, + }, + Required: []string{"key", "value"}, + }, + }, + } +} + +func schema_openstack_resource_controller_v2_api_v1alpha1_ServerMetadataStatus(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "ServerMetadataStatus represents a key-value pair for server metadata in status.", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "key": { + SchemaProps: spec.SchemaProps{ + Description: "key is the metadata key.", + Type: []string{"string"}, + Format: "", + }, + }, + "value": { + SchemaProps: spec.SchemaProps{ + Description: "value is the metadata value.", + Type: []string{"string"}, + Format: "", + }, + }, + }, + }, + }, + } +} + func schema_openstack_resource_controller_v2_api_v1alpha1_ServerPortSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ @@ -8211,12 +8268,31 @@ func schema_openstack_resource_controller_v2_api_v1alpha1_ServerResourceSpec(ref }, }, }, + "metadata": { + VendorExtensible: spec.VendorExtensible{ + Extensions: spec.Extensions{ + "x-kubernetes-list-type": "atomic", + }, + }, + SchemaProps: spec.SchemaProps{ + Description: "metadata is a list of metadata key-value pairs which will be set on the server.", + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/k-orc/openstack-resource-controller/v2/api/v1alpha1.ServerMetadata"), + }, + }, + }, + }, + }, }, Required: []string{"imageRef", "flavorRef", "ports"}, }, }, Dependencies: []string{ - "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.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"}, } } @@ -8340,11 +8416,30 @@ func schema_openstack_resource_controller_v2_api_v1alpha1_ServerResourceStatus(r }, }, }, + "metadata": { + VendorExtensible: spec.VendorExtensible{ + Extensions: spec.Extensions{ + "x-kubernetes-list-type": "atomic", + }, + }, + SchemaProps: spec.SchemaProps{ + Description: "metadata is the list of metadata key-value pairs on the resource.", + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/k-orc/openstack-resource-controller/v2/api/v1alpha1.ServerMetadataStatus"), + }, + }, + }, + }, + }, }, }, }, Dependencies: []string{ - "github.com/k-orc/openstack-resource-controller/v2/api/v1alpha1.ServerInterfaceStatus", "github.com/k-orc/openstack-resource-controller/v2/api/v1alpha1.ServerVolumeStatus"}, + "github.com/k-orc/openstack-resource-controller/v2/api/v1alpha1.ServerInterfaceStatus", "github.com/k-orc/openstack-resource-controller/v2/api/v1alpha1.ServerMetadataStatus", "github.com/k-orc/openstack-resource-controller/v2/api/v1alpha1.ServerVolumeStatus"}, } } diff --git a/config/crd/bases/openstack.k-orc.cloud_servers.yaml b/config/crd/bases/openstack.k-orc.cloud_servers.yaml index c9feaab67..fd28e8b28 100644 --- a/config/crd/bases/openstack.k-orc.cloud_servers.yaml +++ b/config/crd/bases/openstack.k-orc.cloud_servers.yaml @@ -231,6 +231,30 @@ spec: x-kubernetes-validations: - message: keypairRef is immutable rule: self == oldSelf + metadata: + description: metadata is a list of metadata key-value pairs which + will be set on the server. + items: + description: ServerMetadata represents a key-value pair for + server metadata. + properties: + key: + description: key is the metadata key. + maxLength: 255 + minLength: 1 + type: string + value: + description: value is the metadata value. + maxLength: 255 + minLength: 1 + type: string + required: + - key + - value + type: object + maxItems: 128 + type: array + x-kubernetes-list-type: atomic name: description: |- name will be the name of the created resource. If not specified, the @@ -488,6 +512,25 @@ spec: maxItems: 64 type: array x-kubernetes-list-type: atomic + metadata: + description: metadata is the list of metadata key-value pairs + on the resource. + items: + description: ServerMetadataStatus represents a key-value pair + for server metadata in status. + properties: + key: + description: key is the metadata key. + maxLength: 255 + type: string + value: + description: value is the metadata value. + maxLength: 255 + type: string + type: object + maxItems: 128 + type: array + x-kubernetes-list-type: atomic name: description: name is the human-readable name of the resource. Might not be unique. diff --git a/config/samples/openstack_v1alpha1_server.yaml b/config/samples/openstack_v1alpha1_server.yaml index 29691f536..b1e737d43 100644 --- a/config/samples/openstack_v1alpha1_server.yaml +++ b/config/samples/openstack_v1alpha1_server.yaml @@ -20,3 +20,8 @@ spec: tags: - tag1 - tag2 + metadata: + - key: environment + value: development + - key: owner + value: sample diff --git a/internal/controllers/server/actuator.go b/internal/controllers/server/actuator.go index 6a2118695..75923c353 100644 --- a/internal/controllers/server/actuator.go +++ b/internal/controllers/server/actuator.go @@ -285,6 +285,11 @@ func (actuator serverActuator) CreateResource(ctx context.Context, obj *orcv1alp // Sort tags before creation to simplify comparisons slices.Sort(tags) + metadata := make(map[string]string) + for _, m := range resource.Metadata { + metadata[m.Key] = m.Value + } + serverCreateOpts := servers.CreateOpts{ Name: getResourceName(obj), ImageRef: *image.Status.ID, @@ -292,6 +297,7 @@ func (actuator serverActuator) CreateResource(ctx context.Context, obj *orcv1alp Networks: portList, UserData: userData, Tags: tags, + Metadata: metadata, AvailabilityZone: resource.AvailabilityZone, } @@ -343,6 +349,7 @@ func (actuator serverActuator) GetResourceReconcilers(ctx context.Context, orcOb actuator.checkStatus, actuator.updateResource, actuator.reconcileTags, + actuator.reconcileMetadata, actuator.reconcilePortAttachments, actuator.reconcileVolumeAttachments, }, nil @@ -429,6 +436,39 @@ func (actuator serverActuator) reconcileTags(ctx context.Context, obj orcObjectP return tags.ReconcileTags[orcObjectPT, osResourceT](obj.Spec.Resource.Tags, ptr.Deref(osResource.Tags, []string{}), tags.NewServerTagReplacer(actuator.osClient, osResource.ID))(ctx, obj, osResource) } +func (actuator serverActuator) reconcileMetadata(ctx context.Context, obj orcObjectPT, osResource *osResourceT) progress.ReconcileStatus { + log := ctrl.LoggerFrom(ctx) + resource := obj.Spec.Resource + if resource == nil { + return progress.WrapError( + orcerrors.Terminal(orcv1alpha1.ConditionReasonInvalidConfiguration, "Update requested, but spec.resource is not set")) + } + + // Metadata cannot be set on a server that is still building + if osResource.Status == "" || osResource.Status == ServerStatusBuild { + return progress.NewReconcileStatus().WaitingOnOpenStack(progress.WaitingOnReady, serverActivePollingPeriod) + } + + // Build the desired metadata map from spec + desiredMetadata := make(map[string]string) + for _, m := range resource.Metadata { + desiredMetadata[m.Key] = m.Value + } + + // Compare with current metadata + if maps.Equal(desiredMetadata, osResource.Metadata) { + return nil + } + + log.V(logging.Verbose).Info("Updating server metadata") + _, err := actuator.osClient.ReplaceServerMetadata(ctx, osResource.ID, desiredMetadata) + if err != nil { + return progress.WrapError(err) + } + + return progress.NeedsRefresh() +} + func (actuator serverActuator) reconcilePortAttachments(ctx context.Context, obj orcObjectPT, osResource *osResourceT) progress.ReconcileStatus { log := ctrl.LoggerFrom(ctx) resource := obj.Spec.Resource diff --git a/internal/controllers/server/status.go b/internal/controllers/server/status.go index aa7c47ccf..b797e060c 100644 --- a/internal/controllers/server/status.go +++ b/internal/controllers/server/status.go @@ -18,6 +18,8 @@ package server import ( "fmt" + "maps" + "slices" "github.com/go-logr/logr" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -97,5 +99,12 @@ func (serverStatusWriter) ApplyResourceStatus(log logr.Logger, osResource *osRes status.WithInterfaces(interfaceStatus) } + // Sort metadata keys for deterministic output + for _, k := range slices.Sorted(maps.Keys(osResource.Metadata)) { + status.WithMetadata(orcapplyconfigv1alpha1.ServerMetadataStatus(). + WithKey(k). + WithValue(osResource.Metadata[k])) + } + statusApply.WithResource(status) } diff --git a/internal/controllers/server/tests/server-create-full/00-assert.yaml b/internal/controllers/server/tests/server-create-full/00-assert.yaml index 5f351d6d1..751ea6c67 100644 --- a/internal/controllers/server/tests/server-create-full/00-assert.yaml +++ b/internal/controllers/server/tests/server-create-full/00-assert.yaml @@ -66,3 +66,8 @@ status: tags: - tag1 - tag2 + metadata: + - key: environment + value: test + - key: owner + value: kuttl diff --git a/internal/controllers/server/tests/server-create-full/00-create-resource.yaml b/internal/controllers/server/tests/server-create-full/00-create-resource.yaml index 006b18145..5b34af20b 100644 --- a/internal/controllers/server/tests/server-create-full/00-create-resource.yaml +++ b/internal/controllers/server/tests/server-create-full/00-create-resource.yaml @@ -48,3 +48,8 @@ spec: tags: - tag1 - tag2 + metadata: + - key: environment + value: test + - key: owner + value: kuttl diff --git a/internal/controllers/server/tests/server-update/00-assert.yaml b/internal/controllers/server/tests/server-update/00-assert.yaml index 551244650..6964361e5 100644 --- a/internal/controllers/server/tests/server-update/00-assert.yaml +++ b/internal/controllers/server/tests/server-update/00-assert.yaml @@ -24,6 +24,7 @@ assertAll: - celExpr: "server.status.resource.serverGroups[0] == sg.status.id" - celExpr: "!has(server.status.resource.tags)" - celExpr: "!has(server.status.resource.volumes)" + - celExpr: "!has(server.status.resource.metadata)" - celExpr: "size(server.status.resource.interfaces) == 1" - celExpr: "server.status.resource.interfaces[0].portID == port.status.id" --- diff --git a/internal/controllers/server/tests/server-update/01-assert.yaml b/internal/controllers/server/tests/server-update/01-assert.yaml index 473aecab0..db83497d8 100644 --- a/internal/controllers/server/tests/server-update/01-assert.yaml +++ b/internal/controllers/server/tests/server-update/01-assert.yaml @@ -54,6 +54,11 @@ status: tags: - tag1 - tag2 + metadata: + - key: environment + value: staging + - key: team + value: platform conditions: - type: Available status: "True" diff --git a/internal/controllers/server/tests/server-update/01-updated-resource.yaml b/internal/controllers/server/tests/server-update/01-updated-resource.yaml index 248b328a2..ae0cac6df 100644 --- a/internal/controllers/server/tests/server-update/01-updated-resource.yaml +++ b/internal/controllers/server/tests/server-update/01-updated-resource.yaml @@ -44,3 +44,8 @@ spec: tags: - tag1 - tag2 + metadata: + - key: environment + value: staging + - key: team + value: platform diff --git a/internal/controllers/server/tests/server-update/02-assert.yaml b/internal/controllers/server/tests/server-update/02-assert.yaml index 68beeb722..ec2db2777 100644 --- a/internal/controllers/server/tests/server-update/02-assert.yaml +++ b/internal/controllers/server/tests/server-update/02-assert.yaml @@ -32,6 +32,7 @@ assertAll: - celExpr: "server.status.resource.serverGroups[0] == sg.status.id" - celExpr: "!has(server.status.resource.tags)" - celExpr: "!has(server.status.resource.volumes)" + - celExpr: "!has(server.status.resource.metadata)" - celExpr: "!has(volume.status.resource.attachments)" - celExpr: "port1.status.resource.deviceID == server.status.id" - celExpr: "port1.status.resource.status == 'ACTIVE'" diff --git a/internal/osclients/compute.go b/internal/osclients/compute.go index e40154150..43f4396d5 100644 --- a/internal/osclients/compute.go +++ b/internal/osclients/compute.go @@ -72,6 +72,7 @@ type ComputeClient interface { DeleteAttachedInterface(ctx context.Context, serverID, portID string) error ReplaceAllServerAttributesTags(ctx context.Context, resourceID string, opts tags.ReplaceAllOptsBuilder) ([]string, error) + ReplaceServerMetadata(ctx context.Context, serverID string, opts servers.MetadataOpts) (map[string]string, error) } type computeClient struct{ client *gophercloud.ServiceClient } @@ -187,6 +188,10 @@ func (c computeClient) ReplaceAllServerAttributesTags(ctx context.Context, resou return tags.ReplaceAll(ctx, c.client, resourceID, opts).Extract() } +func (c computeClient) ReplaceServerMetadata(ctx context.Context, serverID string, opts servers.MetadataOpts) (map[string]string, error) { + return servers.ResetMetadata(ctx, c.client, serverID, opts).Extract() +} + type computeErrorClient struct{ error } // NewComputeErrorClient returns a ComputeClient in which every method returns the given error. @@ -275,3 +280,7 @@ func (e computeErrorClient) DeleteAttachedInterface(_ context.Context, _, _ stri func (e computeErrorClient) ReplaceAllServerAttributesTags(_ context.Context, _ string, _ tags.ReplaceAllOptsBuilder) ([]string, error) { return nil, e.error } + +func (e computeErrorClient) ReplaceServerMetadata(_ context.Context, _ string, _ servers.MetadataOpts) (map[string]string, error) { + return nil, e.error +} diff --git a/internal/osclients/mock/compute.go b/internal/osclients/mock/compute.go index c22ab6984..f3e185299 100644 --- a/internal/osclients/mock/compute.go +++ b/internal/osclients/mock/compute.go @@ -324,6 +324,21 @@ func (mr *MockComputeClientMockRecorder) ReplaceAllServerAttributesTags(ctx, res return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReplaceAllServerAttributesTags", reflect.TypeOf((*MockComputeClient)(nil).ReplaceAllServerAttributesTags), ctx, resourceID, opts) } +// ReplaceServerMetadata mocks base method. +func (m *MockComputeClient) ReplaceServerMetadata(ctx context.Context, serverID string, opts servers.MetadataOpts) (map[string]string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ReplaceServerMetadata", ctx, serverID, opts) + ret0, _ := ret[0].(map[string]string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ReplaceServerMetadata indicates an expected call of ReplaceServerMetadata. +func (mr *MockComputeClientMockRecorder) ReplaceServerMetadata(ctx, serverID, opts any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReplaceServerMetadata", reflect.TypeOf((*MockComputeClient)(nil).ReplaceServerMetadata), ctx, serverID, opts) +} + // UpdateServer mocks base method. func (m *MockComputeClient) UpdateServer(ctx context.Context, id string, opts servers.UpdateOptsBuilder) (*servers.Server, error) { m.ctrl.T.Helper() diff --git a/pkg/clients/applyconfiguration/api/v1alpha1/servermetadata.go b/pkg/clients/applyconfiguration/api/v1alpha1/servermetadata.go new file mode 100644 index 000000000..796ac43ea --- /dev/null +++ b/pkg/clients/applyconfiguration/api/v1alpha1/servermetadata.go @@ -0,0 +1,48 @@ +/* +Copyright 2025 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 + +// ServerMetadataApplyConfiguration represents a declarative configuration of the ServerMetadata type for use +// with apply. +type ServerMetadataApplyConfiguration struct { + Key *string `json:"key,omitempty"` + Value *string `json:"value,omitempty"` +} + +// ServerMetadataApplyConfiguration constructs a declarative configuration of the ServerMetadata type for use with +// apply. +func ServerMetadata() *ServerMetadataApplyConfiguration { + return &ServerMetadataApplyConfiguration{} +} + +// WithKey sets the Key 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 Key field is set to the value of the last call. +func (b *ServerMetadataApplyConfiguration) WithKey(value string) *ServerMetadataApplyConfiguration { + b.Key = &value + return b +} + +// WithValue sets the Value 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 Value field is set to the value of the last call. +func (b *ServerMetadataApplyConfiguration) WithValue(value string) *ServerMetadataApplyConfiguration { + b.Value = &value + return b +} diff --git a/pkg/clients/applyconfiguration/api/v1alpha1/servermetadatastatus.go b/pkg/clients/applyconfiguration/api/v1alpha1/servermetadatastatus.go new file mode 100644 index 000000000..292dc8045 --- /dev/null +++ b/pkg/clients/applyconfiguration/api/v1alpha1/servermetadatastatus.go @@ -0,0 +1,48 @@ +/* +Copyright 2025 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 + +// ServerMetadataStatusApplyConfiguration represents a declarative configuration of the ServerMetadataStatus type for use +// with apply. +type ServerMetadataStatusApplyConfiguration struct { + Key *string `json:"key,omitempty"` + Value *string `json:"value,omitempty"` +} + +// ServerMetadataStatusApplyConfiguration constructs a declarative configuration of the ServerMetadataStatus type for use with +// apply. +func ServerMetadataStatus() *ServerMetadataStatusApplyConfiguration { + return &ServerMetadataStatusApplyConfiguration{} +} + +// WithKey sets the Key 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 Key field is set to the value of the last call. +func (b *ServerMetadataStatusApplyConfiguration) WithKey(value string) *ServerMetadataStatusApplyConfiguration { + b.Key = &value + return b +} + +// WithValue sets the Value 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 Value field is set to the value of the last call. +func (b *ServerMetadataStatusApplyConfiguration) WithValue(value string) *ServerMetadataStatusApplyConfiguration { + b.Value = &value + return b +} diff --git a/pkg/clients/applyconfiguration/api/v1alpha1/serverresourcespec.go b/pkg/clients/applyconfiguration/api/v1alpha1/serverresourcespec.go index 5233713da..5d82c11ff 100644 --- a/pkg/clients/applyconfiguration/api/v1alpha1/serverresourcespec.go +++ b/pkg/clients/applyconfiguration/api/v1alpha1/serverresourcespec.go @@ -35,6 +35,7 @@ type ServerResourceSpecApplyConfiguration struct { AvailabilityZone *string `json:"availabilityZone,omitempty"` KeypairRef *apiv1alpha1.KubernetesNameRef `json:"keypairRef,omitempty"` Tags []apiv1alpha1.ServerTag `json:"tags,omitempty"` + Metadata []ServerMetadataApplyConfiguration `json:"metadata,omitempty"` } // ServerResourceSpecApplyConfiguration constructs a declarative configuration of the ServerResourceSpec type for use with @@ -134,3 +135,16 @@ func (b *ServerResourceSpecApplyConfiguration) WithTags(values ...apiv1alpha1.Se } return b } + +// WithMetadata adds the given value to the Metadata field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, values provided by each call will be appended to the Metadata field. +func (b *ServerResourceSpecApplyConfiguration) WithMetadata(values ...*ServerMetadataApplyConfiguration) *ServerResourceSpecApplyConfiguration { + for i := range values { + if values[i] == nil { + panic("nil value passed to WithMetadata") + } + b.Metadata = append(b.Metadata, *values[i]) + } + return b +} diff --git a/pkg/clients/applyconfiguration/api/v1alpha1/serverresourcestatus.go b/pkg/clients/applyconfiguration/api/v1alpha1/serverresourcestatus.go index 119583f20..7929b398f 100644 --- a/pkg/clients/applyconfiguration/api/v1alpha1/serverresourcestatus.go +++ b/pkg/clients/applyconfiguration/api/v1alpha1/serverresourcestatus.go @@ -30,6 +30,7 @@ type ServerResourceStatusApplyConfiguration struct { Volumes []ServerVolumeStatusApplyConfiguration `json:"volumes,omitempty"` Interfaces []ServerInterfaceStatusApplyConfiguration `json:"interfaces,omitempty"` Tags []string `json:"tags,omitempty"` + Metadata []ServerMetadataStatusApplyConfiguration `json:"metadata,omitempty"` } // ServerResourceStatusApplyConfiguration constructs a declarative configuration of the ServerResourceStatus type for use with @@ -123,3 +124,16 @@ func (b *ServerResourceStatusApplyConfiguration) WithTags(values ...string) *Ser } return b } + +// WithMetadata adds the given value to the Metadata field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, values provided by each call will be appended to the Metadata field. +func (b *ServerResourceStatusApplyConfiguration) WithMetadata(values ...*ServerMetadataStatusApplyConfiguration) *ServerResourceStatusApplyConfiguration { + for i := range values { + if values[i] == nil { + panic("nil value passed to WithMetadata") + } + b.Metadata = append(b.Metadata, *values[i]) + } + return b +} diff --git a/pkg/clients/applyconfiguration/internal/internal.go b/pkg/clients/applyconfiguration/internal/internal.go index 5b5cb5142..d7126f660 100644 --- a/pkg/clients/applyconfiguration/internal/internal.go +++ b/pkg/clients/applyconfiguration/internal/internal.go @@ -2349,6 +2349,24 @@ var schemaYAML = typed.YAMLObject(`types: - name: portState type: scalar: string +- name: com.github.k-orc.openstack-resource-controller.v2.api.v1alpha1.ServerMetadata + map: + fields: + - name: key + type: + scalar: string + - name: value + type: + scalar: string +- name: com.github.k-orc.openstack-resource-controller.v2.api.v1alpha1.ServerMetadataStatus + map: + fields: + - name: key + type: + scalar: string + - name: value + type: + scalar: string - name: com.github.k-orc.openstack-resource-controller.v2.api.v1alpha1.ServerPortSpec map: fields: @@ -2370,6 +2388,12 @@ var schemaYAML = typed.YAMLObject(`types: - name: keypairRef type: scalar: string + - name: metadata + type: + list: + elementType: + namedType: com.github.k-orc.openstack-resource-controller.v2.api.v1alpha1.ServerMetadata + elementRelationship: atomic - name: name type: scalar: string @@ -2415,6 +2439,12 @@ var schemaYAML = typed.YAMLObject(`types: elementType: namedType: com.github.k-orc.openstack-resource-controller.v2.api.v1alpha1.ServerInterfaceStatus elementRelationship: atomic + - name: metadata + type: + list: + elementType: + namedType: com.github.k-orc.openstack-resource-controller.v2.api.v1alpha1.ServerMetadataStatus + elementRelationship: atomic - name: name type: scalar: string diff --git a/pkg/clients/applyconfiguration/utils.go b/pkg/clients/applyconfiguration/utils.go index e3166fefe..288ec443f 100644 --- a/pkg/clients/applyconfiguration/utils.go +++ b/pkg/clients/applyconfiguration/utils.go @@ -292,6 +292,10 @@ func ForKind(kind schema.GroupVersionKind) interface{} { return &apiv1alpha1.ServerInterfaceFixedIPApplyConfiguration{} case v1alpha1.SchemeGroupVersion.WithKind("ServerInterfaceStatus"): return &apiv1alpha1.ServerInterfaceStatusApplyConfiguration{} + case v1alpha1.SchemeGroupVersion.WithKind("ServerMetadata"): + return &apiv1alpha1.ServerMetadataApplyConfiguration{} + case v1alpha1.SchemeGroupVersion.WithKind("ServerMetadataStatus"): + return &apiv1alpha1.ServerMetadataStatusApplyConfiguration{} case v1alpha1.SchemeGroupVersion.WithKind("ServerPortSpec"): return &apiv1alpha1.ServerPortSpecApplyConfiguration{} case v1alpha1.SchemeGroupVersion.WithKind("ServerResourceSpec"): diff --git a/website/docs/crd-reference.md b/website/docs/crd-reference.md index 23b3b2403..e2010b2b9 100644 --- a/website/docs/crd-reference.md +++ b/website/docs/crd-reference.md @@ -3291,6 +3291,40 @@ _Appears in:_ | `fixedIPs` _[ServerInterfaceFixedIP](#serverinterfacefixedip) array_ | fixedIPs is the list of fixed IP addresses assigned to the interface. | | MaxItems: 32
| +#### ServerMetadata + + + +ServerMetadata represents a key-value pair for server metadata. + + + +_Appears in:_ +- [ServerResourceSpec](#serverresourcespec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `key` _string_ | key is the metadata key. | | MaxLength: 255
MinLength: 1
| +| `value` _string_ | value is the metadata value. | | MaxLength: 255
MinLength: 1
| + + +#### ServerMetadataStatus + + + +ServerMetadataStatus represents a key-value pair for server metadata in status. + + + +_Appears in:_ +- [ServerResourceStatus](#serverresourcestatus) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `key` _string_ | key is the metadata key. | | MaxLength: 255
| +| `value` _string_ | value is the metadata value. | | MaxLength: 255
| + + #### ServerPortSpec @@ -3332,6 +3366,7 @@ _Appears in:_ | `availabilityZone` _string_ | availabilityZone is the availability zone in which to create the server. | | MaxLength: 255
| | `keypairRef` _[KubernetesNameRef](#kubernetesnameref)_ | keypairRef is a reference to a KeyPair object. The server will be
created with this keypair for SSH access. | | MaxLength: 253
MinLength: 1
| | `tags` _[ServerTag](#servertag) array_ | tags is a list of tags which will be applied to the server. | | MaxItems: 50
MaxLength: 80
MinLength: 1
| +| `metadata` _[ServerMetadata](#servermetadata) array_ | Refer to Kubernetes API documentation for fields of `metadata`. | | MaxItems: 128
| #### ServerResourceStatus @@ -3356,6 +3391,7 @@ _Appears in:_ | `volumes` _[ServerVolumeStatus](#servervolumestatus) array_ | volumes contains the volumes attached to the server. | | MaxItems: 64
| | `interfaces` _[ServerInterfaceStatus](#serverinterfacestatus) array_ | interfaces contains the list of interfaces attached to the server. | | MaxItems: 64
| | `tags` _string array_ | tags is the list of tags on the resource. | | MaxItems: 50
items:MaxLength: 1024
| +| `metadata` _[ServerMetadataStatus](#servermetadatastatus) array_ | Refer to Kubernetes API documentation for fields of `metadata`. | | MaxItems: 128
| #### ServerSpec From efc6482ee12c524f2a1e3e24e24f7bcae3c030f1 Mon Sep 17 00:00:00 2001 From: eshulman2 Date: Wed, 31 Dec 2025 09:00:23 +0200 Subject: [PATCH 2/5] Add config drive field to server controller --- api/v1alpha1/server_types.go | 11 +++++++++++ api/v1alpha1/zz_generated.deepcopy.go | 5 +++++ cmd/models-schema/zz_generated.openapi.go | 14 ++++++++++++++ .../crd/bases/openstack.k-orc.cloud_servers.yaml | 13 +++++++++++++ config/samples/openstack_v1alpha1_server.yaml | 1 + internal/controllers/server/actuator.go | 1 + internal/controllers/server/status.go | 3 ++- .../server/tests/server-create-full/00-assert.yaml | 1 + .../server-create-full/00-create-resource.yaml | 1 + .../api/v1alpha1/serverresourcespec.go | 9 +++++++++ .../api/v1alpha1/serverresourcestatus.go | 9 +++++++++ .../applyconfiguration/internal/internal.go | 6 ++++++ website/docs/crd-reference.md | 2 ++ 13 files changed, 75 insertions(+), 1 deletion(-) diff --git a/api/v1alpha1/server_types.go b/api/v1alpha1/server_types.go index 72471e9b0..f4f40b279 100644 --- a/api/v1alpha1/server_types.go +++ b/api/v1alpha1/server_types.go @@ -187,6 +187,13 @@ type ServerResourceSpec struct { // +listType=atomic // +optional Metadata []ServerMetadata `json:"metadata,omitempty"` + + // configDrive specifies whether to attach a config drive to the server. + // When true, configuration data will be available via a special drive + // instead of the metadata service. + // +optional + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="configDrive is immutable" + ConfigDrive *bool `json:"configDrive,omitempty"` } // ServerMetadata represents a key-value pair for server metadata. @@ -288,6 +295,10 @@ type ServerResourceStatus struct { // +listType=atomic // +optional Metadata []ServerMetadataStatus `json:"metadata,omitempty"` + + // configDrive indicates whether the server was booted with a config drive. + // +optional + ConfigDrive bool `json:"configDrive,omitempty"` } // ServerMetadataStatus represents a key-value pair for server metadata in status. diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 2cf6387c0..c82df6d6f 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -4291,6 +4291,11 @@ func (in *ServerResourceSpec) DeepCopyInto(out *ServerResourceSpec) { *out = make([]ServerMetadata, len(*in)) copy(*out, *in) } + if in.ConfigDrive != nil { + in, out := &in.ConfigDrive, &out.ConfigDrive + *out = new(bool) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServerResourceSpec. diff --git a/cmd/models-schema/zz_generated.openapi.go b/cmd/models-schema/zz_generated.openapi.go index 493105f59..a2d45be9c 100644 --- a/cmd/models-schema/zz_generated.openapi.go +++ b/cmd/models-schema/zz_generated.openapi.go @@ -8287,6 +8287,13 @@ func schema_openstack_resource_controller_v2_api_v1alpha1_ServerResourceSpec(ref }, }, }, + "configDrive": { + SchemaProps: spec.SchemaProps{ + Description: "configDrive specifies whether to attach a config drive to the server. When true, configuration data will be available via a special drive instead of the metadata service.", + Type: []string{"boolean"}, + Format: "", + }, + }, }, Required: []string{"imageRef", "flavorRef", "ports"}, }, @@ -8435,6 +8442,13 @@ func schema_openstack_resource_controller_v2_api_v1alpha1_ServerResourceStatus(r }, }, }, + "configDrive": { + SchemaProps: spec.SchemaProps{ + Description: "configDrive indicates whether the server was booted with a config drive.", + Type: []string{"boolean"}, + Format: "", + }, + }, }, }, }, diff --git a/config/crd/bases/openstack.k-orc.cloud_servers.yaml b/config/crd/bases/openstack.k-orc.cloud_servers.yaml index fd28e8b28..2a882bad3 100644 --- a/config/crd/bases/openstack.k-orc.cloud_servers.yaml +++ b/config/crd/bases/openstack.k-orc.cloud_servers.yaml @@ -202,6 +202,15 @@ spec: x-kubernetes-validations: - message: availabilityZone is immutable rule: self == oldSelf + configDrive: + description: |- + configDrive specifies whether to attach a config drive to the server. + When true, configuration data will be available via a special drive + instead of the metadata service. + type: boolean + x-kubernetes-validations: + - message: configDrive is immutable + rule: self == oldSelf flavorRef: description: flavorRef references the flavor to use for the server instance. @@ -455,6 +464,10 @@ spec: server is located. maxLength: 1024 type: string + configDrive: + description: configDrive indicates whether the server was booted + with a config drive. + type: boolean hostID: description: hostID is the host where the server is located in the cloud. diff --git a/config/samples/openstack_v1alpha1_server.yaml b/config/samples/openstack_v1alpha1_server.yaml index b1e737d43..382d6f9b4 100644 --- a/config/samples/openstack_v1alpha1_server.yaml +++ b/config/samples/openstack_v1alpha1_server.yaml @@ -25,3 +25,4 @@ spec: value: development - key: owner value: sample + configDrive: true diff --git a/internal/controllers/server/actuator.go b/internal/controllers/server/actuator.go index 75923c353..8ef320aab 100644 --- a/internal/controllers/server/actuator.go +++ b/internal/controllers/server/actuator.go @@ -299,6 +299,7 @@ func (actuator serverActuator) CreateResource(ctx context.Context, obj *orcv1alp Tags: tags, Metadata: metadata, AvailabilityZone: resource.AvailabilityZone, + ConfigDrive: resource.ConfigDrive, } /* keypairs.CreateOptsExt was merged into servers.CreateOpts in gopher cloud V3 diff --git a/internal/controllers/server/status.go b/internal/controllers/server/status.go index b797e060c..39956c531 100644 --- a/internal/controllers/server/status.go +++ b/internal/controllers/server/status.go @@ -71,7 +71,8 @@ func (serverStatusWriter) ApplyResourceStatus(log logr.Logger, osResource *osRes WithHostID(osResource.HostID). WithAvailabilityZone(osResource.AvailabilityZone). WithServerGroups(ptr.Deref(osResource.ServerGroups, []string{})...). - WithTags(ptr.Deref(osResource.Tags, []string{})...) + WithTags(ptr.Deref(osResource.Tags, []string{})...). + WithConfigDrive(osResource.ConfigDrive) if imageID, ok := osResource.Image["id"]; ok { status.WithImageID(fmt.Sprintf("%s", imageID)) diff --git a/internal/controllers/server/tests/server-create-full/00-assert.yaml b/internal/controllers/server/tests/server-create-full/00-assert.yaml index 751ea6c67..68c65c73b 100644 --- a/internal/controllers/server/tests/server-create-full/00-assert.yaml +++ b/internal/controllers/server/tests/server-create-full/00-assert.yaml @@ -71,3 +71,4 @@ status: value: test - key: owner value: kuttl + configDrive: true diff --git a/internal/controllers/server/tests/server-create-full/00-create-resource.yaml b/internal/controllers/server/tests/server-create-full/00-create-resource.yaml index 5b34af20b..6f82c53f2 100644 --- a/internal/controllers/server/tests/server-create-full/00-create-resource.yaml +++ b/internal/controllers/server/tests/server-create-full/00-create-resource.yaml @@ -53,3 +53,4 @@ spec: value: test - key: owner value: kuttl + configDrive: true diff --git a/pkg/clients/applyconfiguration/api/v1alpha1/serverresourcespec.go b/pkg/clients/applyconfiguration/api/v1alpha1/serverresourcespec.go index 5d82c11ff..c49efc540 100644 --- a/pkg/clients/applyconfiguration/api/v1alpha1/serverresourcespec.go +++ b/pkg/clients/applyconfiguration/api/v1alpha1/serverresourcespec.go @@ -36,6 +36,7 @@ type ServerResourceSpecApplyConfiguration struct { 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 @@ -148,3 +149,11 @@ func (b *ServerResourceSpecApplyConfiguration) WithMetadata(values ...*ServerMet } return b } + +// WithConfigDrive sets the ConfigDrive 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 ConfigDrive field is set to the value of the last call. +func (b *ServerResourceSpecApplyConfiguration) WithConfigDrive(value bool) *ServerResourceSpecApplyConfiguration { + b.ConfigDrive = &value + return b +} diff --git a/pkg/clients/applyconfiguration/api/v1alpha1/serverresourcestatus.go b/pkg/clients/applyconfiguration/api/v1alpha1/serverresourcestatus.go index 7929b398f..60a359afb 100644 --- a/pkg/clients/applyconfiguration/api/v1alpha1/serverresourcestatus.go +++ b/pkg/clients/applyconfiguration/api/v1alpha1/serverresourcestatus.go @@ -31,6 +31,7 @@ type ServerResourceStatusApplyConfiguration struct { Interfaces []ServerInterfaceStatusApplyConfiguration `json:"interfaces,omitempty"` Tags []string `json:"tags,omitempty"` Metadata []ServerMetadataStatusApplyConfiguration `json:"metadata,omitempty"` + ConfigDrive *bool `json:"configDrive,omitempty"` } // ServerResourceStatusApplyConfiguration constructs a declarative configuration of the ServerResourceStatus type for use with @@ -137,3 +138,11 @@ func (b *ServerResourceStatusApplyConfiguration) WithMetadata(values ...*ServerM } return b } + +// WithConfigDrive sets the ConfigDrive 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 ConfigDrive field is set to the value of the last call. +func (b *ServerResourceStatusApplyConfiguration) WithConfigDrive(value bool) *ServerResourceStatusApplyConfiguration { + b.ConfigDrive = &value + return b +} diff --git a/pkg/clients/applyconfiguration/internal/internal.go b/pkg/clients/applyconfiguration/internal/internal.go index d7126f660..5dced644a 100644 --- a/pkg/clients/applyconfiguration/internal/internal.go +++ b/pkg/clients/applyconfiguration/internal/internal.go @@ -2379,6 +2379,9 @@ var schemaYAML = typed.YAMLObject(`types: - name: availabilityZone type: scalar: string + - name: configDrive + type: + scalar: boolean - name: flavorRef type: scalar: string @@ -2427,6 +2430,9 @@ var schemaYAML = typed.YAMLObject(`types: - name: availabilityZone type: scalar: string + - name: configDrive + type: + scalar: boolean - name: hostID type: scalar: string diff --git a/website/docs/crd-reference.md b/website/docs/crd-reference.md index e2010b2b9..ad4a2fac0 100644 --- a/website/docs/crd-reference.md +++ b/website/docs/crd-reference.md @@ -3367,6 +3367,7 @@ _Appears in:_ | `keypairRef` _[KubernetesNameRef](#kubernetesnameref)_ | keypairRef is a reference to a KeyPair object. The server will be
created with this keypair for SSH access. | | MaxLength: 253
MinLength: 1
| | `tags` _[ServerTag](#servertag) array_ | tags is a list of tags which will be applied to the server. | | MaxItems: 50
MaxLength: 80
MinLength: 1
| | `metadata` _[ServerMetadata](#servermetadata) array_ | Refer to Kubernetes API documentation for fields of `metadata`. | | MaxItems: 128
| +| `configDrive` _boolean_ | configDrive specifies whether to attach a config drive to the server.
When true, configuration data will be available via a special drive
instead of the metadata service. | | | #### ServerResourceStatus @@ -3392,6 +3393,7 @@ _Appears in:_ | `interfaces` _[ServerInterfaceStatus](#serverinterfacestatus) array_ | interfaces contains the list of interfaces attached to the server. | | MaxItems: 64
| | `tags` _string array_ | tags is the list of tags on the resource. | | MaxItems: 50
items:MaxLength: 1024
| | `metadata` _[ServerMetadataStatus](#servermetadatastatus) array_ | Refer to Kubernetes API documentation for fields of `metadata`. | | MaxItems: 128
| +| `configDrive` _boolean_ | configDrive indicates whether the server was booted with a config drive. | | | #### ServerSpec From fe5e105b33634bc57dddd5bcd50a4e59fff6fffc Mon Sep 17 00:00:00 2001 From: eshulman2 Date: Wed, 31 Dec 2025 09:50:15 +0200 Subject: [PATCH 3/5] Add SchedulerHints to server controller Add SchedulerHints to server controller NOTE! this change MOVED the ServerGroupRef inside the ServerSchedulerHints --- api/v1alpha1/server_types.go | 62 +++++++- api/v1alpha1/zz_generated.deepcopy.go | 67 ++++++++- cmd/models-schema/zz_generated.openapi.go | 133 ++++++++++++++++-- .../bases/openstack.k-orc.cloud_servers.yaml | 76 ++++++++-- config/samples/openstack_v1alpha1_server.yaml | 3 +- internal/controllers/server/actuator.go | 83 +++++++++-- internal/controllers/server/controller.go | 6 +- .../00-create-resource.yaml | 3 +- .../00-create-everything-but-flavor.yaml | 3 +- .../01-create-everything-but-image.yaml | 3 +- .../02-create-everything-but-port.yaml | 3 +- ...03-create-everything-but-server-group.yaml | 3 +- ...create-everything-but-userdata-secret.yaml | 3 +- .../05-create-everything-but-keypair.yaml | 3 +- .../server-update/00-minimal-resource.yaml | 3 +- .../api/v1alpha1/serverresourcespec.go | 40 +++--- .../api/v1alpha1/serverschedulerhints.go | 118 ++++++++++++++++ .../applyconfiguration/internal/internal.go | 42 +++++- pkg/clients/applyconfiguration/utils.go | 2 + website/docs/crd-reference.md | 26 +++- 20 files changed, 608 insertions(+), 74 deletions(-) create mode 100644 pkg/clients/applyconfiguration/api/v1alpha1/serverschedulerhints.go diff --git a/api/v1alpha1/server_types.go b/api/v1alpha1/server_types.go index f4f40b279..6dad6858a 100644 --- a/api/v1alpha1/server_types.go +++ b/api/v1alpha1/server_types.go @@ -158,12 +158,6 @@ type ServerResourceSpec struct { // +optional Volumes []ServerVolumeSpec `json:"volumes,omitempty"` - // serverGroupRef is a reference to a ServerGroup object. The server - // will be created in the server group. - // +optional - // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="serverGroupRef is immutable" - ServerGroupRef *KubernetesNameRef `json:"serverGroupRef,omitempty"` - // availabilityZone is the availability zone in which to create the server. // +kubebuilder:validation:MaxLength=255 // +optional @@ -194,6 +188,62 @@ type ServerResourceSpec struct { // +optional // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="configDrive is immutable" ConfigDrive *bool `json:"configDrive,omitempty"` + + // schedulerHints provides hints to the Nova scheduler for server placement. + // +optional + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="schedulerHints is immutable" + SchedulerHints *ServerSchedulerHints `json:"schedulerHints,omitempty"` +} + +// ServerSchedulerHints provides hints to the Nova scheduler for server placement. +type ServerSchedulerHints struct { + // serverGroupRef is a reference to a ServerGroup object. The server will be + // scheduled on a host in the specified server group. + // +optional + ServerGroupRef *KubernetesNameRef `json:"serverGroupRef,omitempty"` + + // differentHostServerRefs is a list of references to Server objects. + // The server will be scheduled on a different host than all specified servers. + // +listType=set + // +kubebuilder:validation:MaxItems:=64 + // +optional + DifferentHostServerRefs []KubernetesNameRef `json:"differentHostServerRefs,omitempty"` + + // sameHostServerRefs is a list of references to Server objects. + // The server will be scheduled on the same host as all specified servers. + // +listType=set + // +kubebuilder:validation:MaxItems:=64 + // +optional + SameHostServerRefs []KubernetesNameRef `json:"sameHostServerRefs,omitempty"` + + // query is a conditional statement that results in compute nodes + // able to host the server. + // +kubebuilder:validation:MaxLength:=1024 + // +optional + Query *string `json:"query,omitempty"` + + // targetCell is a cell name where the server will be placed. + // +kubebuilder:validation:MaxLength:=255 + // +optional + TargetCell *string `json:"targetCell,omitempty"` + + // differentCell is a list of cell names where the server should not + // be placed. + // +listType=set + // +kubebuilder:validation:MaxItems:=64 + // +kubebuilder:validation:items:MaxLength=1024 + // +optional + DifferentCell []string `json:"differentCell,omitempty"` + + // buildNearHostIP specifies a subnet of compute nodes to host the server. + // +kubebuilder:validation:MaxLength:=255 + // +optional + BuildNearHostIP *string `json:"buildNearHostIP,omitempty"` + + // additionalProperties is a map of arbitrary key/value pairs that are + // not validated by Nova. + // +optional + AdditionalProperties map[string]string `json:"additionalProperties,omitempty"` } // ServerMetadata represents a key-value pair for server metadata. diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index c82df6d6f..4efd5c428 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -4271,11 +4271,6 @@ func (in *ServerResourceSpec) DeepCopyInto(out *ServerResourceSpec) { (*in)[i].DeepCopyInto(&(*out)[i]) } } - if in.ServerGroupRef != nil { - in, out := &in.ServerGroupRef, &out.ServerGroupRef - *out = new(KubernetesNameRef) - **out = **in - } if in.KeypairRef != nil { in, out := &in.KeypairRef, &out.KeypairRef *out = new(KubernetesNameRef) @@ -4296,6 +4291,11 @@ func (in *ServerResourceSpec) DeepCopyInto(out *ServerResourceSpec) { *out = new(bool) **out = **in } + if in.SchedulerHints != nil { + in, out := &in.SchedulerHints, &out.SchedulerHints + *out = new(ServerSchedulerHints) + (*in).DeepCopyInto(*out) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServerResourceSpec. @@ -4350,6 +4350,63 @@ func (in *ServerResourceStatus) DeepCopy() *ServerResourceStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ServerSchedulerHints) DeepCopyInto(out *ServerSchedulerHints) { + *out = *in + if in.ServerGroupRef != nil { + in, out := &in.ServerGroupRef, &out.ServerGroupRef + *out = new(KubernetesNameRef) + **out = **in + } + if in.DifferentHostServerRefs != nil { + in, out := &in.DifferentHostServerRefs, &out.DifferentHostServerRefs + *out = make([]KubernetesNameRef, len(*in)) + copy(*out, *in) + } + if in.SameHostServerRefs != nil { + in, out := &in.SameHostServerRefs, &out.SameHostServerRefs + *out = make([]KubernetesNameRef, len(*in)) + copy(*out, *in) + } + if in.Query != nil { + in, out := &in.Query, &out.Query + *out = new(string) + **out = **in + } + if in.TargetCell != nil { + in, out := &in.TargetCell, &out.TargetCell + *out = new(string) + **out = **in + } + if in.DifferentCell != nil { + in, out := &in.DifferentCell, &out.DifferentCell + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.BuildNearHostIP != nil { + in, out := &in.BuildNearHostIP, &out.BuildNearHostIP + *out = new(string) + **out = **in + } + if in.AdditionalProperties != nil { + in, out := &in.AdditionalProperties, &out.AdditionalProperties + *out = make(map[string]string, len(*in)) + for key, val := range *in { + (*out)[key] = val + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ServerSchedulerHints. +func (in *ServerSchedulerHints) DeepCopy() *ServerSchedulerHints { + if in == nil { + return nil + } + out := new(ServerSchedulerHints) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ServerSpec) DeepCopyInto(out *ServerSpec) { *out = *in diff --git a/cmd/models-schema/zz_generated.openapi.go b/cmd/models-schema/zz_generated.openapi.go index a2d45be9c..b877bf043 100644 --- a/cmd/models-schema/zz_generated.openapi.go +++ b/cmd/models-schema/zz_generated.openapi.go @@ -180,6 +180,7 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA "github.com/k-orc/openstack-resource-controller/v2/api/v1alpha1.ServerPortSpec": schema_openstack_resource_controller_v2_api_v1alpha1_ServerPortSpec(ref), "github.com/k-orc/openstack-resource-controller/v2/api/v1alpha1.ServerResourceSpec": schema_openstack_resource_controller_v2_api_v1alpha1_ServerResourceSpec(ref), "github.com/k-orc/openstack-resource-controller/v2/api/v1alpha1.ServerResourceStatus": schema_openstack_resource_controller_v2_api_v1alpha1_ServerResourceStatus(ref), + "github.com/k-orc/openstack-resource-controller/v2/api/v1alpha1.ServerSchedulerHints": schema_openstack_resource_controller_v2_api_v1alpha1_ServerSchedulerHints(ref), "github.com/k-orc/openstack-resource-controller/v2/api/v1alpha1.ServerSpec": schema_openstack_resource_controller_v2_api_v1alpha1_ServerSpec(ref), "github.com/k-orc/openstack-resource-controller/v2/api/v1alpha1.ServerStatus": schema_openstack_resource_controller_v2_api_v1alpha1_ServerStatus(ref), "github.com/k-orc/openstack-resource-controller/v2/api/v1alpha1.ServerVolumeSpec": schema_openstack_resource_controller_v2_api_v1alpha1_ServerVolumeSpec(ref), @@ -8227,13 +8228,6 @@ func schema_openstack_resource_controller_v2_api_v1alpha1_ServerResourceSpec(ref }, }, }, - "serverGroupRef": { - SchemaProps: spec.SchemaProps{ - Description: "serverGroupRef is a reference to a ServerGroup object. The server will be created in the server group.", - Type: []string{"string"}, - Format: "", - }, - }, "availabilityZone": { SchemaProps: spec.SchemaProps{ Description: "availabilityZone is the availability zone in which to create the server.", @@ -8294,12 +8288,18 @@ func schema_openstack_resource_controller_v2_api_v1alpha1_ServerResourceSpec(ref Format: "", }, }, + "schedulerHints": { + SchemaProps: spec.SchemaProps{ + Description: "schedulerHints provides hints to the Nova scheduler for server placement.", + Ref: ref("github.com/k-orc/openstack-resource-controller/v2/api/v1alpha1.ServerSchedulerHints"), + }, + }, }, Required: []string{"imageRef", "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.ServerMetadata", "github.com/k-orc/openstack-resource-controller/v2/api/v1alpha1.ServerPortSpec", "github.com/k-orc/openstack-resource-controller/v2/api/v1alpha1.ServerSchedulerHints", "github.com/k-orc/openstack-resource-controller/v2/api/v1alpha1.ServerVolumeSpec", "github.com/k-orc/openstack-resource-controller/v2/api/v1alpha1.UserDataSpec"}, } } @@ -8457,6 +8457,123 @@ func schema_openstack_resource_controller_v2_api_v1alpha1_ServerResourceStatus(r } } +func schema_openstack_resource_controller_v2_api_v1alpha1_ServerSchedulerHints(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Description: "ServerSchedulerHints provides hints to the Nova scheduler for server placement.", + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "serverGroupRef": { + SchemaProps: spec.SchemaProps{ + Description: "serverGroupRef is a reference to a ServerGroup object. The server will be scheduled on a host in the specified server group.", + Type: []string{"string"}, + Format: "", + }, + }, + "differentHostServerRefs": { + VendorExtensible: spec.VendorExtensible{ + Extensions: spec.Extensions{ + "x-kubernetes-list-type": "set", + }, + }, + SchemaProps: spec.SchemaProps{ + Description: "differentHostServerRefs is a list of references to Server objects. The server will be scheduled on a different host than all specified servers.", + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + }, + }, + }, + "sameHostServerRefs": { + VendorExtensible: spec.VendorExtensible{ + Extensions: spec.Extensions{ + "x-kubernetes-list-type": "set", + }, + }, + SchemaProps: spec.SchemaProps{ + Description: "sameHostServerRefs is a list of references to Server objects. The server will be scheduled on the same host as all specified servers.", + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + }, + }, + }, + "query": { + SchemaProps: spec.SchemaProps{ + Description: "query is a conditional statement that results in compute nodes able to host the server.", + Type: []string{"string"}, + Format: "", + }, + }, + "targetCell": { + SchemaProps: spec.SchemaProps{ + Description: "targetCell is a cell name where the server will be placed.", + Type: []string{"string"}, + Format: "", + }, + }, + "differentCell": { + VendorExtensible: spec.VendorExtensible{ + Extensions: spec.Extensions{ + "x-kubernetes-list-type": "set", + }, + }, + SchemaProps: spec.SchemaProps{ + Description: "differentCell is a list of cell names where the server should not be placed.", + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + }, + }, + }, + "buildNearHostIP": { + SchemaProps: spec.SchemaProps{ + Description: "buildNearHostIP specifies a subnet of compute nodes to host the server.", + Type: []string{"string"}, + Format: "", + }, + }, + "additionalProperties": { + SchemaProps: spec.SchemaProps{ + Description: "additionalProperties is a map of arbitrary key/value pairs that are not validated by Nova.", + Type: []string{"object"}, + AdditionalProperties: &spec.SchemaOrBool{ + Allows: true, + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + }, + }, + }, + }, + }, + }, + } +} + func schema_openstack_resource_controller_v2_api_v1alpha1_ServerSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { return common.OpenAPIDefinition{ Schema: spec.Schema{ diff --git a/config/crd/bases/openstack.k-orc.cloud_servers.yaml b/config/crd/bases/openstack.k-orc.cloud_servers.yaml index 2a882bad3..306e0bdd8 100644 --- a/config/crd/bases/openstack.k-orc.cloud_servers.yaml +++ b/config/crd/bases/openstack.k-orc.cloud_servers.yaml @@ -290,15 +290,75 @@ spec: maxItems: 64 type: array x-kubernetes-list-type: atomic - serverGroupRef: - description: |- - serverGroupRef is a reference to a ServerGroup object. The server - will be created in the server group. - maxLength: 253 - minLength: 1 - type: string + schedulerHints: + description: schedulerHints provides hints to the Nova scheduler + for server placement. + properties: + additionalProperties: + additionalProperties: + type: string + description: |- + additionalProperties is a map of arbitrary key/value pairs that are + not validated by Nova. + type: object + buildNearHostIP: + description: buildNearHostIP specifies a subnet of compute + nodes to host the server. + maxLength: 255 + type: string + differentCell: + description: |- + differentCell is a list of cell names where the server should not + be placed. + items: + maxLength: 1024 + type: string + maxItems: 64 + type: array + x-kubernetes-list-type: set + differentHostServerRefs: + description: |- + differentHostServerRefs is a list of references to Server objects. + The server will be scheduled on a different host than all specified servers. + items: + maxLength: 253 + minLength: 1 + type: string + maxItems: 64 + type: array + x-kubernetes-list-type: set + query: + description: |- + query is a conditional statement that results in compute nodes + able to host the server. + maxLength: 1024 + type: string + sameHostServerRefs: + description: |- + sameHostServerRefs is a list of references to Server objects. + The server will be scheduled on the same host as all specified servers. + items: + maxLength: 253 + minLength: 1 + type: string + maxItems: 64 + type: array + x-kubernetes-list-type: set + serverGroupRef: + description: |- + serverGroupRef is a reference to a ServerGroup object. The server will be + scheduled on a host in the specified server group. + maxLength: 253 + minLength: 1 + type: string + targetCell: + description: targetCell is a cell name where the server will + be placed. + maxLength: 255 + type: string + type: object x-kubernetes-validations: - - message: serverGroupRef is immutable + - message: schedulerHints is immutable rule: self == oldSelf tags: description: tags is a list of tags which will be applied to the diff --git a/config/samples/openstack_v1alpha1_server.yaml b/config/samples/openstack_v1alpha1_server.yaml index 382d6f9b4..0ee8c24ff 100644 --- a/config/samples/openstack_v1alpha1_server.yaml +++ b/config/samples/openstack_v1alpha1_server.yaml @@ -14,8 +14,9 @@ spec: - portRef: server-sample volumes: - volumeRef: server-sample - serverGroupRef: server-sample keypairRef: server-sample + schedulerHints: + serverGroupRef: server-sample availabilityZone: nova tags: - tag1 diff --git a/internal/controllers/server/actuator.go b/internal/controllers/server/actuator.go index 8ef320aab..14d4efd82 100644 --- a/internal/controllers/server/actuator.go +++ b/internal/controllers/server/actuator.go @@ -178,13 +178,76 @@ func (actuator serverActuator) getFlavorHelper(ctx context.Context, obj *orcv1al }, &orcv1alpha1.Flavor{}) } -func (actuator serverActuator) getServerGroupHelper(ctx context.Context, obj *orcv1alpha1.Server, resource *orcv1alpha1.ServerResourceSpec) (*orcv1alpha1.ServerGroup, progress.ReconcileStatus) { - if resource.ServerGroupRef == nil { - return &orcv1alpha1.ServerGroup{}, progress.NewReconcileStatus() +func (actuator serverActuator) getSchedulerHintsHelper(ctx context.Context, obj *orcv1alpha1.Server, resource *orcv1alpha1.ServerResourceSpec) (servers.SchedulerHintOpts, progress.ReconcileStatus) { + hints := servers.SchedulerHintOpts{} + + if resource.SchedulerHints == nil { + return hints, progress.NewReconcileStatus() + } + + schedHints := resource.SchedulerHints + reconcileStatus := progress.NewReconcileStatus() + + // Resolve ServerGroupRef to server group ID + if schedHints.ServerGroupRef != nil { + sg, sgReconcileStatus := getDependencyHelper(ctx, actuator.k8sClient, obj, string(*schedHints.ServerGroupRef), "ServerGroup", func(sg *orcv1alpha1.ServerGroup) bool { + return orcv1alpha1.IsAvailable(sg) && sg.Status.ID != nil + }, &orcv1alpha1.ServerGroup{}) + reconcileStatus = reconcileStatus.WithReconcileStatus(sgReconcileStatus) + if sg.Status.ID != nil { + hints.Group = *sg.Status.ID + } } - return getDependencyHelper(ctx, actuator.k8sClient, obj, string(*resource.ServerGroupRef), "ServerGroup", func(sg *orcv1alpha1.ServerGroup) bool { - return orcv1alpha1.IsAvailable(sg) && sg.Status.ID != nil - }, &orcv1alpha1.ServerGroup{}) + + // Resolve differentHostServerRefs to server IDs + if len(schedHints.DifferentHostServerRefs) > 0 { + differentHost := make([]string, 0, len(schedHints.DifferentHostServerRefs)) + for _, ref := range schedHints.DifferentHostServerRefs { + server, serverReconcileStatus := getDependencyHelper(ctx, actuator.k8sClient, obj, string(ref), "Server", func(s *orcv1alpha1.Server) bool { + return s.Status.ID != nil + }, &orcv1alpha1.Server{}) + reconcileStatus = reconcileStatus.WithReconcileStatus(serverReconcileStatus) + if server.Status.ID != nil { + differentHost = append(differentHost, *server.Status.ID) + } + } + hints.DifferentHost = differentHost + } + + // Resolve sameHostServerRefs to server IDs + if len(schedHints.SameHostServerRefs) > 0 { + sameHost := make([]string, 0, len(schedHints.SameHostServerRefs)) + for _, ref := range schedHints.SameHostServerRefs { + server, serverReconcileStatus := getDependencyHelper(ctx, actuator.k8sClient, obj, string(ref), "Server", func(s *orcv1alpha1.Server) bool { + return s.Status.ID != nil + }, &orcv1alpha1.Server{}) + reconcileStatus = reconcileStatus.WithReconcileStatus(serverReconcileStatus) + if server.Status.ID != nil { + sameHost = append(sameHost, *server.Status.ID) + } + } + hints.SameHost = sameHost + } + + if schedHints.Query != nil { + hints.Query = []any{*schedHints.Query} + } + if schedHints.TargetCell != nil { + hints.TargetCell = *schedHints.TargetCell + } + hints.DifferentCell = schedHints.DifferentCell + if schedHints.BuildNearHostIP != nil { + hints.BuildNearHostIP = *schedHints.BuildNearHostIP + } + if schedHints.AdditionalProperties != nil { + additionalProps := make(map[string]any, len(schedHints.AdditionalProperties)) + for k, v := range schedHints.AdditionalProperties { + additionalProps[k] = v + } + hints.AdditionalProperties = additionalProps + } + + return hints, reconcileStatus } func (actuator serverActuator) getKeypairHelper(ctx context.Context, obj *orcv1alpha1.Server, resource *orcv1alpha1.ServerResourceSpec) (*orcv1alpha1.KeyPair, progress.ReconcileStatus) { @@ -265,8 +328,8 @@ func (actuator serverActuator) CreateResource(ctx context.Context, obj *orcv1alp } } - serverGroup, serverGroupReconcileStatus := actuator.getServerGroupHelper(ctx, obj, resource) - reconcileStatus = reconcileStatus.WithReconcileStatus(serverGroupReconcileStatus) + schedulerHints, schedulerHintsReconcileStatus := actuator.getSchedulerHintsHelper(ctx, obj, resource) + reconcileStatus = reconcileStatus.WithReconcileStatus(schedulerHintsReconcileStatus) keypair, keypairReconcileStatus := actuator.getKeypairHelper(ctx, obj, resource) reconcileStatus = reconcileStatus.WithReconcileStatus(keypairReconcileStatus) @@ -313,10 +376,6 @@ func (actuator serverActuator) CreateResource(ctx context.Context, obj *orcv1alp } } - schedulerHints := servers.SchedulerHintOpts{ - Group: ptr.Deref(serverGroup.Status.ID, ""), - } - server, err := actuator.osClient.CreateServer(ctx, createOpts, schedulerHints) // We should require the spec to be updated before retrying a create which returned a non-retryable error diff --git a/internal/controllers/server/controller.go b/internal/controllers/server/controller.go index 95ac9f595..3d7c00771 100644 --- a/internal/controllers/server/controller.go +++ b/internal/controllers/server/controller.go @@ -105,14 +105,14 @@ var ( // No deletion guard for server group, because server group can be safely deleted while // referenced by a server serverGroupDependency = dependency.NewDependency[*orcv1alpha1.ServerList, *orcv1alpha1.ServerGroup]( - "spec.resource.serverGroupRef", + "spec.resource.schedulerHints.serverGroupRef", func(server *orcv1alpha1.Server) []string { resource := server.Spec.Resource - if resource == nil || resource.ServerGroupRef == nil { + if resource == nil || resource.SchedulerHints == nil || resource.SchedulerHints.ServerGroupRef == nil { return nil } - return []string{string(*resource.ServerGroupRef)} + return []string{string(*resource.SchedulerHints.ServerGroupRef)} }, ) diff --git a/internal/controllers/server/tests/server-create-full/00-create-resource.yaml b/internal/controllers/server/tests/server-create-full/00-create-resource.yaml index 6f82c53f2..28b64f532 100644 --- a/internal/controllers/server/tests/server-create-full/00-create-resource.yaml +++ b/internal/controllers/server/tests/server-create-full/00-create-resource.yaml @@ -40,8 +40,9 @@ spec: flavorRef: server-create-full ports: - portRef: server-create-full - serverGroupRef: server-create-full keypairRef: server-create-full + schedulerHints: + serverGroupRef: server-create-full volumes: - volumeRef: server-create-full availabilityZone: nova diff --git a/internal/controllers/server/tests/server-dependency/00-create-everything-but-flavor.yaml b/internal/controllers/server/tests/server-dependency/00-create-everything-but-flavor.yaml index 101976fcf..ae93fc454 100644 --- a/internal/controllers/server/tests/server-dependency/00-create-everything-but-flavor.yaml +++ b/internal/controllers/server/tests/server-dependency/00-create-everything-but-flavor.yaml @@ -87,6 +87,7 @@ spec: flavorRef: server-dependency ports: - portRef: server-dependency - serverGroupRef: server-dependency + schedulerHints: + serverGroupRef: server-dependency userData: secretRef: server-dependency \ No newline at end of file diff --git a/internal/controllers/server/tests/server-dependency/01-create-everything-but-image.yaml b/internal/controllers/server/tests/server-dependency/01-create-everything-but-image.yaml index 5757e4eea..a669f622f 100644 --- a/internal/controllers/server/tests/server-dependency/01-create-everything-but-image.yaml +++ b/internal/controllers/server/tests/server-dependency/01-create-everything-but-image.yaml @@ -27,6 +27,7 @@ spec: flavorRef: server-dependency ports: - portRef: server-dependency - serverGroupRef: server-dependency + schedulerHints: + serverGroupRef: server-dependency userData: secretRef: server-dependency diff --git a/internal/controllers/server/tests/server-dependency/02-create-everything-but-port.yaml b/internal/controllers/server/tests/server-dependency/02-create-everything-but-port.yaml index 4dd1e19b0..45f5348cc 100644 --- a/internal/controllers/server/tests/server-dependency/02-create-everything-but-port.yaml +++ b/internal/controllers/server/tests/server-dependency/02-create-everything-but-port.yaml @@ -38,6 +38,7 @@ spec: flavorRef: server-dependency ports: - portRef: server-dependency - serverGroupRef: server-dependency + schedulerHints: + serverGroupRef: server-dependency userData: secretRef: server-dependency diff --git a/internal/controllers/server/tests/server-dependency/03-create-everything-but-server-group.yaml b/internal/controllers/server/tests/server-dependency/03-create-everything-but-server-group.yaml index 6483cf47f..f15e8960e 100644 --- a/internal/controllers/server/tests/server-dependency/03-create-everything-but-server-group.yaml +++ b/internal/controllers/server/tests/server-dependency/03-create-everything-but-server-group.yaml @@ -37,6 +37,7 @@ spec: flavorRef: server-dependency ports: - portRef: server-dependency - serverGroupRef: server-dependency + schedulerHints: + serverGroupRef: server-dependency userData: secretRef: server-dependency diff --git a/internal/controllers/server/tests/server-dependency/04-create-everything-but-userdata-secret.yaml b/internal/controllers/server/tests/server-dependency/04-create-everything-but-userdata-secret.yaml index bc9196a80..f8b8b4d02 100644 --- a/internal/controllers/server/tests/server-dependency/04-create-everything-but-userdata-secret.yaml +++ b/internal/controllers/server/tests/server-dependency/04-create-everything-but-userdata-secret.yaml @@ -35,6 +35,7 @@ spec: flavorRef: server-dependency ports: - portRef: server-dependency - serverGroupRef: server-dependency + schedulerHints: + serverGroupRef: server-dependency userData: secretRef: server-dependency diff --git a/internal/controllers/server/tests/server-dependency/05-create-everything-but-keypair.yaml b/internal/controllers/server/tests/server-dependency/05-create-everything-but-keypair.yaml index eb4776259..031ed37ac 100644 --- a/internal/controllers/server/tests/server-dependency/05-create-everything-but-keypair.yaml +++ b/internal/controllers/server/tests/server-dependency/05-create-everything-but-keypair.yaml @@ -27,7 +27,8 @@ spec: flavorRef: server-dependency ports: - portRef: server-dependency - serverGroupRef: server-dependency + schedulerHints: + serverGroupRef: server-dependency keypairRef: server-dependency userData: secretRef: server-dependency diff --git a/internal/controllers/server/tests/server-update/00-minimal-resource.yaml b/internal/controllers/server/tests/server-update/00-minimal-resource.yaml index 4a62a151a..95dca9e29 100644 --- a/internal/controllers/server/tests/server-update/00-minimal-resource.yaml +++ b/internal/controllers/server/tests/server-update/00-minimal-resource.yaml @@ -13,4 +13,5 @@ spec: flavorRef: server-update ports: - portRef: server-update - serverGroupRef: server-update \ No newline at end of file + schedulerHints: + serverGroupRef: server-update \ No newline at end of file diff --git a/pkg/clients/applyconfiguration/api/v1alpha1/serverresourcespec.go b/pkg/clients/applyconfiguration/api/v1alpha1/serverresourcespec.go index c49efc540..5799a1d5a 100644 --- a/pkg/clients/applyconfiguration/api/v1alpha1/serverresourcespec.go +++ b/pkg/clients/applyconfiguration/api/v1alpha1/serverresourcespec.go @@ -25,18 +25,18 @@ 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"` + UserData *UserDataSpecApplyConfiguration `json:"userData,omitempty"` + Ports []ServerPortSpecApplyConfiguration `json:"ports,omitempty"` + Volumes []ServerVolumeSpecApplyConfiguration `json:"volumes,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"` + SchedulerHints *ServerSchedulerHintsApplyConfiguration `json:"schedulerHints,omitempty"` } // ServerResourceSpecApplyConfiguration constructs a declarative configuration of the ServerResourceSpec type for use with @@ -103,14 +103,6 @@ func (b *ServerResourceSpecApplyConfiguration) WithVolumes(values ...*ServerVolu return b } -// WithServerGroupRef sets the ServerGroupRef 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 ServerGroupRef field is set to the value of the last call. -func (b *ServerResourceSpecApplyConfiguration) WithServerGroupRef(value apiv1alpha1.KubernetesNameRef) *ServerResourceSpecApplyConfiguration { - b.ServerGroupRef = &value - return b -} - // WithAvailabilityZone sets the AvailabilityZone 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 AvailabilityZone field is set to the value of the last call. @@ -157,3 +149,11 @@ func (b *ServerResourceSpecApplyConfiguration) WithConfigDrive(value bool) *Serv b.ConfigDrive = &value return b } + +// WithSchedulerHints sets the SchedulerHints 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 SchedulerHints field is set to the value of the last call. +func (b *ServerResourceSpecApplyConfiguration) WithSchedulerHints(value *ServerSchedulerHintsApplyConfiguration) *ServerResourceSpecApplyConfiguration { + b.SchedulerHints = value + return b +} diff --git a/pkg/clients/applyconfiguration/api/v1alpha1/serverschedulerhints.go b/pkg/clients/applyconfiguration/api/v1alpha1/serverschedulerhints.go new file mode 100644 index 000000000..d0e89425b --- /dev/null +++ b/pkg/clients/applyconfiguration/api/v1alpha1/serverschedulerhints.go @@ -0,0 +1,118 @@ +/* +Copyright 2025 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" +) + +// ServerSchedulerHintsApplyConfiguration represents a declarative configuration of the ServerSchedulerHints type for use +// with apply. +type ServerSchedulerHintsApplyConfiguration struct { + ServerGroupRef *apiv1alpha1.KubernetesNameRef `json:"serverGroupRef,omitempty"` + DifferentHostServerRefs []apiv1alpha1.KubernetesNameRef `json:"differentHostServerRefs,omitempty"` + SameHostServerRefs []apiv1alpha1.KubernetesNameRef `json:"sameHostServerRefs,omitempty"` + Query *string `json:"query,omitempty"` + TargetCell *string `json:"targetCell,omitempty"` + DifferentCell []string `json:"differentCell,omitempty"` + BuildNearHostIP *string `json:"buildNearHostIP,omitempty"` + AdditionalProperties map[string]string `json:"additionalProperties,omitempty"` +} + +// ServerSchedulerHintsApplyConfiguration constructs a declarative configuration of the ServerSchedulerHints type for use with +// apply. +func ServerSchedulerHints() *ServerSchedulerHintsApplyConfiguration { + return &ServerSchedulerHintsApplyConfiguration{} +} + +// WithServerGroupRef sets the ServerGroupRef 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 ServerGroupRef field is set to the value of the last call. +func (b *ServerSchedulerHintsApplyConfiguration) WithServerGroupRef(value apiv1alpha1.KubernetesNameRef) *ServerSchedulerHintsApplyConfiguration { + b.ServerGroupRef = &value + return b +} + +// WithDifferentHostServerRefs adds the given value to the DifferentHostServerRefs field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, values provided by each call will be appended to the DifferentHostServerRefs field. +func (b *ServerSchedulerHintsApplyConfiguration) WithDifferentHostServerRefs(values ...apiv1alpha1.KubernetesNameRef) *ServerSchedulerHintsApplyConfiguration { + for i := range values { + b.DifferentHostServerRefs = append(b.DifferentHostServerRefs, values[i]) + } + return b +} + +// WithSameHostServerRefs adds the given value to the SameHostServerRefs field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, values provided by each call will be appended to the SameHostServerRefs field. +func (b *ServerSchedulerHintsApplyConfiguration) WithSameHostServerRefs(values ...apiv1alpha1.KubernetesNameRef) *ServerSchedulerHintsApplyConfiguration { + for i := range values { + b.SameHostServerRefs = append(b.SameHostServerRefs, values[i]) + } + return b +} + +// WithQuery sets the Query 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 Query field is set to the value of the last call. +func (b *ServerSchedulerHintsApplyConfiguration) WithQuery(value string) *ServerSchedulerHintsApplyConfiguration { + b.Query = &value + return b +} + +// WithTargetCell sets the TargetCell 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 TargetCell field is set to the value of the last call. +func (b *ServerSchedulerHintsApplyConfiguration) WithTargetCell(value string) *ServerSchedulerHintsApplyConfiguration { + b.TargetCell = &value + return b +} + +// WithDifferentCell adds the given value to the DifferentCell field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, values provided by each call will be appended to the DifferentCell field. +func (b *ServerSchedulerHintsApplyConfiguration) WithDifferentCell(values ...string) *ServerSchedulerHintsApplyConfiguration { + for i := range values { + b.DifferentCell = append(b.DifferentCell, values[i]) + } + return b +} + +// WithBuildNearHostIP sets the BuildNearHostIP 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 BuildNearHostIP field is set to the value of the last call. +func (b *ServerSchedulerHintsApplyConfiguration) WithBuildNearHostIP(value string) *ServerSchedulerHintsApplyConfiguration { + b.BuildNearHostIP = &value + return b +} + +// WithAdditionalProperties puts the entries into the AdditionalProperties field in the declarative configuration +// and returns the receiver, so that objects can be build by chaining "With" function invocations. +// If called multiple times, the entries provided by each call will be put on the AdditionalProperties field, +// overwriting an existing map entries in AdditionalProperties field with the same key. +func (b *ServerSchedulerHintsApplyConfiguration) WithAdditionalProperties(entries map[string]string) *ServerSchedulerHintsApplyConfiguration { + if b.AdditionalProperties == nil && len(entries) > 0 { + b.AdditionalProperties = make(map[string]string, len(entries)) + } + for k, v := range entries { + b.AdditionalProperties[k] = v + } + return b +} diff --git a/pkg/clients/applyconfiguration/internal/internal.go b/pkg/clients/applyconfiguration/internal/internal.go index 5dced644a..3f2f4cd36 100644 --- a/pkg/clients/applyconfiguration/internal/internal.go +++ b/pkg/clients/applyconfiguration/internal/internal.go @@ -2406,9 +2406,9 @@ var schemaYAML = typed.YAMLObject(`types: elementType: namedType: com.github.k-orc.openstack-resource-controller.v2.api.v1alpha1.ServerPortSpec elementRelationship: atomic - - name: serverGroupRef + - name: schedulerHints type: - scalar: string + namedType: com.github.k-orc.openstack-resource-controller.v2.api.v1alpha1.ServerSchedulerHints - name: tags type: list: @@ -2475,6 +2475,44 @@ var schemaYAML = typed.YAMLObject(`types: elementType: namedType: com.github.k-orc.openstack-resource-controller.v2.api.v1alpha1.ServerVolumeStatus elementRelationship: atomic +- name: com.github.k-orc.openstack-resource-controller.v2.api.v1alpha1.ServerSchedulerHints + map: + fields: + - name: additionalProperties + type: + map: + elementType: + scalar: string + - name: buildNearHostIP + type: + scalar: string + - name: differentCell + type: + list: + elementType: + scalar: string + elementRelationship: associative + - name: differentHostServerRefs + type: + list: + elementType: + scalar: string + elementRelationship: associative + - name: query + type: + scalar: string + - name: sameHostServerRefs + type: + list: + elementType: + scalar: string + elementRelationship: associative + - name: serverGroupRef + type: + scalar: string + - name: targetCell + type: + scalar: string - name: com.github.k-orc.openstack-resource-controller.v2.api.v1alpha1.ServerSpec map: fields: diff --git a/pkg/clients/applyconfiguration/utils.go b/pkg/clients/applyconfiguration/utils.go index 288ec443f..6117b09bc 100644 --- a/pkg/clients/applyconfiguration/utils.go +++ b/pkg/clients/applyconfiguration/utils.go @@ -302,6 +302,8 @@ func ForKind(kind schema.GroupVersionKind) interface{} { return &apiv1alpha1.ServerResourceSpecApplyConfiguration{} case v1alpha1.SchemeGroupVersion.WithKind("ServerResourceStatus"): return &apiv1alpha1.ServerResourceStatusApplyConfiguration{} + case v1alpha1.SchemeGroupVersion.WithKind("ServerSchedulerHints"): + return &apiv1alpha1.ServerSchedulerHintsApplyConfiguration{} case v1alpha1.SchemeGroupVersion.WithKind("ServerSpec"): return &apiv1alpha1.ServerSpecApplyConfiguration{} case v1alpha1.SchemeGroupVersion.WithKind("ServerStatus"): diff --git a/website/docs/crd-reference.md b/website/docs/crd-reference.md index ad4a2fac0..df24db0a7 100644 --- a/website/docs/crd-reference.md +++ b/website/docs/crd-reference.md @@ -1629,6 +1629,7 @@ _Appears in:_ - [SecurityGroupResourceSpec](#securitygroupresourcespec) - [ServerPortSpec](#serverportspec) - [ServerResourceSpec](#serverresourcespec) +- [ServerSchedulerHints](#serverschedulerhints) - [ServerVolumeSpec](#servervolumespec) - [SubnetFilter](#subnetfilter) - [SubnetResourceSpec](#subnetresourcespec) @@ -3362,12 +3363,12 @@ _Appears in:_ | `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
| -| `serverGroupRef` _[KubernetesNameRef](#kubernetesnameref)_ | serverGroupRef is a reference to a ServerGroup object. The server
will be created in the server group. | | MaxLength: 253
MinLength: 1
| | `availabilityZone` _string_ | availabilityZone is the availability zone in which to create the server. | | MaxLength: 255
| | `keypairRef` _[KubernetesNameRef](#kubernetesnameref)_ | keypairRef is a reference to a KeyPair object. The server will be
created with this keypair for SSH access. | | MaxLength: 253
MinLength: 1
| | `tags` _[ServerTag](#servertag) array_ | tags is a list of tags which will be applied to the server. | | MaxItems: 50
MaxLength: 80
MinLength: 1
| | `metadata` _[ServerMetadata](#servermetadata) array_ | Refer to Kubernetes API documentation for fields of `metadata`. | | MaxItems: 128
| | `configDrive` _boolean_ | configDrive specifies whether to attach a config drive to the server.
When true, configuration data will be available via a special drive
instead of the metadata service. | | | +| `schedulerHints` _[ServerSchedulerHints](#serverschedulerhints)_ | schedulerHints provides hints to the Nova scheduler for server placement. | | | #### ServerResourceStatus @@ -3396,6 +3397,29 @@ _Appears in:_ | `configDrive` _boolean_ | configDrive indicates whether the server was booted with a config drive. | | | +#### ServerSchedulerHints + + + +ServerSchedulerHints provides hints to the Nova scheduler for server placement. + + + +_Appears in:_ +- [ServerResourceSpec](#serverresourcespec) + +| Field | Description | Default | Validation | +| --- | --- | --- | --- | +| `serverGroupRef` _[KubernetesNameRef](#kubernetesnameref)_ | serverGroupRef is a reference to a ServerGroup object. The server will be
scheduled on a host in the specified server group. | | MaxLength: 253
MinLength: 1
| +| `differentHostServerRefs` _[KubernetesNameRef](#kubernetesnameref) array_ | differentHostServerRefs is a list of references to Server objects.
The server will be scheduled on a different host than all specified servers. | | MaxItems: 64
MaxLength: 253
MinLength: 1
| +| `sameHostServerRefs` _[KubernetesNameRef](#kubernetesnameref) array_ | sameHostServerRefs is a list of references to Server objects.
The server will be scheduled on the same host as all specified servers. | | MaxItems: 64
MaxLength: 253
MinLength: 1
| +| `query` _string_ | query is a conditional statement that results in compute nodes
able to host the server. | | MaxLength: 1024
| +| `targetCell` _string_ | targetCell is a cell name where the server will be placed. | | MaxLength: 255
| +| `differentCell` _string array_ | differentCell is a list of cell names where the server should not
be placed. | | MaxItems: 64
items:MaxLength: 1024
| +| `buildNearHostIP` _string_ | buildNearHostIP specifies a subnet of compute nodes to host the server. | | MaxLength: 255
| +| `additionalProperties` _object (keys:string, values:string)_ | additionalProperties is a map of arbitrary key/value pairs that are
not validated by Nova. | | | + + #### ServerSpec From 002b9b24a5b6ebceddfa9c7a4985a9e485d46a89 Mon Sep 17 00:00:00 2001 From: eshulman2 Date: Wed, 31 Dec 2025 14:15:10 +0200 Subject: [PATCH 4/5] Add imageRef to volume controller Enables creating bootable volumes from images by adding an imageRef field to the Volume spec. When specified, the volume is created with the image baked in, making it suitable for boot-from-volume scenarios. Changes: - Add imageRef field to VolumeResourceSpec - Add bootable and imageID fields to VolumeResourceStatus - Add image dependency with deletion guard - Add kuttl tests for bootable volume creation assisted-by: claude --- api/v1alpha1/volume_types.go | 12 +++++ api/v1alpha1/zz_generated.deepcopy.go | 5 ++ cmd/models-schema/zz_generated.openapi.go | 14 ++++++ .../bases/openstack.k-orc.cloud_volumes.yaml | 16 +++++++ .../openstack_v1alpha1_volume_bootable.yaml | 29 +++++++++++ internal/controllers/volume/actuator.go | 15 ++++++ internal/controllers/volume/controller.go | 21 ++++++++ internal/controllers/volume/status.go | 7 +++ .../volume-create-bootable/00-assert.yaml | 48 +++++++++++++++++++ .../00-create-resource.yaml | 28 +++++++++++ .../volume-create-bootable/00-secret.yaml | 5 ++ .../tests/volume-dependency/00-assert.yaml | 15 ++++++ .../00-create-resources-missing-deps.yaml | 13 +++++ .../api/v1alpha1/volumeresourcespec.go | 9 ++++ .../api/v1alpha1/volumeresourcestatus.go | 9 ++++ .../applyconfiguration/internal/internal.go | 6 +++ website/docs/crd-reference.md | 2 + 17 files changed, 254 insertions(+) create mode 100644 config/samples/openstack_v1alpha1_volume_bootable.yaml create mode 100644 internal/controllers/volume/tests/volume-create-bootable/00-assert.yaml create mode 100644 internal/controllers/volume/tests/volume-create-bootable/00-create-resource.yaml create mode 100644 internal/controllers/volume/tests/volume-create-bootable/00-secret.yaml diff --git a/api/v1alpha1/volume_types.go b/api/v1alpha1/volume_types.go index f50f83daa..49e2f3d06 100644 --- a/api/v1alpha1/volume_types.go +++ b/api/v1alpha1/volume_types.go @@ -56,6 +56,13 @@ type VolumeResourceSpec struct { // +listType=atomic // +optional Metadata []VolumeMetadata `json:"metadata,omitempty"` + + // imageRef is a reference to an ORC Image. If specified, creates a + // bootable volume from this image. The volume size must be >= the + // image's min_disk requirement. + // +optional + // +kubebuilder:validation:XValidation:rule="self == oldSelf",message="imageRef is immutable" + ImageRef *KubernetesNameRef `json:"imageRef,omitempty"` } // VolumeFilter defines an existing resource by its properties @@ -176,6 +183,11 @@ type VolumeResourceStatus struct { // +optional Bootable *bool `json:"bootable,omitempty"` + // imageID is the ID of the image this volume was created from, if any. + // +kubebuilder:validation:MaxLength=1024 + // +optional + ImageID string `json:"imageID,omitempty"` + // encrypted denotes if the volume is encrypted. // +optional Encrypted *bool `json:"encrypted,omitempty"` diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 4efd5c428..4cd8053ac 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -5277,6 +5277,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 b877bf043..dac4f04aa 100644 --- a/cmd/models-schema/zz_generated.openapi.go +++ b/cmd/models-schema/zz_generated.openapi.go @@ -10179,6 +10179,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"}, }, @@ -10310,6 +10317,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 3f2f4cd36..95fee290e 100644 --- a/pkg/clients/applyconfiguration/internal/internal.go +++ b/pkg/clients/applyconfiguration/internal/internal.go @@ -3027,6 +3027,9 @@ var schemaYAML = typed.YAMLObject(`types: - name: description type: scalar: string + - name: imageRef + type: + scalar: string - name: metadata type: list: @@ -3075,6 +3078,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 df24db0a7..1ff7f242a 100644 --- a/website/docs/crd-reference.md +++ b/website/docs/crd-reference.md @@ -3992,6 +3992,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 @@ -4020,6 +4021,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
| From 38a54f963ff2f8f4cd25dfe23929f1138def4af5 Mon Sep 17 00:00:00 2001 From: eshulman2 Date: Wed, 31 Dec 2025 15:05:41 +0200 Subject: [PATCH 5/5] 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 | 9 +++ .../applyconfiguration/internal/internal.go | 12 ++++ pkg/clients/applyconfiguration/utils.go | 2 + website/docs/crd-reference.md | 22 ++++++- 19 files changed, 513 insertions(+), 15 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 6dad6858a..4d977a732 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 4cd8053ac..01487d620 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 dac4f04aa..bf2c14a45 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), @@ -7437,6 +7438,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{ @@ -8172,7 +8201,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: "", }, @@ -8184,6 +8213,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.", @@ -8295,11 +8330,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.ServerSchedulerHints", "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.ServerSchedulerHints", "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 306e0bdd8..28ade6ac1 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 @@ -414,9 +437,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 14d4efd82..9de206047 100644 --- a/internal/controllers/server/actuator.go +++ b/internal/controllers/server/actuator.go @@ -286,15 +286,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 := actuator.getFlavorHelper(ctx, obj, resource) @@ -355,7 +385,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, @@ -363,6 +393,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 3d7c00771..d5ca0f598 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..e265034b8 --- /dev/null +++ b/pkg/clients/applyconfiguration/api/v1alpha1/serverbootvolumespec.go @@ -0,0 +1,52 @@ +/* +Copyright 2025 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 5799a1d5a..5e123f453 100644 --- a/pkg/clients/applyconfiguration/api/v1alpha1/serverresourcespec.go +++ b/pkg/clients/applyconfiguration/api/v1alpha1/serverresourcespec.go @@ -28,6 +28,7 @@ type ServerResourceSpecApplyConfiguration struct { 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"` @@ -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 95fee290e..2a4f3fd67 100644 --- a/pkg/clients/applyconfiguration/internal/internal.go +++ b/pkg/clients/applyconfiguration/internal/internal.go @@ -2163,6 +2163,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: @@ -2379,6 +2388,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 6117b09bc..9540b6aec 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 1ff7f242a..b3ebf5161 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) - [ServerSchedulerHints](#serverschedulerhints) @@ -3029,6 +3030,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 @@ -3358,8 +3377,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
|