From 7ec648d863064c4754851289d9064828eec952a5 Mon Sep 17 00:00:00 2001 From: ZverGuy Date: Wed, 4 Nov 2026 04:16:35 +0700 Subject: [PATCH] feat(observability-proxy): add the chart and image build Tenants read their logs, and later their metrics, through an authorizing proxy that sits in front of the VictoriaLogs and VictoriaMetrics read paths, so the proxy needs a deployable package before the console or tenant Grafana can reach it. The proxy reviews every token and access decision against the API server, so its own role is limited to creating TokenReviews and SubjectAccessReviews and reading ApplicationDefinitions and namespaces. The namespace logs root is authorized as list on cozystack.io logs, which no API server serves; the rule is aggregated into the tenant view role so that a namespace viewer passes that review, as upstream grants pods/log at view. The image ref lands without a digest until the first build stamps it, and the package joins the root build list so every build path refreshes it. Assisted-by: LLM Signed-off-by: ZverGuy --- Makefile | 1 + .../system/observability-proxy/Chart.yaml | 3 + packages/system/observability-proxy/Makefile | 20 ++++ .../images/observability-proxy/Dockerfile | 25 ++++ .../templates/deployment.yaml | 68 +++++++++++ .../observability-proxy/templates/rbac.yaml | 33 ++++++ .../observability-proxy/templates/sa.yaml | 4 + .../templates/service.yaml | 15 +++ .../templates/tenant-clusterroles.yaml | 14 +++ .../tests/deployment_test.yaml | 109 ++++++++++++++++++ .../observability-proxy/tests/rbac_test.yaml | 70 +++++++++++ .../tests/service_test.yaml | 30 +++++ .../system/observability-proxy/values.yaml | 16 +++ 13 files changed, 408 insertions(+) create mode 100644 packages/system/observability-proxy/Chart.yaml create mode 100644 packages/system/observability-proxy/Makefile create mode 100644 packages/system/observability-proxy/images/observability-proxy/Dockerfile create mode 100644 packages/system/observability-proxy/templates/deployment.yaml create mode 100644 packages/system/observability-proxy/templates/rbac.yaml create mode 100644 packages/system/observability-proxy/templates/sa.yaml create mode 100644 packages/system/observability-proxy/templates/service.yaml create mode 100644 packages/system/observability-proxy/templates/tenant-clusterroles.yaml create mode 100644 packages/system/observability-proxy/tests/deployment_test.yaml create mode 100644 packages/system/observability-proxy/tests/rbac_test.yaml create mode 100644 packages/system/observability-proxy/tests/service_test.yaml create mode 100644 packages/system/observability-proxy/values.yaml diff --git a/Makefile b/Makefile index 6da0e1037..6581bf304 100644 --- a/Makefile +++ b/Makefile @@ -17,6 +17,7 @@ build: build-deps make -C packages/system/cozystack-api image make -C packages/system/cozystack-controller image make -C packages/system/backup-controller image + make -C packages/system/observability-proxy image make -C packages/system/backupstrategy-controller image make -C packages/system/lineage-controller-webhook image make -C packages/system/migration-controller image diff --git a/packages/system/observability-proxy/Chart.yaml b/packages/system/observability-proxy/Chart.yaml new file mode 100644 index 000000000..282c2234a --- /dev/null +++ b/packages/system/observability-proxy/Chart.yaml @@ -0,0 +1,3 @@ +apiVersion: v2 +name: cozy-observability-proxy +version: 0.0.0 # Placeholder, the actual version will be automatically set during the build process diff --git a/packages/system/observability-proxy/Makefile b/packages/system/observability-proxy/Makefile new file mode 100644 index 000000000..ee66c0656 --- /dev/null +++ b/packages/system/observability-proxy/Makefile @@ -0,0 +1,20 @@ +NAME=observability-proxy +NAMESPACE=cozy-observability-proxy + +include ../../../hack/common-envs.mk +include ../../../hack/package.mk + +test: + helm unittest . + +image: image-observability-proxy + +image-observability-proxy: + docker buildx build -f images/observability-proxy/Dockerfile ../../.. \ + $(call image-tags,observability-proxy,v$(COZYSTACK_VERSION)) \ + $(call cache-args,observability-proxy) \ + --metadata-file images/observability-proxy.json \ + $(BUILDX_ARGS) + IMAGE="$(REGISTRY)/observability-proxy:$(IMAGE_TAG)@$$(yq e '."containerimage.digest"' images/observability-proxy.json -o json -r)" \ + yq -i '.observabilityProxy.image = strenv(IMAGE)' values.yaml + rm -f images/observability-proxy.json diff --git a/packages/system/observability-proxy/images/observability-proxy/Dockerfile b/packages/system/observability-proxy/images/observability-proxy/Dockerfile new file mode 100644 index 000000000..4615e74ec --- /dev/null +++ b/packages/system/observability-proxy/images/observability-proxy/Dockerfile @@ -0,0 +1,25 @@ +FROM --platform=$BUILDPLATFORM golang:1.27-alpine@sha256:8a5910f31396cd4d89662f56c68b3ae31d374308270a1c3bd96672ee5ed43414 AS builder +ARG GOPROXY + +ARG TARGETOS +ARG TARGETARCH + +WORKDIR /workspace + +COPY go.mod go.sum ./ +RUN for attempt in 1 2 3; do GOOS=$TARGETOS GOARCH=$TARGETARCH go mod download && break; [ "$attempt" = 3 ] && exit 1; sleep $((attempt * 10)); done + +COPY api api/ +COPY pkg pkg/ +COPY cmd cmd/ +COPY internal internal/ + +RUN GOOS=$TARGETOS GOARCH=$TARGETARCH CGO_ENABLED=0 go build -ldflags="-extldflags=-static" -o /observability-proxy ./cmd/observability-proxy + +FROM scratch + +COPY --from=builder /observability-proxy /observability-proxy +COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt + +USER 65532:65532 +ENTRYPOINT ["/observability-proxy"] diff --git a/packages/system/observability-proxy/templates/deployment.yaml b/packages/system/observability-proxy/templates/deployment.yaml new file mode 100644 index 000000000..581c15fde --- /dev/null +++ b/packages/system/observability-proxy/templates/deployment.yaml @@ -0,0 +1,68 @@ +{{- $clusterDomain := index (.Values._cluster | default dict) "cluster-domain" | default "cozy.local" }} +apiVersion: apps/v1 +kind: Deployment +metadata: + name: observability-proxy + labels: + app.kubernetes.io/name: observability-proxy +spec: + replicas: {{ .Values.observabilityProxy.replicas }} + selector: + matchLabels: + app.kubernetes.io/name: observability-proxy + template: + metadata: + labels: + app.kubernetes.io/name: observability-proxy + spec: + serviceAccountName: observability-proxy + affinity: + podAntiAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 100 + podAffinityTerm: + topologyKey: kubernetes.io/hostname + labelSelector: + matchLabels: + app.kubernetes.io/name: observability-proxy + securityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + containers: + - name: observability-proxy + image: {{ .Values.observabilityProxy.image | quote }} + args: + - --listen-address=:8080 + - --probe-address=:8081 + - --cluster-domain={{ $clusterDomain }} + - --root-tenant={{ .Values.observabilityProxy.rootTenant }} + - --auth-cache-ttl={{ .Values.observabilityProxy.authCacheTTL }} + ports: + - name: http + containerPort: 8080 + protocol: TCP + - name: probes + containerPort: 8081 + protocol: TCP + readinessProbe: + httpGet: + path: /readyz + port: probes + initialDelaySeconds: 5 + periodSeconds: 10 + livenessProbe: + httpGet: + path: /healthz + port: probes + initialDelaySeconds: 15 + periodSeconds: 20 + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL + {{- with .Values.observabilityProxy.resources }} + resources: {{- toYaml . | nindent 10 }} + {{- end }} diff --git a/packages/system/observability-proxy/templates/rbac.yaml b/packages/system/observability-proxy/templates/rbac.yaml new file mode 100644 index 000000000..f26b71fe3 --- /dev/null +++ b/packages/system/observability-proxy/templates/rbac.yaml @@ -0,0 +1,33 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: cozy:observability-proxy +rules: +# Every request is authenticated and authorized against the API server. +- apiGroups: ["authentication.k8s.io"] + resources: ["tokenreviews"] + verbs: ["create"] +- apiGroups: ["authorization.k8s.io"] + resources: ["subjectaccessreviews"] + verbs: ["create"] +# Maps an application plural in the path to its Kind. +- apiGroups: ["cozystack.io"] + resources: ["applicationdefinitions"] + verbs: ["get", "list", "watch"] +# The namespace.cozystack.io/monitoring label names the logs store. +- apiGroups: [""] + resources: ["namespaces"] + verbs: ["get", "list", "watch"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: cozy:observability-proxy +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: cozy:observability-proxy +subjects: +- kind: ServiceAccount + name: observability-proxy + namespace: {{ .Release.Namespace }} diff --git a/packages/system/observability-proxy/templates/sa.yaml b/packages/system/observability-proxy/templates/sa.yaml new file mode 100644 index 000000000..5a4eb2182 --- /dev/null +++ b/packages/system/observability-proxy/templates/sa.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: ServiceAccount +metadata: + name: observability-proxy diff --git a/packages/system/observability-proxy/templates/service.yaml b/packages/system/observability-proxy/templates/service.yaml new file mode 100644 index 000000000..6a398532a --- /dev/null +++ b/packages/system/observability-proxy/templates/service.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Service +metadata: + name: observability-proxy + labels: + app.kubernetes.io/name: observability-proxy +spec: + type: ClusterIP + ports: + - name: http + port: 80 + targetPort: http + protocol: TCP + selector: + app.kubernetes.io/name: observability-proxy diff --git a/packages/system/observability-proxy/templates/tenant-clusterroles.yaml b/packages/system/observability-proxy/templates/tenant-clusterroles.yaml new file mode 100644 index 000000000..23460352f --- /dev/null +++ b/packages/system/observability-proxy/templates/tenant-clusterroles.yaml @@ -0,0 +1,14 @@ +# Aggregated into cozy-tenant-view (and consequently use, admin, super-admin). +# No API server serves cozystack.io logs: the rule exists only for the +# SubjectAccessReview the observability proxy asks before it answers a read +# under /apis/cozystack.io/v1alpha1/namespaces//logs/. +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: cozy:observability:view + labels: + rbac.cozystack.io/aggregate-to-tenant-view: "true" +rules: +- apiGroups: ["cozystack.io"] + resources: ["logs"] + verbs: ["list"] diff --git a/packages/system/observability-proxy/tests/deployment_test.yaml b/packages/system/observability-proxy/tests/deployment_test.yaml new file mode 100644 index 000000000..c8415be01 --- /dev/null +++ b/packages/system/observability-proxy/tests/deployment_test.yaml @@ -0,0 +1,109 @@ +suite: observability-proxy Deployment +# The flags, ports and pod label below are the contract the proxy binary, the +# Service, the console nginx and the tenant egress policy are written against. +# A rename on either side breaks routing without failing a render. +templates: + - templates/deployment.yaml + +release: + name: observability-proxy + namespace: cozy-observability-proxy + +tests: + - it: labels the pods with the name the tenant egress policy selects on + asserts: + - isKind: + of: Deployment + - equal: + path: spec.selector.matchLabels + value: + app.kubernetes.io/name: observability-proxy + - equal: + path: spec.template.metadata.labels["app.kubernetes.io/name"] + value: observability-proxy + - equal: + path: spec.template.spec.serviceAccountName + value: observability-proxy + + - it: passes the listen, probe, root tenant and auth cache flags + asserts: + - contains: + path: spec.template.spec.containers[0].args + content: --listen-address=:8080 + - contains: + path: spec.template.spec.containers[0].args + content: --probe-address=:8081 + - contains: + path: spec.template.spec.containers[0].args + content: --root-tenant=tenant-root + - contains: + path: spec.template.spec.containers[0].args + content: --auth-cache-ttl=10s + + - it: falls back to cozy.local when the platform passes no cluster domain + asserts: + - contains: + path: spec.template.spec.containers[0].args + content: --cluster-domain=cozy.local + + - it: passes the platform cluster domain + set: + _cluster: + cluster-domain: cluster.local + asserts: + - contains: + path: spec.template.spec.containers[0].args + content: --cluster-domain=cluster.local + - notContains: + path: spec.template.spec.containers[0].args + content: --cluster-domain=cozy.local + + - it: exposes the API and probe ports + asserts: + - equal: + path: spec.template.spec.containers[0].ports + value: + - name: http + containerPort: 8080 + protocol: TCP + - name: probes + containerPort: 8081 + protocol: TCP + + - it: probes /healthz and /readyz on the probe port + asserts: + - equal: + path: spec.template.spec.containers[0].livenessProbe.httpGet + value: + path: /healthz + port: probes + - equal: + path: spec.template.spec.containers[0].readinessProbe.httpGet + value: + path: /readyz + port: probes + + - it: runs from the image in values + set: + observabilityProxy.image: registry.example/observability-proxy:dev@sha256:0000 + asserts: + - equal: + path: spec.template.spec.containers[0].image + value: registry.example/observability-proxy:dev@sha256:0000 + + - it: satisfies the restricted pod security profile + asserts: + - equal: + path: spec.template.spec.securityContext + value: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + - equal: + path: spec.template.spec.containers[0].securityContext + value: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: + - ALL diff --git a/packages/system/observability-proxy/tests/rbac_test.yaml b/packages/system/observability-proxy/tests/rbac_test.yaml new file mode 100644 index 000000000..d29fe87a3 --- /dev/null +++ b/packages/system/observability-proxy/tests/rbac_test.yaml @@ -0,0 +1,70 @@ +suite: observability-proxy RBAC +# The proxy authenticates and authorizes every request through the API server, +# so a dropped verb here turns every request into an error at runtime rather +# than a render failure. The tenant role is what lets a namespace viewer pass +# the SubjectAccessReview the proxy asks for the namespace logs root. +templates: + - templates/rbac.yaml + - templates/tenant-clusterroles.yaml + +release: + name: observability-proxy + namespace: cozy-observability-proxy + +tests: + - it: lets the proxy review tokens and access, and read ApplicationDefinitions and namespaces + template: templates/rbac.yaml + documentSelector: + path: kind + value: ClusterRole + asserts: + - equal: + path: rules + value: + - apiGroups: ["authentication.k8s.io"] + resources: ["tokenreviews"] + verbs: ["create"] + - apiGroups: ["authorization.k8s.io"] + resources: ["subjectaccessreviews"] + verbs: ["create"] + - apiGroups: ["cozystack.io"] + resources: ["applicationdefinitions"] + verbs: ["get", "list", "watch"] + - apiGroups: [""] + resources: ["namespaces"] + verbs: ["get", "list", "watch"] + + - it: binds the proxy role to the proxy ServiceAccount in the release namespace + template: templates/rbac.yaml + documentSelector: + path: kind + value: ClusterRoleBinding + asserts: + - equal: + path: roleRef + value: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: cozy:observability-proxy + - equal: + path: subjects + value: + - kind: ServiceAccount + name: observability-proxy + namespace: cozy-observability-proxy + + - it: aggregates list on cozystack.io logs into the tenant view role + template: templates/tenant-clusterroles.yaml + asserts: + - isKind: + of: ClusterRole + - equal: + path: metadata.labels + value: + rbac.cozystack.io/aggregate-to-tenant-view: "true" + - equal: + path: rules + value: + - apiGroups: ["cozystack.io"] + resources: ["logs"] + verbs: ["list"] diff --git a/packages/system/observability-proxy/tests/service_test.yaml b/packages/system/observability-proxy/tests/service_test.yaml new file mode 100644 index 000000000..0fb8b0e00 --- /dev/null +++ b/packages/system/observability-proxy/tests/service_test.yaml @@ -0,0 +1,30 @@ +suite: observability-proxy Service +# The console nginx proxies to observability-proxy..svc on port 80 and the +# tenant egress policy allows the pods' 8080, so the name, the port and the +# selector are pinned here. +templates: + - templates/service.yaml + +release: + name: observability-proxy + namespace: cozy-observability-proxy + +tests: + - it: serves port 80 named http on the pods' http port + asserts: + - isKind: + of: Service + - equal: + path: metadata.name + value: observability-proxy + - equal: + path: spec.ports + value: + - name: http + port: 80 + targetPort: http + protocol: TCP + - equal: + path: spec.selector + value: + app.kubernetes.io/name: observability-proxy diff --git a/packages/system/observability-proxy/values.yaml b/packages/system/observability-proxy/values.yaml new file mode 100644 index 000000000..919e450a9 --- /dev/null +++ b/packages/system/observability-proxy/values.yaml @@ -0,0 +1,16 @@ +# Injected by the platform from the cozystack-values Secret. The empty +# default keeps the chart renderable on its own, outside a cluster. +_cluster: {} + +observabilityProxy: + image: "ghcr.io/cozystack/cozystack/observability-proxy:v0.0.0" + replicas: 2 + rootTenant: tenant-root + authCacheTTL: 10s + resources: + requests: + cpu: 10m + memory: 64Mi + limits: + cpu: 500m + memory: 256Mi