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