diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..fac7a21 Binary files /dev/null and b/.DS_Store differ diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..f9bb4ae --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,39 @@ +# CI: fmt, vet, and unit tests on push and PR. +name: CI + +on: + push: + branches: [main, preview] + pull_request: + branches: [main, preview] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: 'go.mod' + + - name: tidy + run: go mod tidy + + - name: fmt + run: | + go fmt ./... + git diff --exit-code + + - name: vet + run: go vet ./... + + - name: test + run: go test ./... -short -coverprofile=cover.out -count=1 + + - name: govulncheck + run: | + go install golang.org/x/vuln/cmd/govulncheck@latest + $(go env GOPATH)/bin/govulncheck ./... diff --git a/.gitignore b/.gitignore index e917e5c..cb1ae66 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,8 @@ Dockerfile.cross *.swp *.swo *~ + +# Local agent / session continuity — do not commit (SSOT = board + issue bodies) +READMEDEV.md +docs/HANDOVER.md +docs/SPRINT-*-PLAN.md diff --git a/Makefile b/Makefile index b7c3006..def1833 100644 --- a/Makefile +++ b/Makefile @@ -137,7 +137,7 @@ ENVTEST ?= $(LOCALBIN)/setup-envtest ## Tool Versions KUSTOMIZE_VERSION ?= v5.0.0 -CONTROLLER_TOOLS_VERSION ?= v0.16.0 +CONTROLLER_TOOLS_VERSION ?= v0.17.2 KUSTOMIZE_INSTALL_SCRIPT ?= "https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh" .PHONY: kustomize diff --git a/cmd/main.go b/cmd/main.go index e00ec9f..1fcf06d 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -31,8 +31,8 @@ import ( ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/healthz" "sigs.k8s.io/controller-runtime/pkg/log/zap" - "sigs.k8s.io/controller-runtime/pkg/webhook" "sigs.k8s.io/controller-runtime/pkg/metrics/filters" + "sigs.k8s.io/controller-runtime/pkg/webhook" amtdv1beta1 "github.com/r6security/phoenix/api/v1beta1" "github.com/r6security/phoenix/internal/controller" @@ -120,6 +120,14 @@ func main() { setupLog.Error(err, "unable to create controller", "controller", "SecurityEvent") os.Exit(1) } + if err = (&controller.NIMReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + Policy: controller.DefaultNIMPolicy(), + }).SetupWithManager(mgr); err != nil { + setupLog.Error(err, "unable to create controller", "controller", "NIM") + os.Exit(1) + } //+kubebuilder:scaffold:builder if err := mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { diff --git a/config/.DS_Store b/config/.DS_Store new file mode 100644 index 0000000..cea3a67 Binary files /dev/null and b/config/.DS_Store differ diff --git a/config/crd/bases/amtd.r6security.com_adaptivemovingtargetdefenses.yaml b/config/crd/bases/amtd.r6security.com_adaptivemovingtargetdefenses.yaml index 3b1c5d5..ac6509f 100644 --- a/config/crd/bases/amtd.r6security.com_adaptivemovingtargetdefenses.yaml +++ b/config/crd/bases/amtd.r6security.com_adaptivemovingtargetdefenses.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.16.0 + controller-gen.kubebuilder.io/version: v0.17.2 name: adaptivemovingtargetdefenses.amtd.r6security.com spec: group: amtd.r6security.com @@ -99,8 +99,9 @@ spec: present in a Container. properties: name: - description: Name of the environment variable. - Must be a C_IDENTIFIER. + description: |- + Name of the environment variable. + May consist of any printable ASCII characters except '='. type: string value: description: |- @@ -159,6 +160,43 @@ spec: - fieldPath type: object x-kubernetes-map-type: atomic + fileKeyRef: + description: |- + FileKeyRef selects a key of the env file. + Requires the EnvFiles feature gate to be enabled. + properties: + key: + description: |- + The key within the env file. An invalid key will prevent the pod from starting. + The keys defined within a source may consist of any printable ASCII characters except '='. + During Alpha stage of the EnvFiles feature gate, the key size is limited to 128 characters. + type: string + optional: + default: false + description: |- + Specify whether the file or its key must be defined. If the file or key + does not exist, then the env var is not published. + If optional is set to true and the specified key does not exist, + the environment variable will not be set in the Pod's containers. + + If optional is set to false and the specified key does not exist, + an error will be returned during Pod creation. + type: boolean + path: + description: |- + The path within the volume from which to select the file. + Must be relative and may not contain the '..' path or start with '..'. + type: string + volumeName: + description: The name of the volume mount + containing the env file. + type: string + required: + - key + - path + - volumeName + type: object + x-kubernetes-map-type: atomic resourceFieldRef: description: |- Selects a resource of the container: only resources limits and requests @@ -228,7 +266,7 @@ spec: Cannot be updated. items: description: EnvFromSource represents the source of - a set of ConfigMaps + a set of ConfigMaps or Secrets properties: configMapRef: description: The ConfigMap to select from @@ -249,8 +287,9 @@ spec: type: object x-kubernetes-map-type: atomic prefix: - description: An optional identifier to prepend - to each key in the ConfigMap. Must be a C_IDENTIFIER. + description: |- + Optional text to prepend to the name of each environment variable. + May consist of any printable ASCII characters except '='. type: string secretRef: description: The Secret to select from @@ -298,7 +337,8 @@ spec: More info: https://kubernetes.io/docs/concepts/containers/container-lifecycle-hooks/#container-hooks properties: exec: - description: Exec specifies the action to take. + description: Exec specifies a command to execute + in the container. properties: command: description: |- @@ -313,7 +353,7 @@ spec: x-kubernetes-list-type: atomic type: object httpGet: - description: HTTPGet specifies the http request + description: HTTPGet specifies an HTTP GET request to perform. properties: host: @@ -364,8 +404,8 @@ spec: - port type: object sleep: - description: Sleep represents the duration that - the container should sleep before being terminated. + description: Sleep represents a duration that + the container should sleep. properties: seconds: description: Seconds is the number of seconds @@ -378,8 +418,8 @@ spec: tcpSocket: description: |- Deprecated. TCPSocket is NOT supported as a LifecycleHandler and kept - for the backward compatibility. There are no validation of this field and - lifecycle hooks will fail in runtime when tcp handler is specified. + for backward compatibility. There is no validation of this field and + lifecycle hooks will fail at runtime when it is specified. properties: host: description: 'Optional: Host name to connect @@ -411,7 +451,8 @@ spec: More info: https://kubernetes.io/docs/concepts/containers/container-lifecycle-hooks/#container-hooks properties: exec: - description: Exec specifies the action to take. + description: Exec specifies a command to execute + in the container. properties: command: description: |- @@ -426,7 +467,7 @@ spec: x-kubernetes-list-type: atomic type: object httpGet: - description: HTTPGet specifies the http request + description: HTTPGet specifies an HTTP GET request to perform. properties: host: @@ -477,8 +518,8 @@ spec: - port type: object sleep: - description: Sleep represents the duration that - the container should sleep before being terminated. + description: Sleep represents a duration that + the container should sleep. properties: seconds: description: Seconds is the number of seconds @@ -491,8 +532,8 @@ spec: tcpSocket: description: |- Deprecated. TCPSocket is NOT supported as a LifecycleHandler and kept - for the backward compatibility. There are no validation of this field and - lifecycle hooks will fail in runtime when tcp handler is specified. + for backward compatibility. There is no validation of this field and + lifecycle hooks will fail at runtime when it is specified. properties: host: description: 'Optional: Host name to connect @@ -511,12 +552,19 @@ spec: - port type: object type: object + stopSignal: + description: |- + StopSignal defines which signal will be sent to a container when it is being stopped. + If not specified, the default is defined by the container runtime in use. + StopSignal can only be set for Pods with a non-empty .spec.os.name + type: string type: object livenessProbe: description: Probes are not allowed for ephemeral containers. properties: exec: - description: Exec specifies the action to take. + description: Exec specifies a command to execute + in the container. properties: command: description: |- @@ -537,8 +585,7 @@ spec: format: int32 type: integer grpc: - description: GRPC specifies an action involving - a GRPC port. + description: GRPC specifies a GRPC HealthCheckRequest. properties: port: description: Port number of the gRPC service. @@ -557,7 +604,7 @@ spec: - port type: object httpGet: - description: HTTPGet specifies the http request + description: HTTPGet specifies an HTTP GET request to perform. properties: host: @@ -625,7 +672,7 @@ spec: format: int32 type: integer tcpSocket: - description: TCPSocket specifies an action involving + description: TCPSocket specifies a connection to a TCP port. properties: host: @@ -719,7 +766,8 @@ spec: description: Probes are not allowed for ephemeral containers. properties: exec: - description: Exec specifies the action to take. + description: Exec specifies a command to execute + in the container. properties: command: description: |- @@ -740,8 +788,7 @@ spec: format: int32 type: integer grpc: - description: GRPC specifies an action involving - a GRPC port. + description: GRPC specifies a GRPC HealthCheckRequest. properties: port: description: Port number of the gRPC service. @@ -760,7 +807,7 @@ spec: - port type: object httpGet: - description: HTTPGet specifies the http request + description: HTTPGet specifies an HTTP GET request to perform. properties: host: @@ -828,7 +875,7 @@ spec: format: int32 type: integer tcpSocket: - description: TCPSocket specifies an action involving + description: TCPSocket specifies a connection to a TCP port. properties: host: @@ -901,7 +948,7 @@ spec: Claims lists the names of resources, defined in spec.resourceClaims, that are used by this container. - This is an alpha field and requires enabling the + This field depends on the DynamicResourceAllocation feature gate. This field is immutable. It can only be set for containers. @@ -1158,7 +1205,8 @@ spec: description: Probes are not allowed for ephemeral containers. properties: exec: - description: Exec specifies the action to take. + description: Exec specifies a command to execute + in the container. properties: command: description: |- @@ -1179,8 +1227,7 @@ spec: format: int32 type: integer grpc: - description: GRPC specifies an action involving - a GRPC port. + description: GRPC specifies a GRPC HealthCheckRequest. properties: port: description: Port number of the gRPC service. @@ -1199,7 +1246,7 @@ spec: - port type: object httpGet: - description: HTTPGet specifies the http request + description: HTTPGet specifies an HTTP GET request to perform. properties: host: @@ -1267,7 +1314,7 @@ spec: format: int32 type: integer tcpSocket: - description: TCPSocket specifies an action involving + description: TCPSocket specifies a connection to a TCP port. properties: host: diff --git a/config/crd/bases/amtd.r6security.com_securityevents.yaml b/config/crd/bases/amtd.r6security.com_securityevents.yaml index 236b5cd..03e1c83 100644 --- a/config/crd/bases/amtd.r6security.com_securityevents.yaml +++ b/config/crd/bases/amtd.r6security.com_securityevents.yaml @@ -3,7 +3,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.16.0 + controller-gen.kubebuilder.io/version: v0.17.2 name: securityevents.amtd.r6security.com spec: group: amtd.r6security.com diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml index 78fe0de..2760a78 100644 --- a/config/rbac/role.yaml +++ b/config/rbac/role.yaml @@ -5,10 +5,9 @@ metadata: name: manager-role rules: - apiGroups: - - amtd.r6security.com + - "" resources: - - adaptivemovingtargetdefenses - - securityevents + - pods verbs: - create - delete @@ -18,25 +17,24 @@ rules: - update - watch - apiGroups: - - amtd.r6security.com + - "" resources: - - adaptivemovingtargetdefenses/finalizers - - securityevents/finalizers + - pods/finalizers verbs: - update - apiGroups: - - amtd.r6security.com + - "" resources: - - adaptivemovingtargetdefenses/status - - securityevents/status + - pods/status verbs: - get - patch - update - apiGroups: - - "" + - amtd.r6security.com resources: - - pods + - adaptivemovingtargetdefenses + - securityevents verbs: - create - delete @@ -46,15 +44,17 @@ rules: - update - watch - apiGroups: - - "" + - amtd.r6security.com resources: - - pods/finalizers + - adaptivemovingtargetdefenses/finalizers + - securityevents/finalizers verbs: - update - apiGroups: - - "" + - amtd.r6security.com resources: - - pods/status + - adaptivemovingtargetdefenses/status + - securityevents/status verbs: - get - patch diff --git a/deploy/manifests/demo-page/demo-page-deployment.yaml b/deploy/manifests/demo-page/demo-page-deployment.yaml index 9900cab..7336fa4 100644 --- a/deploy/manifests/demo-page/demo-page-deployment.yaml +++ b/deploy/manifests/demo-page/demo-page-deployment.yaml @@ -25,6 +25,9 @@ spec: creationTimestamp: null labels: app: demo-page + annotations: + time-based-trigger.amtd.r6security.com/enabled: "true" + time-based-trigger.amtd.r6security.com/schedule: "30s" spec: containers: - image: nginx:1.25.1 diff --git a/docs/.DS_Store b/docs/.DS_Store new file mode 100644 index 0000000..7fa54bf Binary files /dev/null and b/docs/.DS_Store differ diff --git a/docs/INSTALL.md b/docs/INSTALL.md index 2959243..d35a830 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -60,14 +60,18 @@ kubectl apply -n demo-page -f deploy/manifests/time-based-trigger-demo-amtd.yaml kubectl -n moving-target-defense get AdaptiveMovingTargetDefense ``` -3. Enable time backend for the demo-page deployment and schdedule the restart in every 30s: +3. Enable the 30s time-based trigger (optional — the demo deployment already includes these annotations): -``` -kubectl patch -n demo-page deployments.apps demo-page -p '"spec": {"template": { "metadata": {"annotations": {"time-based-trigger.amtd.r6security.com/schedule": "30s"}}}}' -kubectl patch -n demo-page deployments.apps demo-page -p '"spec": {"template": { "metadata": {"annotations": {"time-based-trigger.amtd.r6security.com/enabled": "true"}}}}' -``` + The Time-based Trigger requires **both** annotations on the pod template: `time-based-trigger.amtd.r6security.com/enabled` and `time-based-trigger.amtd.r6security.com/schedule`. The `demo-page-deployment.yaml` in this repo already sets them. If you use a different deployment or need to change the schedule, apply **one** patch that sets both (annotations must be under `spec.template.metadata.annotations`): + + ```bash + kubectl patch -n demo-page deployments.apps demo-page -p '{"spec":{"template":{"metadata":{"annotations":{"time-based-trigger.amtd.r6security.com/enabled":"true","time-based-trigger.amtd.r6security.com/schedule":"30s"}}}}}' + ``` + + **If the trigger doesn't run:** Check that the pod has both annotations: + `kubectl get pod -n demo-page -l app=demo-page -o jsonpath='{.items[0].metadata.annotations}'` -4. Watch pods to see the restarts in every 30 seconds: +4. Watch pods to see the restarts every 30 seconds: ``` watch kubectl -n demo-page get pods diff --git a/docs/NIM.md b/docs/NIM.md new file mode 100644 index 0000000..4a16a0a --- /dev/null +++ b/docs/NIM.md @@ -0,0 +1,27 @@ +# NIM (Node Infrastructure Module) add-on + +Phoenix includes an optional **NIM controller** that performs NIM-enhanced pod restarts when pods have **time-based** SecurityEvents applied (e.g. from a timer or the [Time-based Trigger](https://github.com/r6security/time-based-trigger)). The controller updates NIM annotations on the pod and then deletes the pod with a configurable grace period so the workload is rescheduled. + +## When NIM acts + +- The pod must have the annotation `amtd.r6security.com/applied-sec-events` set (JSON array of SecurityEvent objects), typically by the Phoenix operator or the Time-based Trigger. +- NIM only reacts when at least one of those events has `rule.type=timed` and `rule.source=TimeBasedTrigger`. +- Other event types are ignored. + +## Annotation contract + +| Annotation | Set by | Description | +|------------|--------|-------------| +| `amtd.r6security.com/applied-sec-events` | Phoenix / Time-based Trigger | JSON array of SecurityEvent objects applied to the pod. | +| `nim.r6security.com/pod-startup-state` | NIM controller | One of `pending`, `starting`, `running`, `failed`. | +| `nim.r6security.com/triggers-stopped` | NIM controller | Set to `true` when NIM has applied a restart. | +| `nim.r6security.com/action-active` | NIM controller | Set to `true` when a NIM action is in progress. | +| `nim.r6security.com/last-action-timestamp` | NIM controller | Unix timestamp of the last NIM action. | + +## Default behavior + +- Grace period for pod termination: 30 seconds. +- Requeue interval: 10 seconds. +- Trigger: `type=timed`, `source=TimeBasedTrigger`. + +These can be made configurable in a future release. diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index cf41021..4b7b3f5 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -1,3 +1,9 @@ +## NIM add-on (annotation contract) + +The built-in NIM controller watches pods with applied SecurityEvents and performs NIM-enhanced restarts when a **time-based** trigger is present. See [NIM](NIM.md) for behavior and the annotation contract (`amtd.r6security.com/applied-sec-events`, `nim.r6security.com/*`). + +--- + ## Custom Resources A custom resource is an extension of the Kubernetes API that is not necessarily available in a default Kubernetes installation, however, it can be added at any time by deploying a CustomResourceDefinition. diff --git a/docs/examples/timer-based-app-restart.md b/docs/examples/timer-based-app-restart.md index 7ab5822..4b6f506 100644 --- a/docs/examples/timer-based-app-restart.md +++ b/docs/examples/timer-based-app-restart.md @@ -27,15 +27,16 @@ In this tutorial you will learn how to: ## Configure Time-based Trigger -- Set the timer to 30s: -``` -kubectl patch -n demo-page deployments.apps demo-page -p '"spec": {"template": { "metadata": {"annotations": {"time-based-trigger.amtd.r6security.com/schedule": "30s"}}}}' -``` -- Enable time-based-trigger for the pod -``` -kubectl patch -n demo-page deployments.apps demo-page -p '"spec": {"template": { "metadata": {"annotations": {"time-based-trigger.amtd.r6security.com/enabled": "true"}}}}' +The Time-based Trigger needs **both** annotations on the pod: `time-based-trigger.amtd.r6security.com/enabled` and `time-based-trigger.amtd.r6security.com/schedule`. Set both in one patch (under `spec.template.metadata.annotations`): + +```bash +kubectl patch -n demo-page deployments.apps demo-page -p '{"spec":{"template":{"metadata":{"annotations":{"time-based-trigger.amtd.r6security.com/enabled":"true","time-based-trigger.amtd.r6security.com/schedule":"30s"}}}}}' ``` -Watch pods to see the restarts in every 30 seconds: + +If the trigger doesn't run, verify the pod has both annotations: +`kubectl get pod -n demo-page -l app=demo-page -o jsonpath='{.items[0].metadata.annotations}'` + +Watch pods to see the restarts every 30 seconds: watch kubectl -n demo-page get pods diff --git a/go.mod b/go.mod index 05b9672..6d1dd15 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/r6security/phoenix -go 1.24.0 +go 1.26.1 require ( github.com/go-logr/logr v1.4.3 @@ -58,14 +58,14 @@ require ( github.com/spf13/pflag v1.0.6 // indirect github.com/stoewer/go-strcase v1.3.0 // indirect github.com/x448/float16 v0.8.4 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 // indirect - go.opentelemetry.io/otel v1.33.0 // indirect + go.opentelemetry.io/otel v1.40.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0 // indirect - go.opentelemetry.io/otel/metric v1.33.0 // indirect - go.opentelemetry.io/otel/sdk v1.33.0 // indirect - go.opentelemetry.io/otel/trace v1.33.0 // indirect + go.opentelemetry.io/otel/metric v1.40.0 // indirect + go.opentelemetry.io/otel/sdk v1.40.0 // indirect + go.opentelemetry.io/otel/trace v1.40.0 // indirect go.opentelemetry.io/proto/otlp v1.4.0 // indirect go.uber.org/automaxprocs v1.6.0 // indirect go.uber.org/multierr v1.11.0 // indirect @@ -76,7 +76,7 @@ require ( golang.org/x/net v0.43.0 // indirect golang.org/x/oauth2 v0.27.0 // indirect golang.org/x/sync v0.16.0 // indirect - golang.org/x/sys v0.35.0 // indirect + golang.org/x/sys v0.40.0 // indirect golang.org/x/term v0.34.0 // indirect golang.org/x/text v0.28.0 // indirect golang.org/x/time v0.9.0 // indirect diff --git a/go.sum b/go.sum index 3506f50..c9e5f39 100644 --- a/go.sum +++ b/go.sum @@ -115,8 +115,8 @@ github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= -github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= @@ -134,28 +134,30 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV 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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +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/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/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= 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.33.0 h1:/FerN9bax5LoK51X/sI0SVYrjSE0/yUL7DpxW4K3FWw= -go.opentelemetry.io/otel v1.33.0/go.mod h1:SUUkR6csvUQl+yjReHu5uM3EtVV7MBm5FHKRlNx4I8I= +go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= +go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 h1:Vh5HayB/0HHfOQA7Ctx69E/Y/DcQSMPpKANYVMQ7fBA= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0/go.mod h1:cpgtDBaqD/6ok/UG0jT15/uKjAY8mRA53diogHBg3UI= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0 h1:5pojmb1U1AogINhN3SurB+zm/nIcusopeBNp42f45QM= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0/go.mod h1:57gTHJSE5S1tqg+EKsLPlTWhpHMsWlVmer+LA926XiA= -go.opentelemetry.io/otel/metric v1.33.0 h1:r+JOocAyeRVXD8lZpjdQjzMadVZp2M4WmQ+5WtEnklQ= -go.opentelemetry.io/otel/metric v1.33.0/go.mod h1:L9+Fyctbp6HFTddIxClbQkjtubW6O9QS3Ann/M82u6M= -go.opentelemetry.io/otel/sdk v1.33.0 h1:iax7M131HuAm9QkZotNHEfstof92xM+N8sr3uHXc2IM= -go.opentelemetry.io/otel/sdk v1.33.0/go.mod h1:A1Q5oi7/9XaMlIWzPSxLRWOI8nG3FnzHJNbiENQuihM= -go.opentelemetry.io/otel/trace v1.33.0 h1:cCJuF7LRjUFso9LPnEAHJDB2pqzp+hbO8eu1qqW2d/s= -go.opentelemetry.io/otel/trace v1.33.0/go.mod h1:uIcdVUZMpTAmz0tI1z04GoVSezK37CbGV4fr1f2nBck= +go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= +go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= +go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= +go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= +go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= +go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= +go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= +go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= go.opentelemetry.io/proto/otlp v1.4.0 h1:TA9WRvW6zMwP+Ssb6fLoUIuirti1gGbP28GcKG1jgeg= go.opentelemetry.io/proto/otlp v1.4.0/go.mod h1:PPBWZIP98o2ElSqI35IHfu7hIhSwvc5N38Jw8pXuGFY= go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= @@ -193,8 +195,8 @@ golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 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.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= -golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4= golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/internal/controller/constants.go b/internal/controller/constants.go index dca77c8..a2fb097 100644 --- a/internal/controller/constants.go +++ b/internal/controller/constants.go @@ -27,5 +27,5 @@ const ( R6_SECURITY_EVENT_RECEIVED string = "amtd.r6security.event.received" // R6Security label for AMTD-managed pods (GitHub issue #15) - R6_SECURITY_MANAGED_LABEL string = "r6security.com/managed-by-amtd" + R6_SECURITY_MANAGED_LABEL string = "r6security.com/managed-by-amtd" ) diff --git a/internal/controller/nim_constants.go b/internal/controller/nim_constants.go new file mode 100644 index 0000000..4e6a9cc --- /dev/null +++ b/internal/controller/nim_constants.go @@ -0,0 +1,35 @@ +/* + * Copyright (C) 2023 R6 Security, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ + +package controller + +const ( + // Trigger type/source that the NIM controller reacts to (time-based restarts) + TriggerTypeTimed string = "timed" + TriggerSourceTimeBasedTrigger string = "TimeBasedTrigger" + + // NIM (Node Infrastructure Module) annotation constants + NIM_POD_STARTUP_STATE string = "nim.r6security.com/pod-startup-state" + NIM_STARTUP_STATE_PENDING string = "pending" + NIM_STARTUP_STATE_STARTING string = "starting" + NIM_STARTUP_STATE_RUNNING string = "running" + NIM_STARTUP_STATE_FAILED string = "failed" + NIM_RESCHEDULE_COUNT string = "nim.r6security.com/reschedule-count" + NIM_TRIGGERS_STOPPED string = "nim.r6security.com/triggers-stopped" + NIM_ACTION_ACTIVE string = "nim.r6security.com/action-active" + NIM_LAST_ACTION_TIMESTAMP string = "nim.r6security.com/last-action-timestamp" +) diff --git a/internal/controller/nim_controller.go b/internal/controller/nim_controller.go new file mode 100644 index 0000000..2e32517 --- /dev/null +++ b/internal/controller/nim_controller.go @@ -0,0 +1,196 @@ +/* + * Copyright (C) 2023 R6 Security, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ + +// Package controller implements the NIM reconciler. It watches pods that have the +// applied SecurityEvents annotation set by the Phoenix operator (or Time-based Trigger) +// and, when the applied events include a time-based trigger, performs NIM-enhanced +// restarts: updates NIM annotations on the pod and then deletes the pod so the +// workload is rescheduled. +package controller + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + "time" + + "github.com/go-logr/logr" + corev1 "k8s.io/api/core/v1" + + "k8s.io/apimachinery/pkg/runtime" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" + + amtdv1beta1 "github.com/r6security/phoenix/api/v1beta1" +) + +// NIMPolicy holds configurable NIM behavior (grace period, requeue interval, trigger type/source). +// Zero values are replaced by defaults in effective(). +type NIMPolicy struct { + GracePeriodSeconds int64 + RequeueAfter time.Duration + TriggerType string + TriggerSource string +} + +// DefaultNIMPolicy returns the default NIM policy (30s grace, 10s requeue, timed/TimeBasedTrigger). +func DefaultNIMPolicy() NIMPolicy { + return NIMPolicy{ + GracePeriodSeconds: 30, + RequeueAfter: 10 * time.Second, + TriggerType: TriggerTypeTimed, + TriggerSource: TriggerSourceTimeBasedTrigger, + } +} + +func (p NIMPolicy) effective() NIMPolicy { + def := DefaultNIMPolicy() + if p.GracePeriodSeconds <= 0 { + p.GracePeriodSeconds = def.GracePeriodSeconds + } + if p.RequeueAfter <= 0 { + p.RequeueAfter = def.RequeueAfter + } + if p.TriggerType == "" { + p.TriggerType = def.TriggerType + } + if p.TriggerSource == "" { + p.TriggerSource = def.TriggerSource + } + return p +} + +// NIMReconciler watches pods and applies NIM enhancements when they have time-based SecurityEvent annotations. +type NIMReconciler struct { + client.Client + Scheme *runtime.Scheme + Policy NIMPolicy +} + +//+kubebuilder:rbac:groups=core,resources=pods,verbs=get;list;watch;update;patch;delete +//+kubebuilder:rbac:groups=core,resources=pods/status,verbs=get +//+kubebuilder:rbac:groups=amtd.r6security.com,resources=adaptivemovingtargetdefenses,verbs=get;list;watch + +func (r *NIMReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { + logger := log.FromContext(ctx) + + pod := &corev1.Pod{} + if err := r.Get(ctx, req.NamespacedName, pod); err != nil { + return ctrl.Result{}, client.IgnoreNotFound(err) + } + + logger.Info("NIM controller processing pod", "pod", pod.Name, "namespace", pod.Namespace) + + if pod.Annotations == nil { + logger.Info("Pod has no annotations, skipping", "pod", pod.Name) + return ctrl.Result{}, nil + } + + appliedEventsJSON, hasAppliedEvents := pod.Annotations[AMTD_APPLIED_SECURITY_EVENTS] + if !hasAppliedEvents { + logger.Info("Pod has no SecurityEvent annotations, skipping", "pod", pod.Name) + return ctrl.Result{}, nil + } + + logger.Info("Processing pod for NIM restart", "pod", pod.Name) + logger.Info("Pod has SecurityEvent annotation, processing for NIM", "pod", pod.Name, "appliedEvents", appliedEventsJSON) + + var appliedEvents []amtdv1beta1.SecurityEvent + if err := json.Unmarshal([]byte(appliedEventsJSON), &appliedEvents); err != nil { + logger.Error(err, "Failed to parse applied SecurityEvents", "pod", pod.Name) + return ctrl.Result{}, nil + } + + policy := r.Policy.effective() + timedEvent := FindTimedEvent(appliedEvents, policy.TriggerType, policy.TriggerSource) + if timedEvent == nil { + return ctrl.Result{}, nil + } + + if err := r.processPodWithNIM(ctx, pod, timedEvent, logger); err != nil { + logger.Error(err, "Failed to process pod with NIM", "pod", pod.Name) + return ctrl.Result{}, err + } + + logger.Info("NIM scheduling next automatic restart", "pod", pod.Name, "requeueAfter", policy.RequeueAfter) + return ctrl.Result{RequeueAfter: policy.RequeueAfter}, nil +} + +// FindTimedEvent returns the first SecurityEvent in events whose rule type and source match. +func FindTimedEvent(events []amtdv1beta1.SecurityEvent, triggerType, triggerSource string) *amtdv1beta1.SecurityEvent { + for i := range events { + if events[i].Spec.Rule.Type == triggerType && events[i].Spec.Rule.Source == triggerSource { + return &events[i] + } + } + return nil +} + +func (r *NIMReconciler) processPodWithNIM(ctx context.Context, pod *corev1.Pod, securityEvent *amtdv1beta1.SecurityEvent, logger logr.Logger) error { + policy := r.Policy.effective() + gracePeriodSeconds := policy.GracePeriodSeconds + startupState := r.determinePodStartupState(*pod) + + logger.Info("NIM proceeding with pod reschedule", "pod", pod.Name, "securityEvent", securityEvent.Name) + + if pod.Annotations == nil { + pod.Annotations = map[string]string{} + } + pod.Annotations[NIM_POD_STARTUP_STATE] = startupState + pod.Annotations[NIM_ACTION_ACTIVE] = "true" + pod.Annotations[NIM_LAST_ACTION_TIMESTAMP] = strconv.FormatInt(time.Now().Unix(), 10) + pod.Annotations[NIM_TRIGGERS_STOPPED] = "true" + + if err := r.Update(ctx, pod); err != nil { + return fmt.Errorf("failed to update pod annotations: %w", err) + } + + deleteOpts := &client.DeleteOptions{} + gp := gracePeriodSeconds + deleteOpts.GracePeriodSeconds = &gp + logger.Info("NIM executing pod delete", "pod", pod.Name, "gracePeriod", gp) + if err := r.Delete(ctx, pod, deleteOpts); err != nil { + logger.Error(err, "Failed to delete pod for NIM reschedule", "pod", pod.Name) + return fmt.Errorf("failed to delete pod for NIM reschedule: %w", err) + } + + logger.Info("NIM reschedule executed successfully", "pod", pod.Name, "securityEvent", securityEvent.Name) + return nil +} + +// SetupWithManager sets up the NIM controller with the Manager. +func (r *NIMReconciler) SetupWithManager(mgr ctrl.Manager) error { + return ctrl.NewControllerManagedBy(mgr). + For(&corev1.Pod{}). + Named("nim-pod"). + Complete(r) +} + +func (r *NIMReconciler) determinePodStartupState(pod corev1.Pod) string { + switch pod.Status.Phase { + case corev1.PodPending: + return NIM_STARTUP_STATE_PENDING + case corev1.PodRunning: + return NIM_STARTUP_STATE_RUNNING + case corev1.PodFailed: + return NIM_STARTUP_STATE_FAILED + default: + return NIM_STARTUP_STATE_STARTING + } +} diff --git a/internal/controller/nim_controller_test.go b/internal/controller/nim_controller_test.go new file mode 100644 index 0000000..0504533 --- /dev/null +++ b/internal/controller/nim_controller_test.go @@ -0,0 +1,247 @@ +/* + * Copyright (C) 2023 R6 Security, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the Server Side Public License, version 1, + * as published by MongoDB, Inc. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * Server Side Public License for more details. + * + * You should have received a copy of the Server Side Public License + * along with this program. If not, see + * . + */ + +package controller + +import ( + "context" + "encoding/json" + "testing" + "time" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "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" + crclient "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + amtdv1beta1 "github.com/r6security/phoenix/api/v1beta1" +) + +var nimTestScheme = runtime.NewScheme() + +func init() { + utilruntime.Must(clientgoscheme.AddToScheme(nimTestScheme)) + utilruntime.Must(amtdv1beta1.AddToScheme(nimTestScheme)) +} + +func TestNIMReconciler_Reconcile_NoAnnotation(t *testing.T) { + ctx := context.Background() + pod := nimPodWithAnnotations("test-pod", "default", nil) + client := fake.NewClientBuilder(). + WithScheme(nimTestScheme). + WithObjects(pod). + Build() + reconciler := &NIMReconciler{Client: client, Scheme: nimTestScheme} + + result, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: crclient.ObjectKeyFromObject(pod)}) + if err != nil { + t.Fatalf("Reconcile: %v", err) + } + if result.Requeue || result.RequeueAfter > 0 { + t.Errorf("expected no requeue, got Requeue=%v RequeueAfter=%v", result.Requeue, result.RequeueAfter) + } +} + +func TestNIMReconciler_Reconcile_NoTimedEvent(t *testing.T) { + ctx := context.Background() + applied := []amtdv1beta1.SecurityEvent{ + { + ObjectMeta: metav1.ObjectMeta{Name: "other"}, + Spec: amtdv1beta1.SecurityEventSpec{ + Rule: amtdv1beta1.Rule{Type: "other", Source: "OtherSource"}, + Description: "other", + }, + }, + } + raw, _ := json.Marshal(applied) + ann := map[string]string{AMTD_APPLIED_SECURITY_EVENTS: string(raw)} + pod := nimPodWithAnnotations("test-pod", "default", ann) + client := fake.NewClientBuilder(). + WithScheme(nimTestScheme). + WithObjects(pod). + Build() + reconciler := &NIMReconciler{Client: client, Scheme: nimTestScheme} + + result, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: crclient.ObjectKeyFromObject(pod)}) + if err != nil { + t.Fatalf("Reconcile: %v", err) + } + if result.Requeue || result.RequeueAfter > 0 { + t.Errorf("expected no requeue when no timed event, got Requeue=%v RequeueAfter=%v", result.Requeue, result.RequeueAfter) + } + var after corev1.Pod + if err := client.Get(ctx, crclient.ObjectKeyFromObject(pod), &after); err != nil { + t.Fatalf("pod should still exist: %v", err) + } +} + +func TestNIMReconciler_Reconcile_ValidTimedEvent_UpdateAndDelete(t *testing.T) { + ctx := context.Background() + applied := []amtdv1beta1.SecurityEvent{ + { + ObjectMeta: metav1.ObjectMeta{Name: "timed-1"}, + Spec: amtdv1beta1.SecurityEventSpec{ + Rule: amtdv1beta1.Rule{Type: TriggerTypeTimed, Source: TriggerSourceTimeBasedTrigger}, + Description: "timed", + }, + }, + } + raw, _ := json.Marshal(applied) + ann := map[string]string{AMTD_APPLIED_SECURITY_EVENTS: string(raw)} + pod := nimPodWithAnnotations("test-pod", "default", ann) + client := fake.NewClientBuilder(). + WithScheme(nimTestScheme). + WithObjects(pod). + Build() + reconciler := &NIMReconciler{Client: client, Scheme: nimTestScheme} + + result, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: crclient.ObjectKeyFromObject(pod)}) + if err != nil { + t.Fatalf("Reconcile: %v", err) + } + if result.RequeueAfter != 10*time.Second { + t.Errorf("expected RequeueAfter=10s, got %v", result.RequeueAfter) + } + var after corev1.Pod + if err := client.Get(ctx, crclient.ObjectKeyFromObject(pod), &after); err == nil { + t.Error("expected pod to be deleted after NIM processing") + } +} + +func TestNIMReconciler_Reconcile_InvalidJSON(t *testing.T) { + ctx := context.Background() + ann := map[string]string{AMTD_APPLIED_SECURITY_EVENTS: `not valid json`} + pod := nimPodWithAnnotations("test-pod", "default", ann) + client := fake.NewClientBuilder(). + WithScheme(nimTestScheme). + WithObjects(pod). + Build() + reconciler := &NIMReconciler{Client: client, Scheme: nimTestScheme} + + result, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: crclient.ObjectKeyFromObject(pod)}) + if err != nil { + t.Fatalf("Reconcile should not return error for invalid JSON: %v", err) + } + if result.Requeue || result.RequeueAfter > 0 { + t.Errorf("expected no requeue on invalid JSON, got Requeue=%v RequeueAfter=%v", result.Requeue, result.RequeueAfter) + } +} + +func TestNIMReconciler_Reconcile_PodNotFound(t *testing.T) { + ctx := context.Background() + client := fake.NewClientBuilder().WithScheme(nimTestScheme).Build() + reconciler := &NIMReconciler{Client: client, Scheme: nimTestScheme} + + result, err := reconciler.Reconcile(ctx, ctrl.Request{NamespacedName: crclient.ObjectKeyFromObject(&corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: "missing", Namespace: "default"}, + })}) + if err != nil { + t.Fatalf("Reconcile should ignore NotFound: %v", err) + } + if result.Requeue || result.RequeueAfter > 0 { + t.Errorf("expected no requeue for missing pod") + } +} + +func TestNIMReconciler_DeterminePodStartupState(t *testing.T) { + reconciler := &NIMReconciler{} + tests := []struct { + phase corev1.PodPhase + expect string + }{ + {corev1.PodPending, NIM_STARTUP_STATE_PENDING}, + {corev1.PodRunning, NIM_STARTUP_STATE_RUNNING}, + {corev1.PodFailed, NIM_STARTUP_STATE_FAILED}, + {corev1.PodSucceeded, NIM_STARTUP_STATE_STARTING}, + {corev1.PodUnknown, NIM_STARTUP_STATE_STARTING}, + } + for _, tt := range tests { + t.Run(string(tt.phase), func(t *testing.T) { + pod := corev1.Pod{Status: corev1.PodStatus{Phase: tt.phase}} + got := reconciler.determinePodStartupState(pod) + if got != tt.expect { + t.Errorf("determinePodStartupState(%s) = %q, want %q", tt.phase, got, tt.expect) + } + }) + } +} + +func TestFindTimedEvent(t *testing.T) { + t.Run("empty", func(t *testing.T) { + got := FindTimedEvent(nil, TriggerTypeTimed, TriggerSourceTimeBasedTrigger) + if got != nil { + t.Errorf("FindTimedEvent(nil) = %v, want nil", got) + } + got = FindTimedEvent([]amtdv1beta1.SecurityEvent{}, TriggerTypeTimed, TriggerSourceTimeBasedTrigger) + if got != nil { + t.Errorf("FindTimedEvent(empty) = %v, want nil", got) + } + }) + t.Run("no match", func(t *testing.T) { + events := []amtdv1beta1.SecurityEvent{ + {Spec: amtdv1beta1.SecurityEventSpec{Rule: amtdv1beta1.Rule{Type: "other", Source: "Other"}}}, + } + got := FindTimedEvent(events, TriggerTypeTimed, TriggerSourceTimeBasedTrigger) + if got != nil { + t.Errorf("FindTimedEvent(no match) = %v, want nil", got) + } + }) + t.Run("one match", func(t *testing.T) { + events := []amtdv1beta1.SecurityEvent{ + {ObjectMeta: metav1.ObjectMeta{Name: "timed-1"}, Spec: amtdv1beta1.SecurityEventSpec{Rule: amtdv1beta1.Rule{Type: TriggerTypeTimed, Source: TriggerSourceTimeBasedTrigger}}}, + } + got := FindTimedEvent(events, TriggerTypeTimed, TriggerSourceTimeBasedTrigger) + if got == nil || got.Name != "timed-1" { + t.Errorf("FindTimedEvent(one match) = %v, want event named timed-1", got) + } + }) + t.Run("first of two matches", func(t *testing.T) { + events := []amtdv1beta1.SecurityEvent{ + {ObjectMeta: metav1.ObjectMeta{Name: "first"}, Spec: amtdv1beta1.SecurityEventSpec{Rule: amtdv1beta1.Rule{Type: TriggerTypeTimed, Source: TriggerSourceTimeBasedTrigger}}}, + {ObjectMeta: metav1.ObjectMeta{Name: "second"}, Spec: amtdv1beta1.SecurityEventSpec{Rule: amtdv1beta1.Rule{Type: TriggerTypeTimed, Source: TriggerSourceTimeBasedTrigger}}}, + } + got := FindTimedEvent(events, TriggerTypeTimed, TriggerSourceTimeBasedTrigger) + if got == nil || got.Name != "first" { + t.Errorf("FindTimedEvent(two matches) = %v, want event named first", got) + } + }) + t.Run("type match only no match", func(t *testing.T) { + events := []amtdv1beta1.SecurityEvent{ + {Spec: amtdv1beta1.SecurityEventSpec{Rule: amtdv1beta1.Rule{Type: TriggerTypeTimed, Source: "Other"}}}, + } + got := FindTimedEvent(events, TriggerTypeTimed, TriggerSourceTimeBasedTrigger) + if got != nil { + t.Errorf("FindTimedEvent(type only) = %v, want nil", got) + } + }) +} + +func nimPodWithAnnotations(name, namespace string, annotations map[string]string) *corev1.Pod { + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace, Annotations: annotations}, + Spec: corev1.PodSpec{Containers: []corev1.Container{{Name: "app", Image: "test"}}}, + Status: corev1.PodStatus{Phase: corev1.PodRunning}, + } + if annotations != nil { + pod.Annotations = annotations + } + return pod +} diff --git a/pkg/controllers/register.go b/pkg/controllers/register.go index a39dbc3..292733f 100644 --- a/pkg/controllers/register.go +++ b/pkg/controllers/register.go @@ -18,54 +18,52 @@ package controllers import ( - ctrl "sigs.k8s.io/controller-runtime" + ctrl "sigs.k8s.io/controller-runtime" - internalcontroller "github.com/r6security/phoenix/internal/controller" + internalcontroller "github.com/r6security/phoenix/internal/controller" ) // RegisterCoreControllers registers all core Phoenix controllers with the manager. // This wrapper keeps controller implementations internal while exposing a public entrypoint. func RegisterCoreControllers(mgr ctrl.Manager) error { - if err := (&internalcontroller.AdaptiveMovingTargetDefenseReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - }).SetupWithManager(mgr); err != nil { - return err - } + if err := (&internalcontroller.AdaptiveMovingTargetDefenseReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + return err + } - if err := (&internalcontroller.PodReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - }).SetupWithManager(mgr); err != nil { - return err - } + if err := (&internalcontroller.PodReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + return err + } - if err := (&internalcontroller.SecurityEventReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - }).SetupWithManager(mgr); err != nil { - return err - } + if err := (&internalcontroller.SecurityEventReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + return err + } - return nil + return nil } // RegisterAMTDAndPodControllers registers only AMTD and Pod controllers. func RegisterAMTDAndPodControllers(mgr ctrl.Manager) error { - if err := (&internalcontroller.AdaptiveMovingTargetDefenseReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - }).SetupWithManager(mgr); err != nil { - return err - } + if err := (&internalcontroller.AdaptiveMovingTargetDefenseReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + return err + } - if err := (&internalcontroller.PodReconciler{ - Client: mgr.GetClient(), - Scheme: mgr.GetScheme(), - }).SetupWithManager(mgr); err != nil { - return err - } - return nil + if err := (&internalcontroller.PodReconciler{ + Client: mgr.GetClient(), + Scheme: mgr.GetScheme(), + }).SetupWithManager(mgr); err != nil { + return err + } + return nil } - -