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..9c20c045 --- /dev/null +++ b/build/Dockerfile.admin-operator @@ -0,0 +1,22 @@ +FROM golang:1.26.1-bookworm AS builder + +WORKDIR /workspace + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +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 + +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..f0f413fb --- /dev/null +++ b/deploy/k8s/operators/admin-auth/operator-deployment.yaml @@ -0,0 +1,39 @@ +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 + imagePullPolicy: IfNotPresent + 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..da68b7a1 --- /dev/null +++ b/deploy/k8s/operators/admin-auth/rbac.yaml @@ -0,0 +1,39 @@ +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"] + 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"] + - 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..2dfa3549 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 **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). 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` | `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. | + +--- + +## 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-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 + 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-admin-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..260c3db6 --- /dev/null +++ b/operators/admin-operator/controller/controller.go @@ -0,0 +1,107 @@ +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{}). + Owns(&appsv1.Deployment{}). + 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..93d16f69 --- /dev/null +++ b/operators/admin-operator/controller/controller_test.go @@ -0,0 +1,107 @@ +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{} + 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) + } +} + +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{} + 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) + } +}