From 1c32e006736b4cf51ce720ec06c30a667d9298aa Mon Sep 17 00:00:00 2001 From: Tobias Persson Date: Tue, 18 Nov 2025 10:31:31 +0100 Subject: [PATCH 1/7] Add IUT controller --- PROJECT | 12 + api/v1alpha1/provider_types.go | 3 + api/v1alpha2/groupversion_info.go | 36 +++ api/v1alpha2/iut_types.go | 107 ++++++ api/v1alpha2/zz_generated.deepcopy.go | 132 ++++++++ cmd/main.go | 17 + .../etos.eiffel-community.github.io_iuts.yaml | 168 ++++++++++ ....eiffel-community.github.io_providers.yaml | 4 + config/crd/kustomization.yaml | 1 + config/rbac/iut_admin_role.yaml | 27 ++ config/rbac/iut_editor_role.yaml | 33 ++ config/rbac/iut_viewer_role.yaml | 29 ++ config/rbac/kustomization.yaml | 7 + config/rbac/role.yaml | 3 + config/samples/etos_v1alpha2_iut.yaml | 9 + config/samples/kustomization.yaml | 1 + config/webhook/manifests.yaml | 20 ++ internal/controller/iut_controller.go | 304 ++++++++++++++++++ internal/controller/iut_controller_test.go | 84 +++++ internal/controller/providers.go | 15 + internal/controller/suite_test.go | 4 + internal/controller/utilities.go | 32 +- internal/release/iut.go | 47 +++ internal/release/release.go | 99 ++++++ internal/webhook/v1alpha2/iut_webhook.go | 94 ++++++ internal/webhook/v1alpha2/iut_webhook_test.go | 61 ++++ .../webhook/v1alpha2/webhook_suite_test.go | 164 ++++++++++ 27 files changed, 1511 insertions(+), 2 deletions(-) create mode 100644 api/v1alpha2/groupversion_info.go create mode 100644 api/v1alpha2/iut_types.go create mode 100644 api/v1alpha2/zz_generated.deepcopy.go create mode 100644 config/crd/bases/etos.eiffel-community.github.io_iuts.yaml create mode 100644 config/rbac/iut_admin_role.yaml create mode 100644 config/rbac/iut_editor_role.yaml create mode 100644 config/rbac/iut_viewer_role.yaml create mode 100644 config/samples/etos_v1alpha2_iut.yaml create mode 100644 internal/controller/iut_controller.go create mode 100644 internal/controller/iut_controller_test.go create mode 100644 internal/release/iut.go create mode 100644 internal/release/release.go create mode 100644 internal/webhook/v1alpha2/iut_webhook.go create mode 100644 internal/webhook/v1alpha2/iut_webhook_test.go create mode 100644 internal/webhook/v1alpha2/webhook_suite_test.go diff --git a/PROJECT b/PROJECT index ee64e086..399f9093 100644 --- a/PROJECT +++ b/PROJECT @@ -67,4 +67,16 @@ resources: conversion: true defaulting: true webhookVersion: v1 +- api: + crdVersion: v1 + namespaced: true + controller: true + domain: eiffel-community.github.io + group: etos + kind: Iut + path: github.com/eiffel-community/etos/api/v1alpha2 + version: v1alpha2 + webhooks: + defaulting: true + webhookVersion: v1 version: "3" diff --git a/api/v1alpha1/provider_types.go b/api/v1alpha1/provider_types.go index 9299f051..89182d57 100644 --- a/api/v1alpha1/provider_types.go +++ b/api/v1alpha1/provider_types.go @@ -108,6 +108,9 @@ type ProviderSpec struct { // and don't muddle up the yaml with empty data. JSONTas *JSONTas `json:"jsontas,omitempty"` JSONTasSource *VarSource `json:"jsontasSource,omitempty"` + + // Image describes the docker image to run when providing a resource. + Image string `json:"image,omitempty"` } // ProviderStatus defines the observed state of Provider diff --git a/api/v1alpha2/groupversion_info.go b/api/v1alpha2/groupversion_info.go new file mode 100644 index 00000000..83426bd5 --- /dev/null +++ b/api/v1alpha2/groupversion_info.go @@ -0,0 +1,36 @@ +// Copyright Axis Communications AB. +// +// For a full list of individual contributors, please see the commit history. +// +// 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. + +// Package v1alpha2 contains API Schema definitions for the etos v1alpha2 API group. +// +kubebuilder:object:generate=true +// +groupName=etos.eiffel-community.github.io +package v1alpha2 + +import ( + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/controller-runtime/pkg/scheme" +) + +var ( + // GroupVersion is group version used to register these objects. + GroupVersion = schema.GroupVersion{Group: "etos.eiffel-community.github.io", Version: "v1alpha2"} + + // SchemeBuilder is used to add go types to the GroupVersionKind scheme. + SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} + + // AddToScheme adds the types in this group-version to the given scheme. + AddToScheme = SchemeBuilder.AddToScheme +) diff --git a/api/v1alpha2/iut_types.go b/api/v1alpha2/iut_types.go new file mode 100644 index 00000000..7d2f07ae --- /dev/null +++ b/api/v1alpha2/iut_types.go @@ -0,0 +1,107 @@ +// Copyright Axis Communications AB. +// +// For a full list of individual contributors, please see the commit history. +// +// 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. + +package v1alpha2 + +import ( + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// IutSpec defines the desired state of Iut +type IutSpec struct { + // ID is the ID for the IUT. The ID is a UUID, any version, and regex matches that. + // +kubebuilder:validation:Pattern="^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$" + // +required + ID string `json:"id"` + + // Identity is the PackageURL definition of the IUT. + // +required + Identity string `json:"identity"` + + // EnvironmentRequest is the name of the environmentrequest which requested this IUT. + // +required + EnvironmentRequest string `json:"environmentRequest"` + + // ProviderID is the name of the Provider used to create this Iut. + // +required + ProviderID string `json:"provider_id"` + + // ProviderData is specific data provided by the IUT providers + // +optional + ProviderData *apiextensionsv1.JSON `json:"provider_data,omitempty"` +} + +// IutStatus defines the observed state of Iut. +type IutStatus struct { + // For Kubernetes API conventions, see: + // https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md#typical-status-properties + + // conditions represent the current state of the Iut resource. + // Each condition has a unique type and reflects the status of a specific aspect of the resource. + // + // Standard condition types include: + // - "Available": the resource is fully functional + // - "Progressing": the resource is being created or updated + // - "Degraded": the resource failed to reach or maintain its desired state + // + // The status of each condition is one of True, False, or Unknown. + // +listType=map + // +listMapKey=type + // +optional + Conditions []metav1.Condition `json:"conditions,omitempty"` + + CompletionTime *metav1.Time `json:"completionTime,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status + +// Iut is the Schema for the iuts API +// +kubebuilder:printcolumn:name="Ready",type="string",JSONPath=".status.conditions[?(@.type==\"Active\")].status" +// +kubebuilder:printcolumn:name="Reason",type="string",JSONPath=".status.conditions[?(@.type==\"Active\")].reason" +// +kubebuilder:printcolumn:name="Description",type="string",JSONPath=".status.conditions[?(@.type==\"Active\")].message" +// +kubebuilder:printcolumn:name="Provider",type="string",JSONPath=.spec.provider_id +// +kubebuilder:printcolumn:name="Environment",type="string",JSONPath=".metadata.ownerReferences[?(@.kind==\"Environment\")].name" +// +kubebuilder:printcolumn:name="TestRun",type="string",JSONPath=.metadata.labels.etos\.eiffel-community\.github\.io/id +type Iut struct { + metav1.TypeMeta `json:",inline"` + + // metadata is a standard object metadata + // +optional + metav1.ObjectMeta `json:"metadata,omitempty,omitzero"` + + // spec defines the desired state of Iut + // +required + Spec IutSpec `json:"spec"` + + // status defines the observed state of Iut + // +optional + Status IutStatus `json:"status,omitempty,omitzero"` +} + +// +kubebuilder:object:root=true + +// IutList contains a list of Iut +type IutList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []Iut `json:"items"` +} + +func init() { + SchemeBuilder.Register(&Iut{}, &IutList{}) +} diff --git a/api/v1alpha2/zz_generated.deepcopy.go b/api/v1alpha2/zz_generated.deepcopy.go new file mode 100644 index 00000000..1b110471 --- /dev/null +++ b/api/v1alpha2/zz_generated.deepcopy.go @@ -0,0 +1,132 @@ +//go:build !ignore_autogenerated + +// Copyright Axis Communications AB. +// +// For a full list of individual contributors, please see the commit history. +// +// 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 controller-gen. DO NOT EDIT. + +package v1alpha2 + +import ( + "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Iut) DeepCopyInto(out *Iut) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) + in.Status.DeepCopyInto(&out.Status) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Iut. +func (in *Iut) DeepCopy() *Iut { + if in == nil { + return nil + } + out := new(Iut) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Iut) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *IutList) DeepCopyInto(out *IutList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]Iut, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IutList. +func (in *IutList) DeepCopy() *IutList { + if in == nil { + return nil + } + out := new(IutList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *IutList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *IutSpec) DeepCopyInto(out *IutSpec) { + *out = *in + if in.ProviderData != nil { + in, out := &in.ProviderData, &out.ProviderData + *out = new(v1.JSON) + (*in).DeepCopyInto(*out) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IutSpec. +func (in *IutSpec) DeepCopy() *IutSpec { + if in == nil { + return nil + } + out := new(IutSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *IutStatus) DeepCopyInto(out *IutStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]metav1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.CompletionTime != nil { + in, out := &in.CompletionTime, &out.CompletionTime + *out = (*in).DeepCopy() + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new IutStatus. +func (in *IutStatus) DeepCopy() *IutStatus { + if in == nil { + return nil + } + out := new(IutStatus) + in.DeepCopyInto(out) + return out +} diff --git a/cmd/main.go b/cmd/main.go index 76537bad..c81ed3e0 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -39,8 +39,10 @@ import ( "sigs.k8s.io/controller-runtime/pkg/webhook" etosv1alpha1 "github.com/eiffel-community/etos/api/v1alpha1" + etosv1alpha2 "github.com/eiffel-community/etos/api/v1alpha2" "github.com/eiffel-community/etos/internal/controller" webhooketosv1alpha1 "github.com/eiffel-community/etos/internal/webhook/v1alpha1" + webhookv1alpha2 "github.com/eiffel-community/etos/internal/webhook/v1alpha2" // +kubebuilder:scaffold:imports ) @@ -53,6 +55,7 @@ func init() { utilruntime.Must(clientgoscheme.AddToScheme(scheme)) utilruntime.Must(etosv1alpha1.AddToScheme(scheme)) + utilruntime.Must(etosv1alpha2.AddToScheme(scheme)) // +kubebuilder:scaffold:scheme } @@ -261,6 +264,20 @@ func main() { os.Exit(1) } } + if err := (&controller.IutReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "Iut") + os.Exit(1) + } + // nolint:goconst + if os.Getenv("ENABLE_WEBHOOKS") != "false" { + if err := webhookv1alpha2.SetupIutWebhookWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create webhook", "webhook", "Iut") + os.Exit(1) + } + } // +kubebuilder:scaffold:builder if metricsCertWatcher != nil { diff --git a/config/crd/bases/etos.eiffel-community.github.io_iuts.yaml b/config/crd/bases/etos.eiffel-community.github.io_iuts.yaml new file mode 100644 index 00000000..4035c704 --- /dev/null +++ b/config/crd/bases/etos.eiffel-community.github.io_iuts.yaml @@ -0,0 +1,168 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.17.2 + name: iuts.etos.eiffel-community.github.io +spec: + group: etos.eiffel-community.github.io + names: + kind: Iut + listKind: IutList + plural: iuts + singular: iut + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .status.conditions[?(@.type=="Active")].status + name: Ready + type: string + - jsonPath: .status.conditions[?(@.type=="Active")].reason + name: Reason + type: string + - jsonPath: .status.conditions[?(@.type=="Active")].message + name: Description + type: string + - jsonPath: .spec.provider_id + name: Provider + type: string + - jsonPath: .metadata.ownerReferences[?(@.kind=="Environment")].name + name: Environment + type: string + - jsonPath: .metadata.labels.etos\.eiffel-community\.github\.io/id + name: TestRun + type: string + name: v1alpha2 + schema: + openAPIV3Schema: + description: Iut is the Schema for the iuts API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: spec defines the desired state of Iut + properties: + environmentRequest: + description: EnvironmentRequest is the name of the environmentrequest + which requested this IUT. + type: string + id: + description: ID is the ID for the IUT. The ID is a UUID, any version, + and regex matches that. + pattern: ^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$ + type: string + identity: + description: Identity is the PackageURL definition of the IUT. + type: string + provider_data: + description: ProviderData is specific data provider by the IUT providers + x-kubernetes-preserve-unknown-fields: true + provider_id: + description: ProviderID is the name of the Provider used to create + this Iut. + type: string + required: + - environmentRequest + - id + - identity + - provider_id + type: object + status: + description: status defines the observed state of Iut + properties: + completionTime: + format: date-time + type: string + conditions: + description: |- + conditions represent the current state of the Iut resource. + Each condition has a unique type and reflects the status of a specific aspect of the resource. + + Standard condition types include: + - "Available": the resource is fully functional + - "Progressing": the resource is being created or updated + - "Degraded": the resource failed to reach or maintain its desired state + + The status of each condition is one of True, False, or Unknown. + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + x-kubernetes-list-map-keys: + - type + x-kubernetes-list-type: map + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} diff --git a/config/crd/bases/etos.eiffel-community.github.io_providers.yaml b/config/crd/bases/etos.eiffel-community.github.io_providers.yaml index a0ba82d9..9fc287ac 100644 --- a/config/crd/bases/etos.eiffel-community.github.io_providers.yaml +++ b/config/crd/bases/etos.eiffel-community.github.io_providers.yaml @@ -61,6 +61,10 @@ spec: type: object host: type: string + image: + description: Image describes the docker image to run when providing + a resource. + type: string jsontas: description: |- These are pointers so that they become nil in the Provider object in Kubernetes diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml index 576e5e63..4037cb8a 100644 --- a/config/crd/kustomization.yaml +++ b/config/crd/kustomization.yaml @@ -7,6 +7,7 @@ resources: - bases/etos.eiffel-community.github.io_environments.yaml - bases/etos.eiffel-community.github.io_clusters.yaml - bases/etos.eiffel-community.github.io_environmentrequests.yaml +- bases/etos.eiffel-community.github.io_iuts.yaml # +kubebuilder:scaffold:crdkustomizeresource patches: diff --git a/config/rbac/iut_admin_role.yaml b/config/rbac/iut_admin_role.yaml new file mode 100644 index 00000000..d31597c3 --- /dev/null +++ b/config/rbac/iut_admin_role.yaml @@ -0,0 +1,27 @@ +# This rule is not used by the project etos itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants full permissions ('*') over etos.eiffel-community.github.io. +# This role is intended for users authorized to modify roles and bindings within the cluster, +# enabling them to delegate specific permissions to other users or groups as needed. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: etos + app.kubernetes.io/managed-by: kustomize + name: iut-admin-role +rules: +- apiGroups: + - etos.eiffel-community.github.io + resources: + - iuts + verbs: + - '*' +- apiGroups: + - etos.eiffel-community.github.io + resources: + - iuts/status + verbs: + - get diff --git a/config/rbac/iut_editor_role.yaml b/config/rbac/iut_editor_role.yaml new file mode 100644 index 00000000..d3026dbc --- /dev/null +++ b/config/rbac/iut_editor_role.yaml @@ -0,0 +1,33 @@ +# This rule is not used by the project etos itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants permissions to create, update, and delete resources within the etos.eiffel-community.github.io. +# This role is intended for users who need to manage these resources +# but should not control RBAC or manage permissions for others. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: etos + app.kubernetes.io/managed-by: kustomize + name: iut-editor-role +rules: +- apiGroups: + - etos.eiffel-community.github.io + resources: + - iuts + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - etos.eiffel-community.github.io + resources: + - iuts/status + verbs: + - get diff --git a/config/rbac/iut_viewer_role.yaml b/config/rbac/iut_viewer_role.yaml new file mode 100644 index 00000000..9690e8ce --- /dev/null +++ b/config/rbac/iut_viewer_role.yaml @@ -0,0 +1,29 @@ +# This rule is not used by the project etos itself. +# It is provided to allow the cluster admin to help manage permissions for users. +# +# Grants read-only access to etos.eiffel-community.github.io resources. +# This role is intended for users who need visibility into these resources +# without permissions to modify them. It is ideal for monitoring purposes and limited-access viewing. + +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/name: etos + app.kubernetes.io/managed-by: kustomize + name: iut-viewer-role +rules: +- apiGroups: + - etos.eiffel-community.github.io + resources: + - iuts + verbs: + - get + - list + - watch +- apiGroups: + - etos.eiffel-community.github.io + resources: + - iuts/status + verbs: + - get diff --git a/config/rbac/kustomization.yaml b/config/rbac/kustomization.yaml index a0911bc4..d28c6769 100644 --- a/config/rbac/kustomization.yaml +++ b/config/rbac/kustomization.yaml @@ -38,3 +38,10 @@ resources: - testrun_editor_role.yaml - testrun_viewer_role.yaml +# For each CRD, "Admin", "Editor" and "Viewer" roles are scaffolded by +# default, aiding admins in cluster management. Those roles are +# not used by the etos itself. You can comment the following lines +# if you do not want those helpers be installed with your Project. +- iut_admin_role.yaml +- iut_editor_role.yaml +- iut_viewer_role.yaml \ No newline at end of file diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index e0e94d2e..e6faf74d 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -63,6 +63,7 @@ rules: - clusters - environmentrequests - environments + - iuts - providers verbs: - create @@ -78,6 +79,7 @@ rules: - clusters/finalizers - environmentrequests/finalizers - environments/finalizers + - iuts/finalizers - providers/finalizers - testruns/finalizers verbs: @@ -88,6 +90,7 @@ rules: - clusters/status - environmentrequests/status - environments/status + - iuts/status - providers/status - testruns/status verbs: diff --git a/config/samples/etos_v1alpha2_iut.yaml b/config/samples/etos_v1alpha2_iut.yaml new file mode 100644 index 00000000..94da1102 --- /dev/null +++ b/config/samples/etos_v1alpha2_iut.yaml @@ -0,0 +1,9 @@ +apiVersion: etos.eiffel-community.github.io/v1alpha2 +kind: Iut +metadata: + labels: + app.kubernetes.io/name: etos + app.kubernetes.io/managed-by: kustomize + name: iut-sample +spec: + # TODO(user): Add fields here diff --git a/config/samples/kustomization.yaml b/config/samples/kustomization.yaml index 07164ed7..348393fa 100644 --- a/config/samples/kustomization.yaml +++ b/config/samples/kustomization.yaml @@ -5,4 +5,5 @@ resources: - etos_v1alpha1_environment.yaml - etos_v1alpha1_cluster.yaml - etos_v1alpha1_environmentrequest.yaml +- etos_v1alpha2_iut.yaml # +kubebuilder:scaffold:manifestskustomizesamples diff --git a/config/webhook/manifests.yaml b/config/webhook/manifests.yaml index b29375ff..b5ed985d 100644 --- a/config/webhook/manifests.yaml +++ b/config/webhook/manifests.yaml @@ -64,6 +64,26 @@ webhooks: resources: - testruns sideEffects: None +- admissionReviewVersions: + - v1 + clientConfig: + service: + name: webhook-service + namespace: system + path: /mutate-etos-eiffel-community-github-io-v1alpha2-iut + failurePolicy: Fail + name: miut-v1alpha2.kb.io + rules: + - apiGroups: + - etos.eiffel-community.github.io + apiVersions: + - v1alpha2 + operations: + - CREATE + - UPDATE + resources: + - iuts + sideEffects: None --- apiVersion: admissionregistration.k8s.io/v1 kind: ValidatingWebhookConfiguration diff --git a/internal/controller/iut_controller.go b/internal/controller/iut_controller.go new file mode 100644 index 00000000..f652c4f4 --- /dev/null +++ b/internal/controller/iut_controller.go @@ -0,0 +1,304 @@ +// Copyright Axis Communications AB. +// +// For a full list of individual contributors, please see the commit history. +// +// 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. + +package controller + +import ( + "context" + "errors" + + batchv1 "k8s.io/api/batch/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + logf "sigs.k8s.io/controller-runtime/pkg/log" + + etosv1alpha1 "github.com/eiffel-community/etos/api/v1alpha1" + etosv1alpha2 "github.com/eiffel-community/etos/api/v1alpha2" + "github.com/eiffel-community/etos/internal/controller/jobs" + "github.com/eiffel-community/etos/internal/controller/status" + "github.com/eiffel-community/etos/internal/release" +) + +// IutReconciler reconciles a Iut object +type IutReconciler struct { + client.Client + Scheme *runtime.Scheme +} + +// +kubebuilder:rbac:groups=etos.eiffel-community.github.io,resources=iuts,verbs=get;list;watch;create;update;patch;delete +// +kubebuilder:rbac:groups=etos.eiffel-community.github.io,resources=iuts/status,verbs=get;update;patch +// +kubebuilder:rbac:groups=etos.eiffel-community.github.io,resources=iuts/finalizers,verbs=update + +// Reconcile is part of the main kubernetes reconciliation loop which aims to +// move the current state of the cluster closer to the desired state. +// +// For more details, check Reconcile and its Result here: +// - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.22.1/pkg/reconcile +func (r *IutReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + logger := logf.FromContext(ctx) + + iut := &etosv1alpha2.Iut{} + err := r.Get(ctx, req.NamespacedName, iut) + if err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + if hasOwner(iut.OwnerReferences, "Environment") { + if controllerutil.ContainsFinalizer(iut, providerFinalizer) { + controllerutil.RemoveFinalizer(iut, providerFinalizer) + if err := r.Update(ctx, iut); err != nil { + if apierrors.IsConflict(err) { + return ctrl.Result{Requeue: true}, nil + } + return ctrl.Result{}, err + } + } + // Not being deleted, in use. + if iut.ObjectMeta.DeletionTimestamp.IsZero() { + if meta.SetStatusCondition(&iut.Status.Conditions, + metav1.Condition{ + Status: metav1.ConditionTrue, + Type: status.StatusActive, + Reason: status.ReasonActive, + Message: "In use", + }) { + if err := r.Status().Update(ctx, iut); err != nil { + if apierrors.IsConflict(err) { + return ctrl.Result{Requeue: true}, nil + } + return ctrl.Result{}, err + } + } + // Being deleted, releasing. + } else { + if meta.SetStatusCondition(&iut.Status.Conditions, + metav1.Condition{ + Status: metav1.ConditionFalse, + Type: status.StatusActive, + Reason: status.ReasonPending, + Message: "Releasing IUT", + }) { + if err := r.Status().Update(ctx, iut); err != nil { + if apierrors.IsConflict(err) { + return ctrl.Result{Requeue: true}, nil + } + return ctrl.Result{}, err + } + } + } + logger.Info("Iut is being managed by Environment", "iut", iut.Name) + // We no longer own this IUT. Let the Environment controller manage it. + return ctrl.Result{}, nil + } + // If the IUT is considered 'Completed', it has been released. Check that the object is + // being deleted and contains the finalizer and remove the finalizer. + if iut.Status.CompletionTime != nil { + if !iut.ObjectMeta.DeletionTimestamp.IsZero() { + if controllerutil.ContainsFinalizer(iut, providerFinalizer) { + controllerutil.RemoveFinalizer(iut, providerFinalizer) + if err := r.Update(ctx, iut); err != nil { + if apierrors.IsConflict(err) { + return ctrl.Result{Requeue: true}, nil + } + return ctrl.Result{}, err + } + } + } + return ctrl.Result{}, nil + } + if err := r.reconcile(ctx, iut); err != nil { + if apierrors.IsConflict(err) { + return ctrl.Result{Requeue: true}, nil + } + return ctrl.Result{}, err + } + return ctrl.Result{}, nil +} + +// reconcile an IUT resource to its desired state. +func (r *IutReconciler) reconcile(ctx context.Context, iut *etosv1alpha2.Iut) error { + logger := logf.FromContext(ctx) + + // Set initial statuses if not set. + if active := meta.FindStatusCondition(iut.Status.Conditions, status.StatusActive); active == nil { + meta.SetStatusCondition(&iut.Status.Conditions, + metav1.Condition{ + Status: metav1.ConditionFalse, + Type: status.StatusActive, + Reason: status.ReasonPending, + Message: "Waiting for environment", + }) + return r.Status().Update(ctx, iut) + } else if active.Reason == status.ReasonFailed { + logger.Info("IUT failed, reconciliation canceled") + return nil + } + if iut.ObjectMeta.DeletionTimestamp.IsZero() { + if !controllerutil.ContainsFinalizer(iut, providerFinalizer) { + controllerutil.AddFinalizer(iut, providerFinalizer) + logger.Info("Iut is being managed by Iut controller", "iut", iut.Name) + return r.Update(ctx, iut) + } + } + + if !iut.ObjectMeta.DeletionTimestamp.IsZero() { + return r.reconcileIutReleaser(ctx, iut) + } + return nil +} + +// reconcileIutReleaser gets the status of a release job, creating a new release job if necessary. +func (r *IutReconciler) reconcileIutReleaser(ctx context.Context, iut *etosv1alpha2.Iut) error { + conditions := &iut.Status.Conditions + jobManager := jobs.NewJob(r.Client, IutOwnerKey, iut.GetName(), iut.GetNamespace()) + jobStatus, err := jobManager.Status(ctx) + if err != nil { + return err + } + switch jobStatus { + case jobs.StatusFailed: + result := jobManager.Result(ctx, release.IutReleaserName) + if meta.SetStatusCondition(conditions, + metav1.Condition{ + Type: status.StatusActive, + Status: metav1.ConditionFalse, + Reason: status.ReasonFailed, + Message: result.Description, + }) { + return r.Status().Update(ctx, iut) + } + case jobs.StatusSuccessful: + result := jobManager.Result(ctx, release.IutReleaserName) + var condition metav1.Condition + if result.Conclusion == jobs.ConclusionFailed { + condition = metav1.Condition{ + Type: status.StatusActive, + Status: metav1.ConditionFalse, + Reason: status.ReasonFailed, + Message: result.Description, + } + } else { + condition = metav1.Condition{ + Type: status.StatusActive, + Status: metav1.ConditionFalse, + Reason: status.ReasonCompleted, + Message: result.Description, + } + } + iutCondition := meta.FindStatusCondition(*conditions, status.StatusActive) + iut.Status.CompletionTime = &iutCondition.LastTransitionTime + if meta.SetStatusCondition(conditions, condition) { + return errors.Join(r.Status().Update(ctx, iut), jobManager.Delete(ctx)) + } + case jobs.StatusActive: + if meta.SetStatusCondition(conditions, + metav1.Condition{ + Type: status.StatusActive, + Status: metav1.ConditionFalse, + Reason: status.ReasonPending, + Message: "Releasing IUT", + }) { + return r.Status().Update(ctx, iut) + } + default: + // Since this is a release job, we don't want to release if we are not deleting. + if iut.GetDeletionTimestamp().IsZero() { + return nil + } + if err := jobManager.Create(ctx, iut, r.releaseJob); err != nil { + // When we create a job the job gets a unique name. If there's an error for that unique name the error + // message in Condition.Message is also unique meaning we will update the StatusCondition every time, + // causing a nasty reconciliation loop (when the iut gets updated a new reconciliation starts). + // We mitigate this by checking that StatusReason is not already Failed. + if !isStatusReason(*conditions, status.StatusActive, status.ReasonFailed) && meta.SetStatusCondition(conditions, + metav1.Condition{ + Type: status.StatusActive, + Status: metav1.ConditionFalse, + Reason: status.ReasonFailed, + Message: err.Error(), + }) { + return r.Status().Update(ctx, iut) + } + return err + } + if meta.SetStatusCondition(conditions, metav1.Condition{ + Status: metav1.ConditionFalse, + Type: status.StatusActive, + Reason: status.ReasonPending, + Message: "Releasing IUT", + }) { + return r.Status().Update(ctx, iut) + } + } + return nil +} + +// releaseJob is the job definition for an IUT releaser. +func (r IutReconciler) releaseJob(ctx context.Context, obj client.Object) (*batchv1.Job, error) { + iut, ok := obj.(*etosv1alpha2.Iut) + if !ok { + return nil, errors.New("object received from job manager is not an Iut") + } + + provider, err := getProvider(ctx, r, iut.Spec.ProviderID, iut.GetNamespace()) + if err != nil { + return nil, err + } + environmentrequest := &etosv1alpha1.EnvironmentRequest{} + if err := r.Get(ctx, types.NamespacedName{Name: iut.Spec.EnvironmentRequest, Namespace: iut.Namespace}, environmentrequest); err != nil { + return nil, err + } + + jobSpec := release.IutReleaser(iut, environmentrequest, image(provider), true) + return jobSpec, ctrl.SetControllerReference(iut, jobSpec, r.Scheme) +} + +// registerOwnerIndexForJob will set an index of the jobs that an iut controller owns. +func (r *IutReconciler) registerOwnerIndexForJob(mgr ctrl.Manager) error { + if err := mgr.GetFieldIndexer().IndexField(context.Background(), &batchv1.Job{}, IutOwnerKey, func(rawObj client.Object) []string { + job := rawObj.(*batchv1.Job) + owner := metav1.GetControllerOf(job) + if owner == nil { + return nil + } + if owner.APIVersion != APIv2GroupVersionString || owner.Kind != "Iut" { + return nil + } + + return []string{owner.Name} + }); err != nil { + return err + } + return nil +} + +// SetupWithManager sets up the controller with the Manager. +func (r *IutReconciler) SetupWithManager(mgr ctrl.Manager) error { + // Register indexes for faster lookups + if err := r.registerOwnerIndexForJob(mgr); err != nil { + return err + } + return ctrl.NewControllerManagedBy(mgr). + For(&etosv1alpha2.Iut{}). + Named("iut"). + Owns(&batchv1.Job{}). // Release job + Complete(r) +} diff --git a/internal/controller/iut_controller_test.go b/internal/controller/iut_controller_test.go new file mode 100644 index 00000000..b909e275 --- /dev/null +++ b/internal/controller/iut_controller_test.go @@ -0,0 +1,84 @@ +// Copyright Axis Communications AB. +// +// For a full list of individual contributors, please see the commit history. +// +// 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. + +package controller + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + etosv1alpha2 "github.com/eiffel-community/etos/api/v1alpha2" +) + +var _ = Describe("Iut Controller", func() { + Context("When reconciling a resource", func() { + const resourceName = "test-resource" + + ctx := context.Background() + + typeNamespacedName := types.NamespacedName{ + Name: resourceName, + Namespace: "default", // TODO(user):Modify as needed + } + iut := &etosv1alpha2.Iut{} + + BeforeEach(func() { + By("creating the custom resource for the Kind Iut") + err := k8sClient.Get(ctx, typeNamespacedName, iut) + if err != nil && errors.IsNotFound(err) { + resource := &etosv1alpha2.Iut{ + ObjectMeta: metav1.ObjectMeta{ + Name: resourceName, + Namespace: "default", + }, + // TODO(user): Specify other spec details if needed. + } + Expect(k8sClient.Create(ctx, resource)).To(Succeed()) + } + }) + + AfterEach(func() { + // TODO(user): Cleanup logic after each test, like removing the resource instance. + resource := &etosv1alpha2.Iut{} + err := k8sClient.Get(ctx, typeNamespacedName, resource) + Expect(err).NotTo(HaveOccurred()) + + By("Cleanup the specific resource instance Iut") + Expect(k8sClient.Delete(ctx, resource)).To(Succeed()) + }) + It("should successfully reconcile the resource", func() { + By("Reconciling the created resource") + controllerReconciler := &IutReconciler{ + Client: k8sClient, + Scheme: k8sClient.Scheme(), + } + + _, err := controllerReconciler.Reconcile(ctx, reconcile.Request{ + NamespacedName: typeNamespacedName, + }) + Expect(err).NotTo(HaveOccurred()) + // TODO(user): Add more specific assertions depending on your controller's reconciliation logic. + // Example: If you expect a certain status condition after reconciliation, verify it here. + }) + }) +}) diff --git a/internal/controller/providers.go b/internal/controller/providers.go index 564c3d48..e93a64af 100644 --- a/internal/controller/providers.go +++ b/internal/controller/providers.go @@ -55,3 +55,18 @@ func checkProvider(ctx context.Context, c client.Reader, name string, namespace } return fmt.Errorf("Provider '%s' does not have a status field", name) } + +// getProvider fetches a Provider resource by name from Kubernetes. +func getProvider(ctx context.Context, c client.Reader, name, namespace string) (*etosv1alpha1.Provider, error) { + var provider etosv1alpha1.Provider + return &provider, c.Get(ctx, types.NamespacedName{Name: name, Namespace: namespace}, &provider) +} + +// image returns an image that can be used to execute provider. +func image(provider *etosv1alpha1.Provider) string { + var image string + if provider.Spec.Image != "" { + return provider.Spec.Image + } + return image +} diff --git a/internal/controller/suite_test.go b/internal/controller/suite_test.go index 02ac3cda..de107dce 100644 --- a/internal/controller/suite_test.go +++ b/internal/controller/suite_test.go @@ -33,6 +33,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log/zap" etosv1alpha1 "github.com/eiffel-community/etos/api/v1alpha1" + etosv1alpha2 "github.com/eiffel-community/etos/api/v1alpha2" // +kubebuilder:scaffold:imports ) @@ -62,6 +63,9 @@ var _ = BeforeSuite(func() { err = etosv1alpha1.AddToScheme(scheme.Scheme) Expect(err).NotTo(HaveOccurred()) + err = etosv1alpha2.AddToScheme(scheme.Scheme) + Expect(err).NotTo(HaveOccurred()) + // +kubebuilder:scaffold:scheme By("bootstrapping test environment") diff --git a/internal/controller/utilities.go b/internal/controller/utilities.go index 3fc6f2eb..4262ea38 100644 --- a/internal/controller/utilities.go +++ b/internal/controller/utilities.go @@ -17,16 +17,23 @@ package controller import ( etosv1alpha1 "github.com/eiffel-community/etos/api/v1alpha1" + etosv1alpha2 "github.com/eiffel-community/etos/api/v1alpha2" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) var ( - APIGroupVersionString = etosv1alpha1.GroupVersion.String() + APIGroupVersionString = etosv1alpha1.GroupVersion.String() + APIv2GroupVersionString = etosv1alpha2.GroupVersion.String() ) const ( TestRunOwnerKey = ".metadata.controller.suiterunner" EnvironmentRequestOwnerKey = ".metadata.controller.environmentrequest" EnvironmentOwnerKey = ".metadata.controller.environment" + LogAreaOwnerKey = ".metadata.controller.log-area-provider" + ExecutionSpaceOwnerKey = ".metadata.controller.execution-space-provider" + IutOwnerKey = ".metadata.controller.iut-provider" ) const ( @@ -36,5 +43,26 @@ const ( ) const ( - releaseFinalizer = "etos.eiffel-community.github.io/release" + releaseFinalizer = "etos.eiffel-community.github.io/release" + providerFinalizer = "etos.eiffel-community.github.io/managed-by-provider" ) + +// hasOwner checks if a resource kind exists in ownerReferences. +func hasOwner(ownerReferences []metav1.OwnerReference, kind string) bool { + for _, ownerReference := range ownerReferences { + if ownerReference.Kind == kind { + return true + } + } + return false +} + +// isStatusReason return true when the conditionType is present and reason is set to reason +func isStatusReason(conditions []metav1.Condition, conditionType, reason string) bool { + if condition := meta.FindStatusCondition(conditions, conditionType); condition == nil { + return false + } else if condition.Reason == reason { + return true + } + return false +} diff --git a/internal/release/iut.go b/internal/release/iut.go new file mode 100644 index 00000000..da8e9aa0 --- /dev/null +++ b/internal/release/iut.go @@ -0,0 +1,47 @@ +// Copyright Axis Communications AB. +// +// For a full list of individual contributors, please see the commit history. +// +// 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. +package release + +import ( + "github.com/eiffel-community/etos/api/v1alpha1" + "github.com/eiffel-community/etos/api/v1alpha2" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" +) + +const IutReleaserName = "iut-releaser" + +// IutReleaser returns an IUT releaser job specification. +func IutReleaser(iut *v1alpha2.Iut, environmentrequest *v1alpha1.EnvironmentRequest, image string, noDelete bool) *batchv1.Job { + return ReleaseJob( + iut.Name, + IutReleaserName, + iut.Namespace, + environmentrequest, + IutReleaserContainer(iut, image, noDelete), + ) +} + +// IutReleaserContainer returns an IUT releaser container specification. +func IutReleaserContainer(iut *v1alpha2.Iut, image string, noDelete bool) corev1.Container { + return ReleaseContainer( + iut.Name, + IutReleaserName, + iut.Namespace, + image, + noDelete, + ) +} diff --git a/internal/release/release.go b/internal/release/release.go new file mode 100644 index 00000000..cc64506c --- /dev/null +++ b/internal/release/release.go @@ -0,0 +1,99 @@ +// Copyright Axis Communications AB. +// +// For a full list of individual contributors, please see the commit history. +// +// 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. +package release + +import ( + "fmt" + + "github.com/eiffel-community/etos/api/v1alpha1" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// ReleaseJob returns a batchv1.Job schema populated with containers provided. +func ReleaseJob(jobName, name, namespace string, environmentrequest *v1alpha1.EnvironmentRequest, containers ...corev1.Container) *batchv1.Job { + ttl := int32(300) + grace := int64(30) + backoff := int32(0) + labels := map[string]string{ + "app.kubernetes.io/name": name, + "app.kubernetes.io/part-of": "etos", + "etos.eiffel-community.github.io/environment-request": environmentrequest.Spec.Name, + "etos.eiffel-community.github.io/environment-request-id": environmentrequest.Spec.ID, + } + if cluster := environmentrequest.Labels["etos.eiffel-community.github.io/cluster"]; cluster != "" { + labels["etos.eiffel-community.github.io/cluster"] = cluster + } + if environmentrequest.Spec.Identifier != "" { + labels["etos.eiffel-community.github.io/id"] = environmentrequest.Spec.Identifier + } + + return &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Labels: labels, + Annotations: make(map[string]string), + Name: jobName, + Namespace: namespace, + }, + Spec: batchv1.JobSpec{ + TTLSecondsAfterFinished: &ttl, + BackoffLimit: &backoff, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{ + Name: jobName, + Labels: labels, + }, + Spec: corev1.PodSpec{ + TerminationGracePeriodSeconds: &grace, + ServiceAccountName: environmentrequest.Spec.ServiceAccountName, + RestartPolicy: "Never", + Containers: containers, + }, + }, + }, + } +} + +// ReleaseContainer returns a container specification. +func ReleaseContainer(name, containerName, namespace, image string, noDelete bool) corev1.Container { + args := []string{ + "-release", + fmt.Sprintf("-namespace=%s", namespace), + fmt.Sprintf("-name=%s", name), + } + if noDelete { + args = append(args, "-nodelete") + } + return corev1.Container{ + Name: containerName, + Image: image, + ImagePullPolicy: corev1.PullIfNotPresent, + // TODO: Verify these resourceclaims + Resources: corev1.ResourceRequirements{ + Limits: corev1.ResourceList{ + corev1.ResourceMemory: resource.MustParse("256Mi"), + corev1.ResourceCPU: resource.MustParse("250m"), + }, + Requests: corev1.ResourceList{ + corev1.ResourceMemory: resource.MustParse("128Mi"), + corev1.ResourceCPU: resource.MustParse("100m"), + }, + }, + Args: args, + } +} diff --git a/internal/webhook/v1alpha2/iut_webhook.go b/internal/webhook/v1alpha2/iut_webhook.go new file mode 100644 index 00000000..0928153c --- /dev/null +++ b/internal/webhook/v1alpha2/iut_webhook.go @@ -0,0 +1,94 @@ +// Copyright Axis Communications AB. +// +// For a full list of individual contributors, please see the commit history. +// +// 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. + +package v1alpha2 + +import ( + "context" + "fmt" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/webhook" + + "github.com/eiffel-community/etos/api/v1alpha1" + etosv1alpha2 "github.com/eiffel-community/etos/api/v1alpha2" +) + +// nolint:unused +// log is for logging in this package. +var ( + iutlog = logf.Log.WithName("iut-resource") + cli client.Client +) + +// SetupIutWebhookWithManager registers the webhook for Iut in the manager. +func SetupIutWebhookWithManager(mgr ctrl.Manager) error { + if cli == nil { + cli = mgr.GetClient() + } + return ctrl.NewWebhookManagedBy(mgr).For(&etosv1alpha2.Iut{}). + WithDefaulter(&IutCustomDefaulter{}). + Complete() +} + +// +kubebuilder:webhook:path=/mutate-etos-eiffel-community-github-io-v1alpha2-iut,mutating=true,failurePolicy=fail,sideEffects=None,groups=etos.eiffel-community.github.io,resources=iuts,verbs=create;update,versions=v1alpha2,name=miut-v1alpha2.kb.io,admissionReviewVersions=v1 + +// IutCustomDefaulter struct is responsible for setting default values on the custom resource of the +// Kind Iut when those are created or updated. +// +// NOTE: The +kubebuilder:object:generate=false marker prevents controller-gen from generating DeepCopy methods, +// as it is used only for temporary operations and does not need to be deeply copied. +type IutCustomDefaulter struct { +} + +var _ webhook.CustomDefaulter = &IutCustomDefaulter{} + +// Default implements webhook.CustomDefaulter so a webhook will be registered for the Kind Iut. +func (d *IutCustomDefaulter) Default(ctx context.Context, obj runtime.Object) error { + iut, ok := obj.(*etosv1alpha2.Iut) + + if !ok { + return fmt.Errorf("expected an Iut object but got %T", obj) + } + iutlog.Info("Defaulting for Iut", "name", iut.GetName()) + + environmentrequest := &v1alpha1.EnvironmentRequest{} + namespacedName := types.NamespacedName{Name: iut.Spec.EnvironmentRequest, Namespace: iut.Namespace} + if err := cli.Get(ctx, namespacedName, environmentrequest); err != nil { + iutlog.Error(err, "name", iut.Name, "namespace", iut.Namespace, "environmentRequest", namespacedName.Name, + "Failed to get environmentrequest in namespace") + } + + iut.Labels["etos.eiffel-community.github.io/environment-request"] = environmentrequest.Spec.Name + iut.Labels["etos.eiffel-community.github.io/environment-request-id"] = environmentrequest.Spec.ID + iut.Labels["etos.eiffel-community.github.io/provider"] = iut.Spec.ProviderID + iut.Labels["app.kubernetes.io/part-of"] = "etos" + if iut.Labels["app.kubernetes.io/name"] == "" { + iut.Labels["app.kubernetes.io/name"] = "iut-provider" + } + if cluster := environmentrequest.Labels["etos.eiffel-community.github.io/cluster"]; cluster != "" { + iut.Labels["etos.eiffel-community.github.io/cluster"] = cluster + } + if environmentrequest.Spec.Identifier != "" { + iut.Labels["etos.eiffel-community.github.io/id"] = environmentrequest.Spec.Identifier + } + + return nil +} diff --git a/internal/webhook/v1alpha2/iut_webhook_test.go b/internal/webhook/v1alpha2/iut_webhook_test.go new file mode 100644 index 00000000..3deaf11c --- /dev/null +++ b/internal/webhook/v1alpha2/iut_webhook_test.go @@ -0,0 +1,61 @@ +// Copyright Axis Communications AB. +// +// For a full list of individual contributors, please see the commit history. +// +// 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. + +package v1alpha2 + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + etosv1alpha2 "github.com/eiffel-community/etos/api/v1alpha2" + // TODO (user): Add any additional imports if needed +) + +var _ = Describe("Iut Webhook", func() { + var ( + obj *etosv1alpha2.Iut + oldObj *etosv1alpha2.Iut + defaulter IutCustomDefaulter + ) + + BeforeEach(func() { + obj = &etosv1alpha2.Iut{} + oldObj = &etosv1alpha2.Iut{} + defaulter = IutCustomDefaulter{} + Expect(defaulter).NotTo(BeNil(), "Expected defaulter to be initialized") + Expect(oldObj).NotTo(BeNil(), "Expected oldObj to be initialized") + Expect(obj).NotTo(BeNil(), "Expected obj to be initialized") + // TODO (user): Add any setup logic common to all tests + }) + + AfterEach(func() { + // TODO (user): Add any teardown logic common to all tests + }) + + Context("When creating Iut under Defaulting Webhook", func() { + // TODO (user): Add logic for defaulting webhooks + // Example: + // It("Should apply defaults when a required field is empty", func() { + // By("simulating a scenario where defaults should be applied") + // obj.SomeFieldWithDefault = "" + // By("calling the Default method to apply defaults") + // defaulter.Default(ctx, obj) + // By("checking that the default values are set") + // Expect(obj.SomeFieldWithDefault).To(Equal("default_value")) + // }) + }) + +}) diff --git a/internal/webhook/v1alpha2/webhook_suite_test.go b/internal/webhook/v1alpha2/webhook_suite_test.go new file mode 100644 index 00000000..317bf68a --- /dev/null +++ b/internal/webhook/v1alpha2/webhook_suite_test.go @@ -0,0 +1,164 @@ +// Copyright Axis Communications AB. +// +// For a full list of individual contributors, please see the commit history. +// +// 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. + +package v1alpha2 + +import ( + "context" + "crypto/tls" + "fmt" + "net" + "os" + "path/filepath" + "testing" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/envtest" + logf "sigs.k8s.io/controller-runtime/pkg/log" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + "sigs.k8s.io/controller-runtime/pkg/webhook" + + etosv1alpha2 "github.com/eiffel-community/etos/api/v1alpha2" + // +kubebuilder:scaffold:imports +) + +// These tests use Ginkgo (BDD-style Go testing framework). Refer to +// http://onsi.github.io/ginkgo/ to learn more about Ginkgo. + +var ( + ctx context.Context + cancel context.CancelFunc + k8sClient client.Client + cfg *rest.Config + testEnv *envtest.Environment +) + +func TestAPIs(t *testing.T) { + RegisterFailHandler(Fail) + + RunSpecs(t, "Webhook Suite") +} + +var _ = BeforeSuite(func() { + logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) + + ctx, cancel = context.WithCancel(context.TODO()) + + var err error + err = etosv1alpha2.AddToScheme(scheme.Scheme) + Expect(err).NotTo(HaveOccurred()) + + // +kubebuilder:scaffold:scheme + + By("bootstrapping test environment") + testEnv = &envtest.Environment{ + CRDDirectoryPaths: []string{filepath.Join("..", "..", "..", "config", "crd", "bases")}, + ErrorIfCRDPathMissing: false, + + WebhookInstallOptions: envtest.WebhookInstallOptions{ + Paths: []string{filepath.Join("..", "..", "..", "config", "webhook")}, + }, + } + + // Retrieve the first found binary directory to allow running tests from IDEs + if getFirstFoundEnvTestBinaryDir() != "" { + testEnv.BinaryAssetsDirectory = getFirstFoundEnvTestBinaryDir() + } + + // cfg is defined in this file globally. + cfg, err = testEnv.Start() + Expect(err).NotTo(HaveOccurred()) + Expect(cfg).NotTo(BeNil()) + + k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) + Expect(err).NotTo(HaveOccurred()) + Expect(k8sClient).NotTo(BeNil()) + + // start webhook server using Manager. + webhookInstallOptions := &testEnv.WebhookInstallOptions + mgr, err := ctrl.NewManager(cfg, ctrl.Options{ + Scheme: scheme.Scheme, + WebhookServer: webhook.NewServer(webhook.Options{ + Host: webhookInstallOptions.LocalServingHost, + Port: webhookInstallOptions.LocalServingPort, + CertDir: webhookInstallOptions.LocalServingCertDir, + }), + LeaderElection: false, + Metrics: metricsserver.Options{BindAddress: "0"}, + }) + Expect(err).NotTo(HaveOccurred()) + + err = SetupIutWebhookWithManager(mgr) + Expect(err).NotTo(HaveOccurred()) + + // +kubebuilder:scaffold:webhook + + go func() { + defer GinkgoRecover() + err = mgr.Start(ctx) + Expect(err).NotTo(HaveOccurred()) + }() + + // wait for the webhook server to get ready. + dialer := &net.Dialer{Timeout: time.Second} + addrPort := fmt.Sprintf("%s:%d", webhookInstallOptions.LocalServingHost, webhookInstallOptions.LocalServingPort) + Eventually(func() error { + conn, err := tls.DialWithDialer(dialer, "tcp", addrPort, &tls.Config{InsecureSkipVerify: true}) + if err != nil { + return err + } + + return conn.Close() + }).Should(Succeed()) +}) + +var _ = AfterSuite(func() { + By("tearing down the test environment") + cancel() + err := testEnv.Stop() + Expect(err).NotTo(HaveOccurred()) +}) + +// getFirstFoundEnvTestBinaryDir locates the first binary in the specified path. +// ENVTEST-based tests depend on specific binaries, usually located in paths set by +// controller-runtime. When running tests directly (e.g., via an IDE) without using +// Makefile targets, the 'BinaryAssetsDirectory' must be explicitly configured. +// +// This function streamlines the process by finding the required binaries, similar to +// setting the 'KUBEBUILDER_ASSETS' environment variable. To ensure the binaries are +// properly set up, run 'make setup-envtest' beforehand. +func getFirstFoundEnvTestBinaryDir() string { + basePath := filepath.Join("..", "..", "..", "bin", "k8s") + entries, err := os.ReadDir(basePath) + if err != nil { + logf.Log.Error(err, "Failed to read directory", "path", basePath) + return "" + } + for _, entry := range entries { + if entry.IsDir() { + return filepath.Join(basePath, entry.Name()) + } + } + return "" +} From 24d358fd8032e4f81b7ddd571437ae3d6ea77e60 Mon Sep 17 00:00:00 2001 From: Tobias Persson Date: Tue, 18 Nov 2025 12:34:00 +0100 Subject: [PATCH 2/7] Fix the test --- config/crd/bases/etos.eiffel-community.github.io_iuts.yaml | 2 +- config/samples/etos_v1alpha2_iut.yaml | 5 ++++- internal/controller/iut_controller_test.go | 7 ++++++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/config/crd/bases/etos.eiffel-community.github.io_iuts.yaml b/config/crd/bases/etos.eiffel-community.github.io_iuts.yaml index 4035c704..5c69f5c5 100644 --- a/config/crd/bases/etos.eiffel-community.github.io_iuts.yaml +++ b/config/crd/bases/etos.eiffel-community.github.io_iuts.yaml @@ -71,7 +71,7 @@ spec: description: Identity is the PackageURL definition of the IUT. type: string provider_data: - description: ProviderData is specific data provider by the IUT providers + description: ProviderData is specific data provided by the IUT providers x-kubernetes-preserve-unknown-fields: true provider_id: description: ProviderID is the name of the Provider used to create diff --git a/config/samples/etos_v1alpha2_iut.yaml b/config/samples/etos_v1alpha2_iut.yaml index 94da1102..2658a2b4 100644 --- a/config/samples/etos_v1alpha2_iut.yaml +++ b/config/samples/etos_v1alpha2_iut.yaml @@ -6,4 +6,7 @@ metadata: app.kubernetes.io/managed-by: kustomize name: iut-sample spec: - # TODO(user): Add fields here + id: 5f3fe085-1d12-4ccd-846b-31404c3cf214 + identity: pkg:testrun/etos/eiffel_community + environmentRequest: environmentrequest-sample + provider_id: iut-provider-sample diff --git a/internal/controller/iut_controller_test.go b/internal/controller/iut_controller_test.go index b909e275..bc616c30 100644 --- a/internal/controller/iut_controller_test.go +++ b/internal/controller/iut_controller_test.go @@ -51,7 +51,12 @@ var _ = Describe("Iut Controller", func() { Name: resourceName, Namespace: "default", }, - // TODO(user): Specify other spec details if needed. + Spec: etosv1alpha2.IutSpec{ + ID: "5f3fe085-1d12-4ccd-846b-31404c3cf214", + Identity: "pkg:testrun/etos/eiffel_community", + EnvironmentRequest: "environmentrequest-sample", + ProviderID: "iut-provider-sample", + }, } Expect(k8sClient.Create(ctx, resource)).To(Succeed()) } From 6d5c67ff426bfbac5520b1a35e8d33cacace2d72 Mon Sep 17 00:00:00 2001 From: Tobias Persson Date: Thu, 20 Nov 2025 13:40:15 +0100 Subject: [PATCH 3/7] Add a fake client which can be used for webhook tests --- internal/webhook/v1alpha2/fakeclient.go | 65 +++++++++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 internal/webhook/v1alpha2/fakeclient.go diff --git a/internal/webhook/v1alpha2/fakeclient.go b/internal/webhook/v1alpha2/fakeclient.go new file mode 100644 index 00000000..3efb48d6 --- /dev/null +++ b/internal/webhook/v1alpha2/fakeclient.go @@ -0,0 +1,65 @@ +// Copyright Axis Communications AB. +// +// For a full list of individual contributors, please see the commit history. +// +// 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. + +package v1alpha2 + +import ( + "context" + "encoding/json" + "errors" + + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// fakeReader implements the client.Reader interface +type fakeReader struct { + getResponse client.Object + listResponse client.ObjectList +} + +// FakeReader returns a fake client implementing the client.Reader interface. +// +// Any of the two inputs can be nil, but it is best to at least provide one of them. +// If an input is nil, the corresponding method will return an error. +// The inputs are the resources you expect a webhook to fetch either using .Get or +// .List. +func FakeReader(get client.Object, list client.ObjectList) client.Reader { + return &fakeReader{get, list} +} + +// Get fakes the get response of a controller-runtime client. +func (f *fakeReader) Get(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { + if f.getResponse == nil { + return errors.New("this is an error in the Get method") + } + b, err := json.Marshal(f.getResponse) + if err != nil { + return err + } + return json.Unmarshal(b, obj) +} + +// List fakes the list response of a controller-runtime client. +func (f *fakeReader) List(ctx context.Context, list client.ObjectList, opts ...client.ListOption) error { + if f.listResponse == nil { + return errors.New("this is an error in the List method") + } + b, err := json.Marshal(f.listResponse) + if err != nil { + return err + } + return json.Unmarshal(b, list) +} From aee0895395a0368a4ccf08f494c95f8cf829d5a5 Mon Sep 17 00:00:00 2001 From: Tobias Persson Date: Thu, 20 Nov 2025 13:41:27 +0100 Subject: [PATCH 4/7] Add a test and let the defaulter return error --- internal/webhook/v1alpha2/iut_webhook.go | 19 ++++--- internal/webhook/v1alpha2/iut_webhook_test.go | 52 ++++++++++++++----- 2 files changed, 47 insertions(+), 24 deletions(-) diff --git a/internal/webhook/v1alpha2/iut_webhook.go b/internal/webhook/v1alpha2/iut_webhook.go index 0928153c..f56358f9 100644 --- a/internal/webhook/v1alpha2/iut_webhook.go +++ b/internal/webhook/v1alpha2/iut_webhook.go @@ -32,19 +32,13 @@ import ( ) // nolint:unused -// log is for logging in this package. -var ( - iutlog = logf.Log.WithName("iut-resource") - cli client.Client -) +// iutlog is for logging in this package. +var iutlog = logf.Log.WithName("iut-resource") // SetupIutWebhookWithManager registers the webhook for Iut in the manager. func SetupIutWebhookWithManager(mgr ctrl.Manager) error { - if cli == nil { - cli = mgr.GetClient() - } return ctrl.NewWebhookManagedBy(mgr).For(&etosv1alpha2.Iut{}). - WithDefaulter(&IutCustomDefaulter{}). + WithDefaulter(&IutCustomDefaulter{mgr.GetClient()}). Complete() } @@ -56,6 +50,7 @@ func SetupIutWebhookWithManager(mgr ctrl.Manager) error { // NOTE: The +kubebuilder:object:generate=false marker prevents controller-gen from generating DeepCopy methods, // as it is used only for temporary operations and does not need to be deeply copied. type IutCustomDefaulter struct { + client.Reader } var _ webhook.CustomDefaulter = &IutCustomDefaulter{} @@ -71,11 +66,15 @@ func (d *IutCustomDefaulter) Default(ctx context.Context, obj runtime.Object) er environmentrequest := &v1alpha1.EnvironmentRequest{} namespacedName := types.NamespacedName{Name: iut.Spec.EnvironmentRequest, Namespace: iut.Namespace} - if err := cli.Get(ctx, namespacedName, environmentrequest); err != nil { + if err := d.Get(ctx, namespacedName, environmentrequest); err != nil { iutlog.Error(err, "name", iut.Name, "namespace", iut.Namespace, "environmentRequest", namespacedName.Name, "Failed to get environmentrequest in namespace") + return err } + if iut.Labels == nil { + iut.Labels = make(map[string]string) + } iut.Labels["etos.eiffel-community.github.io/environment-request"] = environmentrequest.Spec.Name iut.Labels["etos.eiffel-community.github.io/environment-request-id"] = environmentrequest.Spec.ID iut.Labels["etos.eiffel-community.github.io/provider"] = iut.Spec.ProviderID diff --git a/internal/webhook/v1alpha2/iut_webhook_test.go b/internal/webhook/v1alpha2/iut_webhook_test.go index 3deaf11c..36a4110f 100644 --- a/internal/webhook/v1alpha2/iut_webhook_test.go +++ b/internal/webhook/v1alpha2/iut_webhook_test.go @@ -19,9 +19,10 @@ package v1alpha2 import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + etosv1alpha1 "github.com/eiffel-community/etos/api/v1alpha1" etosv1alpha2 "github.com/eiffel-community/etos/api/v1alpha2" - // TODO (user): Add any additional imports if needed ) var _ = Describe("Iut Webhook", func() { @@ -29,33 +30,56 @@ var _ = Describe("Iut Webhook", func() { obj *etosv1alpha2.Iut oldObj *etosv1alpha2.Iut defaulter IutCustomDefaulter + + requestID = "89224612-3851-45c9-95c0-72b719ae46ea" + requestIdentifier = "fbb4096d-6529-4c39-bac3-08a7e45bf69a" + requestName = "test-environment-request" + requestLabelCluster = "cluster-sample" + providerID = "iut-provider-sample" ) BeforeEach(func() { + environmentRequest := etosv1alpha1.EnvironmentRequest{ + ObjectMeta: v1.ObjectMeta{ + Labels: map[string]string{ + "etos.eiffel-community.github.io/cluster": requestLabelCluster, + }, + }, + Spec: etosv1alpha1.EnvironmentRequestSpec{ + Identifier: requestIdentifier, + Name: requestName, + ID: requestID, + }, + } obj = &etosv1alpha2.Iut{} oldObj = &etosv1alpha2.Iut{} - defaulter = IutCustomDefaulter{} + defaulter = IutCustomDefaulter{FakeReader(&environmentRequest, nil)} Expect(defaulter).NotTo(BeNil(), "Expected defaulter to be initialized") Expect(oldObj).NotTo(BeNil(), "Expected oldObj to be initialized") Expect(obj).NotTo(BeNil(), "Expected obj to be initialized") - // TODO (user): Add any setup logic common to all tests }) AfterEach(func() { - // TODO (user): Add any teardown logic common to all tests }) Context("When creating Iut under Defaulting Webhook", func() { - // TODO (user): Add logic for defaulting webhooks - // Example: - // It("Should apply defaults when a required field is empty", func() { - // By("simulating a scenario where defaults should be applied") - // obj.SomeFieldWithDefault = "" - // By("calling the Default method to apply defaults") - // defaulter.Default(ctx, obj) - // By("checking that the default values are set") - // Expect(obj.SomeFieldWithDefault).To(Equal("default_value")) - // }) + It("Should apply defaults when a required field is empty", func() { + By("simulating a scenario where defaults should be applied") + obj.Labels = nil + obj.Spec.ProviderID = providerID + By("calling the Default method to apply defaults") + defaulter.Default(ctx, obj) + By("checking that the default values are set") + Expect(obj.Labels).To(Equal(map[string]string{ + "etos.eiffel-community.github.io/environment-request": requestName, + "etos.eiffel-community.github.io/environment-request-id": requestID, + "etos.eiffel-community.github.io/cluster": requestLabelCluster, + "etos.eiffel-community.github.io/provider": providerID, + "etos.eiffel-community.github.io/id": requestIdentifier, + "app.kubernetes.io/part-of": "etos", + "app.kubernetes.io/name": "iut-provider", + })) + }) }) }) From a7e338b464aded61886cc5c8b998da1e7fae3110 Mon Sep 17 00:00:00 2001 From: Tobias Persson Date: Thu, 20 Nov 2025 13:45:24 +0100 Subject: [PATCH 5/7] Check error return in test --- internal/webhook/v1alpha2/iut_webhook_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/webhook/v1alpha2/iut_webhook_test.go b/internal/webhook/v1alpha2/iut_webhook_test.go index 36a4110f..fd2d8c0d 100644 --- a/internal/webhook/v1alpha2/iut_webhook_test.go +++ b/internal/webhook/v1alpha2/iut_webhook_test.go @@ -68,7 +68,7 @@ var _ = Describe("Iut Webhook", func() { obj.Labels = nil obj.Spec.ProviderID = providerID By("calling the Default method to apply defaults") - defaulter.Default(ctx, obj) + Expect(defaulter.Default(ctx, obj)).ToNot(HaveOccurred()) By("checking that the default values are set") Expect(obj.Labels).To(Equal(map[string]string{ "etos.eiffel-community.github.io/environment-request": requestName, From 974e1d052ec3b235236eead9e9403e7babcf5d39 Mon Sep 17 00:00:00 2001 From: Tobias Persson Date: Tue, 25 Nov 2025 09:10:20 +0100 Subject: [PATCH 6/7] Add better code comments --- internal/controller/iut_controller.go | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/internal/controller/iut_controller.go b/internal/controller/iut_controller.go index f652c4f4..fbdcec4f 100644 --- a/internal/controller/iut_controller.go +++ b/internal/controller/iut_controller.go @@ -61,8 +61,11 @@ func (r *IutReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.R if err != nil { return ctrl.Result{}, client.IgnoreNotFound(err) } + + // Ownership handoff: If Environment owns this IUT, we relinquish control if hasOwner(iut.OwnerReferences, "Environment") { if controllerutil.ContainsFinalizer(iut, providerFinalizer) { + // Clean up our finalizer since the environment controller now owns the IUT. controllerutil.RemoveFinalizer(iut, providerFinalizer) if err := r.Update(ctx, iut); err != nil { if apierrors.IsConflict(err) { @@ -71,8 +74,9 @@ func (r *IutReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.R return ctrl.Result{}, err } } - // Not being deleted, in use. + if iut.ObjectMeta.DeletionTimestamp.IsZero() { + // The IUT is not being deleted, in use by environment if meta.SetStatusCondition(&iut.Status.Conditions, metav1.Condition{ Status: metav1.ConditionTrue, @@ -87,8 +91,10 @@ func (r *IutReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.R return ctrl.Result{}, err } } - // Being deleted, releasing. } else { + // The IUT is being deleted, update status to reflect this + // At this point the environment controller has ownership, so no release + // job is being created here. if meta.SetStatusCondition(&iut.Status.Conditions, metav1.Condition{ Status: metav1.ConditionFalse, @@ -105,7 +111,6 @@ func (r *IutReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.R } } logger.Info("Iut is being managed by Environment", "iut", iut.Name) - // We no longer own this IUT. Let the Environment controller manage it. return ctrl.Result{}, nil } // If the IUT is considered 'Completed', it has been released. Check that the object is From 2b36c62d50eb35bfe0e4a195285680c5d53a81f0 Mon Sep 17 00:00:00 2001 From: Tobias Persson Date: Thu, 27 Nov 2025 14:44:37 +0100 Subject: [PATCH 7/7] Clarify a code comment --- internal/controller/iut_controller.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/internal/controller/iut_controller.go b/internal/controller/iut_controller.go index fbdcec4f..9d8f1c2c 100644 --- a/internal/controller/iut_controller.go +++ b/internal/controller/iut_controller.go @@ -92,9 +92,10 @@ func (r *IutReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.R } } } else { - // The IUT is being deleted, update status to reflect this - // At this point the environment controller has ownership, so no release - // job is being created here. + // The IUT is being deleted, update status to reflect this. + // Since the IUT controller at this point no longer has ownership of the IUT we will not + // initiate and handle any release or cleanup, this is now handled by the environment + // controller. if meta.SetStatusCondition(&iut.Status.Conditions, metav1.Condition{ Status: metav1.ConditionFalse,