diff --git a/Taskfile.yaml b/Taskfile.yaml index 8d6ac72..be44484 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -18,7 +18,36 @@ includes: NESTED_MODULES: api API_DIRS: '{{.ROOT_DIR}}/api/core/v1alpha1/...' MANIFEST_OUT: '{{.ROOT_DIR}}/api/crds/manifests' - CODE_DIRS: '{{.ROOT_DIR}}/cmd/... {{.ROOT_DIR}}/internal/... {{.ROOT_DIR}}/test/... {{.ROOT_DIR}}/api/core/v1alpha1/...' + CODE_DIRS: '{{.ROOT_DIR}}/cmd/... {{.ROOT_DIR}}/internal/... {{.ROOT_DIR}}/api/core/v1alpha1/...' COMPONENTS: 'project-workspace-operator' + CRDS_COMPONENTS: 'project-workspace-operator' REPO_URL: 'https://github.com/openmcp-project/project-workspace-operator' ENVTEST_REQUIRED: "true" + +tasks: + platformservice: + desc: " Generates a PlatformService manifest for the current version. Set the VERBOSITY env var to overwrite the default verbosity level (INFO)." + requires: + vars: + - VERSION + vars: + VERBOSITY: + sh: echo "${VERBOSITY:-INFO}" + cmds: + - cmd: | + cat << EOF + apiVersion: openmcp.cloud/v1alpha1 + kind: PlatformService + metadata: + name: project-workspace + spec: + image: ghcr.io/openmcp-project/images/project-workspace-operator:{{.VERSION}} + verbosity: {{.VERBOSITY}} + initCommand: + - platformservice + - init + runCommand: + - platformservice + - run + EOF + silent: true diff --git a/api/core/v1alpha1/common_types.go b/api/core/v1alpha1/common_types.go index 267a502..552702c 100644 --- a/api/core/v1alpha1/common_types.go +++ b/api/core/v1alpha1/common_types.go @@ -47,6 +47,7 @@ func (s Subject) RbacV1() rbacv1.Subject { // MemberOverrides is a resource used to Manage admin access to the Project/Workspace operator resources. // +kubebuilder:object:root=true // +kubebuilder:resource:scope=Cluster +// +kubebuilder:metadata:labels="openmcp.cloud/cluster=onboarding" type MemberOverrides struct { metav1.TypeMeta `json:",inline"` metav1.ObjectMeta `json:"metadata,omitempty"` diff --git a/api/core/v1alpha1/common_webhook.go b/api/core/v1alpha1/common_webhook.go index c26bbf2..5a31707 100644 --- a/api/core/v1alpha1/common_webhook.go +++ b/api/core/v1alpha1/common_webhook.go @@ -3,8 +3,6 @@ package v1alpha1 import ( "context" "fmt" - "os" - "strings" admissionv1 "k8s.io/api/admission/v1" authv1 "k8s.io/api/authentication/v1" @@ -59,11 +57,6 @@ func setMetaDataAnnotation(meta metav1.Object, key, value string) { // TODO move meta.SetAnnotations(labels) } -func isOwnServiceAccount(userinfo authv1.UserInfo) bool { - svcAccUsername := fmt.Sprintf("system:serviceaccount:%s:%s", os.Getenv("POD_NAMESPACE"), os.Getenv("POD_SERVICE_ACCOUNT")) - return strings.HasSuffix(userinfo.Username, svcAccUsername) -} - // userInfoFromContext extracts the authv1.UserInfo from the admission.Request available in the context. Returns an error if the request can't be found. func userInfoFromContext(ctx context.Context) (authv1.UserInfo, error) { req, err := admission.RequestFromContext(ctx) diff --git a/api/core/v1alpha1/groupversion_info.go b/api/core/v1alpha1/groupversion_info.go index 5c872f7..53f48a1 100644 --- a/api/core/v1alpha1/groupversion_info.go +++ b/api/core/v1alpha1/groupversion_info.go @@ -8,9 +8,11 @@ import ( "sigs.k8s.io/controller-runtime/pkg/scheme" ) +const GroupName = "core.openmcp.cloud" + var ( // GroupVersion is group version used to register these objects - GroupVersion = schema.GroupVersion{Group: "core.openmcp.cloud", Version: "v1alpha1"} + GroupVersion = schema.GroupVersion{Group: GroupName, Version: "v1alpha1"} // SchemeBuilder is used to add go types to the GroupVersionKind scheme SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} diff --git a/api/core/v1alpha1/project_types.go b/api/core/v1alpha1/project_types.go index 2299c07..fed3bc8 100644 --- a/api/core/v1alpha1/project_types.go +++ b/api/core/v1alpha1/project_types.go @@ -74,6 +74,7 @@ type ProjectStatus struct { // +kubebuilder:printcolumn:name="Resulting Namespace",type="string",JSONPath=".status.namespace" // +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" // +kubebuilder:validation:XValidation:rule="size(self.metadata.name) <= 25",message="Name must not be longer than 25 characters" +// +kubebuilder:metadata:labels="openmcp.cloud/cluster=onboarding" type Project struct { metav1.TypeMeta `json:",inline"` metav1.ObjectMeta `json:"metadata,omitempty"` diff --git a/api/core/v1alpha1/project_webhook.go b/api/core/v1alpha1/project_webhook.go index ac445a1..97f8d07 100644 --- a/api/core/v1alpha1/project_webhook.go +++ b/api/core/v1alpha1/project_webhook.go @@ -13,27 +13,39 @@ import ( "sigs.k8s.io/controller-runtime/pkg/webhook/admission" ) +// +kubebuilder:object:generate=false +type ProjectWebhook struct { + client.Client + + // Identity is the name of the entity (usually a service account) the project-workspace-operator uses to access the onboarding cluster. + // It is required to exclude the operator's own identity from validation checks. + Identity string + OverrideName string +} + // log is for logging in this package. var projectlog = logf.Log.WithName("project-resource") -func (p *Project) SetupWebhookWithManager(mgr ctrl.Manager, memberOverridesName string) error { +func (p *Project) SetupWebhookWithManager(ctx context.Context, mgr ctrl.Manager, memberOverridesName, identity string) error { + pwh := &ProjectWebhook{ + Client: mgr.GetClient(), + OverrideName: memberOverridesName, + Identity: identity, + } return ctrl.NewWebhookManagedBy(mgr). - For(&Project{}). - WithDefaulter(&Project{}). - WithValidator(&projectValidator{ - Client: mgr.GetClient(), - overrideName: memberOverridesName, - }). + For(p). + WithDefaulter(pwh). + WithValidator(pwh). Complete() } -// +kubebuilder:webhook:path=/mutate-core-openmcp-cloud-v1alpha1-project,mutating=true,failurePolicy=fail,sideEffects=None,groups=core.openmcp.cloud,resources=projects,verbs=create;update,versions=v1alpha1,name=mproject.kb.io,admissionReviewVersions=v1 +// +kubebuilder:webhook:path=/mutate-core-openmcp-cloud-v1alpha1-project,mutating=true,failurePolicy=fail,sideEffects=None,groups=core.openmcp.cloud,resources=projects,verbs=create;update,versions=v1alpha1,name=mproject.openmcp.cloud,admissionReviewVersions=v1 -var _ webhook.CustomDefaulter = &Project{} +var _ webhook.CustomDefaulter = &ProjectWebhook{} // Default implements webhook.CustomDefaulter so a webhook will be registered for the type -func (p *Project) Default(ctx context.Context, obj runtime.Object) error { +func (p *ProjectWebhook) Default(ctx context.Context, obj runtime.Object) error { project, err := expectProject(obj) if err != nil { return err @@ -50,30 +62,12 @@ func (p *Project) Default(ctx context.Context, obj runtime.Object) error { return nil } -// Project must implement webhook.CustomValidator in order for it's Mutating/Validating Webhook configuration to be generated by "github.com/openmcp-project/controller-utils/pkg/init/webhooks" -var _ webhook.CustomValidator = &Project{} - -func (p *Project) ValidateCreate(ctx context.Context, obj runtime.Object) (warnings admission.Warnings, err error) { - return -} -func (p *Project) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.Object) (warnings admission.Warnings, err error) { - return -} -func (p *Project) ValidateDelete(ctx context.Context, obj runtime.Object) (warnings admission.Warnings, err error) { - return -} - -// +kubebuilder:webhook:path=/validate-core-openmcp-cloud-v1alpha1-project,mutating=false,failurePolicy=fail,sideEffects=None,groups=core.openmcp.cloud,resources=projects,verbs=create;update;delete,versions=v1alpha1,name=vproject.kb.io,admissionReviewVersions=v1 - -var _ webhook.CustomValidator = &projectValidator{} +// +kubebuilder:webhook:path=/validate-core-openmcp-cloud-v1alpha1-project,mutating=false,failurePolicy=fail,sideEffects=None,groups=core.openmcp.cloud,resources=projects,verbs=create;update;delete,versions=v1alpha1,name=vproject.openmcp.cloud,admissionReviewVersions=v1 -type projectValidator struct { - client.Client - overrideName string -} +var _ webhook.CustomValidator = &ProjectWebhook{} // ValidateCreate implements webhook.CustomValidator so a webhook will be registered for the type -func (v *projectValidator) ValidateCreate(ctx context.Context, obj runtime.Object) (warnings admission.Warnings, err error) { +func (v *ProjectWebhook) ValidateCreate(ctx context.Context, obj runtime.Object) (warnings admission.Warnings, err error) { project, err := expectProject(obj) if err != nil { return @@ -97,7 +91,7 @@ func (v *projectValidator) ValidateCreate(ctx context.Context, obj runtime.Objec } // ValidateUpdate implements webhook.CustomValidator so a webhook will be registered for the type -func (v *projectValidator) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.Object) (warnings admission.Warnings, err error) { +func (v *ProjectWebhook) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.Object) (warnings admission.Warnings, err error) { oldProject, err := expectProject(oldObj) if err != nil { return @@ -138,7 +132,7 @@ func (v *projectValidator) ValidateUpdate(ctx context.Context, oldObj, newObj ru } // ValidateDelete implements webhook.CustomValidator so a webhook will be registered for the type -func (v *projectValidator) ValidateDelete(ctx context.Context, obj runtime.Object) (warnings admission.Warnings, err error) { +func (v *ProjectWebhook) ValidateDelete(ctx context.Context, obj runtime.Object) (warnings admission.Warnings, err error) { project, err := expectProject(obj) if err != nil { return @@ -160,21 +154,21 @@ func expectProject(obj runtime.Object) (*Project, error) { return project, nil } -func (v *projectValidator) ensureValidRole(ctx context.Context, project *Project) (bool, error) { +func (v *ProjectWebhook) ensureValidRole(ctx context.Context, project *Project) (bool, error) { userInfo, err := userInfoFromContext(ctx) if err != nil { return false, fmt.Errorf("failed to get userInfo") } - if project.UserInfoHasRole(userInfo, ProjectRoleAdmin) || isOwnServiceAccount(userInfo) { + if project.UserInfoHasRole(userInfo, ProjectRoleAdmin) || userInfo.Username == v.Identity { return true, nil } - if v.overrideName == "" { + if v.OverrideName == "" { return false, nil } overrides := &MemberOverrides{} - if err := v.Get(ctx, types.NamespacedName{Name: v.overrideName}, overrides); err != nil { + if err := v.Get(ctx, types.NamespacedName{Name: v.OverrideName}, overrides); err != nil { return false, err } if overrides.HasAdminOverrideForResource(&userInfo, project.Name, project.Kind) { diff --git a/api/core/v1alpha1/pwconfig_types.go b/api/core/v1alpha1/pwconfig_types.go new file mode 100644 index 0000000..e47d4b8 --- /dev/null +++ b/api/core/v1alpha1/pwconfig_types.go @@ -0,0 +1,77 @@ +package v1alpha1 + +import ( + rbacv1 "k8s.io/api/rbac/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// ProjectWorkspaceConfigSpec defines the desired state of ProjectWorkspaceConfig +type ProjectWorkspaceConfigSpec struct { + // +optional + Project ProjectConfig `json:"project"` + // +optional + Workspace WorkspaceConfig `json:"workspace"` + // MemberOverridesName is the name of the MemberOverrides resource that should be used to manage admin access to the projects and workspaces. + // Leave empty to disable. + // +optional + MemberOverridesName string `json:"memberOverridesName,omitempty"` + // Webhook contains the configuration for the webhooks. + // +optional + Webhook WebhookConfig `json:"webhook"` +} + +// ProjectWorkspaceConfig is the Schema for the ProjectWorkspaceConfigs API +// +kubebuilder:object:root=true +// +kubebuilder:resource:scope=Cluster,shortName=pwcfg +// +kubebuilder:metadata:labels="openmcp.cloud/cluster=platform" +type ProjectWorkspaceConfig struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata"` + + Spec ProjectWorkspaceConfigSpec `json:"spec"` +} + +// ProjectConfig contains the configuration for projects. +type ProjectConfig struct { + // +optional + ResourcesBlockingDeletion []metav1.GroupVersionKind `json:"resourcesBlockingDeletion,omitempty"` + // AdditionalPermissions defines additional permissions users should have in a project, depending on their role. + // +optional + AdditionalPermissions map[ProjectMemberRole][]rbacv1.PolicyRule `json:"additionalPermissions,omitempty"` +} + +// WorkspaceConfig contains the configuration for workspaces. +type WorkspaceConfig struct { + // +optional + ResourcesBlockingDeletion []metav1.GroupVersionKind `json:"resourcesBlockingDeletion,omitempty"` + // AdditionalPermissions defines additional permissions users should have in a workspace, depending on their role. + // +optional + AdditionalPermissions map[WorkspaceMemberRole][]rbacv1.PolicyRule `json:"additionalPermissions,omitempty"` +} + +type WebhookConfig struct { + // Disabled specifies whether the webhooks should be disabled. + // +optional + Disabled bool `json:"disabled"` +} + +// +kubebuilder:object:root=true + +// ProjectWorkspaceConfigList contains a list of ProjectWorkspaceConfig +type ProjectWorkspaceConfigList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata"` + Items []ProjectWorkspaceConfig `json:"items"` +} + +func init() { + SchemeBuilder.Register(&ProjectWorkspaceConfig{}, &ProjectWorkspaceConfigList{}) +} + +// SetDefaults sets the default values for the project workspace configuration when not set. +func (pwc *ProjectWorkspaceConfig) SetDefaults() {} + +// Validate validates the project workspace configuration. +func (pwc *ProjectWorkspaceConfig) Validate() error { + return nil +} diff --git a/api/core/v1alpha1/webhook_suite_test.go b/api/core/v1alpha1/webhook_suite_test.go index c3d1993..79240f5 100644 --- a/api/core/v1alpha1/webhook_suite_test.go +++ b/api/core/v1alpha1/webhook_suite_test.go @@ -92,6 +92,8 @@ var _ = BeforeSuite(func() { Expect(err).NotTo(HaveOccurred()) Expect(k8sClient).NotTo(BeNil()) + identity := "nobody" + // start webhook server using Manager webhookInstallOptions := &testEnv.WebhookInstallOptions mgr, err := ctrl.NewManager(cfg, ctrl.Options{ @@ -106,10 +108,10 @@ var _ = BeforeSuite(func() { }) Expect(err).NotTo(HaveOccurred()) - err = (&Project{}).SetupWebhookWithManager(mgr, "test-override") + err = (&Project{}).SetupWebhookWithManager(ctx, mgr, "test-override", identity) Expect(err).NotTo(HaveOccurred()) - err = (&Workspace{}).SetupWebhookWithManager(mgr, "test-override") + err = (&Workspace{}).SetupWebhookWithManager(ctx, mgr, "test-override", identity) Expect(err).NotTo(HaveOccurred()) // +kubebuilder:scaffold:webhook diff --git a/api/core/v1alpha1/workspace_types.go b/api/core/v1alpha1/workspace_types.go index 4e6e9e6..58b81db 100644 --- a/api/core/v1alpha1/workspace_types.go +++ b/api/core/v1alpha1/workspace_types.go @@ -74,6 +74,7 @@ type WorkspaceStatus struct { // +kubebuilder:printcolumn:name="Resulting Namespace",type="string",JSONPath=".status.namespace" // +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" // +kubebuilder:validation:XValidation:rule="size(self.metadata.name) <= 25",message="Name must not be longer than 25 characters" +// +kubebuilder:metadata:labels="openmcp.cloud/cluster=onboarding" type Workspace struct { metav1.TypeMeta `json:",inline"` metav1.ObjectMeta `json:"metadata,omitempty"` diff --git a/api/core/v1alpha1/workspace_webhook.go b/api/core/v1alpha1/workspace_webhook.go index fb7b2c0..be1ce7d 100644 --- a/api/core/v1alpha1/workspace_webhook.go +++ b/api/core/v1alpha1/workspace_webhook.go @@ -17,24 +17,36 @@ import ( // log is for logging in this package. var workspacelog = logf.Log.WithName("workspace-resource") -func (r *Workspace) SetupWebhookWithManager(mgr ctrl.Manager, memberOverridesName string) error { +// +kubebuilder:object:generate=false +type WorkspaceWebhook struct { + client.Client + + // Identity is the name of the entity (usually a service account) the project-workspace-operator uses to access the onboarding cluster. + // It is required to exclude the operator's own identity from validation checks. + Identity string + OverrideName string +} + +func (r *Workspace) SetupWebhookWithManager(ctx context.Context, mgr ctrl.Manager, memberOverridesName, identity string) error { + wswh := &WorkspaceWebhook{ + Client: mgr.GetClient(), + OverrideName: memberOverridesName, + Identity: identity, + } return ctrl.NewWebhookManagedBy(mgr). For(r). - WithDefaulter(&Workspace{}). - WithValidator(&workspaceValidator{ - Client: mgr.GetClient(), - overrideName: memberOverridesName, - }). + WithDefaulter(wswh). + WithValidator(wswh). Complete() } -// +kubebuilder:webhook:path=/mutate-core-openmcp-cloud-v1alpha1-workspace,mutating=true,failurePolicy=fail,sideEffects=None,groups=core.openmcp.cloud,resources=workspaces,verbs=create;update,versions=v1alpha1,name=mworkspace.kb.io,admissionReviewVersions=v1 +// +kubebuilder:webhook:path=/mutate-core-openmcp-cloud-v1alpha1-workspace,mutating=true,failurePolicy=fail,sideEffects=None,groups=core.openmcp.cloud,resources=workspaces,verbs=create;update,versions=v1alpha1,name=mworkspace.openmcp.cloud,admissionReviewVersions=v1 -var _ webhook.CustomDefaulter = &Workspace{} +var _ webhook.CustomDefaulter = &WorkspaceWebhook{} // Default implements webhook.CustomDefaulter so a webhook will be registered for the type -func (w *Workspace) Default(ctx context.Context, obj runtime.Object) error { +func (w *WorkspaceWebhook) Default(ctx context.Context, obj runtime.Object) error { workspace, err := expectWorkspace(obj) if err != nil { return err @@ -50,30 +62,12 @@ func (w *Workspace) Default(ctx context.Context, obj runtime.Object) error { return nil } -// Workspace must implement webhook.CustomValidator in order for it's Mutating/Validating Webhook configuration to be generated by "github.com/openmcp-project/controller-utils/pkg/init/webhooks" -var _ webhook.CustomValidator = &Workspace{} - -func (w *Workspace) ValidateCreate(ctx context.Context, obj runtime.Object) (warnings admission.Warnings, err error) { - return -} -func (w *Workspace) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.Object) (warnings admission.Warnings, err error) { - return -} -func (w *Workspace) ValidateDelete(ctx context.Context, obj runtime.Object) (warnings admission.Warnings, err error) { - return -} - -// +kubebuilder:webhook:path=/validate-core-openmcp-cloud-v1alpha1-workspace,mutating=false,failurePolicy=fail,sideEffects=None,groups=core.openmcp.cloud,resources=workspaces,verbs=create;update;delete,versions=v1alpha1,name=vworkspace.kb.io,admissionReviewVersions=v1 - -var _ webhook.CustomValidator = &workspaceValidator{} +// +kubebuilder:webhook:path=/validate-core-openmcp-cloud-v1alpha1-workspace,mutating=false,failurePolicy=fail,sideEffects=None,groups=core.openmcp.cloud,resources=workspaces,verbs=create;update;delete,versions=v1alpha1,name=vworkspace.openmcp.cloud,admissionReviewVersions=v1 -type workspaceValidator struct { - client.Client - overrideName string -} +var _ webhook.CustomValidator = &WorkspaceWebhook{} // ValidateCreate implements webhook.CustomValidator so a webhook will be registered for the type -func (v *workspaceValidator) ValidateCreate(ctx context.Context, obj runtime.Object) (warnings admission.Warnings, err error) { +func (v *WorkspaceWebhook) ValidateCreate(ctx context.Context, obj runtime.Object) (warnings admission.Warnings, err error) { workspace, err := expectWorkspace(obj) if err != nil { return @@ -96,7 +90,7 @@ func (v *workspaceValidator) ValidateCreate(ctx context.Context, obj runtime.Obj } // ValidateUpdate implements webhook.CustomValidator so a webhook will be registered for the type -func (v *workspaceValidator) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.Object) (warnings admission.Warnings, err error) { +func (v *WorkspaceWebhook) ValidateUpdate(ctx context.Context, oldObj, newObj runtime.Object) (warnings admission.Warnings, err error) { oldWorkspace, err := expectWorkspace(oldObj) if err != nil { return @@ -137,7 +131,7 @@ func (v *workspaceValidator) ValidateUpdate(ctx context.Context, oldObj, newObj } // ValidateDelete implements webhook.CustomValidator so a webhook will be registered for the type -func (v *workspaceValidator) ValidateDelete(ctx context.Context, obj runtime.Object) (warnings admission.Warnings, err error) { +func (v *WorkspaceWebhook) ValidateDelete(ctx context.Context, obj runtime.Object) (warnings admission.Warnings, err error) { workspace, err := expectWorkspace(obj) if err != nil { return @@ -160,21 +154,21 @@ func expectWorkspace(obj runtime.Object) (*Workspace, error) { return workspace, nil } -func (v *workspaceValidator) ensureValidRole(ctx context.Context, workspace *Workspace) (bool, error) { +func (v *WorkspaceWebhook) ensureValidRole(ctx context.Context, workspace *Workspace) (bool, error) { userInfo, err := userInfoFromContext(ctx) if err != nil { return false, fmt.Errorf("failed to get userInfo") } - if workspace.UserInfoHasRole(userInfo, WorkspaceRoleAdmin) || isOwnServiceAccount(userInfo) { + if workspace.UserInfoHasRole(userInfo, WorkspaceRoleAdmin) || userInfo.Username == v.Identity { return true, nil } - if v.overrideName == "" { + if v.OverrideName == "" { return false, nil } overrides := &MemberOverrides{} - if err := v.Get(ctx, types.NamespacedName{Name: v.overrideName}, overrides); err != nil { + if err := v.Get(ctx, types.NamespacedName{Name: v.OverrideName}, overrides); err != nil { return false, err } diff --git a/api/core/v1alpha1/zz_generated.deepcopy.go b/api/core/v1alpha1/zz_generated.deepcopy.go index 12cfab0..ab33202 100644 --- a/api/core/v1alpha1/zz_generated.deepcopy.go +++ b/api/core/v1alpha1/zz_generated.deepcopy.go @@ -7,6 +7,8 @@ package v1alpha1 import ( "encoding/json" + rbacv1 "k8s.io/api/rbac/v1" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" ) @@ -195,6 +197,44 @@ func (in *Project) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ProjectConfig) DeepCopyInto(out *ProjectConfig) { + *out = *in + if in.ResourcesBlockingDeletion != nil { + in, out := &in.ResourcesBlockingDeletion, &out.ResourcesBlockingDeletion + *out = make([]v1.GroupVersionKind, len(*in)) + copy(*out, *in) + } + if in.AdditionalPermissions != nil { + in, out := &in.AdditionalPermissions, &out.AdditionalPermissions + *out = make(map[ProjectMemberRole][]rbacv1.PolicyRule, len(*in)) + for key, val := range *in { + var outVal []rbacv1.PolicyRule + if val == nil { + (*out)[key] = nil + } else { + inVal := (*in)[key] + in, out := &inVal, &outVal + *out = make([]rbacv1.PolicyRule, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + (*out)[key] = outVal + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProjectConfig. +func (in *ProjectConfig) DeepCopy() *ProjectConfig { + if in == nil { + return nil + } + out := new(ProjectConfig) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *ProjectList) DeepCopyInto(out *ProjectList) { *out = *in @@ -292,6 +332,82 @@ func (in *ProjectStatus) DeepCopy() *ProjectStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ProjectWorkspaceConfig) DeepCopyInto(out *ProjectWorkspaceConfig) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProjectWorkspaceConfig. +func (in *ProjectWorkspaceConfig) DeepCopy() *ProjectWorkspaceConfig { + if in == nil { + return nil + } + out := new(ProjectWorkspaceConfig) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ProjectWorkspaceConfig) 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 *ProjectWorkspaceConfigList) DeepCopyInto(out *ProjectWorkspaceConfigList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]ProjectWorkspaceConfig, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProjectWorkspaceConfigList. +func (in *ProjectWorkspaceConfigList) DeepCopy() *ProjectWorkspaceConfigList { + if in == nil { + return nil + } + out := new(ProjectWorkspaceConfigList) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *ProjectWorkspaceConfigList) 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 *ProjectWorkspaceConfigSpec) DeepCopyInto(out *ProjectWorkspaceConfigSpec) { + *out = *in + in.Project.DeepCopyInto(&out.Project) + in.Workspace.DeepCopyInto(&out.Workspace) + out.Webhook = in.Webhook +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProjectWorkspaceConfigSpec. +func (in *ProjectWorkspaceConfigSpec) DeepCopy() *ProjectWorkspaceConfigSpec { + if in == nil { + return nil + } + out := new(ProjectWorkspaceConfigSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *RemainingContentResource) DeepCopyInto(out *RemainingContentResource) { *out = *in @@ -322,6 +438,21 @@ func (in *Subject) DeepCopy() *Subject { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WebhookConfig) DeepCopyInto(out *WebhookConfig) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WebhookConfig. +func (in *WebhookConfig) DeepCopy() *WebhookConfig { + if in == nil { + return nil + } + out := new(WebhookConfig) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Workspace) DeepCopyInto(out *Workspace) { *out = *in @@ -349,6 +480,44 @@ func (in *Workspace) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *WorkspaceConfig) DeepCopyInto(out *WorkspaceConfig) { + *out = *in + if in.ResourcesBlockingDeletion != nil { + in, out := &in.ResourcesBlockingDeletion, &out.ResourcesBlockingDeletion + *out = make([]v1.GroupVersionKind, len(*in)) + copy(*out, *in) + } + if in.AdditionalPermissions != nil { + in, out := &in.AdditionalPermissions, &out.AdditionalPermissions + *out = make(map[WorkspaceMemberRole][]rbacv1.PolicyRule, len(*in)) + for key, val := range *in { + var outVal []rbacv1.PolicyRule + if val == nil { + (*out)[key] = nil + } else { + inVal := (*in)[key] + in, out := &inVal, &outVal + *out = make([]rbacv1.PolicyRule, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + (*out)[key] = outVal + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorkspaceConfig. +func (in *WorkspaceConfig) DeepCopy() *WorkspaceConfig { + if in == nil { + return nil + } + out := new(WorkspaceConfig) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *WorkspaceList) DeepCopyInto(out *WorkspaceList) { *out = *in diff --git a/api/crds/crds.go b/api/crds/crds.go index 65cc101..8f11638 100644 --- a/api/crds/crds.go +++ b/api/crds/crds.go @@ -2,34 +2,14 @@ package crds import ( "embed" - "fmt" - "path/filepath" + crdutil "github.com/openmcp-project/controller-utils/pkg/crds" apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" - "sigs.k8s.io/yaml" ) //go:embed manifests var CRDFS embed.FS -// CRDs returns the generated CustomResourceDefinitions as go structs. -// Panics if anything goes wrong trying to read the CRD files, as an error here is likely related to a wrong build or invalid generated CRDs. -func CRDs() []*apiextv1.CustomResourceDefinition { - files, err := CRDFS.ReadDir("manifests") - if err != nil { - panic(err) - } - res := []*apiextv1.CustomResourceDefinition{} - for _, f := range files { - data, err := CRDFS.ReadFile(filepath.Join("manifests", f.Name())) - if err != nil { - panic(fmt.Errorf("error reading CRD file '%s': %w", f.Name(), err)) - } - crd := &apiextv1.CustomResourceDefinition{} - if err := yaml.Unmarshal(data, crd); err != nil { - panic(fmt.Errorf("error parsing file '%s' into CRD: %w", f.Name(), err)) - } - res = append(res, crd) - } - return res +func CRDs() ([]*apiextv1.CustomResourceDefinition, error) { + return crdutil.CRDsFromFileSystem(CRDFS, "manifests") } diff --git a/api/crds/manifests/core.openmcp.cloud_memberoverrides.yaml b/api/crds/manifests/core.openmcp.cloud_memberoverrides.yaml index 6a20b1a..474cf3f 100644 --- a/api/crds/manifests/core.openmcp.cloud_memberoverrides.yaml +++ b/api/crds/manifests/core.openmcp.cloud_memberoverrides.yaml @@ -4,6 +4,8 @@ kind: CustomResourceDefinition metadata: annotations: controller-gen.kubebuilder.io/version: v0.19.0 + labels: + openmcp.cloud/cluster: onboarding name: memberoverrides.core.openmcp.cloud spec: group: core.openmcp.cloud diff --git a/api/crds/manifests/core.openmcp.cloud_projects.yaml b/api/crds/manifests/core.openmcp.cloud_projects.yaml index 1ee459b..9611477 100644 --- a/api/crds/manifests/core.openmcp.cloud_projects.yaml +++ b/api/crds/manifests/core.openmcp.cloud_projects.yaml @@ -4,6 +4,8 @@ kind: CustomResourceDefinition metadata: annotations: controller-gen.kubebuilder.io/version: v0.19.0 + labels: + openmcp.cloud/cluster: onboarding name: projects.core.openmcp.cloud spec: group: core.openmcp.cloud diff --git a/api/crds/manifests/core.openmcp.cloud_projectworkspaceconfigs.yaml b/api/crds/manifests/core.openmcp.cloud_projectworkspaceconfigs.yaml new file mode 100644 index 0000000..4a7d331 --- /dev/null +++ b/api/crds/manifests/core.openmcp.cloud_projectworkspaceconfigs.yaml @@ -0,0 +1,218 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.19.0 + labels: + openmcp.cloud/cluster: platform + name: projectworkspaceconfigs.core.openmcp.cloud +spec: + group: core.openmcp.cloud + names: + kind: ProjectWorkspaceConfig + listKind: ProjectWorkspaceConfigList + plural: projectworkspaceconfigs + shortNames: + - pwcfg + singular: projectworkspaceconfig + scope: Cluster + versions: + - name: v1alpha1 + schema: + openAPIV3Schema: + description: ProjectWorkspaceConfig is the Schema for the ProjectWorkspaceConfigs + 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: ProjectWorkspaceConfigSpec defines the desired state of ProjectWorkspaceConfig + properties: + memberOverridesName: + description: |- + MemberOverridesName is the name of the MemberOverrides resource that should be used to manage admin access to the projects and workspaces. + Leave empty to disable. + type: string + project: + description: ProjectConfig contains the configuration for projects. + properties: + additionalPermissions: + additionalProperties: + items: + description: |- + PolicyRule holds information that describes a policy rule, but does not contain information + about who the rule applies to or which namespace the rule applies to. + properties: + apiGroups: + description: |- + APIGroups is the name of the APIGroup that contains the resources. If multiple API groups are specified, any action requested against one of + the enumerated resources in any API group will be allowed. "" represents the core API group and "*" represents all API groups. + items: + type: string + type: array + x-kubernetes-list-type: atomic + nonResourceURLs: + description: |- + NonResourceURLs is a set of partial urls that a user should have access to. *s are allowed, but only as the full, final step in the path + Since non-resource URLs are not namespaced, this field is only applicable for ClusterRoles referenced from a ClusterRoleBinding. + Rules can either apply to API resources (such as "pods" or "secrets") or non-resource URL paths (such as "/api"), but not both. + items: + type: string + type: array + x-kubernetes-list-type: atomic + resourceNames: + description: ResourceNames is an optional white list of + names that the rule applies to. An empty set means + that everything is allowed. + items: + type: string + type: array + x-kubernetes-list-type: atomic + resources: + description: Resources is a list of resources this rule + applies to. '*' represents all resources. + items: + type: string + type: array + x-kubernetes-list-type: atomic + verbs: + description: Verbs is a list of Verbs that apply to ALL + the ResourceKinds contained in this rule. '*' represents + all verbs. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - verbs + type: object + type: array + description: AdditionalPermissions defines additional permissions + users should have in a project, depending on their role. + type: object + resourcesBlockingDeletion: + items: + description: |- + GroupVersionKind unambiguously identifies a kind. It doesn't anonymously include GroupVersion + to avoid automatic coercion. It doesn't use a GroupVersion to avoid custom marshalling + properties: + group: + type: string + kind: + type: string + version: + type: string + required: + - group + - kind + - version + type: object + type: array + type: object + webhook: + description: Webhook contains the configuration for the webhooks. + properties: + disabled: + description: Disabled specifies whether the webhooks should be + disabled. + type: boolean + type: object + workspace: + description: WorkspaceConfig contains the configuration for workspaces. + properties: + additionalPermissions: + additionalProperties: + items: + description: |- + PolicyRule holds information that describes a policy rule, but does not contain information + about who the rule applies to or which namespace the rule applies to. + properties: + apiGroups: + description: |- + APIGroups is the name of the APIGroup that contains the resources. If multiple API groups are specified, any action requested against one of + the enumerated resources in any API group will be allowed. "" represents the core API group and "*" represents all API groups. + items: + type: string + type: array + x-kubernetes-list-type: atomic + nonResourceURLs: + description: |- + NonResourceURLs is a set of partial urls that a user should have access to. *s are allowed, but only as the full, final step in the path + Since non-resource URLs are not namespaced, this field is only applicable for ClusterRoles referenced from a ClusterRoleBinding. + Rules can either apply to API resources (such as "pods" or "secrets") or non-resource URL paths (such as "/api"), but not both. + items: + type: string + type: array + x-kubernetes-list-type: atomic + resourceNames: + description: ResourceNames is an optional white list of + names that the rule applies to. An empty set means + that everything is allowed. + items: + type: string + type: array + x-kubernetes-list-type: atomic + resources: + description: Resources is a list of resources this rule + applies to. '*' represents all resources. + items: + type: string + type: array + x-kubernetes-list-type: atomic + verbs: + description: Verbs is a list of Verbs that apply to ALL + the ResourceKinds contained in this rule. '*' represents + all verbs. + items: + type: string + type: array + x-kubernetes-list-type: atomic + required: + - verbs + type: object + type: array + description: AdditionalPermissions defines additional permissions + users should have in a workspace, depending on their role. + type: object + resourcesBlockingDeletion: + items: + description: |- + GroupVersionKind unambiguously identifies a kind. It doesn't anonymously include GroupVersion + to avoid automatic coercion. It doesn't use a GroupVersion to avoid custom marshalling + properties: + group: + type: string + kind: + type: string + version: + type: string + required: + - group + - kind + - version + type: object + type: array + type: object + type: object + required: + - metadata + - spec + type: object + served: true + storage: true diff --git a/api/crds/manifests/core.openmcp.cloud_workspaces.yaml b/api/crds/manifests/core.openmcp.cloud_workspaces.yaml index d7acf54..d3355ad 100644 --- a/api/crds/manifests/core.openmcp.cloud_workspaces.yaml +++ b/api/crds/manifests/core.openmcp.cloud_workspaces.yaml @@ -4,6 +4,8 @@ kind: CustomResourceDefinition metadata: annotations: controller-gen.kubebuilder.io/version: v0.19.0 + labels: + openmcp.cloud/cluster: onboarding name: workspaces.core.openmcp.cloud spec: group: core.openmcp.cloud diff --git a/api/go.mod b/api/go.mod index a640714..d3e77d7 100644 --- a/api/go.mod +++ b/api/go.mod @@ -13,9 +13,10 @@ require ( k8s.io/apimachinery v0.34.1 k8s.io/client-go v0.34.1 sigs.k8s.io/controller-runtime v0.22.3 - sigs.k8s.io/yaml v1.6.0 ) +require sigs.k8s.io/yaml v1.6.0 // indirect + require ( github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/beorn7/perks v1.0.1 // indirect @@ -42,6 +43,7 @@ require ( github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/openmcp-project/openmcp-operator/api v0.15.2 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.23.0 // indirect github.com/prometheus/client_model v0.6.2 // indirect @@ -71,6 +73,7 @@ require ( k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20250814151709-d7b6acb124c3 // indirect k8s.io/utils v0.0.0-20250820121507-0af2bda4dd1d // indirect + sigs.k8s.io/gateway-api v1.4.0 sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect diff --git a/api/go.sum b/api/go.sum index 35ab80f..582f3e1 100644 --- a/api/go.sum +++ b/api/go.sum @@ -89,6 +89,8 @@ github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= github.com/openmcp-project/controller-utils v0.23.1 h1:suuZ9UWJbSE/LbpZCtzpHg6FOqU7BkR4bq6cEAYykyc= github.com/openmcp-project/controller-utils v0.23.1/go.mod h1:fU16gy7PHCqMtNaH/nAnHJyGM1SI5ZUiyQcUwrPy2TQ= +github.com/openmcp-project/openmcp-operator/api v0.15.2 h1:Ujf0NLysUSj0Wiel3qnroDcnnHCXMEpbFd0a9rkZoxY= +github.com/openmcp-project/openmcp-operator/api v0.15.2/go.mod h1:0KytEWVi1Gw5SEjyclhNZmUXks+SqbivLW10fDe7vL4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -210,6 +212,8 @@ k8s.io/utils v0.0.0-20250820121507-0af2bda4dd1d h1:wAhiDyZ4Tdtt7e46e9M5ZSAJ/MnPG k8s.io/utils v0.0.0-20250820121507-0af2bda4dd1d/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= sigs.k8s.io/controller-runtime v0.22.3 h1:I7mfqz/a/WdmDCEnXmSPm8/b/yRTy6JsKKENTijTq8Y= sigs.k8s.io/controller-runtime v0.22.3/go.mod h1:+QX1XUpTXN4mLoblf4tqr5CQcyHPAki2HLXqQMY6vh8= +sigs.k8s.io/gateway-api v1.4.0 h1:ZwlNM6zOHq0h3WUX2gfByPs2yAEsy/EenYJB78jpQfQ= +sigs.k8s.io/gateway-api v1.4.0/go.mod h1:AR5RSqciWP98OPckEjOjh2XJhAe2Na4LHyXD2FUY7Qk= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= diff --git a/api/install/install.go b/api/install/install.go new file mode 100644 index 0000000..f35f7c3 --- /dev/null +++ b/api/install/install.go @@ -0,0 +1,37 @@ +package install + +import ( + authenticationv1 "k8s.io/api/authentication/v1" + apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + gatewayv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" + + clustersv1alpha1 "github.com/openmcp-project/openmcp-operator/api/clusters/v1alpha1" + deployv1alpha1 "github.com/openmcp-project/openmcp-operator/api/provider/v1alpha1" + + pwv1alpha1 "github.com/openmcp-project/project-workspace-operator/api/core/v1alpha1" +) + +func InstallOperatorAPIsPlatform(scheme *runtime.Scheme) *runtime.Scheme { + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + utilruntime.Must(clustersv1alpha1.AddToScheme(scheme)) + utilruntime.Must(deployv1alpha1.AddToScheme(scheme)) + utilruntime.Must(pwv1alpha1.AddToScheme(scheme)) + utilruntime.Must(apiextv1.AddToScheme(scheme)) + utilruntime.Must(gatewayv1.Install(scheme)) + utilruntime.Must(gatewayv1alpha2.Install(scheme)) + + return scheme +} + +func InstallOperatorAPIsOnboarding(scheme *runtime.Scheme) *runtime.Scheme { + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + utilruntime.Must(pwv1alpha1.AddToScheme(scheme)) + utilruntime.Must(apiextv1.AddToScheme(scheme)) + utilruntime.Must(authenticationv1.AddToScheme(scheme)) + + return scheme +} diff --git a/cmd/project-workspace-operator/app/app.go b/cmd/project-workspace-operator/app/app.go new file mode 100644 index 0000000..0e425f0 --- /dev/null +++ b/cmd/project-workspace-operator/app/app.go @@ -0,0 +1,89 @@ +package app + +import ( + "fmt" + "os" + + ctrl "sigs.k8s.io/controller-runtime" + + "github.com/spf13/cobra" + + "github.com/openmcp-project/controller-utils/pkg/clusters" + "github.com/openmcp-project/controller-utils/pkg/logging" +) + +const ( + // WebhookPortPod is the port the webhook server listens on in the pod. + WebhookPortPod = 9443 + // WebhookPortSvc is the port the webhook service exposes. + WebhookPortSvc = 443 +) + +func NewPlatformServiceProjectWorkspaceCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "platformservice ", + Short: "Handles projects and workspaces", + Aliases: []string{"ps"}, + } + cmd.SetOut(os.Stdout) + cmd.SetErr(os.Stderr) + + so := &SharedOptions{ + RawSharedOptions: &RawSharedOptions{}, + PlatformCluster: clusters.New("platform"), + } + so.AddPersistentFlags(cmd) + cmd.AddCommand(NewInitCommand(so)) + cmd.AddCommand(NewRunCommand(so)) + + return cmd +} + +type RawSharedOptions struct { + Environment string `json:"environment"` + ProviderName string `json:"provider-name"` + DryRun bool `json:"dry-run"` +} + +type SharedOptions struct { + *RawSharedOptions + PlatformCluster *clusters.Cluster + + // fields filled in Complete() + Log logging.Logger +} + +func (o *SharedOptions) AddPersistentFlags(cmd *cobra.Command) { + // logging + logging.InitFlags(cmd.PersistentFlags()) + // clusters + o.PlatformCluster.RegisterSingleConfigPathFlag(cmd.PersistentFlags()) + // environment + cmd.PersistentFlags().StringVar(&o.Environment, "environment", "", "Environment name. Required. This is used to distinguish between different environments that are watching the same Onboarding cluster. Must be globally unique.") + // provider name + cmd.PersistentFlags().StringVar(&o.ProviderName, "provider-name", "", "Name of the provider resource.") + cmd.PersistentFlags().BoolVar(&o.DryRun, "dry-run", false, "If set, the command aborts after evaluation of the given flags.") +} + +func (o *SharedOptions) Complete() error { + if o.Environment == "" { + return fmt.Errorf("environment must not be empty") + } + if o.ProviderName == "" { + return fmt.Errorf("provider-name must not be empty") + } + + // build logger + log, err := logging.GetLogger() + if err != nil { + return err + } + o.Log = log + ctrl.SetLogger(o.Log.Logr()) + + if err := o.PlatformCluster.InitializeRESTConfig(); err != nil { + return err + } + + return nil +} diff --git a/cmd/project-workspace-operator/app/init.go b/cmd/project-workspace-operator/app/init.go new file mode 100644 index 0000000..92cb23b --- /dev/null +++ b/cmd/project-workspace-operator/app/init.go @@ -0,0 +1,283 @@ +package app + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/spf13/cobra" + rbacv1 "k8s.io/api/rbac/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/intstr" + "k8s.io/apimachinery/pkg/util/wait" + "sigs.k8s.io/controller-runtime/pkg/client" + + ctrlutils "github.com/openmcp-project/controller-utils/pkg/controller" + crdutil "github.com/openmcp-project/controller-utils/pkg/crds" + "github.com/openmcp-project/controller-utils/pkg/init/webhooks" + "github.com/openmcp-project/controller-utils/pkg/logging" + clustersv1alpha1 "github.com/openmcp-project/openmcp-operator/api/clusters/v1alpha1" + openmcpconst "github.com/openmcp-project/openmcp-operator/api/constants" + "github.com/openmcp-project/openmcp-operator/lib/clusteraccess" + libutils "github.com/openmcp-project/openmcp-operator/lib/utils" + + pwv1alpha1 "github.com/openmcp-project/project-workspace-operator/api/core/v1alpha1" + "github.com/openmcp-project/project-workspace-operator/api/crds" + providerscheme "github.com/openmcp-project/project-workspace-operator/api/install" + "github.com/openmcp-project/project-workspace-operator/internal/controller/core" + "github.com/openmcp-project/project-workspace-operator/internal/dns" +) + +func NewInitCommand(so *SharedOptions) *cobra.Command { + opts := &InitOptions{ + SharedOptions: so, + } + cmd := &cobra.Command{ + Use: "init", + Short: "Initialize Platform Service ProjectWorkspace", + Run: func(cmd *cobra.Command, args []string) { + opts.PrintRawOptions(cmd) + if err := opts.Complete(cmd.Context()); err != nil { + panic(fmt.Errorf("error completing options: %w", err)) + } + opts.PrintCompletedOptions(cmd) + if opts.DryRun { + cmd.Println("=== END OF DRY RUN ===") + return + } + if err := opts.Run(cmd.Context()); err != nil { + panic(err) + } + }, + } + opts.AddFlags(cmd) + + return cmd +} + +type InitOptions struct { + *SharedOptions +} + +func (o *InitOptions) AddFlags(cmd *cobra.Command) {} + +func (o *InitOptions) Complete(ctx context.Context) error { + if err := o.SharedOptions.Complete(); err != nil { + return err + } + + return nil +} + +func (o *InitOptions) Run(ctx context.Context) error { + if err := o.PlatformCluster.InitializeClient(providerscheme.InstallOperatorAPIsPlatform(runtime.NewScheme())); err != nil { + return err + } + + log := o.Log.WithName("main") + ctx = logging.NewContext(ctx, log) + log.Info("Environment", "value", o.Environment) + log.Info("ProviderName", "value", o.ProviderName) + + log.Info("Getting access to the onboarding cluster") + onboardingScheme := runtime.NewScheme() + providerscheme.InstallOperatorAPIsOnboarding(onboardingScheme) + + providerSystemNamespace := os.Getenv(openmcpconst.EnvVariablePodNamespace) + if providerSystemNamespace == "" { + return fmt.Errorf("environment variable %s is not set", openmcpconst.EnvVariablePodNamespace) + } + + clusterAccessManager := clusteraccess.NewClusterAccessManager(o.PlatformCluster.Client(), core.ControllerName, providerSystemNamespace) + clusterAccessManager.WithLogger(&log). + WithInterval(10 * time.Second). + WithTimeout(30 * time.Minute) + + onboardingCluster, err := clusterAccessManager.CreateAndWaitForCluster(ctx, clustersv1alpha1.PURPOSE_ONBOARDING+"-init", clustersv1alpha1.PURPOSE_ONBOARDING, + onboardingScheme, []clustersv1alpha1.PermissionsRequest{ + { + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{"apiextensions.k8s.io"}, + Resources: []string{"customresourcedefinitions"}, + Verbs: []string{"*"}, + }, + { + APIGroups: []string{"admissionregistration.k8s.io"}, + Resources: []string{"mutatingwebhookconfigurations", "validatingwebhookconfigurations"}, + Verbs: []string{"*"}, + }, + { + APIGroups: []string{""}, + Resources: []string{"secrets", "services"}, + Verbs: []string{"*"}, + }, + }, + }, + }) + + if err != nil { + return fmt.Errorf("error creating/updating onboarding cluster: %w", err) + } + + // apply CRDs + log.Info("Creating/updating CRDs") + crdManager := crdutil.NewCRDManager(openmcpconst.ClusterLabel, crds.CRDs) + crdManager.AddCRDLabelToClusterMapping(clustersv1alpha1.PURPOSE_PLATFORM, o.PlatformCluster) + crdManager.AddCRDLabelToClusterMapping(clustersv1alpha1.PURPOSE_ONBOARDING, onboardingCluster) + if err := crdManager.CreateOrUpdateCRDs(ctx, &log); err != nil { + return fmt.Errorf("error creating/updating CRDs: %w", err) + } + + // initialize webhooks + log.Info("Initializing webhooks") + + log.Info("Fetching ProjectWorkspaceConfig") + // this will likely fail a few times while the crd is being registered + pwc := &pwv1alpha1.ProjectWorkspaceConfig{} + if err := o.PlatformCluster.Client().Get(ctx, client.ObjectKey{Name: o.ProviderName}, pwc); err != nil { + return fmt.Errorf("unable to get ProjectWorkspaceConfig '%s': %w", o.ProviderName, err) + } + pwc.SetDefaults() + if err := pwc.Validate(); err != nil { + return fmt.Errorf("invalid ProjectWorkspaceConfig '%s': %w", o.ProviderName, err) + } + + suffix := "-webhook" + whServiceName := ctrlutils.ShortenToXCharactersUnsafe(o.ProviderName, ctrlutils.K8sMaxNameLength-len(suffix)) + suffix + whSecretName, err := libutils.WebhookSecretName(o.ProviderName) + if err != nil { + return fmt.Errorf("unable to determine webhook secret name: %w", err) + } + + // setup gateway for webhooks + dnsInstance := &dns.Instance{ + Name: whServiceName, + Namespace: providerSystemNamespace, + SubDomainPrefix: "pwo-webhooks", + BackendName: whServiceName, + BackendPort: int32(WebhookPortSvc), + } + dnsReconciler := dns.NewReconciler() + timeout := 3 * time.Minute + log.Info("Verifying default Gateway is available", "timeout", timeout.String()) + waitCtx, cancelCtx := context.WithTimeout(ctx, timeout) + defer cancelCtx() + var gatewayResult dns.GatewayReconcileResult + err = wait.PollUntilContextTimeout(waitCtx, 10*time.Second, timeout, true, func(ctx context.Context) (bool, error) { + gatewayResult, err = dnsReconciler.ReconcileGateway(ctx, dnsInstance, o.PlatformCluster) + if err != nil { + log.Error(err, "Error reconciling Gateway, retrying...") + return false, nil + } + if gatewayResult.RequeueAfter > 0 { + log.Debug("Default Gateway is not yet available, retrying...") + return false, nil + } + return true, nil + }) + if err != nil { + return fmt.Errorf("default Gateway did not become available within %s: %w", timeout.String(), err) + } + log.Info("Default Gateway is available", "hostName", gatewayResult.HostName) + + log.Info("Waiting for TLS route to become ready", "timeout", timeout.String()) + waitCtx, cancelCtx = context.WithTimeout(ctx, timeout) + defer cancelCtx() + err = wait.PollUntilContextTimeout(waitCtx, 10*time.Second, timeout, true, func(ctx context.Context) (bool, error) { + if err := dnsReconciler.ReconcileTLSRoute(ctx, dnsInstance, o.PlatformCluster); err != nil { + log.Error(err, "Error reconciling TLS route, retrying...") + return false, nil + } + tlsReady, err := dnsReconciler.IsTLSRouteReady(ctx, dnsInstance, o.PlatformCluster) + if err != nil { + log.Error(err, "Error checking TLS route readiness, retrying...") + return false, nil + } + if !tlsReady { + log.Debug("TLS route is not yet ready, retrying...") + return false, nil + } + return true, nil + }) + if err != nil { + return fmt.Errorf("TLS route did not become ready within %s: %w", timeout.String(), err) + } + log.Info("TLS route is ready") + + installOpts := []webhooks.InstallOption{ + webhooks.WithWebhookService{Name: whServiceName, Namespace: providerSystemNamespace}, + webhooks.WithWebhookSecret{Name: whSecretName, Namespace: providerSystemNamespace}, + webhooks.WithRemoteClient{Client: onboardingCluster.Client()}, + webhooks.WithWebhookServicePort(WebhookPortSvc), + webhooks.WithManagedWebhookService{ + TargetPort: intstr.FromInt32(WebhookPortPod), + SelectorLabels: map[string]string{ + "app.kubernetes.io/component": "controller", + "app.kubernetes.io/managed-by": "openmcp-operator", + "app.kubernetes.io/name": "PlatformService", + "app.kubernetes.io/instance": o.ProviderName, + }, + }, + } + certOpts := []webhooks.CertOption{ + webhooks.WithWebhookService{Name: whServiceName, Namespace: providerSystemNamespace}, + webhooks.WithWebhookSecret{Name: whSecretName, Namespace: providerSystemNamespace}, + } + if o.PlatformCluster.RESTConfig().Host != onboardingCluster.RESTConfig().Host { + // create a URL-based webhook otherwise + installOpts = append(installOpts, webhooks.WithCustomBaseURL(fmt.Sprintf("https://%s:%d", gatewayResult.HostName, gatewayResult.TLSPort))) + certOpts = append(certOpts, webhooks.WithAdditionalDNSNames{gatewayResult.HostName}) + } + + // webhook options we might or might not support at a later time + /* + opts = append(opts, webhooks.WithoutCA) + opts = append(opts, webhooks.WithCustomCA{todo}) + */ + + if !pwc.Spec.Webhook.Disabled { + log.Info("Webhooks are enabled, ensuring required resources ...") + + // Generate webhook certificate + if err := webhooks.GenerateCertificate(ctx, o.PlatformCluster.Client(), certOpts...); err != nil { + return fmt.Errorf("unable to generate webhook certificate: %w", err) + } + + // Install webhooks + err := webhooks.Install( + ctx, + o.PlatformCluster.Client(), + onboardingScheme, + []client.Object{ + &pwv1alpha1.Project{}, + &pwv1alpha1.Workspace{}, + }, + installOpts..., + ) + if err != nil { + return fmt.Errorf("unable to install webhooks: %w", err) + } + } else { + log.Info("Webhooks are disabled, removing webhook resources if they exist ...") + + // Uninstall webhooks + err := webhooks.Uninstall( + ctx, + o.PlatformCluster.Client(), + onboardingScheme, + []client.Object{ + &pwv1alpha1.Project{}, + &pwv1alpha1.Workspace{}, + }, + installOpts..., + ) + if err != nil { + return fmt.Errorf("unable to uninstall webhooks: %w", err) + } + } + + log.Info("Finished init command") + return nil +} diff --git a/cmd/project-workspace-operator/app/print.go b/cmd/project-workspace-operator/app/print.go new file mode 100644 index 0000000..080adc1 --- /dev/null +++ b/cmd/project-workspace-operator/app/print.go @@ -0,0 +1,74 @@ +package app + +import ( + "fmt" + + "sigs.k8s.io/yaml" + + "github.com/spf13/cobra" +) + +func (o *SharedOptions) PrintRaw(cmd *cobra.Command) { + data, err := yaml.Marshal(o.RawSharedOptions) + if err != nil { + cmd.Println(fmt.Errorf("error marshalling raw shared options: %w", err).Error()) + return + } + cmd.Print(string(data)) +} + +func (o *SharedOptions) PrintCompleted(cmd *cobra.Command) { + raw := map[string]any{ + "platformCluster": o.PlatformCluster.APIServerEndpoint(), + "providerName": o.ProviderName, + } + data, err := yaml.Marshal(raw) + if err != nil { + cmd.Println(fmt.Errorf("error marshalling completed shared options: %w", err).Error()) + return + } + cmd.Print(string(data)) +} + +func (o *InitOptions) PrintRaw(cmd *cobra.Command) {} + +func (o *InitOptions) PrintRawOptions(cmd *cobra.Command) { + cmd.Println("########## RAW OPTIONS START ##########") + o.SharedOptions.PrintRaw(cmd) + o.PrintRaw(cmd) + cmd.Println("########## RAW OPTIONS END ##########") +} + +func (o *InitOptions) PrintCompleted(cmd *cobra.Command) {} + +func (o *InitOptions) PrintCompletedOptions(cmd *cobra.Command) { + cmd.Println("########## COMPLETED OPTIONS START ##########") + o.SharedOptions.PrintCompleted(cmd) + o.PrintCompleted(cmd) + cmd.Println("########## COMPLETED OPTIONS END ##########") +} + +func (o *RunOptions) PrintRaw(cmd *cobra.Command) { + data, err := yaml.Marshal(o.RawRunOptions) + if err != nil { + cmd.Println(fmt.Errorf("error marshalling raw options: %w", err).Error()) + return + } + cmd.Print(string(data)) +} + +func (o *RunOptions) PrintRawOptions(cmd *cobra.Command) { + cmd.Println("########## RAW OPTIONS START ##########") + o.SharedOptions.PrintRaw(cmd) + o.PrintRaw(cmd) + cmd.Println("########## RAW OPTIONS END ##########") +} + +func (o *RunOptions) PrintCompleted(cmd *cobra.Command) {} + +func (o *RunOptions) PrintCompletedOptions(cmd *cobra.Command) { + cmd.Println("########## COMPLETED OPTIONS START ##########") + o.SharedOptions.PrintCompleted(cmd) + o.PrintCompleted(cmd) + cmd.Println("########## COMPLETED OPTIONS END ##########") +} diff --git a/cmd/project-workspace-operator/app/run.go b/cmd/project-workspace-operator/app/run.go new file mode 100644 index 0000000..29871b9 --- /dev/null +++ b/cmd/project-workspace-operator/app/run.go @@ -0,0 +1,443 @@ +package app + +import ( + "context" + "crypto/tls" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/spf13/cobra" + + authenticationv1 "k8s.io/api/authentication/v1" + rbacv1 "k8s.io/api/rbac/v1" + apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/sets" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/certwatcher" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/healthz" + "sigs.k8s.io/controller-runtime/pkg/metrics/filters" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + "sigs.k8s.io/controller-runtime/pkg/webhook" + + "github.com/openmcp-project/controller-utils/pkg/logging" + clustersv1alpha1 "github.com/openmcp-project/openmcp-operator/api/clusters/v1alpha1" + openmcpconst "github.com/openmcp-project/openmcp-operator/api/constants" + deployv1alpha1 "github.com/openmcp-project/openmcp-operator/api/provider/v1alpha1" + "github.com/openmcp-project/openmcp-operator/lib/clusteraccess" + + pwv1alpha1 "github.com/openmcp-project/project-workspace-operator/api/core/v1alpha1" + providerscheme "github.com/openmcp-project/project-workspace-operator/api/install" + "github.com/openmcp-project/project-workspace-operator/internal/controller/core" +) + +var setupLog logging.Logger + +func NewRunCommand(so *SharedOptions) *cobra.Command { + opts := &RunOptions{ + SharedOptions: so, + } + cmd := &cobra.Command{ + Use: "run", + Short: "Run the Platform Service ProjectWorkspace", + Run: func(cmd *cobra.Command, args []string) { + opts.PrintRawOptions(cmd) + if err := opts.Complete(cmd.Context()); err != nil { + panic(fmt.Errorf("error completing options: %w", err)) + } + opts.PrintCompletedOptions(cmd) + if opts.DryRun { + cmd.Println("=== END OF DRY RUN ===") + return + } + if err := opts.Run(cmd.Context()); err != nil { + panic(err) + } + }, + } + opts.AddFlags(cmd) + + return cmd +} + +type RawRunOptions struct { + // kubebuilder default flags + MetricsAddr string `json:"metrics-bind-address"` + MetricsCertPath string `json:"metrics-cert-path"` + MetricsCertName string `json:"metrics-cert-name"` + MetricsCertKey string `json:"metrics-cert-key"` + WebhookCertPath string `json:"webhook-cert-path"` + WebhookCertName string `json:"webhook-cert-name"` + WebhookCertKey string `json:"webhook-cert-key"` + EnableLeaderElection bool `json:"leader-elect"` + ProbeAddr string `json:"health-probe-bind-address"` + PprofAddr string `json:"pprof-bind-address"` + SecureMetrics bool `json:"metrics-secure"` + EnableHTTP2 bool `json:"enable-http2"` +} + +type RunOptions struct { + *SharedOptions + RawRunOptions + + // fields filled in Complete() + TLSOpts []func(*tls.Config) + WebhookTLSOpts []func(*tls.Config) + MetricsServerOptions metricsserver.Options + MetricsCertWatcher *certwatcher.CertWatcher + WebhookCertWatcher *certwatcher.CertWatcher +} + +func (o *RunOptions) AddFlags(cmd *cobra.Command) { + // kubebuilder default flags + cmd.Flags().StringVar(&o.MetricsAddr, "metrics-bind-address", "0", "The address the metrics endpoint binds to. Use :8443 for HTTPS or :8080 for HTTP, or leave as 0 to disable the metrics service.") + cmd.Flags().StringVar(&o.ProbeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") + cmd.Flags().StringVar(&o.PprofAddr, "pprof-bind-address", "", "The address the pprof endpoint binds to. Expected format is ':'. Leave empty to disable pprof endpoint.") + cmd.Flags().BoolVar(&o.EnableLeaderElection, "leader-elect", false, "Enable leader election for controller manager. Enabling this will ensure there is only one active controller manager.") + cmd.Flags().BoolVar(&o.SecureMetrics, "metrics-secure", true, "If set, the metrics endpoint is served securely via HTTPS. Use --metrics-secure=false to use HTTP instead.") + cmd.Flags().StringVar(&o.WebhookCertPath, "webhook-cert-path", "", "The directory that contains the webhook certificate.") + cmd.Flags().StringVar(&o.WebhookCertName, "webhook-cert-name", "tls.crt", "The name of the webhook certificate file.") + cmd.Flags().StringVar(&o.WebhookCertKey, "webhook-cert-key", "tls.key", "The name of the webhook key file.") + cmd.Flags().StringVar(&o.MetricsCertPath, "metrics-cert-path", "", "The directory that contains the metrics server certificate.") + cmd.Flags().StringVar(&o.MetricsCertName, "metrics-cert-name", "tls.crt", "The name of the metrics server certificate file.") + cmd.Flags().StringVar(&o.MetricsCertKey, "metrics-cert-key", "tls.key", "The name of the metrics server key file.") + cmd.Flags().BoolVar(&o.EnableHTTP2, "enable-http2", false, "If set, HTTP/2 will be enabled for the metrics and webhook servers") +} + +func (o *RunOptions) Complete(ctx context.Context) error { + if err := o.SharedOptions.Complete(); err != nil { + return err + } + + setupLog = o.Log.WithName("setup") + ctrl.SetLogger(o.Log.Logr()) + + // kubebuilder default stuff + + // if the enable-http2 flag is false (the default), http/2 should be disabled + // due to its vulnerabilities. More specifically, disabling http/2 will + // prevent from being vulnerable to the HTTP/2 Stream Cancellation and + // Rapid Reset CVEs. For more information see: + // - https://github.com/advisories/GHSA-qppj-fm5r-hxr3 + // - https://github.com/advisories/GHSA-4374-p667-p6c8 + disableHTTP2 := func(c *tls.Config) { + setupLog.Info("Disabling http/2") + c.NextProtos = []string{"http/1.1"} + } + + if !o.EnableHTTP2 { + o.TLSOpts = append(o.TLSOpts, disableHTTP2) + } + + // Initial webhook TLS options + o.WebhookTLSOpts = o.TLSOpts + + if len(o.WebhookCertPath) > 0 { + setupLog.Info("Initializing webhook certificate watcher using provided certificates", "webhook-cert-path", o.WebhookCertPath, "webhook-cert-name", o.WebhookCertName, "webhook-cert-key", o.WebhookCertKey) + + var err error + o.WebhookCertWatcher, err = certwatcher.New( + filepath.Join(o.WebhookCertPath, o.WebhookCertName), + filepath.Join(o.WebhookCertPath, o.WebhookCertKey), + ) + if err != nil { + return fmt.Errorf("failed to initialize webhook certificate watcher: %w", err) + } + + o.WebhookTLSOpts = append(o.WebhookTLSOpts, func(config *tls.Config) { + config.GetCertificate = o.WebhookCertWatcher.GetCertificate + }) + } + + // More info: + // - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.20.2/pkg/metrics/server + // - https://book.kubebuilder.io/reference/metrics.html + o.MetricsServerOptions = metricsserver.Options{ + BindAddress: o.MetricsAddr, + SecureServing: o.SecureMetrics, + TLSOpts: o.TLSOpts, + } + + if o.SecureMetrics { + // FilterProvider is used to protect the metrics endpoint with authn/authz. + // These configurations ensure that only authorized users and service accounts + // can access the metrics endpoint. The RBAC are configured in 'config/rbac/kustomization.yaml'. More info: + // https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.20.2/pkg/metrics/filters#WithAuthenticationAndAuthorization + o.MetricsServerOptions.FilterProvider = filters.WithAuthenticationAndAuthorization + } + + // If the certificate is not specified, controller-runtime will automatically + // generate self-signed certificates for the metrics server. While convenient for development and testing, + // this setup is not recommended for production. + // + // TODO(user): If you enable certManager, uncomment the following lines: + // - [METRICS-WITH-CERTS] at config/default/kustomization.yaml to generate and use certificates + // managed by cert-manager for the metrics server. + // - [PROMETHEUS-WITH-CERTS] at config/prometheus/kustomization.yaml for TLS certification. + if len(o.MetricsCertPath) > 0 { + setupLog.Info("Initializing metrics certificate watcher using provided certificates", "metrics-cert-path", o.MetricsCertPath, "metrics-cert-name", o.MetricsCertName, "metrics-cert-key", o.MetricsCertKey) + + var err error + o.MetricsCertWatcher, err = certwatcher.New( + filepath.Join(o.MetricsCertPath, o.MetricsCertName), + filepath.Join(o.MetricsCertPath, o.MetricsCertKey), + ) + if err != nil { + return fmt.Errorf("failed to initialize metrics certificate watcher: %w", err) + } + + o.MetricsServerOptions.TLSOpts = append(o.MetricsServerOptions.TLSOpts, func(config *tls.Config) { + config.GetCertificate = o.MetricsCertWatcher.GetCertificate + }) + } + + return nil +} + +//nolint:gocyclo +func (o *RunOptions) Run(ctx context.Context) error { + if err := o.PlatformCluster.InitializeClient(providerscheme.InstallOperatorAPIsPlatform(runtime.NewScheme())); err != nil { + return err + } + + setupLog = o.Log.WithName("setup") + setupLog.Info("Environment", "value", o.Environment) + setupLog.Info("ProviderName", "value", o.ProviderName) + + setupLog.Info("Fetching ProjectWorkspaceConfig") + pwc := &pwv1alpha1.ProjectWorkspaceConfig{} + if err := o.PlatformCluster.Client().Get(ctx, client.ObjectKey{Name: o.ProviderName}, pwc); err != nil { + return fmt.Errorf("unable to get ProjectWorkspaceConfig '%s': %w", o.ProviderName, err) + } + pwc.SetDefaults() + if err := pwc.Validate(); err != nil { + return fmt.Errorf("invalid ProjectWorkspaceConfig '%s': %w", o.ProviderName, err) + } + + setupLog.Info("Getting access to the onboarding cluster") + onboardingScheme := runtime.NewScheme() + providerscheme.InstallOperatorAPIsOnboarding(onboardingScheme) + + providerSystemNamespace := os.Getenv(openmcpconst.EnvVariablePodNamespace) + if providerSystemNamespace == "" { + return fmt.Errorf("environment variable %s is not set", openmcpconst.EnvVariablePodNamespace) + } + + clusterAccessManager := clusteraccess.NewClusterAccessManager(o.PlatformCluster.Client(), core.ControllerName, providerSystemNamespace) + clusterAccessManager.WithLogger(&setupLog). + WithInterval(10 * time.Second). + WithTimeout(30 * time.Minute) + + onboadingClusterPermissions := []clustersv1alpha1.PermissionsRequest{ + { + Rules: []rbacv1.PolicyRule{ + { + APIGroups: []string{pwv1alpha1.GroupName}, + Resources: []string{"projects", "projects/status", "workspaces", "workspaces/status"}, + Verbs: []string{"*"}, + }, + { + APIGroups: []string{pwv1alpha1.GroupName}, + Resources: []string{"*"}, + Verbs: []string{"list", "get"}, + }, + { + APIGroups: []string{"apiextensions.k8s.io"}, + Resources: []string{"customresourcedefinitions"}, + Verbs: []string{"list", "get"}, + }, + { + APIGroups: []string{""}, + Resources: []string{"namespaces"}, + Verbs: []string{"*"}, + }, + { + APIGroups: []string{"rbac.authorization.k8s.io"}, + Resources: []string{"clusterroles", "clusterrolebindings", "rolebindings"}, + Verbs: []string{"*"}, + }, + { + APIGroups: []string{"authentication.k8s.io/v1"}, + Resources: []string{"selfsubjectreviews"}, + Verbs: []string{"*"}, + }, + }, + }, + } + blockingAPIGroups := sets.New[string]() + for _, pb := range pwc.Spec.Project.ResourcesBlockingDeletion { + if pb.Group != pwv1alpha1.GroupName { + blockingAPIGroups.Insert(pb.Group) + } + } + for _, wsb := range pwc.Spec.Workspace.ResourcesBlockingDeletion { + if wsb.Group != pwv1alpha1.GroupName { + blockingAPIGroups.Insert(wsb.Group) + } + } + for _, bg := range sets.List(blockingAPIGroups) { + onboadingClusterPermissions[0].Rules = append(onboadingClusterPermissions[0].Rules, rbacv1.PolicyRule{ + APIGroups: []string{bg}, + Resources: []string{"*"}, + Verbs: []string{"list", "get"}, + }) + } + onboardingCluster, err := clusterAccessManager.CreateAndWaitForCluster(ctx, clustersv1alpha1.PURPOSE_ONBOARDING, clustersv1alpha1.PURPOSE_ONBOARDING, onboardingScheme, onboadingClusterPermissions) + + if err != nil { + return fmt.Errorf("error creating/updating onboarding cluster: %w", err) + } + + setupLog.Info("Listing ServiceProvider resources") + sps := &deployv1alpha1.ServiceProviderList{} + if err := o.PlatformCluster.Client().List(ctx, sps); err != nil { + return fmt.Errorf("unable to list ServiceProvider resources: %w", err) + } + if len(sps.Items) > 0 { + setupLog.Info("Listing CRDs on the onboarding cluster to lookup service resources") + crds := apiextv1.CustomResourceDefinitionList{} + if err := onboardingCluster.Client().List(ctx, &crds); err != nil { + return fmt.Errorf("unable to list CRDs on onboarding cluster: %w", err) + } + if pwc.Spec.Workspace.AdditionalPermissions == nil { + pwc.Spec.Workspace.AdditionalPermissions = make(map[pwv1alpha1.WorkspaceMemberRole][]rbacv1.PolicyRule) + } + groupToResources := map[string][]string{} + for _, sp := range sps.Items { + managedResources := sp.Status.Resources + for _, r := range managedResources { + // lookup plural name of resource from CRD + found := false + for _, crd := range crds.Items { + if crd.Spec.Group == r.Group && crd.Spec.Names.Kind == r.Kind { + groupToResources[r.Group] = append(groupToResources[r.Group], crd.Spec.Names.Plural) + setupLog.Info("Identified service resource", "serviceProvider", sp.Name, "group", r.Group, "kind", r.Kind, "resource", fmt.Sprintf("%s.%s", crd.Spec.Names.Plural, crd.Spec.Group)) + found = true + break + } + } + if !found { + return fmt.Errorf("unable to find CRD for resource kind '%s' in api group '%s' managed by ServiceProvider %s", r.Kind, r.Group, sp.Name) + } + } + } + // add permissions to pwc spec + for apigroup, resources := range groupToResources { + pwc.Spec.Workspace.AdditionalPermissions[pwv1alpha1.WorkspaceRoleAdmin] = append(pwc.Spec.Workspace.AdditionalPermissions[pwv1alpha1.WorkspaceRoleAdmin], rbacv1.PolicyRule{ + APIGroups: []string{apigroup}, + Resources: resources, + Verbs: []string{"*"}, + }) + pwc.Spec.Workspace.AdditionalPermissions[pwv1alpha1.WorkspaceRoleView] = append(pwc.Spec.Workspace.AdditionalPermissions[pwv1alpha1.WorkspaceRoleView], rbacv1.PolicyRule{ + APIGroups: []string{apigroup}, + Resources: resources, + Verbs: []string{"get", "list", "watch"}, + }) + } + } + + // figure out own identity + review := &authenticationv1.SelfSubjectReview{} + if err := onboardingCluster.Client().Create(ctx, review); err != nil { + return fmt.Errorf("failed to get own identity: %w", err) + } + identity := review.Status.UserInfo.Username + setupLog.Info("Determined own identity to exclude from webhook validation", "identity", identity) + + webhookServer := webhook.NewServer(webhook.Options{ + TLSOpts: o.WebhookTLSOpts, + Port: WebhookPortPod, + }) + + mgr, err := ctrl.NewManager(onboardingCluster.RESTConfig(), ctrl.Options{ + Scheme: onboardingScheme, + Metrics: o.MetricsServerOptions, + WebhookServer: webhookServer, + HealthProbeBindAddress: o.ProbeAddr, + PprofBindAddress: o.PprofAddr, + LeaderElection: o.EnableLeaderElection, + LeaderElectionID: "github.com/openmcp-project/platform-service-project-workspace", + // LeaderElectionReleaseOnCancel defines if the leader should step down voluntarily + // when the Manager ends. This requires the binary to immediately end when the + // Manager is stopped, otherwise, this setting is unsafe. Setting this significantly + // speeds up voluntary leader transitions as the new leader don't have to wait + // LeaseDuration time first. + // + // In the default scaffold provided, the program ends immediately after + // the manager stops, so would be fine to enable this option. However, + // if you are doing or is intended to do any operation such as perform cleanups + // after the manager stops then its usage might be unsafe. + // LeaderElectionReleaseOnCancel: true, + }) + if err != nil { + return fmt.Errorf("unable to create manager: %w", err) + } + + if !pwc.Spec.Webhook.Disabled { + if err = (&pwv1alpha1.Project{}).SetupWebhookWithManager(ctx, mgr, pwc.Spec.MemberOverridesName, identity); err != nil { + return fmt.Errorf("unable to setup Project webhook: %w", err) + } + if err = (&pwv1alpha1.Workspace{}).SetupWebhookWithManager(ctx, mgr, pwc.Spec.MemberOverridesName, identity); err != nil { + return fmt.Errorf("unable to setup Workspace webhook: %w", err) + } + } + + rbacSetup := core.NewRBACSetup(setupLog.Logr(), onboardingCluster.Client(), core.ControllerName, pwc.Spec) + if err := rbacSetup.EnsureResources(ctx); err != nil { + setupLog.Error(err, "unable to create or update RBAC resources") + os.Exit(1) + } + + commonReconciler := core.CommonReconciler{ + Client: mgr.GetClient(), + ControllerName: core.ControllerName, + ProjectWorkspaceConfigSpec: pwc.Spec, + } + + if err = (&core.ProjectReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + CommonReconciler: commonReconciler, + }).SetupWithManager(mgr); err != nil { + return fmt.Errorf("unable to create Project controller: %w", err) + } + + if err = (&core.WorkspaceReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + CommonReconciler: commonReconciler, + }).SetupWithManager(mgr); err != nil { + return fmt.Errorf("unable to create Workspace controller: %w", err) + } + + if o.MetricsCertWatcher != nil { + setupLog.Info("Adding metrics certificate watcher to manager") + if err := mgr.Add(o.MetricsCertWatcher); err != nil { + return fmt.Errorf("unable to add metrics certificate watcher to manager: %w", err) + } + } + + if o.WebhookCertWatcher != nil { + setupLog.Info("Adding webhook certificate watcher to manager") + if err := mgr.Add(o.WebhookCertWatcher); err != nil { + return fmt.Errorf("unable to add webhook certificate watcher to manager: %w", err) + } + } + + if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { + return fmt.Errorf("unable to set up health check: %w", err) + } + if err := mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil { + return fmt.Errorf("unable to set up ready check: %w", err) + } + + setupLog.Info("Starting manager") + if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { + return fmt.Errorf("problem running manager: %w", err) + } + + return nil +} diff --git a/cmd/project-workspace-operator/main.go b/cmd/project-workspace-operator/main.go index 92c9a2b..ef5263d 100644 --- a/cmd/project-workspace-operator/main.go +++ b/cmd/project-workspace-operator/main.go @@ -6,6 +6,7 @@ import ( "fmt" "os" + "github.com/openmcp-project/project-workspace-operator/cmd/project-workspace-operator/app" "github.com/openmcp-project/project-workspace-operator/internal/controller/core/config" "github.com/openmcp-project/project-workspace-operator/internal/controller/core" @@ -21,13 +22,14 @@ import ( "github.com/openmcp-project/controller-utils/pkg/init/crds" "github.com/openmcp-project/controller-utils/pkg/init/webhooks" + authenticationv1 "k8s.io/api/authentication/v1" "k8s.io/client-go/rest" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/healthz" metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" - openmcpv1alpha1 "github.com/openmcp-project/project-workspace-operator/api/core/v1alpha1" + pwv1alpha1 "github.com/openmcp-project/project-workspace-operator/api/core/v1alpha1" pwocrds "github.com/openmcp-project/project-workspace-operator/api/crds" // +kubebuilder:scaffold:imports ) @@ -59,10 +61,10 @@ func NewProjectWorkspaceOperatorCommand() *cobra.Command { return fmt.Errorf("no command specified") }, } - cmd.PersistentFlags().AddGoFlagSet(goflag.CommandLine) cmd.AddCommand(newProjectWorkspaceOperatorInitCommand(options)) cmd.AddCommand(newProjectWorkspaceOperatorStartCommand(options)) + cmd.AddCommand(app.NewPlatformServiceProjectWorkspaceCommand()) return cmd } @@ -101,16 +103,16 @@ func (o *Options) run() { os.Exit(1) } - rbacSetup := core.NewRBACSetup(setupLog.Logr(), crateClient, controllerName) + rbacSetup := core.NewRBACSetup(setupLog.Logr(), crateClient, controllerName, o.ProjectWorkspaceConfig.Spec) if err := rbacSetup.EnsureResources(runContext); err != nil { setupLog.Error(err, "unable to create or update RBAC resources") os.Exit(1) } commonReconciler := core.CommonReconciler{ - Client: mgr.GetClient(), - ControllerName: controllerName, - ProjectWorkspaceConfig: o.ProjectWorkspaceConfig, + Client: mgr.GetClient(), + ControllerName: controllerName, + ProjectWorkspaceConfigSpec: o.ProjectWorkspaceConfig.Spec, } if err = (&core.ProjectReconciler{ @@ -132,12 +134,20 @@ func (o *Options) run() { } if os.Getenv("ENABLE_WEBHOOKS") != "false" { - if err = (&openmcpv1alpha1.Project{}).SetupWebhookWithManager(mgr, *o.MemberOverridesName); err != nil { + // figure out own identity + review := &authenticationv1.SelfSubjectReview{} + if err := crateClient.Create(runContext, review); err != nil { + setupLog.Error(err, "failed to get own identity") + } + identity := review.Status.UserInfo.Username + setupLog.Info("Determined own identity to exclude from webhook validation", "identity", identity) + + if err = (&pwv1alpha1.Project{}).SetupWebhookWithManager(runContext, mgr, *o.MemberOverridesName, identity); err != nil { setupLog.Error(err, "unable to create webhook", "webhook", "Project") os.Exit(1) } - if err = (&openmcpv1alpha1.Workspace{}).SetupWebhookWithManager(mgr, *o.MemberOverridesName); err != nil { + if err = (&pwv1alpha1.Workspace{}).SetupWebhookWithManager(runContext, mgr, *o.MemberOverridesName, identity); err != nil { setupLog.Error(err, "unable to create webhook", "webhook", "Workspace") os.Exit(1) } @@ -172,7 +182,8 @@ func newProjectWorkspaceOperatorInitCommand(options *Options) *cobra.Command { }, } - options.AddInitFlags(cmd.Flags(), cmd.PersistentFlags()) + options.AddInitFlags(cmd.Flags()) + cmd.Flags().AddGoFlagSet(goflag.CommandLine) return cmd } @@ -189,7 +200,8 @@ func newProjectWorkspaceOperatorStartCommand(options *Options) *cobra.Command { }, } - options.AddStartFlags(cmd.Flags(), cmd.PersistentFlags()) + options.AddStartFlags(cmd.Flags()) + cmd.Flags().AddGoFlagSet(goflag.CommandLine) return cmd } @@ -225,8 +237,8 @@ func (o *Options) runInit() { setupClient, scheme, []client.Object{ - &openmcpv1alpha1.Project{}, - &openmcpv1alpha1.Workspace{}, + &pwv1alpha1.Project{}, + &pwv1alpha1.Workspace{}, }, installOptions..., ) @@ -274,7 +286,7 @@ type Options struct { CRDFlags *crds.Flags WebhooksFlags *webhooks.Flags MemberOverridesName *string - ProjectWorkspaceConfig *config.ProjectWorkspaceConfig + ProjectWorkspaceConfig *pwv1alpha1.ProjectWorkspaceConfig } func NewOptions() *Options { @@ -289,7 +301,7 @@ func (o *Options) addCommonFlags(fs *flag.FlagSet) { fs.StringVar(&o.CrateClusterPath, "crate-cluster", "", "Path to the crate cluster kubeconfig file or directory containing either a kubeconfig or host, token, and ca file. Leave empty to use in-cluster config.") } -func (o *Options) AddStartFlags(fs *flag.FlagSet, ps *flag.FlagSet) { +func (o *Options) AddStartFlags(fs *flag.FlagSet) { // standard stuff fs.StringVar(&o.MetricsAddr, "metrics-bind-address", ":8080", "The address the metric endpoint binds to.") fs.StringVar(&o.ProbeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") @@ -307,7 +319,7 @@ func (o *Options) AddStartFlags(fs *flag.FlagSet, ps *flag.FlagSet) { // fs.StringVar(o.MemberOverridesFlags.MemberOverridesName, "use-member-overrides-name", "", "Specify a MemberOverrides resources name.") } -func (o *Options) AddInitFlags(fs *flag.FlagSet, ps *flag.FlagSet) { +func (o *Options) AddInitFlags(fs *flag.FlagSet) { // add common flags o.addCommonFlags(fs) } @@ -335,7 +347,7 @@ func (o *Options) Complete() error { } if o.ProjectWorkspaceConfig == nil { - o.ProjectWorkspaceConfig = &config.ProjectWorkspaceConfig{} + o.ProjectWorkspaceConfig = &pwv1alpha1.ProjectWorkspaceConfig{} } o.ProjectWorkspaceConfig.SetDefaults() diff --git a/cmd/project-workspace-operator/main_test.go b/cmd/project-workspace-operator/main_test.go index a94bf0f..d135d79 100644 --- a/cmd/project-workspace-operator/main_test.go +++ b/cmd/project-workspace-operator/main_test.go @@ -15,7 +15,7 @@ func Test_NewProjectWorkspaceOperatorCommand(t *testing.T) { assert.True(t, cmd.HasSubCommands()) cmds := cmd.Commands() - assert.Len(t, cmds, 2) + assert.Len(t, cmds, 3) cmdNames := []string{} for _, c := range cmds { diff --git a/config/webhook/manifests.yaml b/config/webhook/manifests.yaml index 6117f99..e07f331 100644 --- a/config/webhook/manifests.yaml +++ b/config/webhook/manifests.yaml @@ -12,7 +12,7 @@ webhooks: namespace: system path: /mutate-core-openmcp-cloud-v1alpha1-project failurePolicy: Fail - name: mproject.kb.io + name: mproject.openmcp.cloud rules: - apiGroups: - core.openmcp.cloud @@ -32,7 +32,7 @@ webhooks: namespace: system path: /mutate-core-openmcp-cloud-v1alpha1-workspace failurePolicy: Fail - name: mworkspace.kb.io + name: mworkspace.openmcp.cloud rules: - apiGroups: - core.openmcp.cloud @@ -58,7 +58,7 @@ webhooks: namespace: system path: /validate-core-openmcp-cloud-v1alpha1-project failurePolicy: Fail - name: vproject.kb.io + name: vproject.openmcp.cloud rules: - apiGroups: - core.openmcp.cloud @@ -79,7 +79,7 @@ webhooks: namespace: system path: /validate-core-openmcp-cloud-v1alpha1-workspace failurePolicy: Fail - name: vworkspace.kb.io + name: vworkspace.openmcp.cloud rules: - apiGroups: - core.openmcp.cloud diff --git a/go.mod b/go.mod index a812338..3f31790 100644 --- a/go.mod +++ b/go.mod @@ -4,9 +4,9 @@ go 1.25.3 require ( github.com/go-logr/logr v1.4.3 - github.com/onsi/ginkgo/v2 v2.26.0 + github.com/onsi/ginkgo/v2 v2.27.2 github.com/onsi/gomega v1.38.2 - github.com/openmcp-project/controller-utils v0.23.1 + github.com/openmcp-project/controller-utils v0.23.3-0.20251029131627-9093055d3d4d github.com/openmcp-project/project-workspace-operator/api v0.19.0 github.com/stretchr/testify v1.11.1 k8s.io/api v0.34.1 @@ -17,22 +17,42 @@ require ( ) require ( + cel.dev/expr v0.24.0 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect + github.com/antlr4-go/antlr/v4 v4.13.0 // indirect github.com/blang/semver/v4 v4.0.0 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect github.com/google/btree v1.1.3 // indirect + github.com/google/cel-go v0.26.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/stoewer/go-strcase v1.3.0 // indirect github.com/x448/float16 v0.8.4 // indirect - go.opentelemetry.io/otel v1.35.0 // indirect - go.opentelemetry.io/otel/trace v1.35.0 // indirect - go.uber.org/automaxprocs v1.6.0 // indirect + go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 // indirect + go.opentelemetry.io/otel v1.37.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0 // indirect + go.opentelemetry.io/otel/metric v1.37.0 // indirect + go.opentelemetry.io/otel/sdk v1.37.0 // indirect + go.opentelemetry.io/otel/trace v1.37.0 // indirect + go.opentelemetry.io/proto/otlp v1.5.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/mod v0.28.0 // indirect + golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect + golang.org/x/mod v0.29.0 // indirect golang.org/x/sync v0.17.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1 // indirect + google.golang.org/grpc v1.75.1 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect + k8s.io/apiserver v0.34.1 // indirect k8s.io/component-base v0.34.1 // indirect + sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect ) @@ -62,6 +82,8 @@ require ( github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect + github.com/openmcp-project/openmcp-operator/api v0.16.0 + github.com/openmcp-project/openmcp-operator/lib v0.16.1-0.20251030140622-66804142483a github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.23.0 // indirect github.com/prometheus/client_model v0.6.2 // indirect @@ -72,21 +94,22 @@ require ( github.com/vladimirvivien/gexe v0.4.1 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect - golang.org/x/net v0.44.0 // indirect + golang.org/x/net v0.46.0 // indirect golang.org/x/oauth2 v0.30.0 // indirect - golang.org/x/sys v0.36.0 // indirect - golang.org/x/term v0.35.0 // indirect - golang.org/x/text v0.29.0 // indirect + golang.org/x/sys v0.37.0 // indirect + golang.org/x/term v0.36.0 // indirect + golang.org/x/text v0.30.0 // indirect golang.org/x/time v0.12.0 // indirect - golang.org/x/tools v0.37.0 // indirect + golang.org/x/tools v0.38.0 // indirect gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect google.golang.org/protobuf v1.36.8 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/apiextensions-apiserver v0.34.1 // indirect + k8s.io/apiextensions-apiserver v0.34.1 k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20250814151709-d7b6acb124c3 // indirect k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 + sigs.k8s.io/gateway-api v1.4.0 sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/yaml v1.6.0 ) diff --git a/go.sum b/go.sum index c144737..136d2ec 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,17 @@ +cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= +cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI= +github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= @@ -19,6 +25,8 @@ github.com/evanphx/json-patch v4.12.0+incompatible h1:4onqiflcdA9EOZ4RxV643DvftH github.com/evanphx/json-patch v4.12.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= @@ -27,10 +35,13 @@ github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BN github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo= github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M= github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk= -github.com/gkampitakis/go-snaps v0.5.14 h1:3fAqdB6BCPKHDMHAKRwtPUwYexKtGrNuw8HX/T/4neo= -github.com/gkampitakis/go-snaps v0.5.14/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= +github.com/gkampitakis/go-snaps v0.5.15 h1:amyJrvM1D33cPHwVrjo9jQxX8g/7E2wYdZ+01KS3zGE= +github.com/gkampitakis/go-snaps v0.5.15/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= github.com/go-openapi/jsonpointer v0.21.2 h1:AqQaNADVwq/VnkCmQg6ogE+M3FOsKTytwges0JdwVuA= @@ -45,8 +56,12 @@ github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/cel-go v0.26.0 h1:DPGjXackMpJWH680oGY4lZhYjIameYmR+/6RBdDGmaI= +github.com/google/cel-go v0.26.0/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM= github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -60,6 +75,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= @@ -96,19 +113,21 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= -github.com/onsi/ginkgo/v2 v2.26.0 h1:1J4Wut1IlYZNEAWIV3ALrT9NfiaGW2cDCJQSFQMs/gE= -github.com/onsi/ginkgo/v2 v2.26.0/go.mod h1:qhEywmzWTBUY88kfO0BRvX4py7scov9yR+Az2oavUzw= +github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= +github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= -github.com/openmcp-project/controller-utils v0.23.1 h1:suuZ9UWJbSE/LbpZCtzpHg6FOqU7BkR4bq6cEAYykyc= -github.com/openmcp-project/controller-utils v0.23.1/go.mod h1:fU16gy7PHCqMtNaH/nAnHJyGM1SI5ZUiyQcUwrPy2TQ= +github.com/openmcp-project/controller-utils v0.23.3-0.20251029131627-9093055d3d4d h1:rt+35MizcQBVb3k+i+IHBzxP88zTmvU4voXB9ayRNZY= +github.com/openmcp-project/controller-utils v0.23.3-0.20251029131627-9093055d3d4d/go.mod h1:728AUdj44fDWg224VrFuDVB1A1NnVkXHQMY8R5etmO4= +github.com/openmcp-project/openmcp-operator/api v0.16.0 h1:sVqLiJmxSGTKnVEz274LIaj+2esrMkcD7j9H9n81CMo= +github.com/openmcp-project/openmcp-operator/api v0.16.0/go.mod h1:0KytEWVi1Gw5SEjyclhNZmUXks+SqbivLW10fDe7vL4= +github.com/openmcp-project/openmcp-operator/lib v0.16.1-0.20251030140622-66804142483a h1:xTdGPeFlyg8A+wpLsY+t9uZA940V/P4QFAHV+GUXzC0= +github.com/openmcp-project/openmcp-operator/lib v0.16.1-0.20251030140622-66804142483a/go.mod h1:/5tYrnfo/ExW6LSrKF/ajqWfuE70wtxLoihuUKesHY8= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= -github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc= github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= @@ -125,10 +144,17 @@ github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4 github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stoewer/go-strcase v1.3.0 h1:g0eASXYtp+yvN9fK8sH94oCIk0fau9uV1/ZdJ0AVEzs= +github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= @@ -145,12 +171,26 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= -go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= -go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= -go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= -go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= -go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= -go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 h1:yd02MEjBdJkG3uabWP9apV+OuWRIXGDuJEUJbOHmCFU= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0/go.mod h1:umTcuxiv1n/s/S6/c2AT/g2CQ7u5C59sHDNmfSwgz7Q= +go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= +go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 h1:OeNbIYk/2C15ckl7glBlOBp5+WlYsOElzTNmiPW/x60= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0/go.mod h1:7Bept48yIeqxP2OZ9/AqIpYS94h2or0aB4FypJTc8ZM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0 h1:tgJ0uaNS4c98WRNUEx5U3aDlrDOI5Rs+1Vifcw4DJ8U= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.34.0/go.mod h1:U7HYyW0zt/a9x5J1Kjs+r1f/d4ZHnYFclhYY2+YbeoE= +go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= +go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= +go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= +go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= +go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= +go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= +go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= +go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4= +go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= @@ -164,16 +204,18 @@ go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= -golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U= -golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= -golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= +golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= +golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -184,28 +226,36 @@ golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= -golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ= -golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA= +golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= +golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= +golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= -golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= +golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= -golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7 h1:FiusG7LWj+4byqhbvmB+Q93B/mOxJLN2DTozDuZm4EU= +google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:kXqgZtrWaf6qS3jZOCnCH7WYfrvFjkC51bM8fz3RsCA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1 h1:pmJpJEvT846VzausCQ5d7KreSROcDqmO388w5YbnltA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1/go.mod h1:GmFNa4BdJZ2a8G+wCe9Bg3wwThLrJun751XstdJt5Og= +google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI= +google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -215,6 +265,7 @@ gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnf gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= k8s.io/api v0.34.1 h1:jC+153630BMdlFukegoEL8E/yT7aLyQkIVuwhmwDgJM= @@ -223,6 +274,8 @@ k8s.io/apiextensions-apiserver v0.34.1 h1:NNPBva8FNAPt1iSVwIE0FsdrVriRXMsaWFMqJb k8s.io/apiextensions-apiserver v0.34.1/go.mod h1:hP9Rld3zF5Ay2Of3BeEpLAToP+l4s5UlxiHfqRaRcMc= k8s.io/apimachinery v0.34.1 h1:dTlxFls/eikpJxmAC7MVE8oOeP1zryV7iRyIjB0gky4= k8s.io/apimachinery v0.34.1/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= +k8s.io/apiserver v0.34.1 h1:U3JBGdgANK3dfFcyknWde1G6X1F4bg7PXuvlqt8lITA= +k8s.io/apiserver v0.34.1/go.mod h1:eOOc9nrVqlBI1AFCvVzsob0OxtPZUCPiUJL45JOTBG0= k8s.io/client-go v0.34.1 h1:ZUPJKgXsnKwVwmKKdPfw4tB58+7/Ik3CrjOEhsiZ7mY= k8s.io/client-go v0.34.1/go.mod h1:kA8v0FP+tk6sZA0yKLRG67LWjqufAoSHA2xVGKw9Of8= k8s.io/component-base v0.34.1 h1:v7xFgG+ONhytZNFpIz5/kecwD+sUhVE6HU7qQUiRM4A= @@ -233,10 +286,14 @@ k8s.io/kube-openapi v0.0.0-20250814151709-d7b6acb124c3 h1:liMHz39T5dJO1aOKHLvwaC k8s.io/kube-openapi v0.0.0-20250814151709-d7b6acb124c3/go.mod h1:UZ2yyWbFTpuhSbFhv24aGNOdoRdJZgsIObGBUaYVsts= k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 h1:jpcvIRr3GLoUoEKRkHKSmGjxb6lWwrBlJsXc+eUYQHM= +sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= sigs.k8s.io/controller-runtime v0.22.3 h1:I7mfqz/a/WdmDCEnXmSPm8/b/yRTy6JsKKENTijTq8Y= sigs.k8s.io/controller-runtime v0.22.3/go.mod h1:+QX1XUpTXN4mLoblf4tqr5CQcyHPAki2HLXqQMY6vh8= sigs.k8s.io/e2e-framework v0.6.0 h1:p7hFzHnLKO7eNsWGI2AbC1Mo2IYxidg49BiT4njxkrM= sigs.k8s.io/e2e-framework v0.6.0/go.mod h1:IREnCHnKgRCioLRmNi0hxSJ1kJ+aAdjEKK/gokcZu4k= +sigs.k8s.io/gateway-api v1.4.0 h1:ZwlNM6zOHq0h3WUX2gfByPs2yAEsy/EenYJB78jpQfQ= +sigs.k8s.io/gateway-api v1.4.0/go.mod h1:AR5RSqciWP98OPckEjOjh2XJhAe2Na4LHyXD2FUY7Qk= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= diff --git a/internal/controller/core/common.go b/internal/controller/core/common.go index b85b2db..15b2cf5 100644 --- a/internal/controller/core/common.go +++ b/internal/controller/core/common.go @@ -13,32 +13,32 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" - utilruntime "k8s.io/apimachinery/pkg/util/runtime" - clientgoscheme "k8s.io/client-go/kubernetes/scheme" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/reconcile" - "github.com/openmcp-project/project-workspace-operator/api/core/v1alpha1" + pwv1alpha1 "github.com/openmcp-project/project-workspace-operator/api/core/v1alpha1" "github.com/openmcp-project/project-workspace-operator/api/entities" + "github.com/openmcp-project/project-workspace-operator/api/install" ) var ( Scheme = runtime.NewScheme() - deleteFinalizer = v1alpha1.GroupVersion.Group + deleteFinalizer = pwv1alpha1.GroupVersion.Group + + ControllerName = "project-workspace-operator" ) func init() { - utilruntime.Must(clientgoscheme.AddToScheme(Scheme)) - utilruntime.Must(v1alpha1.AddToScheme(Scheme)) + install.InstallOperatorAPIsOnboarding(Scheme) } type CommonReconciler struct { client.Client - *config.ProjectWorkspaceConfig + pwv1alpha1.ProjectWorkspaceConfigSpec ControllerName string } @@ -58,15 +58,15 @@ func (r *CommonReconciler) handleRemainingContentBeforeDelete(ctx context.Contex return false, nil } - project, isProject := o.(*v1alpha1.Project) - workspace, isWorkspace := o.(*v1alpha1.Workspace) + project, isProject := o.(*pwv1alpha1.Project) + workspace, isWorkspace := o.(*pwv1alpha1.Workspace) if !isProject && !isWorkspace { return false, fmt.Errorf("object is not a Project or Workspace") } var namespace string - var resourcesBlockingDeletion []config.GroupVersionKind + var resourcesBlockingDeletion []metav1.GroupVersionKind if isProject { namespace = project.Status.Namespace @@ -87,13 +87,13 @@ func (r *CommonReconciler) handleRemainingContentBeforeDelete(ctx context.Contex } remainingResources := make([]unstructured.Unstructured, 0) - var remainingResourcesCondition v1alpha1.Condition + var remainingResourcesCondition pwv1alpha1.Condition log := log.FromContext(ctx) for _, gvk := range resourcesBlockingDeletion { resList := &unstructured.UnstructuredList{} - resList.SetGroupVersionKind(gvk.ToSchemaGVK()) + resList.SetGroupVersionKind(config.ToSchemaGVK(gvk)) if err := r.List(ctx, resList, client.InNamespace(namespace)); err != nil { log.Error(err, "failed to list resources") @@ -106,17 +106,17 @@ func (r *CommonReconciler) handleRemainingContentBeforeDelete(ctx context.Contex } if len(remainingResources) > 0 { - resources := make([]v1alpha1.RemainingContentResource, 0, len(remainingResources)) + resources := make([]pwv1alpha1.RemainingContentResource, 0, len(remainingResources)) - remainingResourcesCondition = v1alpha1.Condition{ - Type: v1alpha1.ConditionTypeContentRemaining, - Status: v1alpha1.ConditionStatusTrue, - Reason: v1alpha1.ConditionReasonResourcesRemaining, + remainingResourcesCondition = pwv1alpha1.Condition{ + Type: pwv1alpha1.ConditionTypeContentRemaining, + Status: pwv1alpha1.ConditionStatusTrue, + Reason: pwv1alpha1.ConditionReasonResourcesRemaining, Message: fmt.Sprintf("There are %d remaining resources in namespace %s that are preventing deletion", len(remainingResources), namespace), } for _, res := range remainingResources { - resources = append(resources, v1alpha1.RemainingContentResource{ + resources = append(resources, pwv1alpha1.RemainingContentResource{ APIGroup: res.GetAPIVersion(), Kind: res.GetKind(), Name: res.GetName(), @@ -141,9 +141,9 @@ func (r *CommonReconciler) handleRemainingContentBeforeDelete(ctx context.Contex return true, nil } else { if isProject { - project.RemoveCondition(v1alpha1.ConditionTypeContentRemaining) + project.RemoveCondition(pwv1alpha1.ConditionTypeContentRemaining) } else { - workspace.RemoveCondition(v1alpha1.ConditionTypeContentRemaining) + workspace.RemoveCondition(pwv1alpha1.ConditionTypeContentRemaining) } } diff --git a/internal/controller/core/config/config.go b/internal/controller/core/config/config.go index aa06c3a..51537c7 100644 --- a/internal/controller/core/config/config.go +++ b/internal/controller/core/config/config.go @@ -1,23 +1,19 @@ package config import ( + "errors" "fmt" "os" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/apimachinery/pkg/util/validation/field" "sigs.k8s.io/yaml" -) -// GroupVersionKind represents a Kubernetes GroupVersionKind -type GroupVersionKind struct { - Group string `json:"group"` - Version string `json:"version"` - Kind string `json:"kind"` -} + pwv1alpha1 "github.com/openmcp-project/project-workspace-operator/api/core/v1alpha1" +) // ToSchemaGVK converts a GroupVersionKind to a schema.GroupVersionKind -func (g *GroupVersionKind) ToSchemaGVK() schema.GroupVersionKind { +func ToSchemaGVK(g metav1.GroupVersionKind) schema.GroupVersionKind { return schema.GroupVersionKind{ Group: g.Group, Version: g.Version, @@ -25,46 +21,21 @@ func (g *GroupVersionKind) ToSchemaGVK() schema.GroupVersionKind { } } -// ProjectConfig contains the configuration for projects. -type ProjectConfig struct { - // +optional - ResourcesBlockingDeletion []GroupVersionKind `json:"resourcesBlockingDeletion,omitempty"` -} - -// WorkspaceConfig contains the configuration for workspaces. -type WorkspaceConfig struct { - // +optional - ResourcesBlockingDeletion []GroupVersionKind `json:"resourcesBlockingDeletion,omitempty"` -} - -// ProjectWorkspaceConfig contains the configuration for projects and workspaces. -type ProjectWorkspaceConfig struct { - // +optional - Project ProjectConfig `json:"project,omitempty"` - // +optional - Workspace WorkspaceConfig `json:"workspace,omitempty"` -} - -// SetDefaults sets the default values for the project workspace configuration when not set. -func (pwc *ProjectWorkspaceConfig) SetDefaults() { -} - -// Validate validates the project workspace configuration. -func (pwc *ProjectWorkspaceConfig) Validate() error { - errs := field.ErrorList{} - return errs.ToAggregate() -} - // LoadConfig loads a project workspace configuration from a file. -func LoadConfig(path string) (*ProjectWorkspaceConfig, error) { +func LoadConfig(path string) (*pwv1alpha1.ProjectWorkspaceConfig, error) { data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("error reading config file: %w", err) } - cfg := &ProjectWorkspaceConfig{} + cfg := &pwv1alpha1.ProjectWorkspaceConfig{} err = yaml.Unmarshal(data, cfg) if err != nil { - return nil, fmt.Errorf("error parsing config file: %w", err) + cfgSpec := &pwv1alpha1.ProjectWorkspaceConfigSpec{} + err2 := yaml.Unmarshal(data, cfgSpec) + if err2 != nil { + return nil, fmt.Errorf("config can neither be parsed as full config nor as spec: %w", errors.Join(err, err2)) + } + cfg.Spec = *cfgSpec } return cfg, nil } diff --git a/internal/controller/core/config/config_test.go b/internal/controller/core/config/config_test.go index 18724ed..c240d2a 100644 --- a/internal/controller/core/config/config_test.go +++ b/internal/controller/core/config/config_test.go @@ -5,6 +5,9 @@ import ( "github.com/stretchr/testify/assert" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + pwv1alpha1 "github.com/openmcp-project/project-workspace-operator/api/core/v1alpha1" "github.com/openmcp-project/project-workspace-operator/internal/controller/core/config" ) @@ -29,9 +32,11 @@ func TestLoadConfig(t *testing.T) { } func TestDefaults(t *testing.T) { - pwConfig := &config.ProjectWorkspaceConfig{ - Project: config.ProjectConfig{}, - Workspace: config.WorkspaceConfig{}, + pwConfig := &pwv1alpha1.ProjectWorkspaceConfig{ + Spec: pwv1alpha1.ProjectWorkspaceConfigSpec{ + Project: pwv1alpha1.ProjectConfig{}, + Workspace: pwv1alpha1.WorkspaceConfig{}, + }, } pwConfig.SetDefaults() @@ -40,17 +45,19 @@ func TestDefaults(t *testing.T) { } func TestValidate(t *testing.T) { - pwConfig := &config.ProjectWorkspaceConfig{ - Project: config.ProjectConfig{ - ResourcesBlockingDeletion: []config.GroupVersionKind{ - { - Group: "", - Version: "v1", - Kind: "Secret", + pwConfig := &pwv1alpha1.ProjectWorkspaceConfig{ + Spec: pwv1alpha1.ProjectWorkspaceConfigSpec{ + Project: pwv1alpha1.ProjectConfig{ + ResourcesBlockingDeletion: []metav1.GroupVersionKind{ + { + Group: "", + Version: "v1", + Kind: "Secret", + }, }, }, + Workspace: pwv1alpha1.WorkspaceConfig{}, }, - Workspace: config.WorkspaceConfig{}, } assert.NoError(t, pwConfig.Validate()) diff --git a/internal/controller/core/project_controller_test.go b/internal/controller/core/project_controller_test.go index 252a001..e06d22e 100644 --- a/internal/controller/core/project_controller_test.go +++ b/internal/controller/core/project_controller_test.go @@ -8,8 +8,6 @@ import ( "k8s.io/apimachinery/pkg/util/json" - "github.com/openmcp-project/project-workspace-operator/internal/controller/core/config" - "github.com/stretchr/testify/assert" corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" @@ -24,7 +22,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/reconcile" - "github.com/openmcp-project/project-workspace-operator/api/core/v1alpha1" + pwv1alpha1 "github.com/openmcp-project/project-workspace-operator/api/core/v1alpha1" ) const ( @@ -32,38 +30,38 @@ const ( ) var ( - sampleProject = &v1alpha1.Project{ + sampleProject = &pwv1alpha1.Project{ ObjectMeta: metav1.ObjectMeta{ Name: "sample", }, - Spec: v1alpha1.ProjectSpec{ - Members: []v1alpha1.ProjectMember{ + Spec: pwv1alpha1.ProjectSpec{ + Members: []pwv1alpha1.ProjectMember{ { - Subject: v1alpha1.Subject{ + Subject: pwv1alpha1.Subject{ Kind: rbacv1.UserKind, Name: "user@example.com", }, - Roles: []v1alpha1.ProjectMemberRole{v1alpha1.ProjectRoleAdmin}, + Roles: []pwv1alpha1.ProjectMemberRole{pwv1alpha1.ProjectRoleAdmin}, }, { - Subject: v1alpha1.Subject{ + Subject: pwv1alpha1.Subject{ Kind: rbacv1.GroupKind, Name: "some-group", }, - Roles: []v1alpha1.ProjectMemberRole{v1alpha1.ProjectRoleAdmin}, + Roles: []pwv1alpha1.ProjectMemberRole{pwv1alpha1.ProjectRoleAdmin}, }, { - Subject: v1alpha1.Subject{ + Subject: pwv1alpha1.Subject{ Kind: "ServiceAccount", Name: "default", Namespace: "default", }, - Roles: []v1alpha1.ProjectMemberRole{v1alpha1.ProjectRoleView}, + Roles: []pwv1alpha1.ProjectMemberRole{pwv1alpha1.ProjectRoleView}, }, }, }, } - sampleProjectDeleted = &v1alpha1.Project{ + sampleProjectDeleted = &pwv1alpha1.Project{ ObjectMeta: metav1.ObjectMeta{ Name: "sample", DeletionTimestamp: ptr.To(metav1.Now()), @@ -71,7 +69,7 @@ var ( deleteFinalizer, }, }, - Status: v1alpha1.ProjectStatus{ + Status: pwv1alpha1.ProjectStatus{ Namespace: "project-sample", }, } @@ -95,7 +93,7 @@ func Test_ProjectReconciler_Reconcile(t *testing.T) { }, interceptorFuncs: interceptor.Funcs{ Get: func(ctx context.Context, client client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { - return apierrors.NewNotFound(v1alpha1.GroupVersion.WithResource("projects").GroupResource(), sampleProject.Name) + return apierrors.NewNotFound(pwv1alpha1.GroupVersion.WithResource("projects").GroupResource(), sampleProject.Name) }, }, expectedResult: reconcile.Result{}, @@ -123,7 +121,7 @@ func Test_ProjectReconciler_Reconcile(t *testing.T) { expectedErr: nil, validate: func(t *testing.T, ctx context.Context, c client.Client) error { // check project status - p := &v1alpha1.Project{} + p := &pwv1alpha1.Project{} assert.NoErrorf(t, c.Get(ctx, client.ObjectKeyFromObject(sampleProject), p), "GET failed unexpectedly") assert.Equal(t, "project-sample", p.Status.Namespace) assert.Contains(t, p.Finalizers, deleteFinalizer) @@ -143,9 +141,9 @@ func Test_ProjectReconciler_Reconcile(t *testing.T) { }, } - clusterRoleCreatedForProject(t, ctx, c, p, v1alpha1.ProjectRoleAdmin, true, 2) - clusterRoleBindingCreatedForProject(t, ctx, c, p, v1alpha1.ProjectRoleAdmin, true, expectedAdmins) - roleBindingCreatedForProject(t, ctx, c, p, v1alpha1.ProjectRoleAdmin, true, expectedAdmins) + clusterRoleCreatedForProject(t, ctx, c, p, pwv1alpha1.ProjectRoleAdmin, true, 2) + clusterRoleBindingCreatedForProject(t, ctx, c, p, pwv1alpha1.ProjectRoleAdmin, true, expectedAdmins) + roleBindingCreatedForProject(t, ctx, c, p, pwv1alpha1.ProjectRoleAdmin, true, expectedAdmins) expectedViewers := []rbacv1.Subject{ { @@ -155,9 +153,9 @@ func Test_ProjectReconciler_Reconcile(t *testing.T) { }, } - clusterRoleCreatedForProject(t, ctx, c, p, v1alpha1.ProjectRoleView, true, 2) - clusterRoleBindingCreatedForProject(t, ctx, c, p, v1alpha1.ProjectRoleView, true, expectedViewers) - roleBindingCreatedForProject(t, ctx, c, p, v1alpha1.ProjectRoleView, true, expectedViewers) + clusterRoleCreatedForProject(t, ctx, c, p, pwv1alpha1.ProjectRoleView, true, 2) + clusterRoleBindingCreatedForProject(t, ctx, c, p, pwv1alpha1.ProjectRoleView, true, expectedViewers) + roleBindingCreatedForProject(t, ctx, c, p, pwv1alpha1.ProjectRoleView, true, expectedViewers) return nil }, @@ -176,7 +174,7 @@ func Test_ProjectReconciler_Reconcile(t *testing.T) { expectedErr: nil, validate: func(t *testing.T, ctx context.Context, c client.Client) error { // check project status - p := &v1alpha1.Project{} + p := &pwv1alpha1.Project{} err := c.Get(ctx, client.ObjectKeyFromObject(sampleProjectDeleted), p) assert.True(t, apierrors.IsNotFound(err)) @@ -205,18 +203,18 @@ func Test_ProjectReconciler_Reconcile(t *testing.T) { expectedErr: nil, validate: func(t *testing.T, ctx context.Context, c client.Client) error { // check workspace status - p := &v1alpha1.Project{} + p := &pwv1alpha1.Project{} err := c.Get(ctx, client.ObjectKeyFromObject(sampleProjectDeleted), p) assert.NoError(t, err) assert.Len(t, p.Status.Conditions, 1) - assert.Equal(t, v1alpha1.ConditionTypeContentRemaining, p.Status.Conditions[0].Type) - assert.Equal(t, v1alpha1.ConditionStatusTrue, p.Status.Conditions[0].Status) - assert.Equal(t, v1alpha1.ConditionReasonResourcesRemaining, p.Status.Conditions[0].Reason) + assert.Equal(t, pwv1alpha1.ConditionTypeContentRemaining, p.Status.Conditions[0].Type) + assert.Equal(t, pwv1alpha1.ConditionStatusTrue, p.Status.Conditions[0].Status) + assert.Equal(t, pwv1alpha1.ConditionReasonResourcesRemaining, p.Status.Conditions[0].Reason) assert.NotEmpty(t, p.Status.Conditions[0].Message) assert.NotNil(t, p.Status.Conditions[0].Details) - var remainingResources []v1alpha1.RemainingContentResource + var remainingResources []pwv1alpha1.RemainingContentResource assert.NoError(t, json.Unmarshal(p.Status.Conditions[0].Details, &remainingResources)) assert.Len(t, remainingResources, 1) assert.Equal(t, "v1", remainingResources[0].APIGroup) @@ -249,9 +247,9 @@ func Test_ProjectReconciler_Reconcile(t *testing.T) { CommonReconciler: CommonReconciler{ Client: c, ControllerName: "test", - ProjectWorkspaceConfig: &config.ProjectWorkspaceConfig{ - Project: config.ProjectConfig{ - ResourcesBlockingDeletion: []config.GroupVersionKind{ + ProjectWorkspaceConfigSpec: pwv1alpha1.ProjectWorkspaceConfigSpec{ + Project: pwv1alpha1.ProjectConfig{ + ResourcesBlockingDeletion: []metav1.GroupVersionKind{ { Group: "", Version: "v1", @@ -293,7 +291,7 @@ func newRequest(obj client.Object) ctrl.Request { } } -func namespaceCreatedForProject(t *testing.T, ctx context.Context, c client.Client, p *v1alpha1.Project, expectation bool) *corev1.Namespace { +func namespaceCreatedForProject(t *testing.T, ctx context.Context, c client.Client, p *pwv1alpha1.Project, expectation bool) *corev1.Namespace { ns := &corev1.Namespace{} err := c.Get(ctx, types.NamespacedName{Name: p.Status.Namespace}, ns) if expectation { @@ -305,7 +303,7 @@ func namespaceCreatedForProject(t *testing.T, ctx context.Context, c client.Clie return ns } -func clusterRoleCreatedForProject(t *testing.T, ctx context.Context, c client.Client, p *v1alpha1.Project, role v1alpha1.ProjectMemberRole, expectation bool, expectedRules int) { +func clusterRoleCreatedForProject(t *testing.T, ctx context.Context, c client.Client, p *pwv1alpha1.Project, role pwv1alpha1.ProjectMemberRole, expectation bool, expectedRules int) { cr := &rbacv1.ClusterRole{} err := c.Get(ctx, types.NamespacedName{Name: clusterRoleForEntityAndRole(p, role)}, cr) if expectation { @@ -319,7 +317,7 @@ func clusterRoleCreatedForProject(t *testing.T, ctx context.Context, c client.Cl } } -func clusterRoleBindingCreatedForProject(t *testing.T, ctx context.Context, c client.Client, p *v1alpha1.Project, role v1alpha1.ProjectMemberRole, expectation bool, expectedSubjects []rbacv1.Subject) { +func clusterRoleBindingCreatedForProject(t *testing.T, ctx context.Context, c client.Client, p *pwv1alpha1.Project, role pwv1alpha1.ProjectMemberRole, expectation bool, expectedSubjects []rbacv1.Subject) { crb := &rbacv1.ClusterRoleBinding{} err := c.Get(ctx, types.NamespacedName{Name: clusterRoleForEntityAndRole(p, role)}, crb) if expectation { @@ -333,7 +331,7 @@ func clusterRoleBindingCreatedForProject(t *testing.T, ctx context.Context, c cl } } -func roleBindingCreatedForProject(t *testing.T, ctx context.Context, c client.Client, p *v1alpha1.Project, role v1alpha1.ProjectMemberRole, expectation bool, expectedSubjects []rbacv1.Subject) { +func roleBindingCreatedForProject(t *testing.T, ctx context.Context, c client.Client, p *pwv1alpha1.Project, role pwv1alpha1.ProjectMemberRole, expectation bool, expectedSubjects []rbacv1.Subject) { rb := &rbacv1.RoleBinding{} err := c.Get(ctx, types.NamespacedName{Name: roleBindingForRole(role), Namespace: p.Status.Namespace}, rb) if expectation { diff --git a/internal/controller/core/rbac.go b/internal/controller/core/rbac.go index 9700def..f1e2ce3 100644 --- a/internal/controller/core/rbac.go +++ b/internal/controller/core/rbac.go @@ -11,7 +11,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" - openmcpv1alpha1 "github.com/openmcp-project/project-workspace-operator/api/core/v1alpha1" + pwv1alpha1 "github.com/openmcp-project/project-workspace-operator/api/core/v1alpha1" ) var ( @@ -31,11 +31,12 @@ var ( } ) -func NewRBACSetup(setupLog logr.Logger, c client.Client, controllerName string) *RBACSetup { +func NewRBACSetup(setupLog logr.Logger, c client.Client, controllerName string, cfg pwv1alpha1.ProjectWorkspaceConfigSpec) *RBACSetup { return &RBACSetup{ log: setupLog, client: c, controllerName: controllerName, + config: cfg, } } @@ -43,6 +44,7 @@ type RBACSetup struct { log logr.Logger client client.Client controllerName string + config pwv1alpha1.ProjectWorkspaceConfigSpec } func (setup *RBACSetup) EnsureResources(ctx context.Context) error { @@ -58,9 +60,9 @@ func (setup *RBACSetup) EnsureResources(ctx context.Context) error { } func (setup *RBACSetup) createOrUpdateProjectClusterRoles(ctx context.Context) error { - projectRoles := map[openmcpv1alpha1.ProjectMemberRole][]string{ - openmcpv1alpha1.ProjectRoleAdmin: AllVerbs, - openmcpv1alpha1.ProjectRoleView: ReadOnlyVerbs, + projectRoles := map[pwv1alpha1.ProjectMemberRole][]string{ + pwv1alpha1.ProjectRoleAdmin: AllVerbs, + pwv1alpha1.ProjectRoleView: ReadOnlyVerbs, } for role, verbs := range projectRoles { @@ -75,7 +77,7 @@ func (setup *RBACSetup) createOrUpdateProjectClusterRoles(ctx context.Context) e clusterRole.Rules = []rbacv1.PolicyRule{ { - APIGroups: []string{openmcpv1alpha1.GroupVersion.Group}, + APIGroups: []string{pwv1alpha1.GroupVersion.Group}, Resources: []string{"workspaces"}, Verbs: verbs, }, @@ -96,7 +98,7 @@ func (setup *RBACSetup) createOrUpdateProjectClusterRoles(ctx context.Context) e }, } - if role == openmcpv1alpha1.ProjectRoleAdmin { + if role == pwv1alpha1.ProjectRoleAdmin { clusterRole.Rules = append(clusterRole.Rules, rbacv1.PolicyRule{ APIGroups: []string{corev1.GroupName}, Resources: []string{"serviceaccounts/token"}, @@ -104,6 +106,9 @@ func (setup *RBACSetup) createOrUpdateProjectClusterRoles(ctx context.Context) e }) } + // add roles from config, if defined + clusterRole.Rules = append(clusterRole.Rules, setup.config.Project.AdditionalPermissions[role]...) + return nil }) if err != nil { @@ -116,9 +121,9 @@ func (setup *RBACSetup) createOrUpdateProjectClusterRoles(ctx context.Context) e } func (setup *RBACSetup) createOrUpdateWorkspaceClusterRoles(ctx context.Context) error { - workspaceRoles := map[openmcpv1alpha1.WorkspaceMemberRole][]string{ - openmcpv1alpha1.WorkspaceRoleAdmin: AllVerbs, - openmcpv1alpha1.WorkspaceRoleView: ReadOnlyVerbs, + workspaceRoles := map[pwv1alpha1.WorkspaceMemberRole][]string{ + pwv1alpha1.WorkspaceRoleAdmin: AllVerbs, + pwv1alpha1.WorkspaceRoleView: ReadOnlyVerbs, } for role, verbs := range workspaceRoles { @@ -133,7 +138,7 @@ func (setup *RBACSetup) createOrUpdateWorkspaceClusterRoles(ctx context.Context) clusterRole.Rules = []rbacv1.PolicyRule{ { - APIGroups: []string{openmcpv1alpha1.GroupVersion.Group}, + APIGroups: []string{pwv1alpha1.GroupVersion.Group}, Resources: []string{"managedcontrolplanes", "clusteradmins"}, Verbs: verbs, }, @@ -158,7 +163,7 @@ func (setup *RBACSetup) createOrUpdateWorkspaceClusterRoles(ctx context.Context) }, } - if role == openmcpv1alpha1.WorkspaceRoleAdmin { + if role == pwv1alpha1.WorkspaceRoleAdmin { clusterRole.Rules = append(clusterRole.Rules, rbacv1.PolicyRule{ APIGroups: []string{corev1.GroupName}, Resources: []string{"serviceaccounts/token"}, @@ -166,6 +171,9 @@ func (setup *RBACSetup) createOrUpdateWorkspaceClusterRoles(ctx context.Context) }) } + // add roles from config, if defined + clusterRole.Rules = append(clusterRole.Rules, setup.config.Workspace.AdditionalPermissions[role]...) + return nil }) if err != nil { diff --git a/internal/controller/core/rbac_test.go b/internal/controller/core/rbac_test.go index 979e90d..45d1107 100644 --- a/internal/controller/core/rbac_test.go +++ b/internal/controller/core/rbac_test.go @@ -14,7 +14,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/fake" "sigs.k8s.io/controller-runtime/pkg/client/interceptor" - "github.com/openmcp-project/project-workspace-operator/api/core/v1alpha1" + pwv1alpha1 "github.com/openmcp-project/project-workspace-operator/api/core/v1alpha1" ) func TestRBACSetup_EnsureResources(t *testing.T) { @@ -23,6 +23,7 @@ func TestRBACSetup_EnsureResources(t *testing.T) { interceptorFuncs interceptor.Funcs expectedError *string validateFunc func(ctx context.Context, client client.Client) error + config pwv1alpha1.ProjectWorkspaceConfigSpec }{ { name: "Failed to Create/Update Project Cluster Roles", @@ -40,7 +41,7 @@ func TestRBACSetup_EnsureResources(t *testing.T) { name: "Failed to Create/Update Workspace Cluster Roles", interceptorFuncs: interceptor.Funcs{ Create: func(ctx context.Context, client client.WithWatch, obj client.Object, opts ...client.CreateOption) error { - if role, ok := obj.(*rbacv1.ClusterRole); ok && role.Name == clusterRoleForRole(v1alpha1.WorkspaceRoleView) { + if role, ok := obj.(*rbacv1.ClusterRole); ok && role.Name == clusterRoleForRole(pwv1alpha1.WorkspaceRoleView) { return errors.New("some create error") } return client.Create(ctx, obj) @@ -53,7 +54,7 @@ func TestRBACSetup_EnsureResources(t *testing.T) { expectedError: nil, validateFunc: func(ctx context.Context, client client.Client) error { clusterRoleProjectAdmin := &rbacv1.ClusterRole{} - err := client.Get(ctx, types.NamespacedName{Name: clusterRoleForRole(v1alpha1.ProjectRoleAdmin)}, clusterRoleProjectAdmin) + err := client.Get(ctx, types.NamespacedName{Name: clusterRoleForRole(pwv1alpha1.ProjectRoleAdmin)}, clusterRoleProjectAdmin) if err != nil { return err } @@ -61,7 +62,7 @@ func TestRBACSetup_EnsureResources(t *testing.T) { assert.NotEmpty(t, clusterRoleProjectAdmin.Rules) clusterRoleProjectView := &rbacv1.ClusterRole{} - err = client.Get(ctx, types.NamespacedName{Name: clusterRoleForRole(v1alpha1.ProjectRoleView)}, clusterRoleProjectView) + err = client.Get(ctx, types.NamespacedName{Name: clusterRoleForRole(pwv1alpha1.ProjectRoleView)}, clusterRoleProjectView) if err != nil { return err } @@ -69,7 +70,7 @@ func TestRBACSetup_EnsureResources(t *testing.T) { assert.NotEmpty(t, clusterRoleProjectView.Rules) clusterRoleWorkspaceAdmin := &rbacv1.ClusterRole{} - err = client.Get(ctx, types.NamespacedName{Name: clusterRoleForRole(v1alpha1.WorkspaceRoleAdmin)}, clusterRoleWorkspaceAdmin) + err = client.Get(ctx, types.NamespacedName{Name: clusterRoleForRole(pwv1alpha1.WorkspaceRoleAdmin)}, clusterRoleWorkspaceAdmin) if err != nil { return err } @@ -77,13 +78,106 @@ func TestRBACSetup_EnsureResources(t *testing.T) { assert.NotEmpty(t, clusterRoleWorkspaceAdmin.Rules) clusterRoleWorkspaceView := &rbacv1.ClusterRole{} - err = client.Get(ctx, types.NamespacedName{Name: clusterRoleForRole(v1alpha1.WorkspaceRoleView)}, clusterRoleWorkspaceView) + err = client.Get(ctx, types.NamespacedName{Name: clusterRoleForRole(pwv1alpha1.WorkspaceRoleView)}, clusterRoleWorkspaceView) if err != nil { return err } assert.NotEmpty(t, clusterRoleWorkspaceView.Rules) + return nil + }, + }, + { + name: "Successfully Create/Update Project and Workspace Cluster Roles with additional permissions from config", + expectedError: nil, + config: pwv1alpha1.ProjectWorkspaceConfigSpec{ + Project: pwv1alpha1.ProjectConfig{ + AdditionalPermissions: map[pwv1alpha1.ProjectMemberRole][]rbacv1.PolicyRule{ + pwv1alpha1.ProjectRoleAdmin: { + { + APIGroups: []string{"admin"}, + Resources: []string{"project"}, + Verbs: []string{"*"}, + }, + }, + pwv1alpha1.ProjectRoleView: { + { + APIGroups: []string{"view"}, + Resources: []string{"project"}, + Verbs: []string{"*"}, + }, + }, + }, + }, + Workspace: pwv1alpha1.WorkspaceConfig{ + AdditionalPermissions: map[pwv1alpha1.WorkspaceMemberRole][]rbacv1.PolicyRule{ + pwv1alpha1.WorkspaceRoleAdmin: { + { + APIGroups: []string{"admin"}, + Resources: []string{"workspace"}, + Verbs: []string{"*"}, + }, + }, + pwv1alpha1.WorkspaceRoleView: { + { + APIGroups: []string{"view"}, + Resources: []string{"workspace"}, + Verbs: []string{"*"}, + }, + }, + }, + }, + }, + validateFunc: func(ctx context.Context, client client.Client) error { + clusterRoleProjectAdmin := &rbacv1.ClusterRole{} + err := client.Get(ctx, types.NamespacedName{Name: clusterRoleForRole(pwv1alpha1.ProjectRoleAdmin)}, clusterRoleProjectAdmin) + if err != nil { + return err + } + + assert.Contains(t, clusterRoleProjectAdmin.Rules, rbacv1.PolicyRule{ + APIGroups: []string{"admin"}, + Resources: []string{"project"}, + Verbs: []string{"*"}, + }) + + clusterRoleProjectView := &rbacv1.ClusterRole{} + err = client.Get(ctx, types.NamespacedName{Name: clusterRoleForRole(pwv1alpha1.ProjectRoleView)}, clusterRoleProjectView) + if err != nil { + return err + } + + assert.Contains(t, clusterRoleProjectView.Rules, rbacv1.PolicyRule{ + APIGroups: []string{"view"}, + Resources: []string{"project"}, + Verbs: []string{"*"}, + }) + + clusterRoleWorkspaceAdmin := &rbacv1.ClusterRole{} + err = client.Get(ctx, types.NamespacedName{Name: clusterRoleForRole(pwv1alpha1.WorkspaceRoleAdmin)}, clusterRoleWorkspaceAdmin) + if err != nil { + return err + } + + assert.Contains(t, clusterRoleWorkspaceAdmin.Rules, rbacv1.PolicyRule{ + APIGroups: []string{"admin"}, + Resources: []string{"workspace"}, + Verbs: []string{"*"}, + }) + + clusterRoleWorkspaceView := &rbacv1.ClusterRole{} + err = client.Get(ctx, types.NamespacedName{Name: clusterRoleForRole(pwv1alpha1.WorkspaceRoleView)}, clusterRoleWorkspaceView) + if err != nil { + return err + } + + assert.Contains(t, clusterRoleWorkspaceView.Rules, rbacv1.PolicyRule{ + APIGroups: []string{"view"}, + Resources: []string{"workspace"}, + Verbs: []string{"*"}, + }) + return nil }, }, @@ -93,7 +187,7 @@ func TestRBACSetup_EnsureResources(t *testing.T) { t.Run(tt.name, func(t *testing.T) { ctx := context.TODO() c := fake.NewClientBuilder().WithInterceptorFuncs(tt.interceptorFuncs).Build() - s := NewRBACSetup(testr.New(t), c, "test-rbac-controller") + s := NewRBACSetup(testr.New(t), c, "test-rbac-controller", tt.config) actualError := s.EnsureResources(ctx) diff --git a/internal/controller/core/workspace_controller_test.go b/internal/controller/core/workspace_controller_test.go index d3ddae5..349d5b6 100644 --- a/internal/controller/core/workspace_controller_test.go +++ b/internal/controller/core/workspace_controller_test.go @@ -7,8 +7,6 @@ import ( "k8s.io/apimachinery/pkg/util/json" - "github.com/openmcp-project/project-workspace-operator/internal/controller/core/config" - "github.com/stretchr/testify/assert" corev1 "k8s.io/api/core/v1" rbacv1 "k8s.io/api/rbac/v1" @@ -22,7 +20,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/client/interceptor" "sigs.k8s.io/controller-runtime/pkg/reconcile" - "github.com/openmcp-project/project-workspace-operator/api/core/v1alpha1" + pwv1alpha1 "github.com/openmcp-project/project-workspace-operator/api/core/v1alpha1" ) var ( @@ -34,39 +32,39 @@ var ( }, }, } - sampleWorkspace = &v1alpha1.Workspace{ + sampleWorkspace = &pwv1alpha1.Workspace{ ObjectMeta: metav1.ObjectMeta{ Name: "sample", Namespace: projectNamespace.Name, }, - Spec: v1alpha1.WorkspaceSpec{ - Members: []v1alpha1.WorkspaceMember{ + Spec: pwv1alpha1.WorkspaceSpec{ + Members: []pwv1alpha1.WorkspaceMember{ { - Subject: v1alpha1.Subject{ + Subject: pwv1alpha1.Subject{ Kind: rbacv1.UserKind, Name: "user@example.com", }, - Roles: []v1alpha1.WorkspaceMemberRole{v1alpha1.WorkspaceRoleAdmin}, + Roles: []pwv1alpha1.WorkspaceMemberRole{pwv1alpha1.WorkspaceRoleAdmin}, }, { - Subject: v1alpha1.Subject{ + Subject: pwv1alpha1.Subject{ Kind: rbacv1.GroupKind, Name: "some-group", }, - Roles: []v1alpha1.WorkspaceMemberRole{v1alpha1.WorkspaceRoleAdmin}, + Roles: []pwv1alpha1.WorkspaceMemberRole{pwv1alpha1.WorkspaceRoleAdmin}, }, { - Subject: v1alpha1.Subject{ + Subject: pwv1alpha1.Subject{ Kind: "ServiceAccount", Name: "default", Namespace: "default", }, - Roles: []v1alpha1.WorkspaceMemberRole{v1alpha1.WorkspaceRoleView}, + Roles: []pwv1alpha1.WorkspaceMemberRole{pwv1alpha1.WorkspaceRoleView}, }, }, }, } - sampleWorkspaceDeleted = &v1alpha1.Workspace{ + sampleWorkspaceDeleted = &pwv1alpha1.Workspace{ ObjectMeta: metav1.ObjectMeta{ Name: "sample", Namespace: projectNamespace.Name, @@ -75,7 +73,7 @@ var ( deleteFinalizer, }, }, - Status: v1alpha1.WorkspaceStatus{ + Status: pwv1alpha1.WorkspaceStatus{ Namespace: "project-sample--ws-sample", }, } @@ -98,7 +96,7 @@ func Test_WorkspaceReconciler_Reconcile(t *testing.T) { }, interceptorFuncs: interceptor.Funcs{ Get: func(ctx context.Context, client client.WithWatch, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { - return apierrors.NewNotFound(v1alpha1.GroupVersion.WithResource("workspaces").GroupResource(), sampleWorkspace.Name) + return apierrors.NewNotFound(pwv1alpha1.GroupVersion.WithResource("workspaces").GroupResource(), sampleWorkspace.Name) }, }, expectedResult: reconcile.Result{}, @@ -156,7 +154,7 @@ func Test_WorkspaceReconciler_Reconcile(t *testing.T) { expectedErr: nil, validate: func(t *testing.T, ctx context.Context, c client.Client) error { // check workspace status - ws := &v1alpha1.Workspace{} + ws := &pwv1alpha1.Workspace{} assert.NoErrorf(t, c.Get(ctx, client.ObjectKeyFromObject(sampleWorkspace), ws), "GET failed unexpectedly") assert.Equal(t, "project-sample--ws-sample", ws.Status.Namespace) assert.Contains(t, ws.Finalizers, deleteFinalizer) @@ -176,9 +174,9 @@ func Test_WorkspaceReconciler_Reconcile(t *testing.T) { }, } - clusterRoleCreatedForWorkspace(t, ctx, c, sampleProject, ws, v1alpha1.WorkspaceRoleAdmin, true, 1) - clusterRoleBindingCreatedForWorkspace(t, ctx, c, sampleProject, ws, v1alpha1.WorkspaceRoleAdmin, true, expectedAdmins) - roleBindingCreatedForWorkspace(t, ctx, c, ws, v1alpha1.WorkspaceRoleAdmin, true, expectedAdmins) + clusterRoleCreatedForWorkspace(t, ctx, c, sampleProject, ws, pwv1alpha1.WorkspaceRoleAdmin, true, 1) + clusterRoleBindingCreatedForWorkspace(t, ctx, c, sampleProject, ws, pwv1alpha1.WorkspaceRoleAdmin, true, expectedAdmins) + roleBindingCreatedForWorkspace(t, ctx, c, ws, pwv1alpha1.WorkspaceRoleAdmin, true, expectedAdmins) expectedViewers := []rbacv1.Subject{ { @@ -188,9 +186,9 @@ func Test_WorkspaceReconciler_Reconcile(t *testing.T) { }, } - clusterRoleCreatedForWorkspace(t, ctx, c, sampleProject, ws, v1alpha1.WorkspaceRoleView, true, 1) - clusterRoleBindingCreatedForWorkspace(t, ctx, c, sampleProject, ws, v1alpha1.WorkspaceRoleView, true, expectedViewers) - roleBindingCreatedForWorkspace(t, ctx, c, ws, v1alpha1.WorkspaceRoleView, true, expectedViewers) + clusterRoleCreatedForWorkspace(t, ctx, c, sampleProject, ws, pwv1alpha1.WorkspaceRoleView, true, 1) + clusterRoleBindingCreatedForWorkspace(t, ctx, c, sampleProject, ws, pwv1alpha1.WorkspaceRoleView, true, expectedViewers) + roleBindingCreatedForWorkspace(t, ctx, c, ws, pwv1alpha1.WorkspaceRoleView, true, expectedViewers) return nil }, @@ -211,17 +209,17 @@ func Test_WorkspaceReconciler_Reconcile(t *testing.T) { expectedErr: nil, validate: func(t *testing.T, ctx context.Context, c client.Client) error { // check workspace status - ws := &v1alpha1.Workspace{} + ws := &pwv1alpha1.Workspace{} err := c.Get(ctx, client.ObjectKeyFromObject(sampleWorkspaceDeleted), ws) assert.True(t, apierrors.IsNotFound(err)) namespaceCreatedForWorkspace(t, ctx, c, sampleWorkspaceDeleted, false) - clusterRoleCreatedForWorkspace(t, ctx, c, sampleProject, ws, v1alpha1.WorkspaceRoleAdmin, false, 0) - clusterRoleBindingCreatedForWorkspace(t, ctx, c, sampleProject, ws, v1alpha1.WorkspaceRoleAdmin, false, nil) + clusterRoleCreatedForWorkspace(t, ctx, c, sampleProject, ws, pwv1alpha1.WorkspaceRoleAdmin, false, 0) + clusterRoleBindingCreatedForWorkspace(t, ctx, c, sampleProject, ws, pwv1alpha1.WorkspaceRoleAdmin, false, nil) - clusterRoleCreatedForWorkspace(t, ctx, c, sampleProject, ws, v1alpha1.WorkspaceRoleView, false, 0) - clusterRoleBindingCreatedForWorkspace(t, ctx, c, sampleProject, ws, v1alpha1.WorkspaceRoleView, false, nil) + clusterRoleCreatedForWorkspace(t, ctx, c, sampleProject, ws, pwv1alpha1.WorkspaceRoleView, false, 0) + clusterRoleBindingCreatedForWorkspace(t, ctx, c, sampleProject, ws, pwv1alpha1.WorkspaceRoleView, false, nil) return nil }, @@ -248,19 +246,19 @@ func Test_WorkspaceReconciler_Reconcile(t *testing.T) { expectedErr: nil, validate: func(t *testing.T, ctx context.Context, c client.Client) error { // check workspace status - ws := &v1alpha1.Workspace{} + ws := &pwv1alpha1.Workspace{} err := c.Get(ctx, client.ObjectKeyFromObject(sampleWorkspaceDeleted), ws) assert.NoError(t, err) assert.NotNil(t, ws.GetDeletionTimestamp()) assert.Len(t, ws.Status.Conditions, 1) - assert.Equal(t, v1alpha1.ConditionTypeContentRemaining, ws.Status.Conditions[0].Type) - assert.Equal(t, v1alpha1.ConditionStatusTrue, ws.Status.Conditions[0].Status) - assert.Equal(t, v1alpha1.ConditionReasonResourcesRemaining, ws.Status.Conditions[0].Reason) + assert.Equal(t, pwv1alpha1.ConditionTypeContentRemaining, ws.Status.Conditions[0].Type) + assert.Equal(t, pwv1alpha1.ConditionStatusTrue, ws.Status.Conditions[0].Status) + assert.Equal(t, pwv1alpha1.ConditionReasonResourcesRemaining, ws.Status.Conditions[0].Reason) assert.NotEmpty(t, ws.Status.Conditions[0].Message) assert.NotNil(t, ws.Status.Conditions[0].Details) - var remainingResources []v1alpha1.RemainingContentResource + var remainingResources []pwv1alpha1.RemainingContentResource assert.NoError(t, json.Unmarshal(ws.Status.Conditions[0].Details, &remainingResources)) assert.Len(t, remainingResources, 1) assert.Equal(t, "v1", remainingResources[0].APIGroup) @@ -293,9 +291,9 @@ func Test_WorkspaceReconciler_Reconcile(t *testing.T) { CommonReconciler: CommonReconciler{ Client: c, ControllerName: "test", - ProjectWorkspaceConfig: &config.ProjectWorkspaceConfig{ - Workspace: config.WorkspaceConfig{ - ResourcesBlockingDeletion: []config.GroupVersionKind{ + ProjectWorkspaceConfigSpec: pwv1alpha1.ProjectWorkspaceConfigSpec{ + Workspace: pwv1alpha1.WorkspaceConfig{ + ResourcesBlockingDeletion: []metav1.GroupVersionKind{ { Group: "", Version: "v1", @@ -325,7 +323,7 @@ func Test_WorkspaceReconciler_Reconcile(t *testing.T) { } } -func namespaceCreatedForWorkspace(t *testing.T, ctx context.Context, c client.Client, ws *v1alpha1.Workspace, expectation bool) *corev1.Namespace { +func namespaceCreatedForWorkspace(t *testing.T, ctx context.Context, c client.Client, ws *pwv1alpha1.Workspace, expectation bool) *corev1.Namespace { ns := &corev1.Namespace{} err := c.Get(ctx, types.NamespacedName{Name: ws.Status.Namespace}, ns) if expectation { @@ -337,7 +335,7 @@ func namespaceCreatedForWorkspace(t *testing.T, ctx context.Context, c client.Cl return ns } -func clusterRoleCreatedForWorkspace(t *testing.T, ctx context.Context, c client.Client, p *v1alpha1.Project, ws *v1alpha1.Workspace, role v1alpha1.WorkspaceMemberRole, expectation bool, expectedRules int) { +func clusterRoleCreatedForWorkspace(t *testing.T, ctx context.Context, c client.Client, p *pwv1alpha1.Project, ws *pwv1alpha1.Workspace, role pwv1alpha1.WorkspaceMemberRole, expectation bool, expectedRules int) { cr := &rbacv1.ClusterRole{} err := c.Get(ctx, types.NamespacedName{Name: clusterRoleForEntityAndRoleWithParent(ws, role, p)}, cr) if expectation { @@ -348,7 +346,7 @@ func clusterRoleCreatedForWorkspace(t *testing.T, ctx context.Context, c client. } } -func clusterRoleBindingCreatedForWorkspace(t *testing.T, ctx context.Context, c client.Client, p *v1alpha1.Project, ws *v1alpha1.Workspace, role v1alpha1.WorkspaceMemberRole, expectation bool, expectedSubjects []rbacv1.Subject) { +func clusterRoleBindingCreatedForWorkspace(t *testing.T, ctx context.Context, c client.Client, p *pwv1alpha1.Project, ws *pwv1alpha1.Workspace, role pwv1alpha1.WorkspaceMemberRole, expectation bool, expectedSubjects []rbacv1.Subject) { crb := &rbacv1.ClusterRoleBinding{} err := c.Get(ctx, types.NamespacedName{Name: clusterRoleForEntityAndRoleWithParent(ws, role, p)}, crb) if expectation { @@ -359,7 +357,7 @@ func clusterRoleBindingCreatedForWorkspace(t *testing.T, ctx context.Context, c } } -func roleBindingCreatedForWorkspace(t *testing.T, ctx context.Context, c client.Client, ws *v1alpha1.Workspace, role v1alpha1.WorkspaceMemberRole, expectation bool, expectedSubjects []rbacv1.Subject) { +func roleBindingCreatedForWorkspace(t *testing.T, ctx context.Context, c client.Client, ws *pwv1alpha1.Workspace, role pwv1alpha1.WorkspaceMemberRole, expectation bool, expectedSubjects []rbacv1.Subject) { rb := &rbacv1.RoleBinding{} err := c.Get(ctx, types.NamespacedName{Name: roleBindingForRole(role), Namespace: ws.Status.Namespace}, rb) if expectation { diff --git a/internal/dns/dns.go b/internal/dns/dns.go new file mode 100644 index 0000000..df1b3df --- /dev/null +++ b/internal/dns/dns.go @@ -0,0 +1,264 @@ +package dns + +import ( + "context" + "fmt" + "time" + + "github.com/openmcp-project/controller-utils/pkg/clusters" + "github.com/openmcp-project/controller-utils/pkg/collections/filters" + "github.com/openmcp-project/controller-utils/pkg/logging" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/utils/ptr" + controllerruntime "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + gatewayv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" +) + +const ( + DefaultGatewayName = "default" + DefaultGatewayNamespace = "openmcp-system" + DNSAnnotationKey = "dns.openmcp.cloud/base-domain" + RequeueInterval = 20 * time.Second +) + +// Reconciler is a reconciler for managing DNS records using Gateway API resources. +type Reconciler struct { +} + +// Instance represents a service instance for that the TLSRoute will be managed. +type Instance struct { + // Namespace in which the TLSRoute will be created. + Namespace string + // Name of the TLSRoute. + Name string + // SubDomainPrefix is the prefix for the subdomain that will be created for the instance. + SubDomainPrefix string + // BackendName is the name of the backend service to which the TLSRoute will route traffic. + BackendName string + // BackendPort is the port of the backend service to which the TLSRoute will route traffic. + BackendPort int32 +} + +// GatewayReconcileResult is the result of a gateway reconciliation. +// If Result.Requeue is not set, the gateway is ready and the HostName can be used. +type GatewayReconcileResult struct { + // HostName is the hostname that was created for the instance and can be used for DNS records. + HostName string + // TLSPort is the port under which the gateway accepts TLS traffic. + TLSPort int32 + // Result is the result of the reconciliation. + reconcile.Result +} + +// NewReconciler creates a new DNS reconciler. +func NewReconciler() *Reconciler { + return &Reconciler{} +} + +// ReconcileGateway ensures that the default gateway exists and retrieves the base domain from its annotations. +// It returns the full hostname for the given instance that can be used for DNS records. +// If the default gateway is not found, it will requeue after a predefined interval. +func (r *Reconciler) ReconcileGateway(ctx context.Context, instance *Instance, targetCluster *clusters.Cluster) (GatewayReconcileResult, error) { + log := logging.FromContextOrDiscard(ctx) + + var err error + + // get default gateway + + gateway := &gatewayv1.Gateway{} + gateway.SetName(DefaultGatewayName) + gateway.SetNamespace(DefaultGatewayNamespace) + + if err = targetCluster.Client().Get(ctx, client.ObjectKeyFromObject(gateway), gateway); err != nil { + if errors.IsNotFound(err) { + log.Debug("Default gateway not found, requeueing...") + // default gateway not found + return GatewayReconcileResult{ + Result: reconcile.Result{ + RequeueAfter: RequeueInterval, + }, + }, nil + } + + return GatewayReconcileResult{Result: reconcile.Result{}}, fmt.Errorf("failed to get default gateway: %w", err) + } + + log.Debug("Default Gateway available") + + baseDomain, hasBaseDomain := getBaseDomain(gateway) + if !hasBaseDomain { + return GatewayReconcileResult{Result: reconcile.Result{}}, fmt.Errorf("gateway is missing the %s annotation", DNSAnnotationKey) + } + + log.Debug("Base domain found", "baseDomain", baseDomain) + + tlsPort, hasTLSPort := getTLSPort(gateway) + if !hasTLSPort { + return GatewayReconcileResult{Result: reconcile.Result{}}, fmt.Errorf("gateway either does not have any listeners with TLS protocol or it has multiple ones and none is named 'tls'") + } + + log.Debug("TLS port found", "tlsPort", tlsPort) + + hostName := getHostName(baseDomain, instance) + + return GatewayReconcileResult{ + HostName: hostName, + TLSPort: tlsPort, + Result: reconcile.Result{}, + }, nil +} + +// ReconcileTLSRoute ensures that a TLSRoute exists for the given instance, pointing to the default gateway. +func (r *Reconciler) ReconcileTLSRoute(ctx context.Context, instance *Instance, targetCluster *clusters.Cluster) error { + // get default gateway + + var err error + + gateway := &gatewayv1.Gateway{} + gateway.SetName(DefaultGatewayName) + gateway.SetNamespace(DefaultGatewayNamespace) + + if err = targetCluster.Client().Get(ctx, client.ObjectKeyFromObject(gateway), gateway); err != nil { + return fmt.Errorf("failed to get default gateway: %w", err) + } + + baseDomain, hasBaseDomain := getBaseDomain(gateway) + if !hasBaseDomain { + return fmt.Errorf("gateway is missing the %s annotation", DNSAnnotationKey) + } + + hostName := getHostName(baseDomain, instance) + + tlsRoute := &gatewayv1alpha2.TLSRoute{} + tlsRoute.SetName(instance.Name) + tlsRoute.SetNamespace(instance.Namespace) + + _, err = controllerruntime.CreateOrUpdate(ctx, targetCluster.Client(), tlsRoute, func() error { + tlsRoute.Spec = gatewayv1alpha2.TLSRouteSpec{ + CommonRouteSpec: gatewayv1alpha2.CommonRouteSpec{ + ParentRefs: []gatewayv1alpha2.ParentReference{ + { + Name: gatewayv1.ObjectName(gateway.Name), + Namespace: ptr.To(gatewayv1.Namespace(gateway.Namespace)), + }, + }, + }, + Hostnames: []gatewayv1alpha2.Hostname{ + gatewayv1alpha2.Hostname(hostName), + }, + Rules: []gatewayv1alpha2.TLSRouteRule{ + { + BackendRefs: []gatewayv1alpha2.BackendRef{ + { + BackendObjectReference: gatewayv1alpha2.BackendObjectReference{ + Name: gatewayv1.ObjectName(instance.BackendName), + Port: ptr.To(instance.BackendPort), + }, + }, + }, + }, + }, + } + return nil + }) + + if err != nil { + return fmt.Errorf("failed to create or update TLSRoute: %w", err) + } + + return nil +} + +// IsTLSRouteReady checks if the TLSRoute for the given instance is accepted by the default gateway. +func (r *Reconciler) IsTLSRouteReady(ctx context.Context, instance *Instance, targetCluster *clusters.Cluster) (bool, error) { + log := logging.FromContextOrDiscard(ctx) + + var err error + + tlsRoute := &gatewayv1alpha2.TLSRoute{} + tlsRoute.SetName(instance.Name) + tlsRoute.SetNamespace(instance.Namespace) + + if err = targetCluster.Client().Get(ctx, client.ObjectKeyFromObject(tlsRoute), tlsRoute); err != nil { + return false, fmt.Errorf("failed to get TLSRoute: %w", err) + } + + for _, parent := range tlsRoute.Status.Parents { + if parent.ParentRef.Name == DefaultGatewayName && parent.ParentRef.Namespace != nil && *parent.ParentRef.Namespace == DefaultGatewayNamespace { + for _, cond := range parent.Conditions { + if cond.Type == string(gatewayv1alpha2.RouteConditionAccepted) && cond.Status == "True" { + log.Debug("TLSRoute is accepted by the gateway") + return true, nil + } + } + } + } + + return false, nil +} + +// DeleteTLSRoute deletes the TLSRoute for the given instance. +func (r *Reconciler) DeleteTLSRoute(ctx context.Context, instance *Instance, targetCluster *clusters.Cluster) error { + log := logging.FromContextOrDiscard(ctx) + + tlsRoute := &gatewayv1alpha2.TLSRoute{} + tlsRoute.SetName(instance.Name) + tlsRoute.SetNamespace(instance.Namespace) + + if err := targetCluster.Client().Get(ctx, client.ObjectKeyFromObject(tlsRoute), tlsRoute); err != nil { + if errors.IsNotFound(err) { + log.Debug("TLSRoute already deleted") + return nil + } + return fmt.Errorf("failed to get TLSRoute: %w", err) + } + + if err := targetCluster.Client().Delete(ctx, tlsRoute); client.IgnoreNotFound(err) != nil { + return fmt.Errorf("failed to delete TLSRoute: %w", err) + } + + log.Info("TLSRoute deleted") + + return nil +} + +func getBaseDomain(gateway *gatewayv1.Gateway) (string, bool) { + annotations := gateway.GetAnnotations() + if len(annotations) == 0 { + return "", false + } + + baseDomain, hasBaseDomain := annotations[DNSAnnotationKey] + return baseDomain, hasBaseDomain +} + +func getHostName(baseDomain string, instance *Instance) string { + return fmt.Sprintf("%s.%s", instance.SubDomainPrefix, baseDomain) +} + +// retrieves the TLS port from the gateway and a boolean indicating whether a TLS port was found +// logic as follows: +// - if the gateway has a single listener with TLS protocol, its port (and true) is returned +// - if the gateway has multiple TLS listeners and one is named "tls", its port (and true) is returned +// - in all other cases, (0, false) is returned +func getTLSPort(gateway *gatewayv1.Gateway) (int32, bool) { + tlsListeners := filters.FilterSlice(gateway.Spec.Listeners, func(args ...any) bool { + elem := args[0].(gatewayv1.Listener) + return elem.Protocol == gatewayv1.TLSProtocolType + }) + if len(tlsListeners) == 0 { + return 0, false + } + if len(tlsListeners) == 1 { + return tlsListeners[0].Port, true + } + for _, listener := range tlsListeners { + if listener.Name == "tls" { + return listener.Port, true + } + } + return 0, false +}