You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
synctv/scripts/validate-helm.sh

625 lines
28 KiB
Bash

#!/usr/bin/env bash
set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$repo_root"
chart_dir="${SYNCTV_HELM_CHART_DIR:-helm/synctv}"
namespace="${SYNCTV_HELM_NAMESPACE:-synctv}"
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
fail() {
echo "Helm validation failed: $*" >&2
exit 1
}
assert_template_fails() {
local case_name="$1"
local message="$2"
shift 2
if helm template synctv "$chart_dir" \
--namespace "$namespace" \
"$@" \
>"$tmp_dir/$case_name.yaml" 2>"$tmp_dir/$case_name.err"; then
fail "$message"
fi
}
assert_max_service_name_len() {
local file="$1"
local max_len="${2:-63}"
ruby -ryaml -e '
file = ARGV.fetch(0)
max = Integer(ARGV.fetch(1))
docs = YAML.load_stream(File.read(file)).compact
bad = docs
.select { |doc| doc["kind"] == "Service" }
.map { |doc| doc.dig("metadata", "name").to_s }
.select { |name| name.length > max }
abort("rendered Service name(s) exceed #{max} characters in #{file}: #{bad.join(", ")}") unless bad.empty?
' "$file" "$max_len"
}
assert_pdb_field() {
local file="$1"
local field="$2"
local expected="$3"
ruby -ryaml -e '
file, field, expected = ARGV
docs = YAML.load_stream(File.read(file)).compact
pdb = docs.find { |doc| doc["kind"] == "PodDisruptionBudget" }
abort("PodDisruptionBudget was not rendered in #{file}") unless pdb
actual = pdb.dig("spec", field)
abort("PodDisruptionBudget #{field} expected #{expected.inspect}, got #{actual.inspect}") unless actual.to_s == expected
' "$file" "$field" "$expected"
}
assert_pdb_field_absent() {
local file="$1"
local field="$2"
ruby -ryaml -e '
file, field = ARGV
docs = YAML.load_stream(File.read(file)).compact
pdb = docs.find { |doc| doc["kind"] == "PodDisruptionBudget" }
abort("PodDisruptionBudget was not rendered in #{file}") unless pdb
abort("PodDisruptionBudget #{field} was rendered in #{file}") if pdb.fetch("spec", {}).key?(field)
' "$file" "$field"
}
assert_service() {
local file="$1"
local name="$2"
local type="$3"
ruby -ryaml -e '
file, name, type = ARGV
docs = YAML.load_stream(File.read(file)).compact
service = docs.find { |doc| doc["kind"] == "Service" && doc.dig("metadata", "name") == name }
abort("Service #{name.inspect} was not rendered in #{file}") unless service
actual = service.dig("spec", "type")
abort("Service #{name.inspect} type expected #{type.inspect}, got #{actual.inspect}") unless actual == type
' "$file" "$name" "$type"
}
assert_no_resource_named() {
local file="$1"
local name="$2"
ruby -ryaml -e '
file, name = ARGV
docs = YAML.load_stream(File.read(file)).compact
found = docs.any? { |doc| doc.dig("metadata", "name") == name }
abort("Resource #{name.inspect} was rendered in #{file}") if found
' "$file" "$name"
}
assert_no_certificate_common_name() {
local file="$1"
ruby -ryaml -e '
file = ARGV.fetch(0)
docs = YAML.load_stream(File.read(file)).compact
certificates = docs.select { |doc| doc["kind"] == "Certificate" }
with_common_name = certificates.select { |doc| doc.fetch("spec", {}).key?("commonName") }
abort("Certificate commonName was rendered in #{file}") unless with_common_name.empty?
' "$file"
}
assert_env_secret_key_ref() {
local file="$1"
local env_name="$2"
local secret_key="$3"
ruby -ryaml -e '
file, env_name, secret_key = ARGV
docs = YAML.load_stream(File.read(file)).compact
containers = docs.flat_map do |doc|
next [] unless ["Deployment", "StatefulSet"].include?(doc["kind"])
doc.dig("spec", "template", "spec", "containers") || []
end
env = containers.flat_map { |container| container["env"] || [] }
entry = env.find { |item| item["name"] == env_name }
abort("env #{env_name.inspect} was not rendered in #{file}") unless entry
actual = entry.dig("valueFrom", "secretKeyRef", "key")
abort("env #{env_name.inspect} secret key expected #{secret_key.inspect}, got #{actual.inspect}") unless actual == secret_key
' "$file" "$env_name" "$secret_key"
}
assert_secret_string_data_key_absent() {
local file="$1"
local key="$2"
ruby -ryaml -e '
file, key = ARGV
docs = YAML.load_stream(File.read(file)).compact
found = docs.any? { |doc| doc["kind"] == "Secret" && doc.fetch("stringData", {}).key?(key) }
abort("Secret stringData key #{key.inspect} was unexpectedly rendered in #{file}") if found
' "$file" "$key"
}
assert_env_secret_name() {
local file="$1"
local env_name="$2"
local secret_name="$3"
ruby -ryaml -e '
file, env_name, secret_name = ARGV
docs = YAML.load_stream(File.read(file)).compact
containers = docs.flat_map do |doc|
next [] unless ["Deployment", "StatefulSet"].include?(doc["kind"])
doc.dig("spec", "template", "spec", "containers") || []
end
entry = containers.flat_map { |container| container["env"] || [] }
.find { |item| item["name"] == env_name }
abort("env #{env_name.inspect} was not rendered in #{file}") unless entry
actual = entry.dig("valueFrom", "secretKeyRef", "name")
abort("env #{env_name.inspect} Secret expected #{secret_name.inspect}, got #{actual.inspect}") unless actual == secret_name
' "$file" "$env_name" "$secret_name"
}
assert_bootstrap_env_value() {
local file="$1"
local env_name="$2"
local expected="$3"
ruby -ryaml -e '
file, env_name, expected = ARGV
docs = YAML.load_stream(File.read(file)).compact
job = docs.find do |doc|
doc["kind"] == "Job" && doc.dig("metadata", "labels", "app.kubernetes.io/component") == "secret-bootstrap"
end
abort("secret bootstrap Job was not rendered in #{file}") unless job
containers = job.dig("spec", "template", "spec", "containers") || []
container = containers.find { |item| item["name"] == "secret-bootstrap" }
abort("secret bootstrap container was not rendered in #{file}") unless container
entry = (container["env"] || []).find { |item| item["name"] == env_name }
abort("bootstrap env #{env_name.inspect} was not rendered in #{file}") unless entry
actual = entry["value"].to_s
abort("bootstrap env #{env_name.inspect} expected #{expected.inspect}, got #{actual.inspect}") unless actual == expected
' "$file" "$env_name" "$expected"
}
assert_bootstrap_reconciles_key() {
local file="$1"
local key="$2"
ruby -ryaml -e '
file, key = ARGV
docs = YAML.load_stream(File.read(file)).compact
job = docs.find do |doc|
doc["kind"] == "Job" && doc.dig("metadata", "labels", "app.kubernetes.io/component") == "secret-bootstrap"
end
abort("secret bootstrap Job was not rendered in #{file}") unless job
containers = job.dig("spec", "template", "spec", "containers") || []
script = containers.find { |item| item["name"] == "secret-bootstrap" }&.dig("args", 0).to_s
abort("secret bootstrap does not reconcile #{key.inspect}") unless script.include?("ensure_key #{key} ")
' "$file" "$key"
}
assert_deployment_annotation() {
local file="$1"
local annotation="$2"
local expected="$3"
ruby -ryaml -e '
file, annotation, expected = ARGV
docs = YAML.load_stream(File.read(file)).compact
deployment = docs.find { |doc| doc["kind"] == "Deployment" && doc.dig("metadata", "name") == "synctv" }
abort("synctv Deployment was not rendered in #{file}") unless deployment
actual = deployment.dig("spec", "template", "metadata", "annotations", annotation)
abort("Deployment annotation #{annotation.inspect} expected #{expected.inspect}, got #{actual.inspect}") unless actual == expected
' "$file" "$annotation" "$expected"
}
assert_app_env_contract() {
local file="$1"
ruby -ryaml -e '
file, source_root = ARGV
docs = YAML.load_stream(File.read(file)).compact
deployment = docs.find { |doc| doc["kind"] == "Deployment" && doc.dig("metadata", "name") == "synctv" }
abort("synctv Deployment was not rendered in #{file}") unless deployment
pod_spec = deployment.dig("spec", "template", "spec") || {}
abort("synctv Deployment must set enableServiceLinks=false") unless pod_spec["enableServiceLinks"] == false
container = Array(pod_spec["containers"]).find { |item| item["name"] == "synctv" }
abort("synctv container was not rendered in #{file}") unless container
rendered = Array(container["env"]).filter_map { |item| item["name"] if item["name"].to_s.start_with?("SYNCTV_") }
supported = Dir.glob(File.join(source_root, "**", "*.rs")).flat_map do |path|
File.read(path).scan(/"(SYNCTV_[A-Z0-9_]+)"/).flatten
end.uniq
unknown = rendered - supported
abort("unsupported SYNCTV_ environment variable(s) rendered in #{file}: #{unknown.join(", ")}") unless unknown.empty?
' "$file" synctv/src
}
assert_security_rendering() {
local file="$1"
ruby -ryaml -e '
file = ARGV.fetch(0)
docs = YAML.load_stream(File.read(file)).compact
containers = docs.flat_map do |doc|
next [] unless ["Deployment", "StatefulSet"].include?(doc["kind"])
doc.dig("spec", "template", "spec", "containers") || []
end
images = containers.map { |container| container["image"].to_s }
abort("SyncTV image registry override was not applied") unless images.any? { |image| image.start_with?("registry.example.com/") && image.include?("/synctv:") }
config = docs.find { |doc| doc["kind"] == "ConfigMap" && doc.dig("metadata", "name") == "synctv-config" }
abort("synctv-config ConfigMap was not rendered") unless config
synctv_yaml = config.dig("data", "synctv.yaml")
abort("synctv.yaml ConfigMap entry was not rendered") unless synctv_yaml
app_config = YAML.safe_load(synctv_yaml)
ssrf = app_config.dig("security", "ssrf") || {}
abort("SSRF private-network override was not applied") unless ssrf["allow_private_network_targets"] == true
abort("SSRF allowed host was not applied") unless Array(ssrf["allowed_hosts"]).include?("nas.example.internal")
abort("SSRF allowed IP range was not applied") unless Array(ssrf["allowed_ip_ranges"]).include?("10.0.8.0/24")
' "$file"
}
assert_native_webauthn_rendering() {
local file="$1"
ruby -ryaml -e '
file = ARGV.fetch(0)
docs = YAML.load_stream(File.read(file)).compact
config = docs.find { |doc| doc["kind"] == "ConfigMap" && doc.dig("metadata", "name") == "synctv-config" }
abort("synctv-config ConfigMap was not rendered in #{file}") unless config
synctv_yaml = config.dig("data", "synctv.yaml")
abort("synctv.yaml ConfigMap entry was not rendered in #{file}") unless synctv_yaml
webauthn = YAML.safe_load(synctv_yaml).dig("webauthn") || {}
expected_apple_ids = ["ABCDE12345.org.synctv.app"]
abort("Apple app IDs were not rendered") unless webauthn["apple_app_ids"] == expected_apple_ids
expected_android_apps = [{
"package_name" => "org.synctv.app",
"sha256_cert_fingerprints" => ["AA:BB:CC:DD"]
}]
abort("Android app identities were not rendered") unless webauthn["android_apps"] == expected_android_apps
' "$file"
}
assert_file_storage_s3_file_credentials_rendering() {
local file="$1"
ruby -ryaml -e '
file = ARGV.fetch(0)
docs = YAML.load_stream(File.read(file)).compact
config = docs.find { |doc| doc["kind"] == "ConfigMap" && doc.dig("metadata", "name") == "synctv-config" }
abort("synctv-config ConfigMap was not rendered in #{file}") unless config
synctv_yaml = config.dig("data", "synctv.yaml")
abort("synctv.yaml ConfigMap entry was not rendered in #{file}") unless synctv_yaml
app_config = YAML.safe_load(synctv_yaml)
s3 = app_config.dig("file_storage", "backends", "s3_public") || {}
abort("S3 access_key_id_file was not rendered") unless s3["access_key_id_file"] == "/run/secrets/file-storage-s3/access_key_id"
abort("S3 secret_access_key_file was not rendered") unless s3["secret_access_key_file"] == "/run/secrets/file-storage-s3/secret_access_key"
abort("S3 inline access_key_id was rendered with file credentials") if s3.key?("access_key_id")
abort("S3 inline secret_access_key was rendered with file credentials") if s3.key?("secret_access_key")
deployment = docs.find { |doc| doc["kind"] == "Deployment" && doc.dig("metadata", "name") == "synctv" }
abort("synctv Deployment was not rendered in #{file}") unless deployment
volumes = deployment.dig("spec", "template", "spec", "volumes") || []
volume = volumes.find { |item| item["name"] == "file-storage-s3" }
abort("file-storage-s3 volume was not rendered") unless volume&.dig("secret", "secretName") == "synctv-file-storage-s3"
containers = deployment.dig("spec", "template", "spec", "containers") || []
synctv = containers.find { |item| item["name"] == "synctv" }
abort("synctv container was not rendered") unless synctv
mounts = synctv["volumeMounts"] || []
mount = mounts.find { |item| item["name"] == "file-storage-s3" }
abort("file-storage-s3 volumeMount was not rendered") unless mount
abort("file-storage-s3 mountPath was #{mount["mountPath"].inspect}") unless mount["mountPath"] == "/run/secrets/file-storage-s3"
abort("file-storage-s3 volumeMount should be readOnly") unless mount["readOnly"] == true
' "$file"
}
write_rendered_synctv_config() {
local rendered_manifest="$1"
local rendered_config="$2"
local secret_dir="${3:-}"
ruby -ryaml -e '
file, output, secret_dir = ARGV
docs = YAML.load_stream(File.read(file)).compact
config = docs.find { |doc| doc["kind"] == "ConfigMap" && doc.dig("metadata", "name") == "synctv-config" }
abort("synctv-config ConfigMap was not rendered in #{file}") unless config
synctv_yaml = config.dig("data", "synctv.yaml")
abort("synctv.yaml ConfigMap entry was not rendered in #{file}") unless synctv_yaml
synctv_yaml = synctv_yaml.gsub("/run/secrets/file-storage-s3", secret_dir) unless secret_dir.empty?
File.write(output, synctv_yaml)
' "$rendered_manifest" "$rendered_config" "$secret_dir"
}
run_rendered_synctv_config_validation() {
local rendered_config="$1"
local -a validation_env=(
"PATH=${PATH:-/usr/bin:/bin}"
"HOME=${HOME:-$tmp_dir}"
"TMPDIR=${TMPDIR:-/tmp}"
"SYNCTV_JWT_SECRET=helm-validation-jwt-secret-12345678901234567890"
"SYNCTV_SECURITY_CREDENTIAL_ENCRYPTION_KEY=5656565656565656565656565656565656565656565656565656565656565656"
"SYNCTV_SECURITY_TOTP_ENCRYPTION_KEY=5757575757575757575757575757575757575757575757575757575757575757"
"SYNCTV_SECURITY_EMAIL_OUTBOX_ENCRYPTION_KEY=5858585858585858585858585858585858585858585858585858585858585858"
"SYNCTV_SECURITY_OPAQUE_SERVER_SETUP_SECRET=helm-validation-opaque-secret-123456789012345"
"SYNCTV_SECURITY_PROXY_SIGNING_KEY=helm-validation-proxy-signing-key-123456789012345"
"SYNCTV_SECURITY_MEDIA_SWARM_SIGNING_KEY=helm-validation-media-swarm-signing-key-123456789012345"
"SYNCTV_SECURITY_PROVIDER_SESSION_ENCRYPTION_KEY=helm-validation-provider-session-key-123456789012345"
"SYNCTV_SECURITY_LOGIN_DISCOVERY_KEY=helm-validation-login-discovery-key-123456789012345"
"SYNCTV_SECURITY_WEBAUTHN_ENUMERATION_KEY=helm-validation-webauthn-enumeration-key-123456789012345"
"SYNCTV_FILE_UPLOAD_TOKEN_SECRET=helm-validation-file-upload-key-123456789012345"
"SYNCTV_CLUSTER_SECRET=helm-validation-cluster-secret-12345678901234567890"
"SYNCTV_SERVER_ADVERTISE_HOST=10.0.0.10"
"SYNCTV_REDIS_HOST=synctv-redis"
"SYNCTV_REDIS_PORT=6379"
"SYNCTV_REDIS_DATABASE=0"
)
[ -z "${USER:-}" ] || validation_env+=("USER=$USER")
[ -z "${CARGO_HOME:-}" ] || validation_env+=("CARGO_HOME=$CARGO_HOME")
[ -z "${RUSTUP_HOME:-}" ] || validation_env+=("RUSTUP_HOME=$RUSTUP_HOME")
[ -z "${CARGO_TARGET_DIR:-}" ] || validation_env+=("CARGO_TARGET_DIR=$CARGO_TARGET_DIR")
env -i "${validation_env[@]}" \
refactor(metrics): centralize registry and lifecycle tracking (#446) ## Summary - split Prometheus definitions into domain-owned modules backed by one registry - eagerly initialize every metric family and fail startup on invalid or duplicate definitions - add reusable RAII guards for gauges and relay durations across cancellation, panic, and retry paths - centralize HTTP, WebSocket, gRPC, and relay recording behind bounded-label helpers - enforce the 65-metric bilingual catalog from registered descriptors - persist completed explicit-message deletion and user-ban moderation steps - update the pinned Rust toolchain and CI jobs to `nightly-2026-08-25` - make the CI Clippy job fail on every Rust warning with `-D warnings` - keep Helm configuration-validation builds visible to avoid silent-run termination - separate Rust caches by artifact type and let Helm reuse codegen artifacts from the Build job - work around the nightly global next-solver memory regression and remove obsolete rustc recursion-limit overrides - preserve workspace Rust flags in Docker builds while applying linker flags to the final binary ## Compatibility - preserve all existing metric names, HELP text, label order, and histogram buckets - keep route-template and bounded status/error labels - return HTTP 500 when Prometheus exposition encoding fails - keep the latest nightly while restoring the previous coherence-only trait-solver behavior ## Nightly memory regression The failed CI jobs were terminated by the runner while compiling `synctv-api-http` (SIGTERM, exit 143). Local peak-RSS measurements for that crate were: | Configuration | Peak RSS | | --- | ---: | | `nightly-2026-08-21` | 6.62 GB | | `nightly-2026-08-25`, global next solver | 10.76 GB | | `nightly-2026-08-25`, `-Znext-solver=coherence` | 6.64 GB | Rust enabled `-Znext-solver=globally` by default on nightly in [rust-lang/rust#160619](https://github.com/rust-lang/rust/pull/160619). The same memory blow-up is tracked in [rust-lang/rust#161748](https://github.com/rust-lang/rust/issues/161748), and the official tracking issue documents `-Znext-solver=coherence` as the temporary opt-out. The workspace now applies that option in `.cargo/config.toml`. The Dockerfile previously set `RUSTFLAGS` for linker options, which overrode the workspace configuration and re-enabled the global solver inside image builds. It now uses `cargo rustc` to pass linker flags only to the final binary, preserving the workspace solver setting for every crate. All rustc `#![recursion_limit = "256"]` attributes were removed. The default limit passes with the coherence-only solver, confirming that a higher recursion limit was unrelated to the CI termination. ## Validation - `make build-workspace` - `make clippy-check` (`--workspace --all-targets -- -D warnings`) - `cargo check --locked -p synctv-api-http` - `cargo check --locked -p synctv-api-common -p synctv-api-grpc` - `cargo test -p synctv-api-common --lib` (655 passed, 165 Docker tests ignored) - `cargo test -p synctv-core metrics --lib` (9 passed) - `make fmt-check` - `docker build --check .` - `make validate-helm` - `actionlint .github/workflows/ci.yml .github/workflows/helm-ci.yml` - `npm run validate` in `docs` (124 pages, 353 links, Astro 0 errors/warnings)
4 weeks ago
cargo run -p synctv --bin synctv -- --no-dotenv --config "$rendered_config" config validate --strict
}
validate_rendered_synctv_config() {
local rendered_manifest="$1"
local rendered_config
rendered_config="$tmp_dir/$(basename "$rendered_manifest" .yaml).synctv.yaml"
write_rendered_synctv_config "$rendered_manifest" "$rendered_config"
run_rendered_synctv_config_validation "$rendered_config"
}
validate_rendered_synctv_config_with_file_storage_s3_secret_files() {
local rendered_manifest="$1"
local secret_dir="$tmp_dir/file-storage-s3"
mkdir -p "$secret_dir"
printf '%s\n' "file-storage-access-key" >"$secret_dir/access_key_id"
printf '%s\n' "file-storage-secret-key" >"$secret_dir/secret_access_key"
local rendered_config
rendered_config="$tmp_dir/$(basename "$rendered_manifest" .yaml).synctv.yaml"
write_rendered_synctv_config "$rendered_manifest" "$rendered_config" "$secret_dir"
run_rendered_synctv_config_validation "$rendered_config"
}
chart_version="$(ruby -ryaml -e 'puts YAML.load_file(ARGV.fetch(0)).fetch("version")' "$chart_dir/Chart.yaml")"
app_version="$(ruby -ryaml -e 'puts YAML.load_file(ARGV.fetch(0)).fetch("appVersion")' "$chart_dir/Chart.yaml")"
cargo_version="$(cargo metadata --format-version 1 --no-deps | node -e 'const fs = require("fs"); const meta = JSON.parse(fs.readFileSync(0, "utf8")); process.stdout.write(meta.workspace_default_members.length ? meta.packages.find((pkg) => pkg.id === meta.workspace_default_members[0]).version : meta.packages[0].version);')"
compose_image="$(ruby -ryaml -e '
compose = YAML.load_file(ARGV.fetch(0))
puts compose.fetch("services").fetch("synctv").fetch("image")
' docker-compose.yml)"
docs_default_app_version="$(node --input-type=module -e 'const project = await import("./docs/src/lib/project.ts"); process.stdout.write(project.dockerImageTag);')"
[ -n "$chart_version" ] || fail "$chart_dir/Chart.yaml must define version"
[ -n "$app_version" ] || fail "$chart_dir/Chart.yaml must define appVersion"
[ -n "$cargo_version" ] || fail "Cargo.toml must define workspace.package.version"
[ -n "$compose_image" ] || fail "docker-compose.yml must define the synctv image"
[ -n "$docs_default_app_version" ] || fail "docs/src/lib/project.ts must define defaultAppVersion"
[ "$chart_version" = "$cargo_version" ] ||
fail "chart version ($chart_version) must match Cargo workspace version ($cargo_version)"
[ "$app_version" = "$cargo_version" ] ||
fail "chart appVersion ($app_version) must match Cargo workspace version ($cargo_version)"
case "$compose_image" in
'synctvorg/synctv:${SYNCTV_IMAGE_TAG:-latest}') ;;
*) fail "Compose image ($compose_image) must use the SYNCTV_IMAGE_TAG fallback" ;;
esac
[ "$docs_default_app_version" = "$cargo_version" ] ||
fail "docs default app version ($docs_default_app_version) must match Cargo workspace version ($cargo_version)"
helm lint "$chart_dir"
helm template synctv "$chart_dir" \
--namespace "$namespace" \
>"$tmp_dir/default.yaml"
helm template synctv "$chart_dir" \
--namespace "$namespace" \
>"$tmp_dir/default-repeat.yaml"
cmp -s "$tmp_dir/default.yaml" "$tmp_dir/default-repeat.yaml" ||
fail "default Helm rendering must be deterministic"
helm template synctv "$chart_dir" \
--namespace "$namespace" \
--set existingSecret=synctv-managed-secrets \
--set secretRolloutChecksum=rotation-2 \
>"$tmp_dir/existing-secret.yaml"
helm template synctv "$chart_dir" \
--namespace "$namespace" \
--set secrets.security.emailOutboxEncryptionKey=5959595959595959595959595959595959595959595959595959595959595959 \
>"$tmp_dir/explicit-email-outbox-key.yaml"
assert_template_fails invalid-email-outbox-key \
"invalid secrets.security.emailOutboxEncryptionKey must fail validation" \
--set-string secrets.security.emailOutboxEncryptionKey=invalid
assert_template_fails invalid-credential-key \
"invalid secrets.security.credentialEncryptionKey must fail validation" \
--set-string secrets.security.credentialEncryptionKey=invalid
assert_template_fails short-opaque-secret \
"short secrets.security.opaqueServerSetupSecret must fail validation" \
--set-string secrets.security.opaqueServerSetupSecret=short
assert_template_fails placeholder-proxy-key \
"placeholder secrets.security.proxySigningKey must fail validation" \
--set-string secrets.security.proxySigningKey=CHANGE_ME_proxy_signing_key_1234567890
assert_template_fails duplicate-security-secret \
"duplicate security-domain secret values must fail validation" \
--set-string secrets.jwt.secret=duplicate-security-secret-value-1234567890 \
--set-string secrets.security.proxySigningKey=duplicate-security-secret-value-1234567890
assert_template_fails duplicate-hex-security-secret \
"hexadecimal security-domain secret values must be compared case-insensitively" \
--set-string secrets.security.credentialEncryptionKey=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA \
--set-string secrets.security.totpEncryptionKey=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
helm template synctv "$chart_dir" \
--namespace "$namespace" \
--set existingSecret=synctv-managed-secrets \
--set-string secrets.security.credentialEncryptionKey=ignored-external-value \
>"$tmp_dir/existing-secret-ignores-values.yaml"
helm template synctv "$chart_dir" \
--namespace "$namespace" \
--set ingress.grpc.enabled=true \
>"$tmp_dir/grpc.yaml"
helm template synctv "$chart_dir" \
--namespace "$namespace" \
--set postgresql.mode=kubeblocks \
--set redis.mode=kubeblocks \
>"$tmp_dir/kubeblocks.yaml"
helm template synctv "$chart_dir" \
--namespace "$namespace" \
--set postgresql.mode=kubeblocks \
--set postgresql.kubeblocks.bootstrapAppDatabase=false \
>"$tmp_dir/kubeblocks-no-bootstrap.yaml"
helm template aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa "$chart_dir" \
--namespace "$namespace" \
--set metrics.enabled=true \
--set metrics.tls.enabled=true \
--set metrics.serviceMonitor.enabled=true \
--set metrics.vmServiceScrape.enabled=true \
--set ingress.grpc.enabled=true \
--set headlessService.enabled=true \
>"$tmp_dir/long-release.yaml"
helm template synctv "$chart_dir" \
--namespace "$namespace" \
--set image.registry=registry.example.com \
--set config.security.ssrf.allowPrivateNetworkTargets=true \
--set config.security.ssrf.allowedHosts[0]=nas.example.internal \
--set config.security.ssrf.allowedIpRanges[0]=10.0.8.0/24 \
>"$tmp_dir/security.yaml"
helm template synctv "$chart_dir" \
--namespace "$namespace" \
--set-string config.webauthn.appleAppIds[0]=ABCDE12345.org.synctv.app \
--set-string config.webauthn.androidApps[0].packageName=org.synctv.app \
--set-string config.webauthn.androidApps[0].sha256CertFingerprints[0]=AA:BB:CC:DD \
>"$tmp_dir/native-webauthn.yaml"
helm template synctv "$chart_dir" \
--namespace "$namespace" \
--set secrets.database.readUrl=postgresql://reader:secret@postgres-read:5432/synctv \
--set config.database.useSecretReadUrl=true \
>"$tmp_dir/secret-read-url.yaml"
helm template synctv "$chart_dir" \
--namespace "$namespace" \
--set config.fileStorage.defaultBackend=s3_public \
--set config.fileStorage.backends.s3_public.type=s3 \
--set config.fileStorage.backends.s3_public.endpoint=https://s3.example.com \
--set config.fileStorage.backends.s3_public.bucket=synctv-files \
--set config.fileStorage.backends.s3_public.region=auto \
--set config.fileStorage.backends.s3_public.basePath=files/ \
--set config.fileStorage.backends.s3_public.publicBaseUrl=https://cdn.example.com/files \
--set config.fileStorage.backends.s3_public.accessKeyIdFile=/run/secrets/file-storage-s3/access_key_id \
--set config.fileStorage.backends.s3_public.secretAccessKeyFile=/run/secrets/file-storage-s3/secret_access_key \
--set extraVolumes[0].name=file-storage-s3 \
--set extraVolumes[0].secret.secretName=synctv-file-storage-s3 \
--set extraVolumeMounts[0].name=file-storage-s3 \
--set extraVolumeMounts[0].mountPath=/run/secrets/file-storage-s3 \
--set extraVolumeMounts[0].readOnly=true \
>"$tmp_dir/file-storage-s3-files.yaml"
if helm template synctv "$chart_dir" \
--namespace "$namespace" \
--set replicaCount=2 \
>"$tmp_dir/standalone-replicas.yaml" 2>"$tmp_dir/standalone-replicas.err"; then
fail "replicaCount=2 without cluster mode must fail validation"
fi
if helm template synctv "$chart_dir" \
--namespace "$namespace" \
--set autoscaling.enabled=true \
>"$tmp_dir/standalone-hpa.yaml" 2>"$tmp_dir/standalone-hpa.err"; then
fail "autoscaling beyond one pod without cluster mode must fail validation"
fi
if helm template synctv "$chart_dir" \
--namespace "$namespace" \
--set config.database.useSecretReadUrl=true \
>"$tmp_dir/missing-secret-read-url.yaml" 2>"$tmp_dir/missing-secret-read-url.err"; then
fail "config.database.useSecretReadUrl=true without existingSecret or secrets.database.readUrl must fail validation"
fi
if helm template synctv "$chart_dir" \
--namespace "$namespace" \
--set secretBootstrap.enabled=false \
>"$tmp_dir/missing-secret-owner.yaml" 2>"$tmp_dir/missing-secret-owner.err"; then
fail "secretBootstrap.enabled=false without existingSecret must fail validation"
fi
helm template synctv "$chart_dir" \
--namespace "$namespace" \
--set replicaCount=2 \
--set config.cluster.enabled=true \
>"$tmp_dir/cluster-replicas.yaml"
helm template synctv "$chart_dir" \
--namespace "$namespace" \
--set replicaCount=2 \
--set safety.allowStandaloneReplicas=true \
>"$tmp_dir/acknowledged-standalone-replicas.yaml"
helm template synctv "$chart_dir" \
--namespace "$namespace" \
--set podDisruptionBudget.enabled=true \
>"$tmp_dir/pdb-default.yaml"
assert_pdb_field "$tmp_dir/pdb-default.yaml" maxUnavailable 1
helm template synctv "$chart_dir" \
--namespace "$namespace" \
--set podDisruptionBudget.enabled=true \
--set podDisruptionBudget.minAvailable=2 \
>"$tmp_dir/pdb-legacy-min-available.yaml"
assert_pdb_field "$tmp_dir/pdb-legacy-min-available.yaml" minAvailable 2
assert_pdb_field_absent "$tmp_dir/pdb-legacy-min-available.yaml" maxUnavailable
if helm template synctv "$chart_dir" \
--namespace "$namespace" \
--set config.webrtc.enableBuiltinStun=true \
--set stunService.enabled=true \
--set config.webrtc.stunExternalAddr=203.0.113.10:3478 \
>"$tmp_dir/clusterip-stun.yaml" 2>"$tmp_dir/clusterip-stun.err"; then
fail "ClusterIP STUN service with external STUN address must fail validation"
fi
helm template synctv "$chart_dir" \
--namespace "$namespace" \
--set config.webrtc.enableBuiltinStun=true \
--set stunService.enabled=true \
--set stunService.type=LoadBalancer \
--set config.webrtc.stunExternalAddr=203.0.113.10:3478 \
>"$tmp_dir/loadbalancer-stun.yaml"
assert_service "$tmp_dir/loadbalancer-stun.yaml" synctv-stun LoadBalancer
assert_security_rendering "$tmp_dir/security.yaml"
assert_native_webauthn_rendering "$tmp_dir/native-webauthn.yaml"
for key in \
SYNCTV_SECURITY_CREDENTIAL_ENCRYPTION_KEY \
SYNCTV_SECURITY_TOTP_ENCRYPTION_KEY \
SYNCTV_SECURITY_EMAIL_OUTBOX_ENCRYPTION_KEY \
SYNCTV_SECURITY_OPAQUE_SERVER_SETUP_SECRET \
SYNCTV_SECURITY_PROXY_SIGNING_KEY \
SYNCTV_SECURITY_MEDIA_SWARM_SIGNING_KEY \
SYNCTV_SECURITY_PROVIDER_SESSION_ENCRYPTION_KEY \
SYNCTV_SECURITY_LOGIN_DISCOVERY_KEY \
SYNCTV_SECURITY_WEBAUTHN_ENUMERATION_KEY \
SYNCTV_FILE_UPLOAD_TOKEN_SECRET; do
assert_env_secret_key_ref "$tmp_dir/default.yaml" "$key" "$key"
assert_bootstrap_reconciles_key "$tmp_dir/default.yaml" "$key"
assert_env_secret_key_ref "$tmp_dir/existing-secret.yaml" "$key" "$key"
assert_env_secret_name "$tmp_dir/existing-secret.yaml" "$key" synctv-managed-secrets
assert_secret_string_data_key_absent "$tmp_dir/existing-secret.yaml" "$key"
done
assert_no_resource_named "$tmp_dir/existing-secret.yaml" synctv-secret-bootstrap
assert_deployment_annotation "$tmp_dir/existing-secret.yaml" synctv.io/secret-rollout-checksum rotation-2
assert_bootstrap_env_value "$tmp_dir/explicit-email-outbox-key.yaml" EMAIL_OUTBOX_KEY 5959595959595959595959595959595959595959595959595959595959595959
assert_file_storage_s3_file_credentials_rendering "$tmp_dir/file-storage-s3-files.yaml"
validate_rendered_synctv_config "$tmp_dir/default.yaml"
assert_app_env_contract "$tmp_dir/default.yaml"
validate_rendered_synctv_config "$tmp_dir/security.yaml"
validate_rendered_synctv_config "$tmp_dir/cluster-replicas.yaml"
validate_rendered_synctv_config_with_file_storage_s3_secret_files "$tmp_dir/file-storage-s3-files.yaml"
assert_env_secret_key_ref "$tmp_dir/secret-read-url.yaml" SYNCTV_DATABASE_READ_URL SYNCTV_DATABASE_READ_URL
assert_bootstrap_env_value "$tmp_dir/secret-read-url.yaml" DATABASE_READ_URL postgresql://reader:secret@postgres-read:5432/synctv
assert_env_secret_key_ref "$tmp_dir/kubeblocks.yaml" SYNCTV_DATABASE_PASSWORD SYNCTV_DATABASE_PASSWORD
assert_no_resource_named "$tmp_dir/kubeblocks-no-bootstrap.yaml" bootstrap-postgresql-app-db
assert_max_service_name_len "$tmp_dir/long-release.yaml" 63
assert_no_certificate_common_name "$tmp_dir/long-release.yaml"
echo "Helm chart validation passed."