mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
feat(push): support platform-managed gateway runtime pods (#8113)
Platform-owned service meshes need runtime pod identity and a replacement network policy without patching rendered gateway manifests. Add runtime-only pod labels, service account and shutdown-grace settings, plus an explicit named replacement-policy mode. Migration isolation remains independent; the default render is unchanged. Chart 0.3.4 publication and live deployment verification follow in the separate release and deployment work. ### Validation Added tests cover runtime-only settings, reserved selector labels, minimum shutdown grace, replacement-policy acknowledgement and conflicting settings. The complete chart render suite and release-contract tests pass. Default rendering matches the current main chart byte-for-byte. Repository-wide `just ci` reached unit tests but failed in existing buzz-acp tests outside this chart change. Live mesh behavior has not been exercised. --------- Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
@@ -3,6 +3,6 @@ apiVersion: v2
|
||||
# branches (see docs/push-gateway-deployment.md, "Gateway chart release").
|
||||
name: buzz-push-gateway
|
||||
description: Public capability-gated APNs last-hop gateway for Buzz
|
||||
version: 0.3.3
|
||||
version: 0.3.4
|
||||
appVersion: "0.1.0"
|
||||
type: application
|
||||
|
||||
47
deploy/charts/buzz-push-gateway/README.md
Normal file
47
deploy/charts/buzz-push-gateway/README.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# Buzz push gateway chart
|
||||
|
||||
### Platform-managed runtime integration
|
||||
|
||||
`podLabels`, `serviceAccountName`, and `terminationGracePeriodSeconds` configure
|
||||
only runtime pods. Defaults retain the existing pod specification; the grace
|
||||
period cannot be shorter than 60 seconds. Additional labels cannot override the
|
||||
chart's name, instance, or component selectors. Migration settings remain separate.
|
||||
|
||||
A parent chart that supplies its own runtime NetworkPolicy can set
|
||||
`networkPolicy.enabled=false` and `networkPolicy.externalPolicyName` to that
|
||||
policy's name. The name is an explicit acknowledgement, not a cluster existence
|
||||
check: the parent must render and validate a policy selecting the runtime pods
|
||||
before deployment. Supplying a replacement name while the upstream policy is
|
||||
enabled is rejected. The migration NetworkPolicy remains enabled independently.
|
||||
|
||||
External-policy releases must run `tests/check-external-policy.rb` on the
|
||||
**complete parent render including hooks**, before install or upgrade. Helm 3
|
||||
excludes hooks from its post-renderer input, so `--post-renderer` alone is not a
|
||||
sufficient gate. Render without `--no-hooks` or resource filtering, using the
|
||||
same chart, release name, namespace, values and capabilities as the deployment:
|
||||
|
||||
```sh
|
||||
set -o pipefail
|
||||
helm template RELEASE PARENT_CHART --namespace NAMESPACE -f VALUES.yaml \
|
||||
| ruby tests/check-external-policy.rb --expect DEPLOYMENT POLICY NAMESPACE > validated-release.yaml
|
||||
```
|
||||
|
||||
`--expect` is mandatory for external-policy deployment and CI invocations.
|
||||
Provide the intended Deployment name, replacement policy name and release namespace
|
||||
from deployment configuration, not by extracting the annotations being checked.
|
||||
Repeat it for each external-policy Deployment. Missing Deployments or removed or
|
||||
overwritten markers then fail closed. No-argument mode is only for renders that
|
||||
intentionally have no external-policy requirement, such as default-mode tests.
|
||||
|
||||
For upgrade preflight, include `--is-upgrade`. Deployment automation must stop if
|
||||
this pipeline fails and must not change the chart or rendering inputs between
|
||||
validation and deployment. Run the post-renderer additionally during Helm
|
||||
install/upgrade to check ordinary resources, but retain the full-render preflight
|
||||
to detect hook collisions. GitOps render pipelines must validate the complete
|
||||
hook-inclusive output before submitting it to their reconciler.
|
||||
The Deployment records the required policy name. The gate rejects a missing,
|
||||
misspelled, duplicate, wrong-namespace or non-selecting replacement, and requires
|
||||
both ingress and egress isolation. It emits no manifests on failure and preserves
|
||||
successful output byte-for-byte. Parent CI must run this same gate on its complete
|
||||
render; a subchart-only render intentionally fails in external-policy mode.
|
||||
The deployment owner must separately validate the allowed traffic and rolling drain.
|
||||
@@ -3,6 +3,11 @@ kind: Deployment
|
||||
metadata:
|
||||
name: {{ include "push.name" . }}
|
||||
labels: {{- include "push.runtimeLabels" . | nindent 4 }}
|
||||
{{- if not .Values.networkPolicy.enabled }}
|
||||
annotations:
|
||||
buzz.block.xyz/release-namespace: {{ .Release.Namespace | quote }}
|
||||
buzz.block.xyz/external-network-policy: {{ .Values.networkPolicy.externalPolicyName | quote }}
|
||||
{{- end }}
|
||||
spec:
|
||||
replicas: {{ .Values.replicaCount }}
|
||||
strategy: { type: RollingUpdate, rollingUpdate: { maxSurge: 1, maxUnavailable: 0 } }
|
||||
@@ -11,12 +16,18 @@ spec:
|
||||
template:
|
||||
metadata:
|
||||
labels: {{- include "push.runtimeLabels" . | nindent 8 }}
|
||||
{{- with .Values.podLabels }}
|
||||
{{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
{{- with .Values.podAnnotations }}
|
||||
annotations: {{- toYaml . | nindent 8 }}
|
||||
{{- end }}
|
||||
spec:
|
||||
automountServiceAccountToken: false
|
||||
terminationGracePeriodSeconds: 60
|
||||
{{- with .Values.serviceAccountName }}
|
||||
serviceAccountName: {{ . | quote }}
|
||||
{{- end }}
|
||||
terminationGracePeriodSeconds: {{ .Values.terminationGracePeriodSeconds }}
|
||||
securityContext: { runAsNonRoot: true, runAsUser: 65532, runAsGroup: 65532, fsGroup: 65532, seccompProfile: { type: RuntimeDefault } }
|
||||
{{- with .Values.image.pullSecrets }}
|
||||
imagePullSecrets: {{ toYaml . | nindent 8 }}
|
||||
|
||||
107
deploy/charts/buzz-push-gateway/tests/check-external-policy.rb
Executable file
107
deploy/charts/buzz-push-gateway/tests/check-external-policy.rb
Executable file
@@ -0,0 +1,107 @@
|
||||
#!/usr/bin/env ruby
|
||||
# Validate the complete release; strict external callers supply --expect.
|
||||
require 'yaml'
|
||||
require 'date'
|
||||
# Strict callers provide expectations independently of rendered annotations.
|
||||
expectations = []
|
||||
until ARGV.empty?
|
||||
abort 'usage: check-external-policy.rb [--expect DEPLOYMENT POLICY NAMESPACE]...' unless ARGV.shift == '--expect' && ARGV.length >= 3
|
||||
expectations << ARGV.shift(3)
|
||||
end
|
||||
input = STDIN.read
|
||||
resources = YAML.parse_stream(input).children.map do |doc|
|
||||
stream = Psych::Nodes::Stream.new
|
||||
stream.children << doc
|
||||
YAML.safe_load(stream.to_yaml, permitted_classes: [Date, Time], aliases: true)
|
||||
end.compact
|
||||
# Match Kubernetes Unstructured.IsList: an items array, including typed lists.
|
||||
# https://github.com/kubernetes/apimachinery/blob/v0.34.1/pkg/apis/meta/v1/unstructured/unstructured.go
|
||||
def flatten_resources(resource, enclosing_hooks = {})
|
||||
annotations = resource.dig('metadata', 'annotations') || {}
|
||||
hooks = enclosing_hooks.merge(annotations.select { |k, _| %w[helm.sh/hook argocd.argoproj.io/hook].include?(k) })
|
||||
if resource['items'].is_a?(Array)
|
||||
resource.fetch('items').flat_map { |item| flatten_resources(item, hooks) }
|
||||
else
|
||||
unless hooks.empty?
|
||||
resource = Marshal.load(Marshal.dump(resource))
|
||||
resource['metadata'] ||= {}
|
||||
resource['metadata']['annotations'] = annotations.merge(hooks)
|
||||
end
|
||||
[resource]
|
||||
end
|
||||
end
|
||||
resources = resources.flat_map { |resource| flatten_resources(resource) }
|
||||
def label_value?(value)
|
||||
value.is_a?(String) && value.length <= 63 && (value.empty? || /\A[A-Za-z0-9](?:[-_.A-Za-z0-9]*[A-Za-z0-9])?\z/.match?(value))
|
||||
end
|
||||
|
||||
def label_key?(key)
|
||||
return false unless key.is_a?(String)
|
||||
parts = key.split('/', -1)
|
||||
return false unless [1, 2].include?(parts.length) && !parts.last.empty? && label_value?(parts.last)
|
||||
return true if parts.length == 1
|
||||
prefix = parts.first
|
||||
prefix.length <= 253 && /\A[a-z0-9](?:[-a-z0-9]*[a-z0-9])?(?:\.[a-z0-9](?:[-a-z0-9]*[a-z0-9])?)*\z/.match?(prefix)
|
||||
end
|
||||
annotation = 'buzz.block.xyz/external-network-policy'
|
||||
expectations.each do |deployment_name, policy_name, namespace|
|
||||
candidates = resources.select do |r|
|
||||
r['kind'] == 'Deployment' && r.dig('metadata', 'name') == deployment_name &&
|
||||
(r.dig('metadata', 'namespace').to_s.empty? ? namespace : r.dig('metadata', 'namespace')) == namespace
|
||||
end
|
||||
abort "external policy #{policy_name}: expected exactly one deployment #{deployment_name}" unless candidates.length == 1
|
||||
annotations = candidates.first.dig('metadata', 'annotations') || {}
|
||||
abort "external policy #{policy_name}: missing or overwritten ownership marker" unless annotations[annotation] == policy_name && annotations['buzz.block.xyz/release-namespace'] == namespace
|
||||
end
|
||||
resources.select { |r| r['kind'] == 'Deployment' }.each do |deployment|
|
||||
name = deployment.dig('metadata', 'annotations', annotation)
|
||||
next unless name
|
||||
release_namespace = deployment.dig('metadata', 'annotations', 'buzz.block.xyz/release-namespace')
|
||||
abort "external policy #{name}: missing release namespace" if !release_namespace.is_a?(String) || release_namespace.empty?
|
||||
namespace = deployment.dig('metadata', 'namespace')
|
||||
namespace = release_namespace if namespace.nil? || namespace.empty?
|
||||
policies = resources.select do |r|
|
||||
r['apiVersion'] == 'networking.k8s.io/v1' && r['kind'] == 'NetworkPolicy' &&
|
||||
r.dig('metadata', 'name') == name &&
|
||||
(r.dig('metadata', 'namespace').to_s.empty? ? release_namespace : r.dig('metadata', 'namespace')) == namespace
|
||||
end
|
||||
abort "external policy #{name}: expected exactly one replacement in #{namespace}" unless policies.length == 1
|
||||
abort "external policy #{name}: replacement must not be a Helm hook" if policies.first.dig('metadata', 'annotations', 'helm.sh/hook')
|
||||
abort "external policy #{name}: replacement must not be an Argo CD hook" if policies.first.dig('metadata', 'annotations', 'argocd.argoproj.io/hook')
|
||||
spec = policies.first.fetch('spec')
|
||||
labels = deployment.dig('spec', 'template', 'metadata', 'labels')
|
||||
selector = spec.fetch('podSelector')
|
||||
# Require the immutable runtime identity explicitly, not a namespace-wide policy.
|
||||
identity = deployment.dig('spec', 'selector', 'matchLabels')
|
||||
match = selector.fetch('matchLabels', {})
|
||||
expressions = selector.fetch('matchExpressions', [])
|
||||
identity_bound = identity.all? do |k, v|
|
||||
match[k] == v || expressions.any? { |e| e['key'] == k && e['operator'] == 'In' && e['values'] == [v] }
|
||||
end
|
||||
abort "external policy #{name}: missing runtime identity selector" unless identity_bound
|
||||
abort "external policy #{name}: selector does not match runtime" unless match.all? { |k, v| labels[k] == v }
|
||||
selector.fetch('matchExpressions', []).each do |expression|
|
||||
key = expression.fetch('key')
|
||||
values = expression.fetch('values', [])
|
||||
operator = expression.fetch('operator')
|
||||
valid_values = values.is_a?(Array) && values.all? { |v| label_value?(v) }
|
||||
valid_values &&= %w[In NotIn].include?(operator) ? !values.empty? : (%w[Exists DoesNotExist].include?(operator) && values.empty?)
|
||||
abort "external policy #{name}: invalid selector expression" unless label_key?(key) && valid_values
|
||||
matches = case operator
|
||||
when 'In' then labels.key?(key) && values.include?(labels[key])
|
||||
when 'NotIn' then !labels.key?(key) || !values.include?(labels[key])
|
||||
when 'Exists' then labels.key?(key)
|
||||
when 'DoesNotExist' then !labels.key?(key)
|
||||
else false
|
||||
end
|
||||
abort "external policy #{name}: expression does not match runtime" unless matches
|
||||
end
|
||||
types = spec['policyTypes']
|
||||
if types.nil? || types.empty?
|
||||
types = ['Ingress']
|
||||
types << 'Egress' unless spec.fetch('egress', []).empty?
|
||||
end
|
||||
abort "external policy #{name}: must isolate ingress and egress" unless %w[Ingress Egress].all? { |t| types.include?(t) }
|
||||
end
|
||||
# Preserve Helm output byte-for-byte only after every deployment passes.
|
||||
STDOUT.write(input)
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
bash deploy/charts/buzz-push-gateway/tests/runtime-options.sh
|
||||
bash deploy/charts/buzz-push-gateway/tests/grant-lifetime.sh
|
||||
bash deploy/charts/buzz-push-gateway/tests/migration-annotations.sh
|
||||
out=$(mktemp); production_out=$(mktemp); route_out=$(mktemp); datadog_out=$(mktemp)
|
||||
|
||||
257
deploy/charts/buzz-push-gateway/tests/runtime-options.sh
Normal file
257
deploy/charts/buzz-push-gateway/tests/runtime-options.sh
Normal file
@@ -0,0 +1,257 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
chart=deploy/charts/buzz-push-gateway
|
||||
out=$(mktemp -d)
|
||||
trap 'rm -rf "$out"' EXIT
|
||||
helm template push "$chart" >"$out/default.yaml"
|
||||
helm template push "$chart" \
|
||||
--set-string 'podLabels.example\.com/runtime=gateway' \
|
||||
--set serviceAccountName=mesh-gateway \
|
||||
--set terminationGracePeriodSeconds=90 >"$out/runtime.yaml"
|
||||
helm template push "$chart" --set networkPolicy.enabled=false \
|
||||
--set networkPolicy.externalPolicyName=platform-runtime >"$out/external.yaml"
|
||||
env -u GEM_HOME -u GEM_PATH -u RUBYLIB -u RUBYOPT ruby -ryaml - "$out" <<'RUBY'
|
||||
base, runtime, external = %w[default runtime external].map { |name| YAML.load_stream(File.read("#{ARGV[0]}/#{name}.yaml")).compact }
|
||||
pod = runtime.find { |r| r['kind'] == 'Deployment' }.dig('spec', 'template')
|
||||
raise 'runtime label missing' unless pod['metadata']['labels'].delete('example.com/runtime') == 'gateway'
|
||||
raise 'service account missing' unless pod['spec'].delete('serviceAccountName') == 'mesh-gateway'
|
||||
raise 'grace period missing' unless pod['spec']['terminationGracePeriodSeconds'] == 90
|
||||
pod['spec']['terminationGracePeriodSeconds'] = 60
|
||||
raise 'runtime options changed unrelated resources or selectors' unless runtime == base
|
||||
expected = base.reject { |r| r['kind'] == 'NetworkPolicy' && r['metadata']['name'] == 'push-buzz-push-gateway' }
|
||||
external.find { |r| r['kind'] == 'Deployment' }['metadata'].delete('annotations')
|
||||
raise 'external policy mode changed migration isolation or other resources' unless external == expected
|
||||
RUBY
|
||||
reject() {
|
||||
if helm template push "$chart" "$@" >"$out/invalid.log" 2>&1; then
|
||||
echo "expected invalid runtime options to fail: $*" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -q 'schema' "$out/invalid.log"; then
|
||||
cat "$out/invalid.log" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
reject --set networkPolicy.enabled=false
|
||||
reject --set networkPolicy.enabled=false --set-string 'networkPolicy.externalPolicyName= '
|
||||
reject --set networkPolicy.externalPolicyName=conflicting-policy
|
||||
reject --set terminationGracePeriodSeconds=59
|
||||
reject --set podLabels.invalid=true
|
||||
for label in name instance component; do
|
||||
reject --set-string "podLabels.app\\.kubernetes\\.io/$label=override"
|
||||
done
|
||||
|
||||
# Kubernetes NetworkPolicy names are DNS subdomains, not single DNS labels.
|
||||
long_name=$(printf '%0253d' 0)
|
||||
for name in runtime.platform.example "$long_name"; do
|
||||
helm template push "$chart" --set networkPolicy.enabled=false \
|
||||
--set-string "networkPolicy.externalPolicyName=$name" >/dev/null
|
||||
done
|
||||
for name in "$long_name"x .runtime runtime. runtime..example Runtime runtime.-example; do
|
||||
reject --set networkPolicy.enabled=false --set-string "networkPolicy.externalPolicyName=$name"
|
||||
done
|
||||
|
||||
# Parent policy owns scrape access in external mode. Internal policy retains its guard.
|
||||
helm template push "$chart" --set networkPolicy.enabled=false \
|
||||
--set networkPolicy.externalPolicyName=platform-runtime \
|
||||
--set podMonitor.enabled=true >"$out/external-monitor.yaml"
|
||||
env -u GEM_HOME -u GEM_PATH -u RUBYLIB -u RUBYOPT ruby -ryaml - "$out/external-monitor.yaml" <<'RUBY'
|
||||
resources = YAML.load_stream(File.read(ARGV[0])).compact
|
||||
raise 'PodMonitor missing in external mode' unless resources.any? { |r| r['kind'] == 'PodMonitor' }
|
||||
policies = resources.select { |r| r['kind'] == 'NetworkPolicy' }
|
||||
raise 'external mode must retain only migration policy' unless policies.map { |r| r['metadata']['name'] } == ['push-buzz-push-gateway-migration']
|
||||
RUBY
|
||||
reject --set podMonitor.enabled=true
|
||||
reject --set podMonitor.enabled=true --set networkPolicy.monitoring.enabled=true
|
||||
|
||||
# External ownership replaces runtime egress only; migration still needs DB/DNS.
|
||||
helm template push "$chart" --set networkPolicy.enabled=false \
|
||||
--set networkPolicy.externalPolicyName=platform-runtime \
|
||||
--set-json 'networkPolicy.apnsEgressCidrs=[]' >/dev/null
|
||||
reject --set-json 'networkPolicy.apnsEgressCidrs=[]'
|
||||
reject --set networkPolicy.enabled=false --set networkPolicy.externalPolicyName=platform-runtime \
|
||||
--set-json 'networkPolicy.postgresEgressCidrs=[]'
|
||||
reject --set networkPolicy.enabled=false --set networkPolicy.externalPolicyName=platform-runtime \
|
||||
--set networkPolicy.dns=null
|
||||
reject --set terminationGracePeriodSeconds=null
|
||||
|
||||
# Kubernetes DNS-subdomain service accounts and qualified label keys.
|
||||
for name in '' runtime.platform.example "$long_name"; do
|
||||
helm template push "$chart" --set-string "serviceAccountName=$name" >/dev/null
|
||||
done
|
||||
for name in INVALID_NAME ' ' "$long_name"x .runtime runtime. runtime..example; do
|
||||
reject --set-string "serviceAccountName=$name"
|
||||
done
|
||||
long_label=$(printf '%063d' 0)
|
||||
for key in 'example.com/Runtime_v1' "$long_label" "$long_name/$long_label"; do
|
||||
helm template push "$chart" --set-json "podLabels={\"$key\":\"valid_value\"}" >/dev/null
|
||||
done
|
||||
for key in 'bad key' '/name' 'prefix/' 'UPPER.example/name' 'a/b/c' "$long_label"x "$long_name"x/name; do
|
||||
reject --set-json "podLabels={\"$key\":\"value\"}"
|
||||
done
|
||||
reject --set-json 'podLabels={"valid":"bad value"}'
|
||||
|
||||
# Combined parent render gate: mutations must fail without emitting manifests.
|
||||
env -u GEM_HOME -u GEM_PATH -u RUBYLIB -u RUBYOPT ruby -ryaml - "$out" <<'RUBY'
|
||||
resources = YAML.load_stream(File.read("#{ARGV[0]}/external.yaml")).compact
|
||||
pod = resources.find { |r| r['kind'] == 'Deployment' }
|
||||
policy = {'apiVersion' => 'networking.k8s.io/v1', 'kind' => 'NetworkPolicy',
|
||||
'metadata' => {'name' => 'platform-runtime'},
|
||||
'spec' => {'podSelector' => {'matchLabels' => pod.dig('spec', 'selector', 'matchLabels').dup},
|
||||
'policyTypes' => %w[Ingress Egress], 'ingress' => [], 'egress' => []}}
|
||||
File.write("#{ARGV[0]}/combined.yaml", (resources + [policy]).map(&:to_yaml).join)
|
||||
list = {'apiVersion' => 'v1', 'kind' => 'List', 'items' => [policy]}
|
||||
File.write("#{ARGV[0]}/list.yaml", (resources + [list]).map(&:to_yaml).join)
|
||||
File.write("#{ARGV[0]}/duplicate-list.yaml", (resources + [policy, list]).map(&:to_yaml).join)
|
||||
typed_list = {'apiVersion' => 'networking.k8s.io/v1', 'kind' => 'NetworkPolicyList', 'items' => [policy]}
|
||||
File.write("#{ARGV[0]}/typed-list.yaml", (resources + [typed_list]).map(&:to_yaml).join)
|
||||
File.write("#{ARGV[0]}/duplicate-typed-list.yaml", (resources + [policy, typed_list]).map(&:to_yaml).join)
|
||||
expression_policy = Marshal.load(Marshal.dump(policy))
|
||||
identity = expression_policy['spec']['podSelector'].delete('matchLabels')
|
||||
expression_policy['spec']['podSelector']['matchExpressions'] = identity.map { |k, v| {'key' => k, 'operator' => 'In', 'values' => [v]} }
|
||||
File.write("#{ARGV[0]}/expression-identity.yaml", (resources + [expression_policy]).map(&:to_yaml).join)
|
||||
%w[helm.sh/hook argocd.argoproj.io/hook].each_with_index do |key, i|
|
||||
wrapped = Marshal.load(Marshal.dump(typed_list))
|
||||
wrapped['metadata'] = {'annotations' => {key => i == 0 ? 'pre-upgrade' : 'PreSync'}}
|
||||
File.write("#{ARGV[0]}/wrapped-hook-#{i}.yaml", (resources + [wrapped]).map(&:to_yaml).join)
|
||||
end
|
||||
defaulted = Marshal.load(Marshal.dump(policy))
|
||||
defaulted['spec'].delete('policyTypes')
|
||||
defaulted['spec']['egress'] = [{'ports' => [{'protocol' => 'TCP', 'port' => 443}]}]
|
||||
File.write("#{ARGV[0]}/defaulted-types.yaml", (resources + [defaulted]).map(&:to_yaml).join)
|
||||
variants = {
|
||||
'invalid-key' => ->(p) { p['spec']['podSelector']['matchExpressions'] = [{'key' => 'bad key', 'operator' => 'DoesNotExist'}] },
|
||||
'invalid-value' => ->(p) { p['spec']['podSelector']['matchExpressions'] = [{'key' => 'absent', 'operator' => 'NotIn', 'values' => ['bad value']}] },
|
||||
'numeric-value' => ->(p) { p['spec']['podSelector']['matchExpressions'] = [{'key' => 'absent', 'operator' => 'NotIn', 'values' => [123]}] },
|
||||
'unknown-operator' => ->(p) { p['spec']['podSelector']['matchExpressions'] = [{'key' => 'absent', 'operator' => 'Unknown'}] },
|
||||
'empty-notin' => ->(p) { p['spec']['podSelector']['matchExpressions'] = [{'key' => 'absent', 'operator' => 'NotIn', 'values' => []}] },
|
||||
'valued-exists' => ->(p) { p['spec']['podSelector']['matchExpressions'] = [{'key' => 'app.kubernetes.io/name', 'operator' => 'Exists', 'values' => ['buzz-push-gateway']}] },
|
||||
'argo-skip' => ->(p) { p['metadata']['annotations'] = {'argocd.argoproj.io/hook' => 'Skip'} },
|
||||
'argo-presync' => ->(p) { p['metadata']['annotations'] = {'argocd.argoproj.io/hook' => 'PreSync', 'argocd.argoproj.io/hook-delete-policy' => 'HookSucceeded'} },
|
||||
'hook-policy' => ->(p) { p['metadata']['annotations'] = {'helm.sh/hook' => 'pre-install', 'helm.sh/hook-delete-policy' => 'hook-succeeded'} },
|
||||
'misspelled' => ->(p) { p['metadata']['name'] = 'typo' },
|
||||
'wrong-selector' => ->(p) { p['spec']['podSelector']['matchLabels']['app.kubernetes.io/instance'] = 'other' },
|
||||
'empty-selector' => ->(p) { p['spec']['podSelector'] = {} },
|
||||
'wrong-namespace' => ->(p) { p['metadata']['namespace'] = 'other' },
|
||||
'ingress-only' => ->(p) { p['spec']['policyTypes'] = ['Ingress'] },
|
||||
'wrong-expression' => ->(p) { p['spec']['podSelector']['matchExpressions'] = [{'key' => 'absent', 'operator' => 'Exists'}] }
|
||||
}
|
||||
variants.each do |name, mutate|
|
||||
changed = Marshal.load(Marshal.dump(policy))
|
||||
mutate.call(changed)
|
||||
File.write("#{ARGV[0]}/#{name}.yaml", (resources + [changed]).map(&:to_yaml).join)
|
||||
end
|
||||
RUBY
|
||||
gate() { env -u GEM_HOME -u GEM_PATH -u RUBYLIB -u RUBYOPT ruby "$chart/tests/check-external-policy.rb"; }
|
||||
gate <"$out/default.yaml" >"$out/gated.yaml"
|
||||
cmp "$out/default.yaml" "$out/gated.yaml"
|
||||
gate <"$out/combined.yaml" >"$out/gated.yaml"
|
||||
cmp "$out/combined.yaml" "$out/gated.yaml"
|
||||
gate <"$out/list.yaml" >"$out/gated.yaml"
|
||||
cmp "$out/list.yaml" "$out/gated.yaml"
|
||||
for valid in defaulted-types typed-list expression-identity; do
|
||||
gate <"$out/$valid.yaml" >"$out/gated.yaml"
|
||||
cmp "$out/$valid.yaml" "$out/gated.yaml"
|
||||
done
|
||||
for mutation in wrapped-hook-0 wrapped-hook-1 duplicate-typed-list argo-skip argo-presync invalid-key invalid-value numeric-value unknown-operator empty-notin valued-exists duplicate-list external hook-policy misspelled wrong-selector empty-selector wrong-namespace ingress-only wrong-expression; do
|
||||
if gate <"$out/$mutation.yaml" >"$out/gated.yaml" 2>"$out/gate-error"; then
|
||||
echo "expected combined-render gate to reject $mutation" >&2
|
||||
exit 1
|
||||
fi
|
||||
test ! -s "$out/gated.yaml"
|
||||
grep -q 'external policy' "$out/gate-error"
|
||||
done
|
||||
|
||||
# Helm assigns omitted namespaces to the actual release namespace.
|
||||
helm template push "$chart" --namespace tenant-runtime --set networkPolicy.enabled=false \
|
||||
--set networkPolicy.externalPolicyName=platform-runtime >"$out/tenant.yaml"
|
||||
env -u GEM_HOME -u GEM_PATH -u RUBYLIB -u RUBYOPT ruby -ryaml - "$out" <<'RUBY'
|
||||
resources = YAML.load_stream(File.read("#{ARGV[0]}/tenant.yaml")).compact
|
||||
policy = YAML.load_stream(File.read("#{ARGV[0]}/combined.yaml")).compact.last
|
||||
{'omitted' => nil, 'empty' => '', 'explicit' => 'tenant-runtime', 'default' => 'default', 'wrong' => 'other'}.each do |variant, namespace|
|
||||
candidate = Marshal.load(Marshal.dump(policy))
|
||||
candidate['metadata']['namespace'] = namespace if namespace
|
||||
File.write("#{ARGV[0]}/tenant-#{variant}.yaml", (resources + [candidate]).map(&:to_yaml).join)
|
||||
end
|
||||
# Missing release metadata must fail closed rather than assume default.
|
||||
resources.find { |r| r['kind'] == 'Deployment' }['metadata']['annotations'].delete('buzz.block.xyz/release-namespace')
|
||||
File.write("#{ARGV[0]}/tenant-missing-annotation.yaml", (resources + [policy]).map(&:to_yaml).join)
|
||||
RUBY
|
||||
for variant in omitted empty explicit; do
|
||||
gate <"$out/tenant-$variant.yaml" >"$out/gated.yaml"
|
||||
cmp "$out/tenant-$variant.yaml" "$out/gated.yaml"
|
||||
done
|
||||
for variant in default wrong missing-annotation; do
|
||||
if gate <"$out/tenant-$variant.yaml" >"$out/gated.yaml" 2>"$out/gate-error"; then
|
||||
echo "expected release-namespace gate to reject $variant" >&2
|
||||
exit 1
|
||||
fi
|
||||
test ! -s "$out/gated.yaml"
|
||||
grep -q 'external policy' "$out/gate-error"
|
||||
done
|
||||
|
||||
# Valid parent YAML aliases in unrelated resources must pass through unchanged.
|
||||
cat "$out/combined.yaml" >"$out/aliases.yaml"
|
||||
cat >>"$out/aliases.yaml" <<'YAML'
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: ConfigMap
|
||||
metadata:
|
||||
name: unrelated
|
||||
data:
|
||||
first: &shared "value"
|
||||
second: *shared
|
||||
date: 2026-10-06
|
||||
timestamp: 2026-10-06T16:00:00Z
|
||||
YAML
|
||||
gate <"$out/aliases.yaml" >"$out/gated.yaml"
|
||||
cmp "$out/aliases.yaml" "$out/gated.yaml"
|
||||
# Standard YAML scalars must not enable arbitrary Ruby object construction.
|
||||
printf '%s\n' '--- !ruby/object:Object {}' >"$out/ruby-object.yaml"
|
||||
if gate <"$out/ruby-object.yaml" >"$out/gated.yaml" 2>"$out/gate-error"; then
|
||||
echo 'expected Ruby object tag to be rejected' >&2
|
||||
exit 1
|
||||
fi
|
||||
test ! -s "$out/gated.yaml"
|
||||
grep -q 'DisallowedClass' "$out/gate-error"
|
||||
|
||||
# Helm 3 excludes hooks from post-renderer stdin. The complete template stream
|
||||
# must reject a hook colliding with an otherwise valid ordinary replacement.
|
||||
mkdir -p "$out/parent/templates"
|
||||
printf 'apiVersion: v2\nname: policy-hook-fixture\nversion: 0.1.0\n' >"$out/parent/Chart.yaml"
|
||||
cp "$out/combined.yaml" "$out/parent/templates/runtime.yaml"
|
||||
cat >"$out/parent/templates/hook.yaml" <<'YAML'
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: NetworkPolicy
|
||||
metadata:
|
||||
name: platform-runtime
|
||||
annotations:
|
||||
helm.sh/hook: pre-upgrade
|
||||
helm.sh/hook-delete-policy: before-hook-creation,hook-succeeded
|
||||
spec:
|
||||
podSelector: {}
|
||||
policyTypes: [Ingress, Egress]
|
||||
YAML
|
||||
if [[ $(helm version --short) == v3.* ]]; then
|
||||
helm template push "$out/parent" --is-upgrade --post-renderer "$chart/tests/check-external-policy.rb" >"$out/post-rendered.yaml"
|
||||
fi
|
||||
if helm template push "$out/parent" --is-upgrade | gate >"$out/gated.yaml" 2>"$out/gate-error"; then
|
||||
echo 'expected complete render to reject a hook collision' >&2
|
||||
exit 1
|
||||
fi
|
||||
test ! -s "$out/gated.yaml"
|
||||
grep -q 'external policy' "$out/gate-error"
|
||||
|
||||
# External-mode callers must bind expectations independently of the marker.
|
||||
env -u GEM_HOME -u GEM_PATH -u RUBYLIB -u RUBYOPT ruby -ryaml - "$out" <<'RUBY'
|
||||
resources = YAML.load_stream(File.read("#{ARGV[0]}/combined.yaml")).compact
|
||||
pod = resources.find { |r| r['kind'] == 'Deployment' }
|
||||
pod['metadata'].delete('annotations')
|
||||
File.write("#{ARGV[0]}/missing-marker.yaml", resources.map(&:to_yaml).join)
|
||||
RUBY
|
||||
if env -u GEM_HOME -u GEM_PATH -u RUBYLIB -u RUBYOPT ruby "$chart/tests/check-external-policy.rb" --expect push-buzz-push-gateway platform-runtime default <"$out/missing-marker.yaml" >"$out/gated.yaml" 2>"$out/gate-error"; then
|
||||
echo 'expected strict gate to reject removed marker' >&2
|
||||
exit 1
|
||||
fi
|
||||
test ! -s "$out/gated.yaml"
|
||||
grep -q 'missing or overwritten ownership marker' "$out/gate-error"
|
||||
@@ -81,17 +81,15 @@
|
||||
"type": "object",
|
||||
"required": [
|
||||
"enabled",
|
||||
"apnsEgressCidrs",
|
||||
"postgresEgressCidrs",
|
||||
"dns"
|
||||
],
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"const": true
|
||||
"type": "boolean"
|
||||
},
|
||||
"apnsEgressCidrs": {
|
||||
"type": "array",
|
||||
"minItems": 1
|
||||
"type": "array"
|
||||
},
|
||||
"postgresEgressCidrs": {
|
||||
"type": "array",
|
||||
@@ -146,8 +144,51 @@
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"externalPolicyName": {
|
||||
"type": "string",
|
||||
"description": "Name of the replacement runtime NetworkPolicy supplied by the parent chart. Required when the upstream policy is disabled.",
|
||||
"pattern": "^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$",
|
||||
"maxLength": 253
|
||||
}
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"const": false
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"enabled"
|
||||
]
|
||||
},
|
||||
"then": {
|
||||
"required": [
|
||||
"externalPolicyName"
|
||||
],
|
||||
"properties": {
|
||||
"externalPolicyName": {
|
||||
"minLength": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"else": {
|
||||
"properties": {
|
||||
"externalPolicyName": {
|
||||
"maxLength": 0
|
||||
},
|
||||
"apnsEgressCidrs": {
|
||||
"minItems": 1
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"apnsEgressCidrs"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"podMonitor": {
|
||||
"type": "object",
|
||||
@@ -247,6 +288,46 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"podLabels": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"not": {
|
||||
"enum": [
|
||||
"app.kubernetes.io/name",
|
||||
"app.kubernetes.io/instance",
|
||||
"app.kubernetes.io/component"
|
||||
]
|
||||
},
|
||||
"maxLength": 317,
|
||||
"pattern": "^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?[A-Za-z0-9]([A-Za-z0-9_.-]{0,61}[A-Za-z0-9])?$",
|
||||
"allOf": [
|
||||
{
|
||||
"anyOf": [
|
||||
{
|
||||
"pattern": "^[^/]+$"
|
||||
},
|
||||
{
|
||||
"pattern": "^[^/]{1,253}/"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"maxLength": 63,
|
||||
"pattern": "^([A-Za-z0-9]([A-Za-z0-9_.-]{0,61}[A-Za-z0-9])?)?$"
|
||||
}
|
||||
},
|
||||
"serviceAccountName": {
|
||||
"type": "string",
|
||||
"maxLength": 253,
|
||||
"pattern": "^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*)?$"
|
||||
},
|
||||
"terminationGracePeriodSeconds": {
|
||||
"type": "integer",
|
||||
"minimum": 60
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
@@ -316,11 +397,12 @@
|
||||
"profiles",
|
||||
"httpRoute",
|
||||
"image",
|
||||
"migration"
|
||||
"migration",
|
||||
"terminationGracePeriodSeconds"
|
||||
],
|
||||
"allOf": [
|
||||
{
|
||||
"$comment": "A PodMonitor requires scoped 8081 ingress. External scrapers such as Datadog may enable that ingress without rendering a PodMonitor.",
|
||||
"$comment": "A PodMonitor requires scoped 8081 ingress when this chart owns the runtime policy. In external-policy mode the named parent policy owns scrape access.",
|
||||
"if": {
|
||||
"properties": {
|
||||
"podMonitor": {
|
||||
@@ -332,10 +414,21 @@
|
||||
"required": [
|
||||
"enabled"
|
||||
]
|
||||
},
|
||||
"networkPolicy": {
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"const": true
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"enabled"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"podMonitor"
|
||||
"podMonitor",
|
||||
"networkPolicy"
|
||||
]
|
||||
},
|
||||
"then": {
|
||||
|
||||
@@ -51,6 +51,8 @@ podDisruptionBudget:
|
||||
enabled: true
|
||||
minAvailable: 1
|
||||
networkPolicy:
|
||||
# Parent chart must supply this named replacement when enabled is false.
|
||||
externalPolicyName: ""
|
||||
enabled: true
|
||||
# Kubernetes NetworkPolicy cannot allow DNS names. Production operators must
|
||||
# narrow these CIDRs to their PostgreSQL/NAT destinations where supported.
|
||||
@@ -97,3 +99,8 @@ topologySpreadConstraints:
|
||||
labelSelector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: buzz-push-gateway
|
||||
|
||||
# Runtime-only mesh integration. Migration settings remain separate.
|
||||
podLabels: {}
|
||||
serviceAccountName: ""
|
||||
terminationGracePeriodSeconds: 60
|
||||
|
||||
Reference in New Issue
Block a user