From 46993808eaa585fb959c2c4231299e9cd04e8d34 Mon Sep 17 00:00:00 2001 From: Artur Reznikov Date: Mon, 26 Oct 2026 14:36:17 -0700 Subject: [PATCH 1/3] feat(api/misc): address low-med hard security Signed-off-by: Artur Reznikov --- Dockerfile | 4 +- Dockerfile.beacon | 4 +- Dockerfile.uc | 4 +- api/v1alpha1/consensuscapsule_types.go | 3 +- api/v1alpha1/envoyproxy_types.go | 5 +- api/v1alpha1/haproxycapsule_types.go | 3 + api/v1alpha1/networkupgrade_common_types.go | 2 +- api/v1alpha1/orbit_types.go | 3 +- ...ator.solo.hiero.org_consensuscapsules.yaml | 8 +- .../operator.solo.hiero.org_envoyproxies.yaml | 6 ++ ...erator.solo.hiero.org_haproxycapsules.yaml | 6 ++ ...solo.hiero.org_networkupgradeexecutes.yaml | 12 +-- ....solo.hiero.org_networkupgradefreezes.yaml | 12 +-- ...solo.hiero.org_networkupgradeprepares.yaml | 12 +-- .../bases/operator.solo.hiero.org_orbits.yaml | 3 +- internal/beacon/commands/file.go | 9 ++- internal/beacon/commands/reasons.go | 1 + internal/controller/envoyproxy_controller.go | 4 + .../controller/haproxycapsule_controller.go | 4 + internal/controller/proxy_digest_policy.go | 35 ++++++++ .../controller/proxy_digest_policy_test.go | 58 ++++++++++++++ internal/resources/consensus/cheetah_test.go | 8 +- internal/resources/consensus/defaults.go | 5 ++ internal/resources/consensus/deployment.go | 55 +++++++++---- internal/resources/consensus/reasons.go | 2 + internal/resources/consensus/role_test.go | 13 +++ .../resources/consensus/statefulset_test.go | 79 +++++++++++++++++++ 27 files changed, 307 insertions(+), 53 deletions(-) create mode 100644 internal/controller/proxy_digest_policy.go create mode 100644 internal/controller/proxy_digest_policy_test.go diff --git a/Dockerfile b/Dockerfile index e16e50cd8..237ff23a5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ # Selector stage: pick the pre-compiled binary for the target architecture. # TARGETARCH is set by docker buildx to the target platform (amd64/arm64), # which matches our binary suffix — unlike uname -m which reflects the builder. -FROM debian:bookworm-slim AS selector +FROM debian:bookworm-slim@sha256:7ff6ba31527b9e336162fa061746c366c4b2032f4d2cff2078bea43f78efe3f8 AS selector ARG TARGETARCH @@ -15,7 +15,7 @@ RUN cd /tmp && \ chmod +x /manager # Final stage -FROM gcr.io/distroless/static-debian13:nonroot +FROM gcr.io/distroless/static-debian13:nonroot@sha256:18f4cc26c9ffa7f20a359298be7deadac5c607c8e03a02b8119f1a5fc2f82c99 WORKDIR / diff --git a/Dockerfile.beacon b/Dockerfile.beacon index 29f9756e4..4262610b8 100644 --- a/Dockerfile.beacon +++ b/Dockerfile.beacon @@ -11,7 +11,7 @@ # Selector stage: pick the pre-compiled binary for the target architecture. # TARGETARCH is set by docker buildx to the target platform (amd64/arm64), # which matches our binary suffix — unlike uname -m which reflects the builder. -FROM debian:bookworm-slim AS selector +FROM debian:bookworm-slim@sha256:7ff6ba31527b9e336162fa061746c366c4b2032f4d2cff2078bea43f78efe3f8 AS selector ARG TARGETARCH @@ -30,7 +30,7 @@ RUN cd /tmp && \ # so any shared volume ownership lines up. The runtime user is still overridable # per deployment via the pod/container securityContext, which takes precedence # over this image USER. -FROM debian:bookworm-slim +FROM debian:bookworm-slim@sha256:7ff6ba31527b9e336162fa061746c366c4b2032f4d2cff2078bea43f78efe3f8 # Match the consensus-node and UC images: hedera group and user, both 2000. RUN groupadd --gid 2000 hedera && \ diff --git a/Dockerfile.uc b/Dockerfile.uc index 2ba7b842d..ec5a3054c 100644 --- a/Dockerfile.uc +++ b/Dockerfile.uc @@ -8,7 +8,7 @@ # Selector stage: pick the pre-compiled binary for the target architecture. # TARGETARCH is set by docker buildx to the target platform (amd64/arm64), # which matches our binary suffix — unlike uname -m which reflects the builder. -FROM debian:bookworm-slim AS selector +FROM debian:bookworm-slim@sha256:7ff6ba31527b9e336162fa061746c366c4b2032f4d2cff2078bea43f78efe3f8 AS selector ARG TARGETARCH @@ -30,7 +30,7 @@ RUN cd /tmp && \ # # We use debian:bookworm-slim (not distroless) so the image ships a shell for # `kubectl exec` debugging of the shared upgrade tree. -FROM debian:bookworm-slim +FROM debian:bookworm-slim@sha256:7ff6ba31527b9e336162fa061746c366c4b2032f4d2cff2078bea43f78efe3f8 # Match the consensus-node image: hedera group and user, both 2000. RUN groupadd --gid 2000 hedera && \ diff --git a/api/v1alpha1/consensuscapsule_types.go b/api/v1alpha1/consensuscapsule_types.go index a5afdd11c..8e9f642ef 100644 --- a/api/v1alpha1/consensuscapsule_types.go +++ b/api/v1alpha1/consensuscapsule_types.go @@ -128,7 +128,7 @@ type ConsensusCapsuleSpec struct { // +optional NodeProperties map[string]string `json:"nodeProperties,omitempty"` - // hostPath volume containing config.txt + // hostPath volume containing config.txt, mounted read-only. Legacy; prefer the config CR refs. // +optional ConfigFolder *corev1.HostPathVolumeSource `json:"configFolder,omitempty"` @@ -166,6 +166,7 @@ type ConsensusCapsuleSpec struct { // ConsensusSecrets holds TLS and signing key references. type ConsensusSecrets struct { + // HostPath mounts node keys read-only from the host. Legacy; prefer secretResources. // +optional HostPath *corev1.HostPathVolumeSource `json:"hostPath,omitempty"` diff --git a/api/v1alpha1/envoyproxy_types.go b/api/v1alpha1/envoyproxy_types.go index 4352f20d3..9c3658559 100644 --- a/api/v1alpha1/envoyproxy_types.go +++ b/api/v1alpha1/envoyproxy_types.go @@ -39,8 +39,11 @@ type EnvoyProxySpec struct { // +kubebuilder:validation:MinLength=1 Orbit string `json:"orbit"` - // Image is the Envoy container image. + // Image is the Envoy container image, pinned by a non-latest tag or a sha256 digest. // +kubebuilder:default="envoyproxy/envoy-distroless:v1.21.6" + // +kubebuilder:validation:MaxLength=512 + // +kubebuilder:validation:XValidation:rule="self.matches('@sha256:[a-f0-9]{64}$') || self.matches(':[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$')",message="image must be pinned by a tag or a sha256 digest" + // +kubebuilder:validation:XValidation:rule="!self.lowerAscii().matches(':latest(@sha256:[a-f0-9]{64})?$')",message="image must not use the mutable 'latest' tag" // +optional Image string `json:"image,omitempty"` diff --git a/api/v1alpha1/haproxycapsule_types.go b/api/v1alpha1/haproxycapsule_types.go index db40cf938..411f240fa 100644 --- a/api/v1alpha1/haproxycapsule_types.go +++ b/api/v1alpha1/haproxycapsule_types.go @@ -70,6 +70,9 @@ type HAProxyCapsuleSpec struct { // Image is the HAProxy container image. // +kubebuilder:default="haproxy:2.8.10-alpine3.20" + // +kubebuilder:validation:MaxLength=512 + // +kubebuilder:validation:XValidation:rule="self.matches('@sha256:[a-f0-9]{64}$') || self.matches(':[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$')",message="image must be pinned by a tag or a sha256 digest" + // +kubebuilder:validation:XValidation:rule="!self.lowerAscii().matches(':latest(@sha256:[a-f0-9]{64})?$')",message="image must not use the mutable 'latest' tag" // +optional Image string `json:"image,omitempty"` diff --git a/api/v1alpha1/networkupgrade_common_types.go b/api/v1alpha1/networkupgrade_common_types.go index 6f23711ca..05a797efd 100644 --- a/api/v1alpha1/networkupgrade_common_types.go +++ b/api/v1alpha1/networkupgrade_common_types.go @@ -108,7 +108,7 @@ type ImageRegistrySpec struct { // Image is a "registry[:port]/repository[:tag][@sha256:digest]" reference, // e.g. "ghcr.io/hashgraph/consensus-node:0.75.0" or // "registry.example.com:5000/consensus-node@sha256:<64 hex chars>". - // +kubebuilder:validation:Pattern=`^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?(:[0-9]+)?(/[a-zA-Z0-9._-]+)+(:[a-zA-Z0-9._-]+)?(@sha256:[a-f0-9]{64})?$` + // +kubebuilder:validation:Pattern=`^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?(:[0-9]+)?(/[a-zA-Z0-9._-]+)+(:[a-zA-Z0-9._-]+(@sha256:[a-f0-9]{64})?|@sha256:[a-f0-9]{64})$` Image string `json:"image"` // Per-registry layer hashes for non-deterministic builds. // +optional diff --git a/api/v1alpha1/orbit_types.go b/api/v1alpha1/orbit_types.go index 1cca58df4..611a23441 100644 --- a/api/v1alpha1/orbit_types.go +++ b/api/v1alpha1/orbit_types.go @@ -41,7 +41,8 @@ type OrbitSpec struct { // default image (relying on a default is not permitted under this policy). This is the // opt-in, fresh-deploy counterpart to the always-on digest enforcement of the network // upgrade flow; use it to guarantee digest-pinned images from day one rather than only - // after the first upgrade. Default false (tag-only images are allowed on deploy). + // after the first upgrade. Also applies to EnvoyProxy and HAProxyCapsule spec.image. + // Default false (tag-only images are allowed on deploy). // +optional RequireDigestOnDeploy bool `json:"requireDigestOnDeploy,omitempty"` diff --git a/config/crd/bases/operator.solo.hiero.org_consensuscapsules.yaml b/config/crd/bases/operator.solo.hiero.org_consensuscapsules.yaml index 56b0432a8..a82187729 100644 --- a/config/crd/bases/operator.solo.hiero.org_consensuscapsules.yaml +++ b/config/crd/bases/operator.solo.hiero.org_consensuscapsules.yaml @@ -90,7 +90,8 @@ spec: at data/config/bootstrap.properties. Optional — CN falls back to defaults. type: string configFolder: - description: hostPath volume containing config.txt + description: hostPath volume containing config.txt, mounted read-only. + Legacy; prefer the config CR refs. properties: path: description: |- @@ -23278,9 +23279,8 @@ spec: description: key/cert secret references properties: hostPath: - description: |- - Represents a host path mapped into a pod. - Host path volumes do not support ownership management or SELinux relabeling. + description: HostPath mounts node keys read-only from the host. + Legacy; prefer secretResources. properties: path: description: |- diff --git a/config/crd/bases/operator.solo.hiero.org_envoyproxies.yaml b/config/crd/bases/operator.solo.hiero.org_envoyproxies.yaml index 32944db2b..60c2e13a4 100644 --- a/config/crd/bases/operator.solo.hiero.org_envoyproxies.yaml +++ b/config/crd/bases/operator.solo.hiero.org_envoyproxies.yaml @@ -1185,7 +1185,13 @@ spec: image: default: envoyproxy/envoy-distroless:v1.21.6 description: Image is the Envoy container image. + maxLength: 512 type: string + x-kubernetes-validations: + - message: image must be pinned by a tag or a sha256 digest + rule: self.matches('@sha256:[a-f0-9]{64}$') || self.matches(':[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$') + - message: image must not use the mutable 'latest' tag + rule: '!self.lowerAscii().matches('':latest(@sha256:[a-f0-9]{64})?$'')' ingressSpec: description: IngressSpec is required when serviceType is Ingress. properties: diff --git a/config/crd/bases/operator.solo.hiero.org_haproxycapsules.yaml b/config/crd/bases/operator.solo.hiero.org_haproxycapsules.yaml index cbfb60fb3..ee8ac32a9 100644 --- a/config/crd/bases/operator.solo.hiero.org_haproxycapsules.yaml +++ b/config/crd/bases/operator.solo.hiero.org_haproxycapsules.yaml @@ -1198,7 +1198,13 @@ spec: image: default: haproxy:2.8.10-alpine3.20 description: Image is the HAProxy container image. + maxLength: 512 type: string + x-kubernetes-validations: + - message: image must be pinned by a tag or a sha256 digest + rule: self.matches('@sha256:[a-f0-9]{64}$') || self.matches(':[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$') + - message: image must not use the mutable 'latest' tag + rule: '!self.lowerAscii().matches('':latest(@sha256:[a-f0-9]{64})?$'')' ingressSpec: description: IngressSpec is required when serviceType is Ingress. properties: diff --git a/config/crd/bases/operator.solo.hiero.org_networkupgradeexecutes.yaml b/config/crd/bases/operator.solo.hiero.org_networkupgradeexecutes.yaml index 19de7bcbd..e8e3c4ca2 100644 --- a/config/crd/bases/operator.solo.hiero.org_networkupgradeexecutes.yaml +++ b/config/crd/bases/operator.solo.hiero.org_networkupgradeexecutes.yaml @@ -111,7 +111,7 @@ spec: Image is a "registry[:port]/repository[:tag][@sha256:digest]" reference, e.g. "ghcr.io/hashgraph/consensus-node:0.75.0" or "registry.example.com:5000/consensus-node@sha256:<64 hex chars>". - pattern: ^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?(:[0-9]+)?(/[a-zA-Z0-9._-]+)+(:[a-zA-Z0-9._-]+)?(@sha256:[a-f0-9]{64})?$ + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?(:[0-9]+)?(/[a-zA-Z0-9._-]+)+(:[a-zA-Z0-9._-]+(@sha256:[a-f0-9]{64})?|@sha256:[a-f0-9]{64})$ type: string layerHashes: additionalProperties: @@ -187,7 +187,7 @@ spec: Image is a "registry[:port]/repository[:tag][@sha256:digest]" reference, e.g. "ghcr.io/hashgraph/consensus-node:0.75.0" or "registry.example.com:5000/consensus-node@sha256:<64 hex chars>". - pattern: ^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?(:[0-9]+)?(/[a-zA-Z0-9._-]+)+(:[a-zA-Z0-9._-]+)?(@sha256:[a-f0-9]{64})?$ + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?(:[0-9]+)?(/[a-zA-Z0-9._-]+)+(:[a-zA-Z0-9._-]+(@sha256:[a-f0-9]{64})?|@sha256:[a-f0-9]{64})$ type: string layerHashes: additionalProperties: @@ -263,7 +263,7 @@ spec: Image is a "registry[:port]/repository[:tag][@sha256:digest]" reference, e.g. "ghcr.io/hashgraph/consensus-node:0.75.0" or "registry.example.com:5000/consensus-node@sha256:<64 hex chars>". - pattern: ^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?(:[0-9]+)?(/[a-zA-Z0-9._-]+)+(:[a-zA-Z0-9._-]+)?(@sha256:[a-f0-9]{64})?$ + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?(:[0-9]+)?(/[a-zA-Z0-9._-]+)+(:[a-zA-Z0-9._-]+(@sha256:[a-f0-9]{64})?|@sha256:[a-f0-9]{64})$ type: string layerHashes: additionalProperties: @@ -339,7 +339,7 @@ spec: Image is a "registry[:port]/repository[:tag][@sha256:digest]" reference, e.g. "ghcr.io/hashgraph/consensus-node:0.75.0" or "registry.example.com:5000/consensus-node@sha256:<64 hex chars>". - pattern: ^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?(:[0-9]+)?(/[a-zA-Z0-9._-]+)+(:[a-zA-Z0-9._-]+)?(@sha256:[a-f0-9]{64})?$ + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?(:[0-9]+)?(/[a-zA-Z0-9._-]+)+(:[a-zA-Z0-9._-]+(@sha256:[a-f0-9]{64})?|@sha256:[a-f0-9]{64})$ type: string layerHashes: additionalProperties: @@ -415,7 +415,7 @@ spec: Image is a "registry[:port]/repository[:tag][@sha256:digest]" reference, e.g. "ghcr.io/hashgraph/consensus-node:0.75.0" or "registry.example.com:5000/consensus-node@sha256:<64 hex chars>". - pattern: ^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?(:[0-9]+)?(/[a-zA-Z0-9._-]+)+(:[a-zA-Z0-9._-]+)?(@sha256:[a-f0-9]{64})?$ + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?(:[0-9]+)?(/[a-zA-Z0-9._-]+)+(:[a-zA-Z0-9._-]+(@sha256:[a-f0-9]{64})?|@sha256:[a-f0-9]{64})$ type: string layerHashes: additionalProperties: @@ -491,7 +491,7 @@ spec: Image is a "registry[:port]/repository[:tag][@sha256:digest]" reference, e.g. "ghcr.io/hashgraph/consensus-node:0.75.0" or "registry.example.com:5000/consensus-node@sha256:<64 hex chars>". - pattern: ^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?(:[0-9]+)?(/[a-zA-Z0-9._-]+)+(:[a-zA-Z0-9._-]+)?(@sha256:[a-f0-9]{64})?$ + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?(:[0-9]+)?(/[a-zA-Z0-9._-]+)+(:[a-zA-Z0-9._-]+(@sha256:[a-f0-9]{64})?|@sha256:[a-f0-9]{64})$ type: string layerHashes: additionalProperties: diff --git a/config/crd/bases/operator.solo.hiero.org_networkupgradefreezes.yaml b/config/crd/bases/operator.solo.hiero.org_networkupgradefreezes.yaml index a509b274a..da72de4ad 100644 --- a/config/crd/bases/operator.solo.hiero.org_networkupgradefreezes.yaml +++ b/config/crd/bases/operator.solo.hiero.org_networkupgradefreezes.yaml @@ -111,7 +111,7 @@ spec: Image is a "registry[:port]/repository[:tag][@sha256:digest]" reference, e.g. "ghcr.io/hashgraph/consensus-node:0.75.0" or "registry.example.com:5000/consensus-node@sha256:<64 hex chars>". - pattern: ^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?(:[0-9]+)?(/[a-zA-Z0-9._-]+)+(:[a-zA-Z0-9._-]+)?(@sha256:[a-f0-9]{64})?$ + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?(:[0-9]+)?(/[a-zA-Z0-9._-]+)+(:[a-zA-Z0-9._-]+(@sha256:[a-f0-9]{64})?|@sha256:[a-f0-9]{64})$ type: string layerHashes: additionalProperties: @@ -187,7 +187,7 @@ spec: Image is a "registry[:port]/repository[:tag][@sha256:digest]" reference, e.g. "ghcr.io/hashgraph/consensus-node:0.75.0" or "registry.example.com:5000/consensus-node@sha256:<64 hex chars>". - pattern: ^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?(:[0-9]+)?(/[a-zA-Z0-9._-]+)+(:[a-zA-Z0-9._-]+)?(@sha256:[a-f0-9]{64})?$ + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?(:[0-9]+)?(/[a-zA-Z0-9._-]+)+(:[a-zA-Z0-9._-]+(@sha256:[a-f0-9]{64})?|@sha256:[a-f0-9]{64})$ type: string layerHashes: additionalProperties: @@ -263,7 +263,7 @@ spec: Image is a "registry[:port]/repository[:tag][@sha256:digest]" reference, e.g. "ghcr.io/hashgraph/consensus-node:0.75.0" or "registry.example.com:5000/consensus-node@sha256:<64 hex chars>". - pattern: ^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?(:[0-9]+)?(/[a-zA-Z0-9._-]+)+(:[a-zA-Z0-9._-]+)?(@sha256:[a-f0-9]{64})?$ + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?(:[0-9]+)?(/[a-zA-Z0-9._-]+)+(:[a-zA-Z0-9._-]+(@sha256:[a-f0-9]{64})?|@sha256:[a-f0-9]{64})$ type: string layerHashes: additionalProperties: @@ -339,7 +339,7 @@ spec: Image is a "registry[:port]/repository[:tag][@sha256:digest]" reference, e.g. "ghcr.io/hashgraph/consensus-node:0.75.0" or "registry.example.com:5000/consensus-node@sha256:<64 hex chars>". - pattern: ^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?(:[0-9]+)?(/[a-zA-Z0-9._-]+)+(:[a-zA-Z0-9._-]+)?(@sha256:[a-f0-9]{64})?$ + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?(:[0-9]+)?(/[a-zA-Z0-9._-]+)+(:[a-zA-Z0-9._-]+(@sha256:[a-f0-9]{64})?|@sha256:[a-f0-9]{64})$ type: string layerHashes: additionalProperties: @@ -415,7 +415,7 @@ spec: Image is a "registry[:port]/repository[:tag][@sha256:digest]" reference, e.g. "ghcr.io/hashgraph/consensus-node:0.75.0" or "registry.example.com:5000/consensus-node@sha256:<64 hex chars>". - pattern: ^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?(:[0-9]+)?(/[a-zA-Z0-9._-]+)+(:[a-zA-Z0-9._-]+)?(@sha256:[a-f0-9]{64})?$ + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?(:[0-9]+)?(/[a-zA-Z0-9._-]+)+(:[a-zA-Z0-9._-]+(@sha256:[a-f0-9]{64})?|@sha256:[a-f0-9]{64})$ type: string layerHashes: additionalProperties: @@ -491,7 +491,7 @@ spec: Image is a "registry[:port]/repository[:tag][@sha256:digest]" reference, e.g. "ghcr.io/hashgraph/consensus-node:0.75.0" or "registry.example.com:5000/consensus-node@sha256:<64 hex chars>". - pattern: ^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?(:[0-9]+)?(/[a-zA-Z0-9._-]+)+(:[a-zA-Z0-9._-]+)?(@sha256:[a-f0-9]{64})?$ + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?(:[0-9]+)?(/[a-zA-Z0-9._-]+)+(:[a-zA-Z0-9._-]+(@sha256:[a-f0-9]{64})?|@sha256:[a-f0-9]{64})$ type: string layerHashes: additionalProperties: diff --git a/config/crd/bases/operator.solo.hiero.org_networkupgradeprepares.yaml b/config/crd/bases/operator.solo.hiero.org_networkupgradeprepares.yaml index f03924f95..4f4e6a25c 100644 --- a/config/crd/bases/operator.solo.hiero.org_networkupgradeprepares.yaml +++ b/config/crd/bases/operator.solo.hiero.org_networkupgradeprepares.yaml @@ -108,7 +108,7 @@ spec: Image is a "registry[:port]/repository[:tag][@sha256:digest]" reference, e.g. "ghcr.io/hashgraph/consensus-node:0.75.0" or "registry.example.com:5000/consensus-node@sha256:<64 hex chars>". - pattern: ^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?(:[0-9]+)?(/[a-zA-Z0-9._-]+)+(:[a-zA-Z0-9._-]+)?(@sha256:[a-f0-9]{64})?$ + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?(:[0-9]+)?(/[a-zA-Z0-9._-]+)+(:[a-zA-Z0-9._-]+(@sha256:[a-f0-9]{64})?|@sha256:[a-f0-9]{64})$ type: string layerHashes: additionalProperties: @@ -184,7 +184,7 @@ spec: Image is a "registry[:port]/repository[:tag][@sha256:digest]" reference, e.g. "ghcr.io/hashgraph/consensus-node:0.75.0" or "registry.example.com:5000/consensus-node@sha256:<64 hex chars>". - pattern: ^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?(:[0-9]+)?(/[a-zA-Z0-9._-]+)+(:[a-zA-Z0-9._-]+)?(@sha256:[a-f0-9]{64})?$ + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?(:[0-9]+)?(/[a-zA-Z0-9._-]+)+(:[a-zA-Z0-9._-]+(@sha256:[a-f0-9]{64})?|@sha256:[a-f0-9]{64})$ type: string layerHashes: additionalProperties: @@ -260,7 +260,7 @@ spec: Image is a "registry[:port]/repository[:tag][@sha256:digest]" reference, e.g. "ghcr.io/hashgraph/consensus-node:0.75.0" or "registry.example.com:5000/consensus-node@sha256:<64 hex chars>". - pattern: ^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?(:[0-9]+)?(/[a-zA-Z0-9._-]+)+(:[a-zA-Z0-9._-]+)?(@sha256:[a-f0-9]{64})?$ + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?(:[0-9]+)?(/[a-zA-Z0-9._-]+)+(:[a-zA-Z0-9._-]+(@sha256:[a-f0-9]{64})?|@sha256:[a-f0-9]{64})$ type: string layerHashes: additionalProperties: @@ -336,7 +336,7 @@ spec: Image is a "registry[:port]/repository[:tag][@sha256:digest]" reference, e.g. "ghcr.io/hashgraph/consensus-node:0.75.0" or "registry.example.com:5000/consensus-node@sha256:<64 hex chars>". - pattern: ^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?(:[0-9]+)?(/[a-zA-Z0-9._-]+)+(:[a-zA-Z0-9._-]+)?(@sha256:[a-f0-9]{64})?$ + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?(:[0-9]+)?(/[a-zA-Z0-9._-]+)+(:[a-zA-Z0-9._-]+(@sha256:[a-f0-9]{64})?|@sha256:[a-f0-9]{64})$ type: string layerHashes: additionalProperties: @@ -412,7 +412,7 @@ spec: Image is a "registry[:port]/repository[:tag][@sha256:digest]" reference, e.g. "ghcr.io/hashgraph/consensus-node:0.75.0" or "registry.example.com:5000/consensus-node@sha256:<64 hex chars>". - pattern: ^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?(:[0-9]+)?(/[a-zA-Z0-9._-]+)+(:[a-zA-Z0-9._-]+)?(@sha256:[a-f0-9]{64})?$ + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?(:[0-9]+)?(/[a-zA-Z0-9._-]+)+(:[a-zA-Z0-9._-]+(@sha256:[a-f0-9]{64})?|@sha256:[a-f0-9]{64})$ type: string layerHashes: additionalProperties: @@ -488,7 +488,7 @@ spec: Image is a "registry[:port]/repository[:tag][@sha256:digest]" reference, e.g. "ghcr.io/hashgraph/consensus-node:0.75.0" or "registry.example.com:5000/consensus-node@sha256:<64 hex chars>". - pattern: ^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?(:[0-9]+)?(/[a-zA-Z0-9._-]+)+(:[a-zA-Z0-9._-]+)?(@sha256:[a-f0-9]{64})?$ + pattern: ^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?(:[0-9]+)?(/[a-zA-Z0-9._-]+)+(:[a-zA-Z0-9._-]+(@sha256:[a-f0-9]{64})?|@sha256:[a-f0-9]{64})$ type: string layerHashes: additionalProperties: diff --git a/config/crd/bases/operator.solo.hiero.org_orbits.yaml b/config/crd/bases/operator.solo.hiero.org_orbits.yaml index b17186fbb..4f2c3b73c 100644 --- a/config/crd/bases/operator.solo.hiero.org_orbits.yaml +++ b/config/crd/bases/operator.solo.hiero.org_orbits.yaml @@ -224,7 +224,8 @@ spec: default image (relying on a default is not permitted under this policy). This is the opt-in, fresh-deploy counterpart to the always-on digest enforcement of the network upgrade flow; use it to guarantee digest-pinned images from day one rather than only - after the first upgrade. Default false (tag-only images are allowed on deploy). + after the first upgrade. Also applies to EnvoyProxy and HAProxyCapsule spec.image. + Default false (tag-only images are allowed on deploy). type: boolean telemetry: description: Telemetry contains telemetry configuration diff --git a/internal/beacon/commands/file.go b/internal/beacon/commands/file.go index 4c1016622..e54f6d992 100644 --- a/internal/beacon/commands/file.go +++ b/internal/beacon/commands/file.go @@ -12,6 +12,7 @@ import ( "strings" "github.com/automa-saga/errx" + "github.com/automa-saga/logx" hiero "github.com/hiero-ledger/hiero-sdk-go/v2/sdk" "github.com/spf13/cobra" @@ -150,7 +151,13 @@ func runFileUpload(cmd *cobra.Command, resolve SessionFunc, zipPath string, file if want, provided, err := expectedHash(expectHash, expectHashFile); err != nil { return err - } else if provided && !bytes.Equal(want, localRaw) { + } else if !provided { + logx.As().Warn(). + Str("reason", reasonUnverifiedPackage.String()). + Str("zip", zipPath). + Str("sha384", localHex). + Msg("Uploading unverified package: no --expect-hash/--expect-hash-file given; confirm the SHA-384 against the release before freeze prepare") + } else if !bytes.Equal(want, localRaw) { return errx.Decorate( errInput.New("local package hash %s does not match expected %s", localHex, hex.EncodeToString(want)), reasonExpectedHashMismatch, diff --git a/internal/beacon/commands/reasons.go b/internal/beacon/commands/reasons.go index 56672a601..13b9f656e 100644 --- a/internal/beacon/commands/reasons.go +++ b/internal/beacon/commands/reasons.go @@ -18,6 +18,7 @@ const ( reasonExpectHashReadFailed errx.Reason = "ExpectHashReadFailed" reasonExpectHashInvalid errx.Reason = "ExpectHashInvalid" reasonExpectedHashMismatch errx.Reason = "ExpectedHashMismatch" + reasonUnverifiedPackage errx.Reason = "UnverifiedPackage" reasonNotPrivilegedPayer errx.Reason = "NotPrivilegedPayer" reasonInvalidCheckpoint errx.Reason = "InvalidCheckpoint" ) diff --git a/internal/controller/envoyproxy_controller.go b/internal/controller/envoyproxy_controller.go index 0fe16288e..7e67b1c51 100644 --- a/internal/controller/envoyproxy_controller.go +++ b/internal/controller/envoyproxy_controller.go @@ -78,6 +78,10 @@ func (r *EnvoyProxyReconciler) Reconcile(ctx context.Context, req ctrl.Request) return ctrl.Result{Requeue: true}, nil } + if err := validateProxyDigestPolicy(ctx, r.Client, envoyObj.Spec.Orbit, "EnvoyProxy", envoyObj.Name, envoy.GetImage(envoyObj)); err != nil { + return ctrl.Result{}, err + } + // Run subreconcilers result, err := r.subreconciler.Reconcile(ctx, envoyObj) if err != nil { diff --git a/internal/controller/haproxycapsule_controller.go b/internal/controller/haproxycapsule_controller.go index fd56906cc..e9ca43d2a 100644 --- a/internal/controller/haproxycapsule_controller.go +++ b/internal/controller/haproxycapsule_controller.go @@ -78,6 +78,10 @@ func (r *HAProxyCapsuleReconciler) Reconcile(ctx context.Context, req ctrl.Reque return ctrl.Result{Requeue: true}, nil } + if err := validateProxyDigestPolicy(ctx, r.Client, haproxyObj.Spec.Orbit, "HAProxyCapsule", haproxyObj.Name, haproxy.GetImage(haproxyObj)); err != nil { + return ctrl.Result{}, err + } + // Run subreconcilers result, err := r.subreconciler.Reconcile(ctx, haproxyObj) if err != nil { diff --git a/internal/controller/proxy_digest_policy.go b/internal/controller/proxy_digest_policy.go new file mode 100644 index 000000000..5adbce4ad --- /dev/null +++ b/internal/controller/proxy_digest_policy.go @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: Apache-2.0 + +package controller + +import ( + "context" + "strings" + + "github.com/automa-saga/errx" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + operatorv1alpha1 "github.com/hashgraph/solo-operator/api/v1alpha1" +) + +// validateProxyDigestPolicy enforces the Orbit's requireDigestOnDeploy on a proxy image. +func validateProxyDigestPolicy(ctx context.Context, c client.Client, orbitName, kind, name, image string) error { + orbit := &operatorv1alpha1.Orbit{} + if err := c.Get(ctx, client.ObjectKey{Name: orbitName}, orbit); err != nil { + if apierrors.IsNotFound(err) { + return nil + } + return errx.WithReason(errK8sAPI.Wrap(err, "get Orbit %s for digest policy", orbitName), ReasonGetFailed) + } + if !orbit.Spec.RequireDigestOnDeploy || strings.Contains(image, "@sha256:") { + return nil + } + return reconcile.TerminalError(errx.WithHints( + errx.WithReason( + errImageDigestRequired.New("Orbit %q requires digest-pinned images on deploy, but %s %q uses unpinned image %q", orbitName, kind, name, image), + ReasonImageDigestRequired), + "Set spec.image to a reference ending in @sha256:, or set the Orbit's spec.requireDigestOnDeploy=false.", + )) +} diff --git a/internal/controller/proxy_digest_policy_test.go b/internal/controller/proxy_digest_policy_test.go new file mode 100644 index 000000000..d2f77f14d --- /dev/null +++ b/internal/controller/proxy_digest_policy_test.go @@ -0,0 +1,58 @@ +// SPDX-License-Identifier: Apache-2.0 + +package controller + +import ( + "context" + "errors" + "testing" + + "github.com/joomcode/errorx" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + "sigs.k8s.io/controller-runtime/pkg/reconcile" + + operatorv1alpha1 "github.com/hashgraph/solo-operator/api/v1alpha1" +) + +func TestValidateProxyDigestPolicy(t *testing.T) { + const pinned = "haproxy:2.8.10@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + orbit := func(require bool) *operatorv1alpha1.Orbit { + return &operatorv1alpha1.Orbit{ + ObjectMeta: metav1.ObjectMeta{Name: "orbit"}, + Spec: operatorv1alpha1.OrbitSpec{RequireDigestOnDeploy: require}, + } + } + tests := []struct { + name string + orbit *operatorv1alpha1.Orbit + image string + terminal bool + }{ + {"policy off allows tag", orbit(false), "haproxy:2.8.10", false}, + {"policy on allows digest", orbit(true), pinned, false}, + {"policy on rejects tag", orbit(true), "haproxy:2.8.10", true}, + {"missing orbit is not blocking", nil, "haproxy:2.8.10", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + b := fake.NewClientBuilder().WithScheme(newControllerTestScheme()) + if tt.orbit != nil { + b = b.WithObjects(tt.orbit) + } + err := validateProxyDigestPolicy(context.Background(), b.Build(), "orbit", "HAProxyCapsule", "hap", tt.image) + if !tt.terminal { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + return + } + if !errors.Is(err, reconcile.TerminalError(nil)) { + t.Fatalf("expected a terminal error, got %v", err) + } + if !errorx.IsOfType(err, errImageDigestRequired) { + t.Errorf("expected errImageDigestRequired, got %v", err) + } + }) + } +} diff --git a/internal/resources/consensus/cheetah_test.go b/internal/resources/consensus/cheetah_test.go index c7f8a1968..bfff73170 100644 --- a/internal/resources/consensus/cheetah_test.go +++ b/internal/resources/consensus/cheetah_test.go @@ -663,7 +663,7 @@ func TestAddSidecarContainers_Cheetah(t *testing.T) { } } -func TestApplyCheetahFSGroup(t *testing.T) { +func TestApplyFSGroup(t *testing.T) { reconciler := &StatefulSetSubReconciler{} t.Run("sets fsGroup 2000 when cheetah enabled and no security context set", func(t *testing.T) { @@ -677,7 +677,7 @@ func TestApplyCheetahFSGroup(t *testing.T) { }, } deploy := &appsv1.StatefulSet{} - reconciler.applyCheetahFSGroup(deploy, capsule) + reconciler.applyFSGroup(deploy, capsule) if deploy.Spec.Template.Spec.SecurityContext == nil || deploy.Spec.Template.Spec.SecurityContext.FSGroup == nil { t.Fatal("expected fsGroup to be set") } @@ -706,7 +706,7 @@ func TestApplyCheetahFSGroup(t *testing.T) { }, }, } - reconciler.applyCheetahFSGroup(deploy, capsule) + reconciler.applyFSGroup(deploy, capsule) if *deploy.Spec.Template.Spec.SecurityContext.FSGroup != 1000 { t.Errorf("expected user fsGroup 1000 to be preserved, got %d", *deploy.Spec.Template.Spec.SecurityContext.FSGroup) } @@ -715,7 +715,7 @@ func TestApplyCheetahFSGroup(t *testing.T) { t.Run("no-op when cheetah not enabled", func(t *testing.T) { capsule := &operatorv1alpha1.ConsensusCapsule{} deploy := &appsv1.StatefulSet{} - reconciler.applyCheetahFSGroup(deploy, capsule) + reconciler.applyFSGroup(deploy, capsule) if deploy.Spec.Template.Spec.SecurityContext != nil { t.Error("expected no security context when cheetah not enabled") } diff --git a/internal/resources/consensus/defaults.go b/internal/resources/consensus/defaults.go index 3288f88ed..ce388c347 100644 --- a/internal/resources/consensus/defaults.go +++ b/internal/resources/consensus/defaults.go @@ -25,6 +25,11 @@ const ( // AlloyUID is the "alloy" user in grafana/alloy; the image itself runs as root. AlloyUID int64 = 473 + configMapMode int32 = 0o644 + + // Readable by the node only via pod fsGroup. + secretKeyMode int32 = 0o440 + // AlloyStoragePath is writable by AlloyUID, unlike the default relative to "/". AlloyStoragePath = "/var/lib/alloy/data" diff --git a/internal/resources/consensus/deployment.go b/internal/resources/consensus/deployment.go index 156ebba4a..e9fbeb1e4 100644 --- a/internal/resources/consensus/deployment.go +++ b/internal/resources/consensus/deployment.go @@ -103,6 +103,7 @@ func (r *StatefulSetSubReconciler) buildStatefulSet(ctx context.Context, capsule } zeroBased := orbit != nil && orbit.IsNodeNameZeroBased() volumes, volumeMounts := r.buildVolumes(capsule, zeroBased) + warnHostPathVolumes(ctx, capsule) consensusContainer, err := r.buildConsensusNodeContainer(capsule, volumeMounts) if err != nil { @@ -171,7 +172,7 @@ func (r *StatefulSetSubReconciler) buildStatefulSet(ctx context.Context, capsule } r.applyPodProperties(sts, capsule) - r.applyCheetahFSGroup(sts, capsule) + r.applyFSGroup(sts, capsule) r.applyImagePullSecrets(sts, capsule, resolvedAlloy) // DeprecatedServiceAccount must mirror ServiceAccountName (set after applyPodProperties // which may override ServiceAccountName). @@ -337,7 +338,8 @@ func (r *StatefulSetSubReconciler) buildVolumes(capsule *operatorv1alpha1.Consen pvc := capsule.Spec.PersistentVolumeClaims // ── ConfigMap volumes ──────────────────────────────────────────────────── - defaultMode := int32(420) // 0644 + defaultMode := configMapMode + keyMode := secretKeyMode optionalTrue := true volumes := []corev1.Volume{ { @@ -488,7 +490,7 @@ func (r *StatefulSetSubReconciler) buildVolumes(capsule *operatorv1alpha1.Consen }, }) volumeMounts = append(volumeMounts, corev1.VolumeMount{ - Name: "keys", MountPath: HapiBase + "/data/keys", + Name: "keys", MountPath: HapiBase + "/data/keys", ReadOnly: true, }) } else if capsule.Spec.Secrets.SecretResources != nil { var projectedSources []corev1.VolumeProjection @@ -526,7 +528,7 @@ func (r *StatefulSetSubReconciler) buildVolumes(capsule *operatorv1alpha1.Consen VolumeSource: corev1.VolumeSource{ Projected: &corev1.ProjectedVolumeSource{ Sources: projectedSources, - DefaultMode: &defaultMode, + DefaultMode: &keyMode, }, }, }) @@ -541,7 +543,7 @@ func (r *StatefulSetSubReconciler) buildVolumes(capsule *operatorv1alpha1.Consen Secret: &corev1.SecretVolumeSource{ SecretName: sr.GrpcTlsPrivateKey.SecretName, Items: []corev1.KeyToPath{{Key: sr.GrpcTlsPrivateKey.KeyName, Path: "hedera.key"}}, - DefaultMode: &defaultMode, + DefaultMode: &keyMode, }, }, }) @@ -576,7 +578,7 @@ func (r *StatefulSetSubReconciler) buildVolumes(capsule *operatorv1alpha1.Consen }, }) volumeMounts = append(volumeMounts, corev1.VolumeMount{ - Name: "config-folder", MountPath: "/mnt/config", + Name: "config-folder", MountPath: "/mnt/config", ReadOnly: true, }) } @@ -652,7 +654,7 @@ func (r *StatefulSetSubReconciler) addSidecarContainers(ctx context.Context, sts (containers.EventsUploader != nil && containers.EventsUploader.Enabled) if cheetahEnabled { - dm := int32(420) + dm := configMapMode sts.Spec.Template.Spec.Volumes = append(sts.Spec.Template.Spec.Volumes, corev1.Volume{ Name: "solo-cheetah-config", VolumeSource: corev1.VolumeSource{ @@ -730,7 +732,7 @@ func (r *StatefulSetSubReconciler) addSidecarContainers(ctx context.Context, sts r.event(ctx, capsule, corev1.EventTypeNormal, operatorv1alpha1.EventComponentSkipped, "Skipped alloy: omitted from spec") } else { - dm := int32(420) + dm := configMapMode sts.Spec.Template.Spec.Volumes = append(sts.Spec.Template.Spec.Volumes, corev1.Volume{ Name: "alloy-config", VolumeSource: corev1.VolumeSource{ @@ -954,24 +956,47 @@ func (r *StatefulSetSubReconciler) buildUcContainer(capsule *operatorv1alpha1.Co return container } -// applyCheetahFSGroup sets fsGroup on the pod security context when any Cheetah uploader is enabled. -func (r *StatefulSetSubReconciler) applyCheetahFSGroup(sts *appsv1.StatefulSet, capsule *operatorv1alpha1.ConsensusCapsule) { +// applyFSGroup sets fsGroup for Cheetah PVC sharing and group-readable key volumes. +func (r *StatefulSetSubReconciler) applyFSGroup(sts *appsv1.StatefulSet, capsule *operatorv1alpha1.ConsensusCapsule) { containers := capsule.Spec.PodProperties.Containers cheetahEnabled := (containers.BlocksUploader != nil && containers.BlocksUploader.Enabled) || (containers.RecordsUploader != nil && containers.RecordsUploader.Enabled) || (containers.EventsUploader != nil && containers.EventsUploader.Enabled) - if !cheetahEnabled { + if !cheetahEnabled && !mountsSecretKeys(capsule) { return } - if sts.Spec.Template.Spec.SecurityContext == nil { - sts.Spec.Template.Spec.SecurityContext = &corev1.PodSecurityContext{} + psc := sts.Spec.Template.Spec.SecurityContext + if psc == nil { + psc = &corev1.PodSecurityContext{} + sts.Spec.Template.Spec.SecurityContext = psc } - if sts.Spec.Template.Spec.SecurityContext.FSGroup == nil { + if psc.FSGroup == nil { fsGroup := HederaUID - sts.Spec.Template.Spec.SecurityContext.FSGroup = &fsGroup + psc.FSGroup = &fsGroup + } + // Avoid re-chowning large PVCs on every start. + if psc.FSGroupChangePolicy == nil { + policy := corev1.FSGroupChangeOnRootMismatch + psc.FSGroupChangePolicy = &policy } } +func warnHostPathVolumes(ctx context.Context, capsule *operatorv1alpha1.ConsensusCapsule) { + if s := capsule.Spec.Secrets; s != nil && s.HostPath != nil { + common.Warn(ctx, ReasonHostPathVolume).Str("path", s.HostPath.Path). + Msg("spec.secrets.hostPath is deprecated: node keys are read from the host; use spec.secrets.secretResources") + } + if cf := capsule.Spec.ConfigFolder; cf != nil { + common.Warn(ctx, ReasonHostPathVolume).Str("path", cf.Path). + Msg("spec.configFolder is deprecated: it mounts a host directory into the node pod; use the config CRs instead") + } +} + +func mountsSecretKeys(capsule *operatorv1alpha1.ConsensusCapsule) bool { + s := capsule.Spec.Secrets + return s != nil && s.HostPath == nil && s.SecretResources != nil +} + // applyImagePullSecrets collects ImagePullSecrets from every container's SoftwareVersion // and applies the deduplicated set to the pod template, so private-registry images can be pulled. // The Alloy secret is sourced from resolvedAlloy (Orbit defaults merged with capsule overrides) diff --git a/internal/resources/consensus/reasons.go b/internal/resources/consensus/reasons.go index b74a5f6af..f0cd6f174 100644 --- a/internal/resources/consensus/reasons.go +++ b/internal/resources/consensus/reasons.go @@ -8,6 +8,8 @@ import "github.com/automa-saga/errx" const ( ReasonOrbitDefaultsResolved errx.Reason = "OrbitDefaultsResolved" + ReasonHostPathVolume errx.Reason = "HostPathVolume" + // ReasonExecTransportFailed: the pods/exec stream could not be established // (pod not found/ready, or transport error). ReasonExecTransportFailed errx.Reason = "ExecTransportFailed" diff --git a/internal/resources/consensus/role_test.go b/internal/resources/consensus/role_test.go index 445a5270d..23a65cb75 100644 --- a/internal/resources/consensus/role_test.go +++ b/internal/resources/consensus/role_test.go @@ -134,3 +134,16 @@ func TestBuildRole_MainnetNoConfigCRGrants(t *testing.T) { t.Error("expected networkupgradeexecutes grant in mainnet mode, got none") } } + +func TestBuildRole_NoConfigMapOrSecretRead(t *testing.T) { + capsule := newTestCapsule() + capsule.Spec.PodProperties.Containers.UC = &operatorv1alpha1.UcSidecar{Enabled: true} + for _, clusterOnly := range []bool{true, false} { + role := (&RoleSubReconciler{}).buildRole(capsule, clusterOnly) + for _, res := range []string{"secrets", "configmaps"} { + if rule := findRule(role.Rules, res); rule != nil { + t.Errorf("clusterOnly=%v: unexpected %s grant %+v", clusterOnly, res, rule) + } + } + } +} diff --git a/internal/resources/consensus/statefulset_test.go b/internal/resources/consensus/statefulset_test.go index 4c8c28065..6ccd20d85 100644 --- a/internal/resources/consensus/statefulset_test.go +++ b/internal/resources/consensus/statefulset_test.go @@ -965,3 +965,82 @@ func TestBuildBackupUploaderContainer_EnvVars(t *testing.T) { }) } } + +func TestBuildVolumes_PrivateKeyModes(t *testing.T) { + r := newStsReconciler() + capsule := capsuleWithSecretResources("node0-keys", "s-private-node0.pem") + capsule.Spec.Secrets.SecretResources.GrpcTlsPrivateKey = operatorv1alpha1.KeyReference{SecretName: "grpc", KeyName: "tls.key"} + capsule.Spec.Secrets.SecretResources.GrpcTlsPublicCertificate = operatorv1alpha1.KeyReference{SecretName: "grpc", KeyName: "tls.crt"} + + volumes, _ := r.buildVolumes(capsule, false) + + want := map[string]int32{"keys": secretKeyMode, "grpc-tls-key": secretKeyMode, "grpc-tls-cert": configMapMode} + for _, v := range volumes { + wantMode, ok := want[v.Name] + if !ok { + continue + } + var got *int32 + switch { + case v.Projected != nil: + got = v.Projected.DefaultMode + case v.Secret != nil: + got = v.Secret.DefaultMode + } + if got == nil || *got != wantMode { + t.Errorf("volume %q: defaultMode %v, want %#o", v.Name, got, wantMode) + } + delete(want, v.Name) + } + if len(want) != 0 { + t.Errorf("missing volumes: %v", want) + } +} + +func TestApplyFSGroup_SecretKeys(t *testing.T) { + r := newStsReconciler() + + t.Run("secret-backed keys get fsGroup and OnRootMismatch", func(t *testing.T) { + sts := &appsv1.StatefulSet{} + r.applyFSGroup(sts, capsuleWithSecretResources("node0-keys", "s-private-node0.pem")) + psc := sts.Spec.Template.Spec.SecurityContext + if psc == nil || psc.FSGroup == nil || *psc.FSGroup != HederaUID { + t.Fatalf("expected fsGroup %d, got %+v", HederaUID, psc) + } + if psc.FSGroupChangePolicy == nil || *psc.FSGroupChangePolicy != corev1.FSGroupChangeOnRootMismatch { + t.Errorf("expected fsGroupChangePolicy OnRootMismatch, got %v", psc.FSGroupChangePolicy) + } + }) + + t.Run("hostPath keys leave the pod security context alone", func(t *testing.T) { + capsule := capsuleWithImage("v0.50.0") + capsule.Spec.Secrets = &operatorv1alpha1.ConsensusSecrets{HostPath: &corev1.HostPathVolumeSource{Path: "/keys"}} + sts := &appsv1.StatefulSet{} + r.applyFSGroup(sts, capsule) + if sts.Spec.Template.Spec.SecurityContext != nil { + t.Errorf("expected no security context, got %+v", sts.Spec.Template.Spec.SecurityContext) + } + }) +} + +func TestBuildVolumes_HostPathMountsReadOnly(t *testing.T) { + r := newStsReconciler() + capsule := capsuleWithImage("v0.50.0") + capsule.Spec.Secrets = &operatorv1alpha1.ConsensusSecrets{HostPath: &corev1.HostPathVolumeSource{Path: "/keys"}} + capsule.Spec.ConfigFolder = &corev1.HostPathVolumeSource{Path: "/config"} + + _, mounts := r.buildVolumes(capsule, false) + + want := map[string]bool{"keys": true, "config-folder": true} + for _, m := range mounts { + if want[m.Name] { + if !m.ReadOnly { + t.Errorf("hostPath mount %q must be read-only", m.Name) + } + delete(want, m.Name) + } + } + if len(want) != 0 { + t.Errorf("missing mounts: %v", want) + } +} From e7460e3b6edd4abd45e426feee474dd6c5b382e0 Mon Sep 17 00:00:00 2001 From: Artur Reznikov Date: Mon, 26 Oct 2026 14:36:44 -0700 Subject: [PATCH 2/3] fix(api/rbac): fix rbac Signed-off-by: Artur Reznikov --- api/v1alpha1/envoyproxy_types.go | 2 +- internal/resources/consensus/role.go | 6 ++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/api/v1alpha1/envoyproxy_types.go b/api/v1alpha1/envoyproxy_types.go index 9c3658559..da6e6067c 100644 --- a/api/v1alpha1/envoyproxy_types.go +++ b/api/v1alpha1/envoyproxy_types.go @@ -39,7 +39,7 @@ type EnvoyProxySpec struct { // +kubebuilder:validation:MinLength=1 Orbit string `json:"orbit"` - // Image is the Envoy container image, pinned by a non-latest tag or a sha256 digest. + // Image is the Envoy container image. // +kubebuilder:default="envoyproxy/envoy-distroless:v1.21.6" // +kubebuilder:validation:MaxLength=512 // +kubebuilder:validation:XValidation:rule="self.matches('@sha256:[a-f0-9]{64}$') || self.matches(':[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$')",message="image must be pinned by a tag or a sha256 digest" diff --git a/internal/resources/consensus/role.go b/internal/resources/consensus/role.go index c5f34b58e..03a0c27cb 100644 --- a/internal/resources/consensus/role.go +++ b/internal/resources/consensus/role.go @@ -69,10 +69,8 @@ func (r *RoleSubReconciler) Reconcile(ctx context.Context, capsule *operatorv1al } func (r *RoleSubReconciler) buildRole(capsule *operatorv1alpha1.ConsensusCapsule, clusterOnly bool) *rbacv1.Role { - rules := []rbacv1.PolicyRule{ - {APIGroups: []string{""}, Resources: []string{"configmaps"}, Verbs: []string{"get", "list", "watch"}}, - {APIGroups: []string{""}, Resources: []string{"secrets"}, Verbs: []string{"get", "list", "watch"}}, - } + // No configmaps/secrets: the pod gets them via volumes. + var rules []rbacv1.PolicyRule sidecar := capsule.Spec.PodProperties.Containers.UC if sidecar != nil && sidecar.Enabled { From 2fe2955317b48cff5c856d20ff26aae3888d005e Mon Sep 17 00:00:00 2001 From: Artur Reznikov Date: Tue, 27 Oct 2026 11:27:53 -0700 Subject: [PATCH 3/3] fix(api): rm ConfigFolder Signed-off-by: Artur Reznikov --- api/v1alpha1/consensuscapsule_types.go | 4 ---- api/v1alpha1/zz_generated.deepcopy.go | 5 ----- ...ator.solo.hiero.org_consensuscapsules.yaml | 19 ------------------- internal/resources/consensus/deployment.go | 17 ----------------- .../resources/consensus/statefulset_test.go | 14 +++++--------- 5 files changed, 5 insertions(+), 54 deletions(-) diff --git a/api/v1alpha1/consensuscapsule_types.go b/api/v1alpha1/consensuscapsule_types.go index 8e9f642ef..e37a6cc4f 100644 --- a/api/v1alpha1/consensuscapsule_types.go +++ b/api/v1alpha1/consensuscapsule_types.go @@ -128,10 +128,6 @@ type ConsensusCapsuleSpec struct { // +optional NodeProperties map[string]string `json:"nodeProperties,omitempty"` - // hostPath volume containing config.txt, mounted read-only. Legacy; prefer the config CR refs. - // +optional - ConfigFolder *corev1.HostPathVolumeSource `json:"configFolder,omitempty"` - // +kubebuilder:default=LoadBalancer // +optional ServiceType ServiceType `json:"serviceType,omitempty"` diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 3d8651db6..2aebca74e 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -1024,11 +1024,6 @@ func (in *ConsensusCapsuleSpec) DeepCopyInto(out *ConsensusCapsuleSpec) { (*out)[key] = val } } - if in.ConfigFolder != nil { - in, out := &in.ConfigFolder, &out.ConfigFolder - *out = new(v1.HostPathVolumeSource) - (*in).DeepCopyInto(*out) - } if in.ServiceAnnotations != nil { in, out := &in.ServiceAnnotations, &out.ServiceAnnotations *out = make(map[string]string, len(*in)) diff --git a/config/crd/bases/operator.solo.hiero.org_consensuscapsules.yaml b/config/crd/bases/operator.solo.hiero.org_consensuscapsules.yaml index a82187729..d8b8402a2 100644 --- a/config/crd/bases/operator.solo.hiero.org_consensuscapsules.yaml +++ b/config/crd/bases/operator.solo.hiero.org_consensuscapsules.yaml @@ -89,25 +89,6 @@ spec: references a BootstrapProperties CR whose managed ConfigMap is subPath-mounted at data/config/bootstrap.properties. Optional — CN falls back to defaults. type: string - configFolder: - description: hostPath volume containing config.txt, mounted read-only. - Legacy; prefer the config CR refs. - properties: - path: - description: |- - path of the directory on the host. - If the path is a symlink, it will follow the link to the real path. - More info: https://kubernetes.io/docs/concepts/storage/volumes#hostpath - type: string - type: - description: |- - type for HostPath Volume - Defaults to "" - More info: https://kubernetes.io/docs/concepts/storage/volumes#hostpath - type: string - required: - - path - type: object desiredRunState: description: |- DesiredRunState is the desired s6 process state (Running or Stopped). diff --git a/internal/resources/consensus/deployment.go b/internal/resources/consensus/deployment.go index e9fbeb1e4..15edecdba 100644 --- a/internal/resources/consensus/deployment.go +++ b/internal/resources/consensus/deployment.go @@ -569,19 +569,6 @@ func (r *StatefulSetSubReconciler) buildVolumes(capsule *operatorv1alpha1.Consen } } - // ── Legacy hostPath config folder ──────────────────────────────────────── - if capsule.Spec.ConfigFolder != nil { - volumes = append(volumes, corev1.Volume{ - Name: "config-folder", - VolumeSource: corev1.VolumeSource{ - HostPath: capsule.Spec.ConfigFolder, - }, - }) - volumeMounts = append(volumeMounts, corev1.VolumeMount{ - Name: "config-folder", MountPath: "/mnt/config", ReadOnly: true, - }) - } - // ── Pod-level volume overrides ─────────────────────────────────────────── // replaceOrAppend replaces an existing volume with the same name or appends it, // preventing duplicate volume names that the Kubernetes API would reject. @@ -986,10 +973,6 @@ func warnHostPathVolumes(ctx context.Context, capsule *operatorv1alpha1.Consensu common.Warn(ctx, ReasonHostPathVolume).Str("path", s.HostPath.Path). Msg("spec.secrets.hostPath is deprecated: node keys are read from the host; use spec.secrets.secretResources") } - if cf := capsule.Spec.ConfigFolder; cf != nil { - common.Warn(ctx, ReasonHostPathVolume).Str("path", cf.Path). - Msg("spec.configFolder is deprecated: it mounts a host directory into the node pod; use the config CRs instead") - } } func mountsSecretKeys(capsule *operatorv1alpha1.ConsensusCapsule) bool { diff --git a/internal/resources/consensus/statefulset_test.go b/internal/resources/consensus/statefulset_test.go index 6ccd20d85..f9b8a72be 100644 --- a/internal/resources/consensus/statefulset_test.go +++ b/internal/resources/consensus/statefulset_test.go @@ -1023,24 +1023,20 @@ func TestApplyFSGroup_SecretKeys(t *testing.T) { }) } -func TestBuildVolumes_HostPathMountsReadOnly(t *testing.T) { +func TestBuildVolumes_HostPathKeysReadOnly(t *testing.T) { r := newStsReconciler() capsule := capsuleWithImage("v0.50.0") capsule.Spec.Secrets = &operatorv1alpha1.ConsensusSecrets{HostPath: &corev1.HostPathVolumeSource{Path: "/keys"}} - capsule.Spec.ConfigFolder = &corev1.HostPathVolumeSource{Path: "/config"} _, mounts := r.buildVolumes(capsule, false) - want := map[string]bool{"keys": true, "config-folder": true} for _, m := range mounts { - if want[m.Name] { + if m.Name == "keys" { if !m.ReadOnly { - t.Errorf("hostPath mount %q must be read-only", m.Name) + t.Error("hostPath keys mount must be read-only") } - delete(want, m.Name) + return } } - if len(want) != 0 { - t.Errorf("missing mounts: %v", want) - } + t.Error("missing keys mount") }