From bb76e497510889ae135acd2b6ed49ce6c21889cd Mon Sep 17 00:00:00 2001 From: Vadz-Danil Date: Sat, 20 Jun 2026 19:30:23 +0300 Subject: [PATCH 1/2] Implement Admin-operator for auth-admin team --- .gitignore | 1 + Makefile | 23 ++- build/Dockerfile.admin-operator | 19 ++ cmd/admin-operator/main.go | 65 ++++++ deploy/k8s/operators/admin-auth/crd.yaml | 57 ++++++ .../k8s/operators/admin-auth/example-cr.yaml | 8 + .../admin-auth/operator-deployment.yaml | 38 ++++ deploy/k8s/operators/admin-auth/rbac.yaml | 36 ++++ docs/k8s/admin-operator.md | 190 +++++++++++++++++- .../api/v1alpha1/adminappprofile_types.go | 34 ++++ .../admin-operator/api/v1alpha1/deep_copy.go | 63 ++++++ .../api/v1alpha1/groupversion_info.go | 22 ++ .../admin-operator/controller/controller.go | 106 ++++++++++ .../controller/controller_test.go | 103 ++++++++++ 14 files changed, 754 insertions(+), 11 deletions(-) create mode 100644 build/Dockerfile.admin-operator create mode 100644 cmd/admin-operator/main.go create mode 100644 deploy/k8s/operators/admin-auth/crd.yaml create mode 100644 deploy/k8s/operators/admin-auth/example-cr.yaml create mode 100644 deploy/k8s/operators/admin-auth/operator-deployment.yaml create mode 100644 deploy/k8s/operators/admin-auth/rbac.yaml create mode 100644 operators/admin-operator/api/v1alpha1/adminappprofile_types.go create mode 100644 operators/admin-operator/api/v1alpha1/deep_copy.go create mode 100644 operators/admin-operator/api/v1alpha1/groupversion_info.go create mode 100644 operators/admin-operator/controller/controller.go create mode 100644 operators/admin-operator/controller/controller_test.go diff --git a/.gitignore b/.gitignore index 05627588..400f6759 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,4 @@ __pycache__/ mcp/admin-auth-server/audit.log mcp/admin-auth-server/uv.lock +pkg/gateway diff --git a/Makefile b/Makefile index 2af58985..286d3fb1 100644 --- a/Makefile +++ b/Makefile @@ -7,6 +7,7 @@ .PHONY: goose-up goose-down goose-status goose-create .PHONY: docker-build docker-build-business-operator docker-push-business-operator .PHONY: k8s-secrets k8s-up k8s-down k8s-migrate +.PHONY: run-admin-service stop-admin-service run-admin-operator stop-admin-operator apply-cr REGISTRY ?= mykolashevchenko TAG ?= latest @@ -210,4 +211,24 @@ k8s-down: kubectl delete namespace $(K8S_NAMESPACE) --ignore-not-found=true run-guest-operator: - go run cmd/guest-operator/main.go \ No newline at end of file + go run cmd/guest-operator/main.go + +run-admin-service: + kubectl apply -k deploy/k8s/admin-auth + +stop-admin-service: + kubectl delete -k deploy/k8s/admin-auth --ignore-not-found=true + +run-admin-operator: + docker build -t admin-operator:latest -f build/Dockerfile.admin-operator . + kubectl apply -f deploy/k8s/operators/admin-auth/crd.yaml + kubectl apply -f deploy/k8s/operators/admin-auth/rbac.yaml + kubectl apply -f deploy/k8s/operators/admin-auth/operator-deployment.yaml + +stop-admin-operator: + kubectl delete -f deploy/k8s/operators/admin-auth/operator-deployment.yaml --ignore-not-found=true + kubectl delete -f deploy/k8s/operators/admin-auth/rbac.yaml --ignore-not-found=true + kubectl delete -f deploy/k8s/operators/admin-auth/crd.yaml --ignore-not-found=true + +apply-cr: + kubectl apply -f deploy/k8s/operators/admin-auth/example-cr.yaml \ No newline at end of file diff --git a/build/Dockerfile.admin-operator b/build/Dockerfile.admin-operator new file mode 100644 index 00000000..5a6c33dc --- /dev/null +++ b/build/Dockerfile.admin-operator @@ -0,0 +1,19 @@ +FROM golang:1.26.1-bookworm AS builder + +WORKDIR /workspace + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +RUN CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -a -o manager ./cmd/admin-operator/main.go + +FROM alpine:3.20 + +WORKDIR / +COPY --from=builder /workspace/manager . + +USER 65532:65532 + +ENTRYPOINT ["/manager"] \ No newline at end of file diff --git a/cmd/admin-operator/main.go b/cmd/admin-operator/main.go new file mode 100644 index 00000000..a27a1d54 --- /dev/null +++ b/cmd/admin-operator/main.go @@ -0,0 +1,65 @@ +package main + +import ( + "flag" + "os" + + "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/log/zap" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" + + adminv1alpha1 "github.com/ua-academy-projects/share-bite/operators/admin-operator/api/v1alpha1" + "github.com/ua-academy-projects/share-bite/operators/admin-operator/controller" +) + +var scheme = runtime.NewScheme() +var setupLog = ctrl.Log.WithName("setup") + +func init() { + utilruntime.Must(clientgoscheme.AddToScheme(scheme)) + utilruntime.Must(adminv1alpha1.AddToScheme(scheme)) +} + +func main() { + var metricsAddr string + var enableLeaderElection bool + flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metric endpoint binds to.") + flag.BoolVar(&enableLeaderElection, "leader-elect", false, "Enable leader election for controller manager.") + + opts := zap.Options{Development: true} + opts.BindFlags(flag.CommandLine) + flag.Parse() + + ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) + + mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ + Scheme: scheme, + Metrics: metricsserver.Options{ + BindAddress: metricsAddr, + }, + HealthProbeBindAddress: ":8081", + LeaderElection: enableLeaderElection, + LeaderElectionID: "admin-operator.sharebite.dev", + }) + if err != nil { + setupLog.Error(err, "unable to start manager") + os.Exit(1) + } + + if err = (&controller.AdminAppProfileReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "AdminAppProfile") + os.Exit(1) + } + + setupLog.Info("starting manager") + if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { + setupLog.Error(err, "problem running manager") + os.Exit(1) + } +} diff --git a/deploy/k8s/operators/admin-auth/crd.yaml b/deploy/k8s/operators/admin-auth/crd.yaml new file mode 100644 index 00000000..418f8860 --- /dev/null +++ b/deploy/k8s/operators/admin-auth/crd.yaml @@ -0,0 +1,57 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: adminappprofiles.admin.sharebite.dev +spec: + group: admin.sharebite.dev + names: + kind: AdminAppProfile + listKind: AdminAppProfileList + plural: adminappprofiles + singular: adminappprofile + scope: Namespaced + versions: + - name: v1alpha1 + served: true + storage: true + subresources: + status: {} + schema: + openAPIV3Schema: + type: object + required: + - spec + properties: + spec: + type: object + properties: + replicas: + type: integer + format: int32 + minimum: 0 + enabled: + type: boolean + deploymentName: + type: string + required: + - replicas + - enabled + status: + type: object + properties: + conditions: + type: array + items: + type: object + properties: + type: + type: string + status: + type: string + reason: + type: string + message: + type: string + lastTransitionTime: + type: string + format: date-time \ No newline at end of file diff --git a/deploy/k8s/operators/admin-auth/example-cr.yaml b/deploy/k8s/operators/admin-auth/example-cr.yaml new file mode 100644 index 00000000..0cad20c8 --- /dev/null +++ b/deploy/k8s/operators/admin-auth/example-cr.yaml @@ -0,0 +1,8 @@ +apiVersion: admin.sharebite.dev/v1alpha1 +kind: AdminAppProfile +metadata: + name: admin-auth-profile + namespace: share-bite-local +spec: + replicas: 2 + enabled: true \ No newline at end of file diff --git a/deploy/k8s/operators/admin-auth/operator-deployment.yaml b/deploy/k8s/operators/admin-auth/operator-deployment.yaml new file mode 100644 index 00000000..423e0098 --- /dev/null +++ b/deploy/k8s/operators/admin-auth/operator-deployment.yaml @@ -0,0 +1,38 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: admin-operator + namespace: share-bite-local +spec: + replicas: 1 + selector: + matchLabels: + app: admin-operator + template: + metadata: + labels: + app: admin-operator + spec: + serviceAccountName: admin-operator-sa + securityContext: + runAsNonRoot: true + runAsUser: 1000 + seccompProfile: + type: RuntimeDefault + containers: + - name: operator + image: admin-operator:latest + command: + - /manager + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: ["ALL"] + resources: + limits: + cpu: 200m + memory: 128Mi + requests: + cpu: 50m + memory: 64Mi \ No newline at end of file diff --git a/deploy/k8s/operators/admin-auth/rbac.yaml b/deploy/k8s/operators/admin-auth/rbac.yaml new file mode 100644 index 00000000..c415b187 --- /dev/null +++ b/deploy/k8s/operators/admin-auth/rbac.yaml @@ -0,0 +1,36 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: admin-operator-sa + namespace: share-bite-local +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: admin-operator-role +rules: + - apiGroups: ["apps"] + resources: ["deployments"] + verbs: ["watch", "update", "get", "list", "patch"] + - apiGroups: ["admin.sharebite.dev"] + resources: ["adminappprofiles", "adminappprofiles/status"] + verbs: ["watch", "update", "get", "list", "patch", "create", "delete"] + - apiGroups: ["coordination.k8s.io"] + resources: ["leases"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + - apiGroups: [""] + resources: ["events"] + verbs: ["create", "patch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: admin-operator-rb +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: admin-operator-role +subjects: + - kind: ServiceAccount + name: admin-operator-sa + namespace: share-bite-local \ No newline at end of file diff --git a/docs/k8s/admin-operator.md b/docs/k8s/admin-operator.md index 309f2532..93914a49 100644 --- a/docs/k8s/admin-operator.md +++ b/docs/k8s/admin-operator.md @@ -1,17 +1,187 @@ # admin-operator -Scales the **admin-auth-api** Deployment from an `AdminAppProfile` resource. +Scales the **business-api** Deployment from a `AdminAppProfile` resource. -Implementation: [#216](https://github.com/ua-academy-projects/share-bite/issues/216). Conventions: [operators-overview.md](./operators-overview.md). Stack: [operator-framework-go.md](./operator-framework-go.md). +Implementation: [#216](https://github.com/ua-academy-projects/share-bite/issues/216). +Conventions: [operators-overview.md](./operators-overview.md). +Stack: [operator-framework-go.md](./operator-framework-go.md). ## Quick reference -| Item | Value | -|------|--------| -| Binary | `cmd/admin-operator` | -| CRD | `AdminAppProfile` (`admin.sharebite.dev/v1alpha1`) | -| Default Deployment | `admin-auth-api` | -| Manifests | `deploy/k8s/operators/admin/` | -| Run locally | `make run-admin-operator` | +| Item | Value | +|--------------------|----------------------------------------------------| +| Binary | `cmd/admin-operator` | +| CRD | `AdminAppProfile` (`admin.sharebite.dev/v1alpha1`) | +| Default Deployment | `admin-auth-api` | +| Manifests | `deploy/k8s/operators/admin-auth/` | +| Run locally | `make run-admin-operator` | -Install and verification steps will land here once the operator is in the repo. +--- + +## Overview + +The **Admin Operator** is a custom Kubernetes controller designed for the **Share-Bite** platform. Its primary +responsibility is +to orchestrate, protect, and manage the lifecycle of the admin authentication and management backend deployments ( +admin-auth-api) dynamically. By extending the Kubernetes API with a Custom Resource Definition (CRD), the operator +automates scaling, healing, and status tracking based on administrative profiles while adhering strictly to the +project's hardening and isolation standards. + +--- + +## Custom Resource Definition (CRD) + +The operator manages a custom resource named `AdminAppProfile`. This resource acts as a declarative contract defining +the desired operational state of a target business deployment. + +### Specification (`spec`) + +| Field | Type | Description | +|:-----------------|:----------|:--------------------------------------------------------------------------------------------------| +| `deploymentName` | `string` | Optional. The exact name of the target `Deployment` to be managed. Defaults to `admin-auth-api`. | +| `enabled` | `boolean` | Global power switch. `true` scales to the desired replicas; `false` scales the deployment to `0`. | +| `replicas` | `int32` | The desired number of active pods to maintain when `enabled` is `true`. | + +### Status (`status`) + +| Field | Type | Description | +|:-------------|:--------|:-------------------------------------------------------------------------------------------------------------| +| `conditions` | `array` | A list of structured operational states (e.g., `Ready=True` when observed replicas match the desired count). | + +--- + +## Architecture & Reconciliation Loop + +The core engine of the operator is its **Reconciliation Loop**, which continuously enforces synchronization between the +declared `AdminAppProfile` and the actual state of the cluster. The operator uses `controller-runtime` to watch both +the CRD and the underlying `Deployment` for changes. + +```text ++---------------------------------------------------------+ +| Kubernetes API Server | ++---------------------------+-----------------------------+ + | + | Watches Events (CRD & Deployment) + v ++---------------------------------------------------------+ +| Admin Operator (Go) | +| Maintains loop to align actual state with desired spec | ++---------------------------+-----------------------------+ + | + +-------------+-------------+ + | | + [enabled == true] [enabled == false] + | | + v v ++--------------------------+ +--------------------------+ +| Fetch Target Deployment | | Fetch Target Deployment | +| Patch spec.replicas | | Patch replicas to 0 | +| Status: Ready / Scaled | | Status: Ready / Scaled | ++--------------------------+ +--------------------------+ +``` + +### Reconciliation Workflow + +1. **Event Trigger:** The controller catches an event (Create/Update/Delete) for `AdminAppProfile` or its owned + `Deployment`. + +2. **Resource Fetching:** Retrieves the CRD and determines the target deployment name. + +3. **Drift Check:** Compares the actual `deployment.spec.replicas` against the desired state. If there is a drift, it + issues a Patch request. + +4. **Status Update:** Evaluates `deployment.status.readyReplicas`. + - If they match desired replicas → sets `Ready=True` (Reason: `Scaled`). + - If they do not match → sets `Ready=False` (Reason: `Scaling`). + +5. **Error Resilience:** If the deployment is missing, sets `Ready=False` (Reason: `DeploymentNotFound`) and safely + requeues. + +--- + +## RBAC Configuration + +To safely interact with the cluster, the operator requires the following RBAC permissions: + +| API Group | Resources | Verbs | Purpose | +|:----------------------|:-----------------------------|:--------------------------------------------------|:---------------------------------------------------| +| `apps` | `deployments` | `get, list, watch, update, patch` | Allows observing and scaling target deployments. | +| `admin.sharebite.dev` | `adminappprofiles` | `get, list, watch, create, update, patch, delete` | Full lifecycle control over the CRD. | +| `admin.sharebite.dev` | `businessappprofiles/status` | `get, update, patch` | Restricted permission to report status conditions. | +| `coordination.k8s.io` | `leases` | `get, list, watch, create, update, patch, delete` | Manages Leader Election locks. | +| `` (core) | `events` | `create, patch` | Writes controller events to the cluster. | + +--- + +## Security Context & Hardening + +The admin-operator deployment is fully hardened for Production environments using advanced security features to +guarantee an immutable runtime footprint: + +- Privilege Restriction: allowPrivilegeEscalation: false prevents the binary from gaining more permissions than its + parent process. + +- Rootless Execution: Runs strictly as a non-root user (runAsNonRoot: true, runAsUser: 1000 or 65532) to mitigate + container breakout risks. + +- Read-Only Root File System: readOnlyRootFilesystem: true enforces an immutable file system. No malware or arbitrary + code can be written to the runtime container. + +- Linux Capabilities: Linux kernel capabilities are entirely stripped using capabilities.drop: ["ALL"]. + +- Syscall Filtering: Uses seccompProfile: { type: RuntimeDefault } to strictly limit the allowed system calls to + standard safe behaviors. + +## Developer Guide + +### Prerequisites + +- A running Kubernetes cluster (e.g., local k3s or Docker Desktop). +- Local Kubernetes context set up `kubectl cluster-info` +- The project's central **Makefile** located in the root workspace directory. + +### Automation Workflow (Using the Makefile) + +The entire runtime environment can be stood up, managed, and torn down cleanly using predefined short targets: + +1. Spin up the Core Admin Infrastructure + Deploy the supporting stack (Postgres Database, Redis Cache, Admin Auth API API, and Database Migrations) via + Kustomize: + ```bash + make run-auth-service + ``` +2. Build and Deploy the Admin Operator Container + Automatically compile the Go codebase, package it into a secure Docker image, register the CRDs, apply the + ClusterRBAC rules, and launch the operator Pod: + ```bash + make run-admin-operator + ``` + +3. Apply the Custom Configuration Contract + Trigger your desired operational state using a local sample custom resource profile: + ```bash + make apply-cr + ``` +4. Clean up and Teardown + To cleanly wipe out the running infrastructure and controllers from your local cluster namespace without breaking + configurations, execute: + ```bash + make stop-admin-operator + make stop-auth-service + ``` + +### Example Usage + +To test the operator, apply a sample CR: + +```yaml +apiVersion: admin.sharebite.dev/v1alpha1 +kind: AdminAppProfile +metadata: + name: admin-api-profile + namespace: share-bite-local +spec: + deploymentName: admin-auth-api + replicas: 2 + enabled: true +``` diff --git a/operators/admin-operator/api/v1alpha1/adminappprofile_types.go b/operators/admin-operator/api/v1alpha1/adminappprofile_types.go new file mode 100644 index 00000000..e550bf1b --- /dev/null +++ b/operators/admin-operator/api/v1alpha1/adminappprofile_types.go @@ -0,0 +1,34 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +type AdminAppProfileSpec struct { + Replicas int32 `json:"replicas"` + Enabled bool `json:"enabled"` + DeploymentName string `json:"deploymentName,omitempty"` +} + +type AdminAppProfileStatus struct { + Conditions []metav1.Condition `json:"conditions,omitempty"` +} + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status + +type AdminAppProfile struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec AdminAppProfileSpec `json:"spec,omitempty"` + Status AdminAppProfileStatus `json:"status,omitempty"` +} + +// +kubebuilder:object:root=true + +type AdminAppProfileList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []AdminAppProfile `json:"items"` +} diff --git a/operators/admin-operator/api/v1alpha1/deep_copy.go b/operators/admin-operator/api/v1alpha1/deep_copy.go new file mode 100644 index 00000000..b5c2dd8a --- /dev/null +++ b/operators/admin-operator/api/v1alpha1/deep_copy.go @@ -0,0 +1,63 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +func (in *AdminAppProfile) DeepCopyInto(out *AdminAppProfile) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Status.DeepCopyInto(&out.Status) +} +func (in *AdminAppProfile) DeepCopy() *AdminAppProfile { + if in == nil { + return nil + } + out := new(AdminAppProfile) + in.DeepCopyInto(out) + return out +} +func (in *AdminAppProfile) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} +func (in *AdminAppProfileList) DeepCopyInto(out *AdminAppProfileList) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ListMeta.DeepCopyInto(&out.ListMeta) + if in.Items != nil { + in, out := &in.Items, &out.Items + *out = make([]AdminAppProfile, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} +func (in *AdminAppProfileList) DeepCopy() *AdminAppProfileList { + if in == nil { + return nil + } + out := new(AdminAppProfileList) + in.DeepCopyInto(out) + return out +} +func (in *AdminAppProfileList) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} +func (in *AdminAppProfileStatus) DeepCopyInto(out *AdminAppProfileStatus) { + *out = *in + if in.Conditions != nil { + in, out := &in.Conditions, &out.Conditions + *out = make([]metav1.Condition, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} diff --git a/operators/admin-operator/api/v1alpha1/groupversion_info.go b/operators/admin-operator/api/v1alpha1/groupversion_info.go new file mode 100644 index 00000000..cd354a78 --- /dev/null +++ b/operators/admin-operator/api/v1alpha1/groupversion_info.go @@ -0,0 +1,22 @@ +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +var ( + GroupVersion = schema.GroupVersion{Group: "admin.sharebite.dev", Version: "v1alpha1"} + SchemeBuilder = runtime.NewSchemeBuilder(addKnownTypes) + AddToScheme = SchemeBuilder.AddToScheme +) + +func addKnownTypes(scheme *runtime.Scheme) error { + scheme.AddKnownTypes(GroupVersion, + &AdminAppProfile{}, + &AdminAppProfileList{}, + ) + metav1.AddToGroupVersion(scheme, GroupVersion) + return nil +} diff --git a/operators/admin-operator/controller/controller.go b/operators/admin-operator/controller/controller.go new file mode 100644 index 00000000..6ab78aee --- /dev/null +++ b/operators/admin-operator/controller/controller.go @@ -0,0 +1,106 @@ +package controller + +import ( + "context" + "time" + + appsv1 "k8s.io/api/apps/v1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" + + adminv1alpha1 "github.com/ua-academy-projects/share-bite/operators/admin-operator/api/v1alpha1" +) + +const ( + DefaultDeploymentName = "admin-auth-api" + MissingDeploymentRequeueDelay = 5 * time.Second + ScalingRequeueDelay = 3 * time.Second +) + +type AdminAppProfileReconciler struct { + client.Client + Scheme *runtime.Scheme +} + +func (r *AdminAppProfileReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + logger := log.FromContext(ctx) + + var profile adminv1alpha1.AdminAppProfile + if err := r.Get(ctx, req.NamespacedName, &profile); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + deployName := profile.Spec.DeploymentName + if deployName == "" { + deployName = DefaultDeploymentName + } + + var desiredReplicas int32 = 0 + if profile.Spec.Enabled { + if profile.Spec.Replicas < 0 { + if err := r.updateStatus(ctx, &profile, metav1.ConditionFalse, "InvalidSpec", "spec.replicas cannot be negative"); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{}, nil + } + desiredReplicas = profile.Spec.Replicas + } + + var deploy appsv1.Deployment + deployReq := types.NamespacedName{Name: deployName, Namespace: profile.Namespace} + if err := r.Get(ctx, deployReq, &deploy); err != nil { + if errors.IsNotFound(err) { + logger.Info("Deployment not found, waiting...", "Deployment", deployName) + if statusErr := r.updateStatus(ctx, &profile, metav1.ConditionFalse, "DeploymentNotFound", "Target deployment is missing"); statusErr != nil { + return ctrl.Result{}, statusErr + } + return ctrl.Result{RequeueAfter: MissingDeploymentRequeueDelay}, nil + } + return ctrl.Result{}, err + } + + if deploy.Spec.Replicas == nil || *deploy.Spec.Replicas != desiredReplicas { + logger.Info("Updating Deployment replicas", "Current", deploy.Spec.Replicas, "Desired", desiredReplicas) + deploy.Spec.Replicas = &desiredReplicas + if err := r.Update(ctx, &deploy); err != nil { + return ctrl.Result{}, err + } + } + + if deploy.Status.ReadyReplicas == desiredReplicas { + if err := r.updateStatus(ctx, &profile, metav1.ConditionTrue, "Scaled", "Deployment reached desired replicas"); err != nil { + return ctrl.Result{}, err + } + } else { + if err := r.updateStatus(ctx, &profile, metav1.ConditionFalse, "Scaling", "Waiting for pods to be ready"); err != nil { + return ctrl.Result{}, err + } + return ctrl.Result{RequeueAfter: ScalingRequeueDelay}, nil + } + + return ctrl.Result{}, nil +} + +func (r *AdminAppProfileReconciler) updateStatus(ctx context.Context, profile *adminv1alpha1.AdminAppProfile, status metav1.ConditionStatus, reason, message string) error { + condition := metav1.Condition{ + Type: "Ready", + Status: status, + Reason: reason, + Message: message, + LastTransitionTime: metav1.Now(), + } + meta.SetStatusCondition(&profile.Status.Conditions, condition) + return r.Status().Update(ctx, profile) +} + +func (r *AdminAppProfileReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&adminv1alpha1.AdminAppProfile{}). + Complete(r) +} diff --git a/operators/admin-operator/controller/controller_test.go b/operators/admin-operator/controller/controller_test.go new file mode 100644 index 00000000..e0c5a9c8 --- /dev/null +++ b/operators/admin-operator/controller/controller_test.go @@ -0,0 +1,103 @@ +package controller + +import ( + "context" + "testing" + + appsv1 "k8s.io/api/apps/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/kubernetes/scheme" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + adminv1alpha1 "github.com/ua-academy-projects/share-bite/operators/admin-operator/api/v1alpha1" +) + +func TestReconcile_EnabledFalse(t *testing.T) { + s := scheme.Scheme + _ = adminv1alpha1.AddToScheme(s) + + profile := &adminv1alpha1.AdminAppProfile{ + ObjectMeta: metav1.ObjectMeta{Name: "test-profile", Namespace: "default"}, + Spec: adminv1alpha1.AdminAppProfileSpec{Replicas: 3, Enabled: false}, + } + + var initialReplicas int32 = 3 + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "admin-auth-api", Namespace: "default"}, + Spec: appsv1.DeploymentSpec{Replicas: &initialReplicas}, + } + + cl := fake.NewClientBuilder().WithScheme(s).WithObjects(profile, deployment).WithStatusSubresource(profile).Build() + r := &AdminAppProfileReconciler{Client: cl, Scheme: s} + + req := ctrl.Request{NamespacedName: types.NamespacedName{Name: "test-profile", Namespace: "default"}} + _, err := r.Reconcile(context.Background(), req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + updatedDep := &appsv1.Deployment{} + _ = cl.Get(context.Background(), types.NamespacedName{Name: "admin-auth-api", Namespace: "default"}, updatedDep) + + if *updatedDep.Spec.Replicas != 0 { + t.Errorf("expected 0 replicas, got %d", *updatedDep.Spec.Replicas) + } +} + +func TestReconcile_MissingDeployment(t *testing.T) { + s := scheme.Scheme + _ = adminv1alpha1.AddToScheme(s) + + profile := &adminv1alpha1.AdminAppProfile{ + ObjectMeta: metav1.ObjectMeta{Name: "test-profile", Namespace: "default"}, + Spec: adminv1alpha1.AdminAppProfileSpec{Replicas: 2, Enabled: true}, + } + + cl := fake.NewClientBuilder().WithScheme(s).WithObjects(profile).WithStatusSubresource(profile).Build() + r := &AdminAppProfileReconciler{Client: cl, Scheme: s} + + req := ctrl.Request{NamespacedName: types.NamespacedName{Name: "test-profile", Namespace: "default"}} + res, err := r.Reconcile(context.Background(), req) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if res.RequeueAfter != 5000000000 { + t.Errorf("expected RequeueAfter 5s, got %v", res.RequeueAfter) + } +} + +func TestReconcile_HappyPath(t *testing.T) { + s := scheme.Scheme + _ = adminv1alpha1.AddToScheme(s) + + profile := &adminv1alpha1.AdminAppProfile{ + ObjectMeta: metav1.ObjectMeta{Name: "test-profile", Namespace: "default"}, + Spec: adminv1alpha1.AdminAppProfileSpec{Replicas: 3, Enabled: true}, + } + + var initialReplicas int32 = 1 + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{Name: "admin-auth-api", Namespace: "default"}, + Spec: appsv1.DeploymentSpec{Replicas: &initialReplicas}, + } + + cl := fake.NewClientBuilder().WithScheme(s).WithObjects(profile, deployment).WithStatusSubresource(profile).Build() + r := &AdminAppProfileReconciler{Client: cl, Scheme: s} + + req := ctrl.Request{NamespacedName: types.NamespacedName{Name: "test-profile", Namespace: "default"}} + _, err := r.Reconcile(context.Background(), req) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + updatedDep := &appsv1.Deployment{} + _ = cl.Get(context.Background(), types.NamespacedName{Name: "admin-auth-api", Namespace: "default"}, updatedDep) + + if *updatedDep.Spec.Replicas != 3 { + t.Errorf("expected 3 replicas, got %d", *updatedDep.Spec.Replicas) + } +} From 6dcbcd7368a2c61b2ad637f8a0bd3965baf759fc Mon Sep 17 00:00:00 2001 From: Vadz-Danil Date: Sat, 20 Jun 2026 19:50:16 +0300 Subject: [PATCH 2/2] fix code rabbit issues --- build/Dockerfile.admin-operator | 5 ++++- .../admin-auth/operator-deployment.yaml | 1 + deploy/k8s/operators/admin-auth/rbac.yaml | 7 +++++-- docs/k8s/admin-operator.md | 20 +++++++++---------- .../admin-operator/controller/controller.go | 1 + .../controller/controller_test.go | 8 ++++++-- 6 files changed, 27 insertions(+), 15 deletions(-) diff --git a/build/Dockerfile.admin-operator b/build/Dockerfile.admin-operator index 5a6c33dc..9c20c045 100644 --- a/build/Dockerfile.admin-operator +++ b/build/Dockerfile.admin-operator @@ -7,7 +7,10 @@ RUN go mod download COPY . . -RUN CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -a -o manager ./cmd/admin-operator/main.go +ARG TARGETOS=linux +ARG TARGETARCH + +RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -a -o manager ./cmd/admin-operator/main.go FROM alpine:3.20 diff --git a/deploy/k8s/operators/admin-auth/operator-deployment.yaml b/deploy/k8s/operators/admin-auth/operator-deployment.yaml index 423e0098..f0f413fb 100644 --- a/deploy/k8s/operators/admin-auth/operator-deployment.yaml +++ b/deploy/k8s/operators/admin-auth/operator-deployment.yaml @@ -22,6 +22,7 @@ spec: containers: - name: operator image: admin-operator:latest + imagePullPolicy: IfNotPresent command: - /manager securityContext: diff --git a/deploy/k8s/operators/admin-auth/rbac.yaml b/deploy/k8s/operators/admin-auth/rbac.yaml index c415b187..da68b7a1 100644 --- a/deploy/k8s/operators/admin-auth/rbac.yaml +++ b/deploy/k8s/operators/admin-auth/rbac.yaml @@ -13,8 +13,11 @@ rules: resources: ["deployments"] verbs: ["watch", "update", "get", "list", "patch"] - apiGroups: ["admin.sharebite.dev"] - resources: ["adminappprofiles", "adminappprofiles/status"] - verbs: ["watch", "update", "get", "list", "patch", "create", "delete"] + resources: ["adminappprofiles"] + verbs: ["get", "list", "watch"] + - apiGroups: ["admin.sharebite.dev"] + resources: ["adminappprofiles/status"] + verbs: ["get", "update", "patch"] - apiGroups: ["coordination.k8s.io"] resources: ["leases"] verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] diff --git a/docs/k8s/admin-operator.md b/docs/k8s/admin-operator.md index 93914a49..2dfa3549 100644 --- a/docs/k8s/admin-operator.md +++ b/docs/k8s/admin-operator.md @@ -1,6 +1,6 @@ # admin-operator -Scales the **business-api** Deployment from a `AdminAppProfile` resource. +Scales the **admin-api** Deployment from a `AdminAppProfile` resource. Implementation: [#216](https://github.com/ua-academy-projects/share-bite/issues/216). Conventions: [operators-overview.md](./operators-overview.md). @@ -103,13 +103,13 @@ the CRD and the underlying `Deployment` for changes. To safely interact with the cluster, the operator requires the following RBAC permissions: -| API Group | Resources | Verbs | Purpose | -|:----------------------|:-----------------------------|:--------------------------------------------------|:---------------------------------------------------| -| `apps` | `deployments` | `get, list, watch, update, patch` | Allows observing and scaling target deployments. | -| `admin.sharebite.dev` | `adminappprofiles` | `get, list, watch, create, update, patch, delete` | Full lifecycle control over the CRD. | -| `admin.sharebite.dev` | `businessappprofiles/status` | `get, update, patch` | Restricted permission to report status conditions. | -| `coordination.k8s.io` | `leases` | `get, list, watch, create, update, patch, delete` | Manages Leader Election locks. | -| `` (core) | `events` | `create, patch` | Writes controller events to the cluster. | +| API Group | Resources | Verbs | Purpose | +|:----------------------|:--------------------------|:--------------------------------------------------|:---------------------------------------------------| +| `apps` | `deployments` | `get, list, watch, update, patch` | Allows observing and scaling target deployments. | +| `admin.sharebite.dev` | `adminappprofiles` | `get, list, watch, create, update, patch, delete` | Full lifecycle control over the CRD. | +| `admin.sharebite.dev` | `adminappprofiles/status` | `get, update, patch` | Restricted permission to report status conditions. | +| `coordination.k8s.io` | `leases` | `get, list, watch, create, update, patch, delete` | Manages Leader Election locks. | +| `` (core) | `events` | `create, patch` | Writes controller events to the cluster. | --- @@ -148,7 +148,7 @@ The entire runtime environment can be stood up, managed, and torn down cleanly u Deploy the supporting stack (Postgres Database, Redis Cache, Admin Auth API API, and Database Migrations) via Kustomize: ```bash - make run-auth-service + make run-admin-service ``` 2. Build and Deploy the Admin Operator Container Automatically compile the Go codebase, package it into a secure Docker image, register the CRDs, apply the @@ -167,7 +167,7 @@ The entire runtime environment can be stood up, managed, and torn down cleanly u configurations, execute: ```bash make stop-admin-operator - make stop-auth-service + make stop-admin-service ``` ### Example Usage diff --git a/operators/admin-operator/controller/controller.go b/operators/admin-operator/controller/controller.go index 6ab78aee..260c3db6 100644 --- a/operators/admin-operator/controller/controller.go +++ b/operators/admin-operator/controller/controller.go @@ -102,5 +102,6 @@ func (r *AdminAppProfileReconciler) updateStatus(ctx context.Context, profile *a func (r *AdminAppProfileReconciler) SetupWithManager(mgr ctrl.Manager) error { return ctrl.NewControllerManagedBy(mgr). For(&adminv1alpha1.AdminAppProfile{}). + Owns(&appsv1.Deployment{}). Complete(r) } diff --git a/operators/admin-operator/controller/controller_test.go b/operators/admin-operator/controller/controller_test.go index e0c5a9c8..93d16f69 100644 --- a/operators/admin-operator/controller/controller_test.go +++ b/operators/admin-operator/controller/controller_test.go @@ -39,7 +39,9 @@ func TestReconcile_EnabledFalse(t *testing.T) { } updatedDep := &appsv1.Deployment{} - _ = cl.Get(context.Background(), types.NamespacedName{Name: "admin-auth-api", Namespace: "default"}, updatedDep) + if err := cl.Get(context.Background(), types.NamespacedName{Name: "admin-auth-api", Namespace: "default"}, updatedDep); err != nil { + t.Fatalf("failed to get deployment: %v", err) + } if *updatedDep.Spec.Replicas != 0 { t.Errorf("expected 0 replicas, got %d", *updatedDep.Spec.Replicas) @@ -95,7 +97,9 @@ func TestReconcile_HappyPath(t *testing.T) { } updatedDep := &appsv1.Deployment{} - _ = cl.Get(context.Background(), types.NamespacedName{Name: "admin-auth-api", Namespace: "default"}, updatedDep) + if err := cl.Get(context.Background(), types.NamespacedName{Name: "admin-auth-api", Namespace: "default"}, updatedDep); err != nil { + t.Fatalf("failed to get deployment: %v", err) + } if *updatedDep.Spec.Replicas != 3 { t.Errorf("expected 3 replicas, got %d", *updatedDep.Spec.Replicas)