From fd3912757e713c3dd8d3ca78db20927330dc2f20 Mon Sep 17 00:00:00 2001 From: Federico Maleh Date: Wed, 27 May 2026 12:01:02 -0300 Subject: [PATCH 1/8] fix(k8s,scheduled_task): file-type parameter no longer leaks binary as env var --- CHANGELOG.md | 1 + k8s/deployment/templates/deployment.yaml.tpl | 2 +- k8s/deployment/templates/secret.yaml.tpl | 3 +- k8s/deployment/tests/build_deployment.bats | 106 ++++++++++++++++++ .../deployment/templates/deployment.yaml.tpl | 2 +- 5 files changed, 111 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 14165264..0248fca4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +- Fix deployment failure for `file`-type parameters with binary content (e.g., P12 certificates): the env var injected from the deployment Secret now carries the file's destination path instead of the raw content, avoiding `invalid environment variable ... contains nul byte` errors. The file is still mounted at `destination_path` as before. - Public and private scopes now register DNS records in their correct Route53 hosted zone when using `DNS_TYPE=external_dns`, preventing cross-zone record leakage - Add configurable main HTTP port for k8s scopes (default 8080) and HTTP support for additional ports - Improve **wait deployment active** failure logging: consolidate repeated `Unhealthy` probe events per pod into a single human-readable line, emit a progress heartbeat every 10% of timeout, and surface a targeted suggested fix based on the probe failure mode (port not open / HTTP non-2xx / probe timeout) diff --git a/k8s/deployment/templates/deployment.yaml.tpl b/k8s/deployment/templates/deployment.yaml.tpl index 3552c483..74ae8ed5 100644 --- a/k8s/deployment/templates/deployment.yaml.tpl +++ b/k8s/deployment/templates/deployment.yaml.tpl @@ -377,7 +377,7 @@ spec: secret: secretName: s-{{ $.scope.id }}-d-{{ $.deployment.id }} items: - - key: {{ printf "app-data-%s" (filepath.Base .destination_path) }} + - key: {{ printf "app-file-%s" (filepath.Base .destination_path) }} path: {{ filepath.Base .destination_path }} {{- end }} {{- end }} diff --git a/k8s/deployment/templates/secret.yaml.tpl b/k8s/deployment/templates/secret.yaml.tpl index baa9564d..13a6d8b4 100644 --- a/k8s/deployment/templates/secret.yaml.tpl +++ b/k8s/deployment/templates/secret.yaml.tpl @@ -39,7 +39,8 @@ data: {{- end }} {{- if and (eq .type "file") }} {{- if gt (len .values) 0 }} - {{ printf "app-data-%s" (filepath.Base .destination_path) }}: {{ index .values 0 "value" | regexp.Replace "^data:[^;]+;base64," "" }} + {{ printf "app-data-%s" (filepath.Base .destination_path) }}: {{ .destination_path | base64.Encode }} + {{ printf "app-file-%s" (filepath.Base .destination_path) }}: {{ index .values 0 "value" | regexp.Replace "^data:[^;]+;base64," "" }} {{- end }} {{- end }} {{- end }} diff --git a/k8s/deployment/tests/build_deployment.bats b/k8s/deployment/tests/build_deployment.bats index f010afce..585c8f40 100644 --- a/k8s/deployment/tests/build_deployment.bats +++ b/k8s/deployment/tests/build_deployment.bats @@ -170,3 +170,109 @@ teardown() { [ "$status" -eq 0 ] [ ! -f "$OUTPUT_DIR/context-scope-123.json" ] } + +# ============================================================================= +# Rendering Tests — real gomplate, assert on rendered output +# ============================================================================= +# These tests run the actual `gomplate` binary against the templates and +# verify the rendered Secret + Deployment YAML have the right shape. +# +# Regression guard for the file-type parameter bug: binary file content used +# to be stored under Secret key `app-data-` and then leaked into the +# container env block via `envFrom`, which runc rejects with +# `invalid environment variable ... contains nul byte`. The fix splits the +# Secret key into: +# - app-data- -> destination_path (string, env-safe) +# - app-file- -> raw binary (volume-mount-only) +# and updates the volume mount to read from the new key. + +# Minimal context that satisfies all five templates' required fields. +# Includes both an `environment` and a `file` parameter so we can assert on +# the file-specific keys without ignoring the rest of the Secret content. +_render_context() { + cat <<'JSON' +{ + "account": {"id": "acc1", "slug": "acct"}, + "namespace": {"id": "ns1", "slug": "nsps"}, + "application": {"id": "app1", "slug": "appslug"}, + "release": {"semver": "1.0.0"}, + "scope": { + "id": "scope-123", + "slug": "scopeslug", + "domain": "x.example.com", + "dimensions": {"env": "dev"}, + "capabilities": { + "cpu_millicores": 100, + "ram_memory": 128, + "additional_ports": [], + "scaling_type": "fixed", + "autoscaling": { + "min_replicas": 1, + "max_replicas": 3, + "target_cpu_utilization": 80, + "target_memory_enabled": false, + "target_memory_utilization": 80 + }, + "health_check": {"path": "/health", "timeout_seconds": 1, "period_seconds": 5, "initial_delay_seconds": 5} + } + }, + "deployment": {"id": "deploy-456"}, + "k8s_namespace": "ns-test", + "k8s_modifiers": {}, + "asset": {"url": "example.com/app:latest"}, + "main_http_port": 8080, + "traffic_image": "example.com/traffic:latest", + "container_cpu_in_millicores": 50, + "container_memory_in_memory": 64, + "pull_secrets": {"ENABLED": false, "SECRETS": []}, + "region": "us-east-1", + "component": "app", + "service_account_name": "", + "traffic_manager_config_map": "", + "pdb_enabled": "false", + "pdb_max_unavailable": "25%", + "parameters": { + "results": [ + {"type": "environment", "variable": "MY_VAR", "values": [{"value": "hello"}]}, + {"type": "file", "destination_path": "/etc/certs/test.p12", "values": [{"value": "data:application/x-pkcs12;base64,QUFBQkJC"}]} + ] + } +} +JSON +} + +@test "build_deployment: file-type parameter renders path env var and separate binary key" { + unset -f gomplate # use the real gomplate binary, not the setup mock + + export CONTEXT="$(_render_context)" + + run bash "$BATS_TEST_DIRNAME/../build_deployment" + [ "$status" -eq 0 ] + + local secret_file="$OUTPUT_DIR/secret-scope-123-deploy-456.yaml" + local deploy_file="$OUTPUT_DIR/deployment-scope-123-deploy-456.yaml" + + assert_file_exists "$secret_file" + assert_file_exists "$deploy_file" + + # Secret: app-data- holds the base64-encoded destination path, + # so envFrom injects a NUL-byte-free env var. + local expected_path_b64 + expected_path_b64=$(printf '%s' '/etc/certs/test.p12' | base64) + assert_contains "$(cat "$secret_file")" "app-data-test.p12: ${expected_path_b64}" + + # Secret: app-file- holds the raw base64 binary content for the + # volume mount. + assert_contains "$(cat "$secret_file")" "app-file-test.p12: QUFBQkJC" + + # Regression guard: the app-data key MUST NEVER carry the raw binary + # (that's the original bug — runc rejects NUL bytes in env vars). + ! grep -E '^[[:space:]]*app-data-test\.p12:[[:space:]]+QUFBQkJC[[:space:]]*$' "$secret_file" + + # Deployment: the volume mount items reference the binary key. + assert_contains "$(cat "$deploy_file")" "key: app-file-test.p12" + + # Regression guard: the volume mount must not read from the env-var key, + # otherwise the materialized file would contain the path string, not the cert. + ! grep -F 'key: app-data-test.p12' "$deploy_file" +} diff --git a/scheduled_task/deployment/templates/deployment.yaml.tpl b/scheduled_task/deployment/templates/deployment.yaml.tpl index b5c677d6..926640ea 100644 --- a/scheduled_task/deployment/templates/deployment.yaml.tpl +++ b/scheduled_task/deployment/templates/deployment.yaml.tpl @@ -170,7 +170,7 @@ spec: secret: secretName: s-{{ $.scope.id }}-d-{{ $.deployment.id }} items: - - key: {{ printf "app-data-%s" (filepath.Base .destination_path) }} + - key: {{ printf "app-file-%s" (filepath.Base .destination_path) }} path: {{ filepath.Base .destination_path }} {{- end }} {{- end }} From f6118cec2e59d5d555b16587635b865fc2b06d48 Mon Sep 17 00:00:00 2001 From: Federico Maleh Date: Wed, 27 May 2026 12:10:59 -0300 Subject: [PATCH 2/8] docs(changelog): tighten file-parameter fix entry --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0248fca4..613e124b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] -- Fix deployment failure for `file`-type parameters with binary content (e.g., P12 certificates): the env var injected from the deployment Secret now carries the file's destination path instead of the raw content, avoiding `invalid environment variable ... contains nul byte` errors. The file is still mounted at `destination_path` as before. +- Fix: do not inject file parameter as env vars - Public and private scopes now register DNS records in their correct Route53 hosted zone when using `DNS_TYPE=external_dns`, preventing cross-zone record leakage - Add configurable main HTTP port for k8s scopes (default 8080) and HTTP support for additional ports - Improve **wait deployment active** failure logging: consolidate repeated `Unhealthy` probe events per pod into a single human-readable line, emit a progress heartbeat every 10% of timeout, and surface a targeted suggested fix based on the probe failure mode (port not open / HTTP non-2xx / probe timeout) From 4c57d1ebbb768db14909626e33e3e8c85f81d2fb Mon Sep 17 00:00:00 2001 From: Federico Maleh Date: Wed, 27 May 2026 13:57:38 -0300 Subject: [PATCH 3/8] fix(k8s,scheduled_task): isolate file binary in a dedicated Secret --- k8s/deployment/build_deployment | 13 ++++ k8s/deployment/templates/deployment.yaml.tpl | 11 ++- .../templates/secret-files.yaml.tpl | 48 ++++++++++++ k8s/deployment/templates/secret.yaml.tpl | 6 -- k8s/deployment/tests/build_deployment.bats | 76 +++++++++++++------ k8s/values.yaml | 1 + scheduled_task/deployment/build_deployment | 14 ++++ .../deployment/templates/deployment.yaml.tpl | 11 ++- 8 files changed, 148 insertions(+), 32 deletions(-) create mode 100644 k8s/deployment/templates/secret-files.yaml.tpl diff --git a/k8s/deployment/build_deployment b/k8s/deployment/build_deployment index a51bf971..6a333b8e 100755 --- a/k8s/deployment/build_deployment +++ b/k8s/deployment/build_deployment @@ -3,6 +3,7 @@ DEPLOYMENT_PATH="$OUTPUT_DIR/deployment-$SCOPE_ID-$DEPLOYMENT_ID.yaml" SECRET_PATH="$OUTPUT_DIR/secret-$SCOPE_ID-$DEPLOYMENT_ID.yaml" +SECRET_FILES_PATH="$OUTPUT_DIR/secret-files-$SCOPE_ID-$DEPLOYMENT_ID.yaml" SCALING_PATH="$OUTPUT_DIR/scaling-$SCOPE_ID-$DEPLOYMENT_ID.yaml" SERVICE_TEMPLATE_PATH="$OUTPUT_DIR/service-$SCOPE_ID-$DEPLOYMENT_ID.yaml" PDB_PATH="$OUTPUT_DIR/pdb-$SCOPE_ID-$DEPLOYMENT_ID.yaml" @@ -38,6 +39,18 @@ if [[ $TEMPLATE_GENERATION_STATUS -ne 0 ]]; then fi log info " ✅ Secret template: $SECRET_PATH" +gomplate -c .="$CONTEXT_PATH" \ + --file "$SECRET_FILES_TEMPLATE" \ + --out "$SECRET_FILES_PATH" + +TEMPLATE_GENERATION_STATUS=$? + +if [[ $TEMPLATE_GENERATION_STATUS -ne 0 ]]; then + log error " ❌ Failed to build secret-files template" + exit 1 +fi +log info " ✅ Secret-files template: $SECRET_FILES_PATH" + gomplate -c .="$CONTEXT_PATH" \ --file "$SCALING_TEMPLATE" \ --out "$SCALING_PATH" diff --git a/k8s/deployment/templates/deployment.yaml.tpl b/k8s/deployment/templates/deployment.yaml.tpl index 74ae8ed5..2816940c 100644 --- a/k8s/deployment/templates/deployment.yaml.tpl +++ b/k8s/deployment/templates/deployment.yaml.tpl @@ -295,6 +295,15 @@ spec: envFrom: - secretRef: name: s-{{ .scope.id }}-d-{{ .deployment.id }} + {{- if .parameters.results }} + env: + {{- range .parameters.results }} + {{- if and (eq .type "file") (gt (len .values) 0) }} + - name: {{ printf "app-data-%s" (filepath.Base .destination_path) }} + value: {{ .destination_path | quote }} + {{- end }} + {{- end }} + {{- end }} image: >- {{ .asset.url }} securityContext: @@ -375,7 +384,7 @@ spec: {{- if gt (len .values) 0 }} - name: {{ printf "file-%s" (filepath.Base .destination_path | strings.ReplaceAll "." "-" | strings.ReplaceAll "_" "-") }} secret: - secretName: s-{{ $.scope.id }}-d-{{ $.deployment.id }} + secretName: s-{{ $.scope.id }}-d-{{ $.deployment.id }}-files items: - key: {{ printf "app-file-%s" (filepath.Base .destination_path) }} path: {{ filepath.Base .destination_path }} diff --git a/k8s/deployment/templates/secret-files.yaml.tpl b/k8s/deployment/templates/secret-files.yaml.tpl new file mode 100644 index 00000000..ea602cba --- /dev/null +++ b/k8s/deployment/templates/secret-files.yaml.tpl @@ -0,0 +1,48 @@ +{{- $hasFile := false -}} +{{- if .parameters.results -}} + {{- range .parameters.results -}} + {{- if and (eq .type "file") (gt (len .values) 0) -}} + {{- $hasFile = true -}} + {{- end -}} + {{- end -}} +{{- end -}} +{{- if $hasFile -}} +apiVersion: v1 +kind: Secret +immutable: true +metadata: + name: s-{{ .scope.id }}-d-{{ .deployment.id }}-files + namespace: {{ .k8s_namespace }} + labels: + nullplatform: "true" + account: {{ .account.slug }} + account_id: "{{ .account.id }}" + namespace: {{ .namespace.slug }} + namespace_id: "{{ .namespace.id }}" + application: {{ .application.slug }} + application_id: "{{ .application.id }}" + scope: {{ .scope.slug }} + scope_id: "{{ .scope.id }}" + deployment_id: "{{ .deployment.id }}" +{{- $global := index .k8s_modifiers "global" }} +{{- if $global }} + {{- $labels := index $global "labels" }} + {{- if $labels }} +{{ data.ToYAML $labels | indent 4 }} + {{- end }} +{{- end }} +{{- $secret := index .k8s_modifiers "secret" }} +{{- if $secret }} + {{- $labels := index $secret "labels" }} + {{- if $labels }} +{{ data.ToYAML $labels | indent 4 }} + {{- end }} +{{- end }} +data: +{{- range .parameters.results }} + {{- if and (eq .type "file") (gt (len .values) 0) }} + {{ printf "app-file-%s" (filepath.Base .destination_path) }}: {{ index .values 0 "value" | regexp.Replace "^data:[^;]+;base64," "" }} + {{- end }} +{{- end }} +type: Opaque +{{- end -}} diff --git a/k8s/deployment/templates/secret.yaml.tpl b/k8s/deployment/templates/secret.yaml.tpl index 13a6d8b4..59028c66 100644 --- a/k8s/deployment/templates/secret.yaml.tpl +++ b/k8s/deployment/templates/secret.yaml.tpl @@ -37,12 +37,6 @@ data: {{ .variable }}: {{ index .values 0 "value" | base64.Encode }} {{- end }} {{- end }} - {{- if and (eq .type "file") }} - {{- if gt (len .values) 0 }} - {{ printf "app-data-%s" (filepath.Base .destination_path) }}: {{ .destination_path | base64.Encode }} - {{ printf "app-file-%s" (filepath.Base .destination_path) }}: {{ index .values 0 "value" | regexp.Replace "^data:[^;]+;base64," "" }} - {{- end }} - {{- end }} {{- end }} {{- end }} NP_ACCOUNT: {{ .account.slug | base64.Encode }} diff --git a/k8s/deployment/tests/build_deployment.bats b/k8s/deployment/tests/build_deployment.bats index 585c8f40..1042e572 100644 --- a/k8s/deployment/tests/build_deployment.bats +++ b/k8s/deployment/tests/build_deployment.bats @@ -18,6 +18,7 @@ setup() { # Template paths export DEPLOYMENT_TEMPLATE="$PROJECT_ROOT/k8s/deployment/templates/deployment.yaml.tpl" export SECRET_TEMPLATE="$PROJECT_ROOT/k8s/deployment/templates/secret.yaml.tpl" + export SECRET_FILES_TEMPLATE="$PROJECT_ROOT/k8s/deployment/templates/secret-files.yaml.tpl" export SCALING_TEMPLATE="$PROJECT_ROOT/k8s/deployment/templates/scaling.yaml.tpl" export SERVICE_TEMPLATE="$PROJECT_ROOT/k8s/deployment/templates/service.yaml.tpl" export PDB_TEMPLATE="$PROJECT_ROOT/k8s/deployment/templates/pdb.yaml.tpl" @@ -143,6 +144,13 @@ teardown() { assert_file_exists "$OUTPUT_DIR/secret-scope-123-deploy-456.yaml" } +@test "build_deployment: creates secret-files file with correct name" { + run bash "$BATS_TEST_DIRNAME/../build_deployment" + + [ "$status" -eq 0 ] + assert_file_exists "$OUTPUT_DIR/secret-files-scope-123-deploy-456.yaml" +} + @test "build_deployment: creates scaling file with correct name" { run bash "$BATS_TEST_DIRNAME/../build_deployment" @@ -178,13 +186,14 @@ teardown() { # verify the rendered Secret + Deployment YAML have the right shape. # # Regression guard for the file-type parameter bug: binary file content used -# to be stored under Secret key `app-data-` and then leaked into the -# container env block via `envFrom`, which runc rejects with -# `invalid environment variable ... contains nul byte`. The fix splits the -# Secret key into: -# - app-data- -> destination_path (string, env-safe) -# - app-file- -> raw binary (volume-mount-only) -# and updates the volume mount to read from the new key. +# to be stored under Secret key `app-data-` in the env-var Secret, +# which then leaked into the container env block via `envFrom`, which runc +# rejects with `invalid environment variable ... contains nul byte`. The fix +# splits the storage into two Secrets: +# - s--d- env-only, consumed via envFrom (safe) +# - s--d--files binary-only, consumed only by the volume mount +# Plus a plain `env:` entry on the application container that carries the +# file's destination path under name `app-data-`. # Minimal context that satisfies all five templates' required fields. # Includes both an `environment` and a `file` parameter so we can assert on @@ -241,7 +250,7 @@ _render_context() { JSON } -@test "build_deployment: file-type parameter renders path env var and separate binary key" { +@test "build_deployment: file-type parameter splits binary into a separate Secret" { unset -f gomplate # use the real gomplate binary, not the setup mock export CONTEXT="$(_render_context)" @@ -250,29 +259,48 @@ JSON [ "$status" -eq 0 ] local secret_file="$OUTPUT_DIR/secret-scope-123-deploy-456.yaml" + local secret_files_file="$OUTPUT_DIR/secret-files-scope-123-deploy-456.yaml" local deploy_file="$OUTPUT_DIR/deployment-scope-123-deploy-456.yaml" assert_file_exists "$secret_file" + assert_file_exists "$secret_files_file" assert_file_exists "$deploy_file" - # Secret: app-data- holds the base64-encoded destination path, - # so envFrom injects a NUL-byte-free env var. - local expected_path_b64 - expected_path_b64=$(printf '%s' '/etc/certs/test.p12' | base64) - assert_contains "$(cat "$secret_file")" "app-data-test.p12: ${expected_path_b64}" + # The env-var Secret MUST NOT contain anything that pulls in binary content + # via envFrom. Both app-data-* and app-file-* keys are forbidden here. + ! grep -E 'app-(data|file)-test\.p12' "$secret_file" + + # The files Secret carries only the binary content, named so the volume mount + # can reference it. The Secret is in a separate object so `envFrom` on the + # env-var Secret cannot reach these bytes. + assert_contains "$(cat "$secret_files_file")" "name: s-scope-123-d-deploy-456-files" + assert_contains "$(cat "$secret_files_file")" "app-file-test.p12: QUFBQkJC" + ! grep -E 'app-data-test\.p12' "$secret_files_file" + + # The deployment exposes the destination path to the app via a plain `env:` + # entry on the application container (not via any Secret) — no NUL bytes. + assert_contains "$(cat "$deploy_file")" "- name: app-data-test.p12" + assert_contains "$(cat "$deploy_file")" 'value: "/etc/certs/test.p12"' + + # The volume mount reads bytes from the files Secret, with key matching the + # one produced by secret-files.yaml.tpl. + assert_contains "$(cat "$deploy_file")" "secretName: s-scope-123-d-deploy-456-files" + assert_contains "$(cat "$deploy_file")" "key: app-file-test.p12" +} - # Secret: app-file- holds the raw base64 binary content for the - # volume mount. - assert_contains "$(cat "$secret_file")" "app-file-test.p12: QUFBQkJC" +@test "build_deployment: secret-files renders empty when no file params" { + unset -f gomplate - # Regression guard: the app-data key MUST NEVER carry the raw binary - # (that's the original bug — runc rejects NUL bytes in env vars). - ! grep -E '^[[:space:]]*app-data-test\.p12:[[:space:]]+QUFBQkJC[[:space:]]*$' "$secret_file" + # Same context as _render_context but with the file-type param removed. + export CONTEXT="$(_render_context | jq '.parameters.results |= map(select(.type != "file"))')" - # Deployment: the volume mount items reference the binary key. - assert_contains "$(cat "$deploy_file")" "key: app-file-test.p12" + run bash "$BATS_TEST_DIRNAME/../build_deployment" + [ "$status" -eq 0 ] - # Regression guard: the volume mount must not read from the env-var key, - # otherwise the materialized file would contain the path string, not the cert. - ! grep -F 'key: app-data-test.p12' "$deploy_file" + # gomplate skips writing the output file when the template renders empty, + # which is the signal to apply_templates (which iterates the OUTPUT_DIR and + # skips zero-byte/missing files) to not create an empty files-Secret in the + # cluster. + local secret_files_file="$OUTPUT_DIR/secret-files-scope-123-deploy-456.yaml" + [ ! -f "$secret_files_file" ] || [ ! -s "$secret_files_file" ] } diff --git a/k8s/values.yaml b/k8s/values.yaml index d053bc0a..97c68ce6 100644 --- a/k8s/values.yaml +++ b/k8s/values.yaml @@ -22,6 +22,7 @@ configuration: DEPLOYMENT_MAX_WAIT_IN_SECONDS: 600 DEPLOYMENT_TEMPLATE: "$SERVICE_PATH/deployment/templates/deployment.yaml.tpl" SECRET_TEMPLATE: "$SERVICE_PATH/deployment/templates/secret.yaml.tpl" + SECRET_FILES_TEMPLATE: "$SERVICE_PATH/deployment/templates/secret-files.yaml.tpl" SCALING_TEMPLATE: "$SERVICE_PATH/deployment/templates/scaling.yaml.tpl" SERVICE_TEMPLATE: "$SERVICE_PATH/deployment/templates/service.yaml.tpl" PDB_TEMPLATE: "$SERVICE_PATH/deployment/templates/pdb.yaml.tpl" diff --git a/scheduled_task/deployment/build_deployment b/scheduled_task/deployment/build_deployment index a39f925e..c64f5780 100644 --- a/scheduled_task/deployment/build_deployment +++ b/scheduled_task/deployment/build_deployment @@ -2,6 +2,7 @@ DEPLOYMENT_PATH="$OUTPUT_DIR/deployment-$SCOPE_ID-$DEPLOYMENT_ID.yaml" SECRET_PATH="$OUTPUT_DIR/secret-$SCOPE_ID-$DEPLOYMENT_ID.yaml" +SECRET_FILES_PATH="$OUTPUT_DIR/secret-files-$SCOPE_ID-$DEPLOYMENT_ID.yaml" CONTEXT_PATH="$OUTPUT_DIR/context-$SCOPE_ID.json" echo "$CONTEXT" | jq --arg replicas "$REPLICAS" '. + {replicas: $replicas}' > "$CONTEXT_PATH" @@ -32,4 +33,17 @@ if [[ $TEMPLATE_GENERATION_STATUS -ne 0 ]]; then exit 1 fi +echo "Building Template: $SECRET_FILES_TEMPLATE to $SECRET_FILES_PATH" + +gomplate -c .="$CONTEXT_PATH" \ + --file "$SECRET_FILES_TEMPLATE" \ + --out "$SECRET_FILES_PATH" + +TEMPLATE_GENERATION_STATUS=$? + +if [[ $TEMPLATE_GENERATION_STATUS -ne 0 ]]; then + echo "Error building secret-files template" + exit 1 +fi + rm "$CONTEXT_PATH" diff --git a/scheduled_task/deployment/templates/deployment.yaml.tpl b/scheduled_task/deployment/templates/deployment.yaml.tpl index 926640ea..3a8e5712 100644 --- a/scheduled_task/deployment/templates/deployment.yaml.tpl +++ b/scheduled_task/deployment/templates/deployment.yaml.tpl @@ -139,6 +139,15 @@ spec: envFrom: - secretRef: name: s-{{ .scope.id }}-d-{{ .deployment.id }} + {{- if .parameters.results }} + env: + {{- range .parameters.results }} + {{- if and (eq .type "file") (gt (len .values) 0) }} + - name: {{ printf "app-data-%s" (filepath.Base .destination_path) }} + value: {{ .destination_path | quote }} + {{- end }} + {{- end }} + {{- end }} image: {{ .asset.url }} resources: limits: @@ -168,7 +177,7 @@ spec: {{- if gt (len .values) 0 }} - name: {{ printf "file-%s" (filepath.Base .destination_path | strings.ReplaceAll "." "-") }} secret: - secretName: s-{{ $.scope.id }}-d-{{ $.deployment.id }} + secretName: s-{{ $.scope.id }}-d-{{ $.deployment.id }}-files items: - key: {{ printf "app-file-%s" (filepath.Base .destination_path) }} path: {{ filepath.Base .destination_path }} From 8a093eb8aaf70daeb2dafa04f8c5c5f7414ebce9 Mon Sep 17 00:00:00 2001 From: Federico Maleh Date: Wed, 27 May 2026 14:20:27 -0300 Subject: [PATCH 4/8] refactor(k8s,scheduled_task): derive file-param identifiers from .name --- k8s/deployment/templates/deployment.yaml.tpl | 11 ++++++---- .../templates/secret-files.yaml.tpl | 3 ++- k8s/deployment/tests/build_deployment.bats | 21 ++++++++++--------- .../deployment/templates/deployment.yaml.tpl | 11 ++++++---- 4 files changed, 27 insertions(+), 19 deletions(-) diff --git a/k8s/deployment/templates/deployment.yaml.tpl b/k8s/deployment/templates/deployment.yaml.tpl index 2816940c..34752aef 100644 --- a/k8s/deployment/templates/deployment.yaml.tpl +++ b/k8s/deployment/templates/deployment.yaml.tpl @@ -299,7 +299,8 @@ spec: env: {{- range .parameters.results }} {{- if and (eq .type "file") (gt (len .values) 0) }} - - name: {{ printf "app-data-%s" (filepath.Base .destination_path) }} + {{- $key := .name | strings.ToLower | regexp.Replace "[^a-z0-9]+" "-" | strings.Trim "-" }} + - name: {{ printf "app-data-%s" $key }} value: {{ .destination_path | quote }} {{- end }} {{- end }} @@ -364,7 +365,8 @@ spec: {{- range .parameters.results }} {{- if and (eq .type "file") }} {{- if gt (len .values) 0 }} - - name: {{ printf "file-%s" (filepath.Base .destination_path | strings.ReplaceAll "." "-" | strings.ReplaceAll "_" "-") }} + {{- $key := .name | strings.ToLower | regexp.Replace "[^a-z0-9]+" "-" | strings.Trim "-" }} + - name: {{ printf "file-%s" $key }} mountPath: {{ .destination_path }} subPath: {{ filepath.Base .destination_path }} readOnly: true @@ -382,11 +384,12 @@ spec: {{- range .parameters.results }} {{- if and (eq .type "file") }} {{- if gt (len .values) 0 }} - - name: {{ printf "file-%s" (filepath.Base .destination_path | strings.ReplaceAll "." "-" | strings.ReplaceAll "_" "-") }} + {{- $key := .name | strings.ToLower | regexp.Replace "[^a-z0-9]+" "-" | strings.Trim "-" }} + - name: {{ printf "file-%s" $key }} secret: secretName: s-{{ $.scope.id }}-d-{{ $.deployment.id }}-files items: - - key: {{ printf "app-file-%s" (filepath.Base .destination_path) }} + - key: {{ printf "app-file-%s" $key }} path: {{ filepath.Base .destination_path }} {{- end }} {{- end }} diff --git a/k8s/deployment/templates/secret-files.yaml.tpl b/k8s/deployment/templates/secret-files.yaml.tpl index ea602cba..883a3f66 100644 --- a/k8s/deployment/templates/secret-files.yaml.tpl +++ b/k8s/deployment/templates/secret-files.yaml.tpl @@ -41,7 +41,8 @@ metadata: data: {{- range .parameters.results }} {{- if and (eq .type "file") (gt (len .values) 0) }} - {{ printf "app-file-%s" (filepath.Base .destination_path) }}: {{ index .values 0 "value" | regexp.Replace "^data:[^;]+;base64," "" }} + {{- $key := .name | strings.ToLower | regexp.Replace "[^a-z0-9]+" "-" | strings.Trim "-" }} + {{ printf "app-file-%s" $key }}: {{ index .values 0 "value" | regexp.Replace "^data:[^;]+;base64," "" }} {{- end }} {{- end }} type: Opaque diff --git a/k8s/deployment/tests/build_deployment.bats b/k8s/deployment/tests/build_deployment.bats index 1042e572..2b2762f6 100644 --- a/k8s/deployment/tests/build_deployment.bats +++ b/k8s/deployment/tests/build_deployment.bats @@ -243,7 +243,7 @@ _render_context() { "parameters": { "results": [ {"type": "environment", "variable": "MY_VAR", "values": [{"value": "hello"}]}, - {"type": "file", "destination_path": "/etc/certs/test.p12", "values": [{"value": "data:application/x-pkcs12;base64,QUFBQkJC"}]} + {"type": "file", "name": "API P12 Cert!", "destination_path": "/etc/certs/test.p12", "values": [{"value": "data:application/x-pkcs12;base64,QUFBQkJC"}]} ] } } @@ -268,24 +268,25 @@ JSON # The env-var Secret MUST NOT contain anything that pulls in binary content # via envFrom. Both app-data-* and app-file-* keys are forbidden here. - ! grep -E 'app-(data|file)-test\.p12' "$secret_file" + ! grep -E 'app-(data|file)-' "$secret_file" - # The files Secret carries only the binary content, named so the volume mount - # can reference it. The Secret is in a separate object so `envFrom` on the - # env-var Secret cannot reach these bytes. + # Param name "API P12 Cert!" sanitizes to api-p12-cert (lowercase, runs of + # non-alphanumeric collapse to '-', leading/trailing '-' trimmed). The same + # token is reused as env name suffix, Secret data key, and volume name. assert_contains "$(cat "$secret_files_file")" "name: s-scope-123-d-deploy-456-files" - assert_contains "$(cat "$secret_files_file")" "app-file-test.p12: QUFBQkJC" - ! grep -E 'app-data-test\.p12' "$secret_files_file" + assert_contains "$(cat "$secret_files_file")" "app-file-api-p12-cert: QUFBQkJC" + ! grep -E 'app-data-' "$secret_files_file" # The deployment exposes the destination path to the app via a plain `env:` - # entry on the application container (not via any Secret) — no NUL bytes. - assert_contains "$(cat "$deploy_file")" "- name: app-data-test.p12" + # entry on the application container (not via any Secret) — no NUL bytes, + # and the env var name is derived from the parameter's display name. + assert_contains "$(cat "$deploy_file")" "- name: app-data-api-p12-cert" assert_contains "$(cat "$deploy_file")" 'value: "/etc/certs/test.p12"' # The volume mount reads bytes from the files Secret, with key matching the # one produced by secret-files.yaml.tpl. assert_contains "$(cat "$deploy_file")" "secretName: s-scope-123-d-deploy-456-files" - assert_contains "$(cat "$deploy_file")" "key: app-file-test.p12" + assert_contains "$(cat "$deploy_file")" "key: app-file-api-p12-cert" } @test "build_deployment: secret-files renders empty when no file params" { diff --git a/scheduled_task/deployment/templates/deployment.yaml.tpl b/scheduled_task/deployment/templates/deployment.yaml.tpl index 3a8e5712..14e8b123 100644 --- a/scheduled_task/deployment/templates/deployment.yaml.tpl +++ b/scheduled_task/deployment/templates/deployment.yaml.tpl @@ -143,7 +143,8 @@ spec: env: {{- range .parameters.results }} {{- if and (eq .type "file") (gt (len .values) 0) }} - - name: {{ printf "app-data-%s" (filepath.Base .destination_path) }} + {{- $key := .name | strings.ToLower | regexp.Replace "[^a-z0-9]+" "-" | strings.Trim "-" }} + - name: {{ printf "app-data-%s" $key }} value: {{ .destination_path | quote }} {{- end }} {{- end }} @@ -162,7 +163,8 @@ spec: {{- range .parameters.results }} {{- if and (eq .type "file") }} {{- if gt (len .values) 0 }} - - name: {{ printf "file-%s" (filepath.Base .destination_path | strings.ReplaceAll "." "-") }} + {{- $key := .name | strings.ToLower | regexp.Replace "[^a-z0-9]+" "-" | strings.Trim "-" }} + - name: {{ printf "file-%s" $key }} mountPath: {{ .destination_path }} subPath: {{ filepath.Base .destination_path }} readOnly: true @@ -175,11 +177,12 @@ spec: {{- range .parameters.results }} {{- if and (eq .type "file") }} {{- if gt (len .values) 0 }} - - name: {{ printf "file-%s" (filepath.Base .destination_path | strings.ReplaceAll "." "-") }} + {{- $key := .name | strings.ToLower | regexp.Replace "[^a-z0-9]+" "-" | strings.Trim "-" }} + - name: {{ printf "file-%s" $key }} secret: secretName: s-{{ $.scope.id }}-d-{{ $.deployment.id }}-files items: - - key: {{ printf "app-file-%s" (filepath.Base .destination_path) }} + - key: {{ printf "app-file-%s" $key }} path: {{ filepath.Base .destination_path }} {{- end }} {{- end }} From dc679d2e4c8ef27a300a17c506237327ca324f70 Mon Sep 17 00:00:00 2001 From: Federico Maleh Date: Wed, 27 May 2026 15:03:36 -0300 Subject: [PATCH 5/8] fix(k8s,scheduled_task): omit env: block when no file params --- k8s/deployment/templates/deployment.yaml.tpl | 8 +++++++ k8s/deployment/tests/build_deployment.bats | 23 +++++++++++++++++++ .../deployment/templates/deployment.yaml.tpl | 8 +++++++ 3 files changed, 39 insertions(+) diff --git a/k8s/deployment/templates/deployment.yaml.tpl b/k8s/deployment/templates/deployment.yaml.tpl index 34752aef..ffa3d6d5 100644 --- a/k8s/deployment/templates/deployment.yaml.tpl +++ b/k8s/deployment/templates/deployment.yaml.tpl @@ -295,7 +295,15 @@ spec: envFrom: - secretRef: name: s-{{ .scope.id }}-d-{{ .deployment.id }} + {{- $hasFile := false }} {{- if .parameters.results }} + {{- range .parameters.results }} + {{- if and (eq .type "file") (gt (len .values) 0) }} + {{- $hasFile = true }} + {{- end }} + {{- end }} + {{- end }} + {{- if $hasFile }} env: {{- range .parameters.results }} {{- if and (eq .type "file") (gt (len .values) 0) }} diff --git a/k8s/deployment/tests/build_deployment.bats b/k8s/deployment/tests/build_deployment.bats index 2b2762f6..f9df61f9 100644 --- a/k8s/deployment/tests/build_deployment.bats +++ b/k8s/deployment/tests/build_deployment.bats @@ -305,3 +305,26 @@ JSON local secret_files_file="$OUTPUT_DIR/secret-files-scope-123-deploy-456.yaml" [ ! -f "$secret_files_file" ] || [ ! -s "$secret_files_file" ] } + +@test "build_deployment: deployment omits env: block when no file params" { + unset -f gomplate + + # Env-only param set. An empty `env:` followed by `image:` at the same indent + # is rejected by strict YAML-to-JSON converters (the deployment agent), so + # the block must not be emitted at all when there are no file params. + export CONTEXT="$(_render_context | jq '.parameters.results |= map(select(.type != "file"))')" + + run bash "$BATS_TEST_DIRNAME/../build_deployment" + [ "$status" -eq 0 ] + + local deploy_file="$OUTPUT_DIR/deployment-scope-123-deploy-456.yaml" + assert_file_exists "$deploy_file" + + # Slice the application container block (from its `- name: application` + # header up to the next sibling `- name:` or `restartPolicy`) and assert + # no `env:` key appears inside it. The traffic-manager sidecar also has + # `env:`, so a file-wide grep would false-positive. + local app_block + app_block=$(awk '/^ - name: application$/{flag=1} flag && /^ restartPolicy:/{flag=0} flag' "$deploy_file") + ! grep -qE '^ env:' <<< "$app_block" +} diff --git a/scheduled_task/deployment/templates/deployment.yaml.tpl b/scheduled_task/deployment/templates/deployment.yaml.tpl index 14e8b123..8ec86e3c 100644 --- a/scheduled_task/deployment/templates/deployment.yaml.tpl +++ b/scheduled_task/deployment/templates/deployment.yaml.tpl @@ -139,7 +139,15 @@ spec: envFrom: - secretRef: name: s-{{ .scope.id }}-d-{{ .deployment.id }} + {{- $hasFile := false }} {{- if .parameters.results }} + {{- range .parameters.results }} + {{- if and (eq .type "file") (gt (len .values) 0) }} + {{- $hasFile = true }} + {{- end }} + {{- end }} + {{- end }} + {{- if $hasFile }} env: {{- range .parameters.results }} {{- if and (eq .type "file") (gt (len .values) 0) }} From cd89c5f3528ff1e98e39ad8856656909e50f142d Mon Sep 17 00:00:00 2001 From: Federico Maleh Date: Wed, 27 May 2026 15:05:41 -0300 Subject: [PATCH 6/8] Revert "fix(k8s,scheduled_task): omit env: block when no file params" This reverts commit dc679d2e4c8ef27a300a17c506237327ca324f70. --- k8s/deployment/templates/deployment.yaml.tpl | 8 ------- k8s/deployment/tests/build_deployment.bats | 23 ------------------- .../deployment/templates/deployment.yaml.tpl | 8 ------- 3 files changed, 39 deletions(-) diff --git a/k8s/deployment/templates/deployment.yaml.tpl b/k8s/deployment/templates/deployment.yaml.tpl index ffa3d6d5..34752aef 100644 --- a/k8s/deployment/templates/deployment.yaml.tpl +++ b/k8s/deployment/templates/deployment.yaml.tpl @@ -295,15 +295,7 @@ spec: envFrom: - secretRef: name: s-{{ .scope.id }}-d-{{ .deployment.id }} - {{- $hasFile := false }} {{- if .parameters.results }} - {{- range .parameters.results }} - {{- if and (eq .type "file") (gt (len .values) 0) }} - {{- $hasFile = true }} - {{- end }} - {{- end }} - {{- end }} - {{- if $hasFile }} env: {{- range .parameters.results }} {{- if and (eq .type "file") (gt (len .values) 0) }} diff --git a/k8s/deployment/tests/build_deployment.bats b/k8s/deployment/tests/build_deployment.bats index f9df61f9..2b2762f6 100644 --- a/k8s/deployment/tests/build_deployment.bats +++ b/k8s/deployment/tests/build_deployment.bats @@ -305,26 +305,3 @@ JSON local secret_files_file="$OUTPUT_DIR/secret-files-scope-123-deploy-456.yaml" [ ! -f "$secret_files_file" ] || [ ! -s "$secret_files_file" ] } - -@test "build_deployment: deployment omits env: block when no file params" { - unset -f gomplate - - # Env-only param set. An empty `env:` followed by `image:` at the same indent - # is rejected by strict YAML-to-JSON converters (the deployment agent), so - # the block must not be emitted at all when there are no file params. - export CONTEXT="$(_render_context | jq '.parameters.results |= map(select(.type != "file"))')" - - run bash "$BATS_TEST_DIRNAME/../build_deployment" - [ "$status" -eq 0 ] - - local deploy_file="$OUTPUT_DIR/deployment-scope-123-deploy-456.yaml" - assert_file_exists "$deploy_file" - - # Slice the application container block (from its `- name: application` - # header up to the next sibling `- name:` or `restartPolicy`) and assert - # no `env:` key appears inside it. The traffic-manager sidecar also has - # `env:`, so a file-wide grep would false-positive. - local app_block - app_block=$(awk '/^ - name: application$/{flag=1} flag && /^ restartPolicy:/{flag=0} flag' "$deploy_file") - ! grep -qE '^ env:' <<< "$app_block" -} diff --git a/scheduled_task/deployment/templates/deployment.yaml.tpl b/scheduled_task/deployment/templates/deployment.yaml.tpl index 8ec86e3c..14e8b123 100644 --- a/scheduled_task/deployment/templates/deployment.yaml.tpl +++ b/scheduled_task/deployment/templates/deployment.yaml.tpl @@ -139,15 +139,7 @@ spec: envFrom: - secretRef: name: s-{{ .scope.id }}-d-{{ .deployment.id }} - {{- $hasFile := false }} {{- if .parameters.results }} - {{- range .parameters.results }} - {{- if and (eq .type "file") (gt (len .values) 0) }} - {{- $hasFile = true }} - {{- end }} - {{- end }} - {{- end }} - {{- if $hasFile }} env: {{- range .parameters.results }} {{- if and (eq .type "file") (gt (len .values) 0) }} From b994dfa89a332e38fec2b33b546d75060f7d903e Mon Sep 17 00:00:00 2001 From: Federico Maleh Date: Wed, 27 May 2026 15:43:38 -0300 Subject: [PATCH 7/8] fix(k8s,scheduled_task): quote destination_path in YAML to escape flow chars --- k8s/deployment/templates/deployment.yaml.tpl | 6 +++--- k8s/deployment/tests/build_deployment.bats | 10 ++++++++-- .../deployment/templates/deployment.yaml.tpl | 6 +++--- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/k8s/deployment/templates/deployment.yaml.tpl b/k8s/deployment/templates/deployment.yaml.tpl index 34752aef..e8313a92 100644 --- a/k8s/deployment/templates/deployment.yaml.tpl +++ b/k8s/deployment/templates/deployment.yaml.tpl @@ -367,8 +367,8 @@ spec: {{- if gt (len .values) 0 }} {{- $key := .name | strings.ToLower | regexp.Replace "[^a-z0-9]+" "-" | strings.Trim "-" }} - name: {{ printf "file-%s" $key }} - mountPath: {{ .destination_path }} - subPath: {{ filepath.Base .destination_path }} + mountPath: {{ .destination_path | quote }} + subPath: {{ filepath.Base .destination_path | quote }} readOnly: true {{- end }} {{- end }} @@ -390,7 +390,7 @@ spec: secretName: s-{{ $.scope.id }}-d-{{ $.deployment.id }}-files items: - key: {{ printf "app-file-%s" $key }} - path: {{ filepath.Base .destination_path }} + path: {{ filepath.Base .destination_path | quote }} {{- end }} {{- end }} {{- end }} diff --git a/k8s/deployment/tests/build_deployment.bats b/k8s/deployment/tests/build_deployment.bats index 2b2762f6..41adfe2a 100644 --- a/k8s/deployment/tests/build_deployment.bats +++ b/k8s/deployment/tests/build_deployment.bats @@ -243,7 +243,7 @@ _render_context() { "parameters": { "results": [ {"type": "environment", "variable": "MY_VAR", "values": [{"value": "hello"}]}, - {"type": "file", "name": "API P12 Cert!", "destination_path": "/etc/certs/test.p12", "values": [{"value": "data:application/x-pkcs12;base64,QUFBQkJC"}]} + {"type": "file", "name": "API P12 Cert!", "destination_path": "/app-data/[2026-05-27] cert.p12", "values": [{"value": "data:application/x-pkcs12;base64,QUFBQkJC"}]} ] } } @@ -281,7 +281,13 @@ JSON # entry on the application container (not via any Secret) — no NUL bytes, # and the env var name is derived from the parameter's display name. assert_contains "$(cat "$deploy_file")" "- name: app-data-api-p12-cert" - assert_contains "$(cat "$deploy_file")" 'value: "/etc/certs/test.p12"' + # The path starts with `[`, which YAML parses as a flow sequence unless the + # value is quoted. mountPath, subPath, path, and the env value must all be + # quoted; otherwise the deployment agent fails with `did not find expected key`. + assert_contains "$(cat "$deploy_file")" 'value: "/app-data/[2026-05-27] cert.p12"' + assert_contains "$(cat "$deploy_file")" 'mountPath: "/app-data/[2026-05-27] cert.p12"' + assert_contains "$(cat "$deploy_file")" 'subPath: "[2026-05-27] cert.p12"' + assert_contains "$(cat "$deploy_file")" 'path: "[2026-05-27] cert.p12"' # The volume mount reads bytes from the files Secret, with key matching the # one produced by secret-files.yaml.tpl. diff --git a/scheduled_task/deployment/templates/deployment.yaml.tpl b/scheduled_task/deployment/templates/deployment.yaml.tpl index 14e8b123..a1d9f1f1 100644 --- a/scheduled_task/deployment/templates/deployment.yaml.tpl +++ b/scheduled_task/deployment/templates/deployment.yaml.tpl @@ -165,8 +165,8 @@ spec: {{- if gt (len .values) 0 }} {{- $key := .name | strings.ToLower | regexp.Replace "[^a-z0-9]+" "-" | strings.Trim "-" }} - name: {{ printf "file-%s" $key }} - mountPath: {{ .destination_path }} - subPath: {{ filepath.Base .destination_path }} + mountPath: {{ .destination_path | quote }} + subPath: {{ filepath.Base .destination_path | quote }} readOnly: true {{- end }} {{- end }} @@ -183,7 +183,7 @@ spec: secretName: s-{{ $.scope.id }}-d-{{ $.deployment.id }}-files items: - key: {{ printf "app-file-%s" $key }} - path: {{ filepath.Base .destination_path }} + path: {{ filepath.Base .destination_path | quote }} {{- end }} {{- end }} {{- end }} From e43f0f3f4ebde1faef439511755ee05390203645 Mon Sep 17 00:00:00 2001 From: Federico Maleh Date: Wed, 27 May 2026 16:02:23 -0300 Subject: [PATCH 8/8] test(scheduled_task): add build_deployment render test for file params --- .../deployment/tests/build_deployment.bats | 166 ++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 scheduled_task/deployment/tests/build_deployment.bats diff --git a/scheduled_task/deployment/tests/build_deployment.bats b/scheduled_task/deployment/tests/build_deployment.bats new file mode 100644 index 00000000..aa29dd2b --- /dev/null +++ b/scheduled_task/deployment/tests/build_deployment.bats @@ -0,0 +1,166 @@ +#!/usr/bin/env bats +# ============================================================================= +# Tests for scheduled_task/deployment/build_deployment. +# +# Mirrors k8s/deployment/tests/build_deployment.bats with a scheduled_task +# context (CronJob instead of Deployment). The same file-parameter regressions +# apply because scheduled_task reuses the k8s secret templates and ships its +# own deployment template that follows the same two-Secret + sanitized-name +# pattern. +# ============================================================================= + +setup() { + export PROJECT_ROOT="$(cd "$BATS_TEST_DIRNAME/../../.." && pwd)" + source "$PROJECT_ROOT/testing/assertions.sh" + + export OUTPUT_DIR="$(mktemp -d)" + export SCOPE_ID="scope-123" + export DEPLOYMENT_ID="deploy-456" + export REPLICAS="1" + + # scheduled_task reuses the k8s secret templates and ships its own + # deployment template under scheduled_task/deployment/templates/. + export DEPLOYMENT_TEMPLATE="$PROJECT_ROOT/scheduled_task/deployment/templates/deployment.yaml.tpl" + export SECRET_TEMPLATE="$PROJECT_ROOT/k8s/deployment/templates/secret.yaml.tpl" + export SECRET_FILES_TEMPLATE="$PROJECT_ROOT/k8s/deployment/templates/secret-files.yaml.tpl" + + export CONTEXT='{}' + + # Mock gomplate for orchestration tests (any test that doesn't `unset -f`). + gomplate() { + local out_file="" + while [[ $# -gt 0 ]]; do + case $1 in + --out) out_file="$2"; shift 2 ;; + *) shift ;; + esac + done + echo "apiVersion: v1" > "$out_file" + return 0 + } + export -f gomplate +} + +teardown() { + rm -rf "$OUTPUT_DIR" + unset -f gomplate +} + +# ============================================================================= +# File creation — confirms the script renders deployment + both Secrets +# ============================================================================= +@test "build_deployment: creates deployment file with correct name" { + run bash "$BATS_TEST_DIRNAME/../build_deployment" + [ "$status" -eq 0 ] + assert_file_exists "$OUTPUT_DIR/deployment-scope-123-deploy-456.yaml" +} + +@test "build_deployment: creates secret file with correct name" { + run bash "$BATS_TEST_DIRNAME/../build_deployment" + [ "$status" -eq 0 ] + assert_file_exists "$OUTPUT_DIR/secret-scope-123-deploy-456.yaml" +} + +@test "build_deployment: creates secret-files file with correct name" { + run bash "$BATS_TEST_DIRNAME/../build_deployment" + [ "$status" -eq 0 ] + assert_file_exists "$OUTPUT_DIR/secret-files-scope-123-deploy-456.yaml" +} + +# ============================================================================= +# Rendering tests — real gomplate, assert on rendered output +# ============================================================================= +# Minimal context that satisfies the scheduled_task deployment template plus +# the shared k8s secret + secret-files templates. Includes a file param with +# (a) a display name that needs sanitizing and (b) a destination_path with a +# leading `[` to lock in YAML quoting at every insertion point. +_render_context() { + cat <<'JSON' +{ + "account": {"id": "acc1", "slug": "acct"}, + "namespace": {"id": "ns1", "slug": "nsps"}, + "application": {"id": "app1", "slug": "appslug"}, + "release": {"semver": "1.0.0"}, + "scope": { + "id": "scope-123", + "slug": "scopeslug", + "domain": "x.example.com", + "dimensions": {"env": "dev"}, + "capabilities": { + "cpu_millicores": 100, + "ram_memory": 128, + "cron": "*/5 * * * *", + "concurrency_policy": "Forbid", + "history_limit": {"successful": 3, "failed": 1}, + "retries": 0 + } + }, + "deployment": {"id": "deploy-456"}, + "k8s_namespace": "ns-test", + "k8s_modifiers": {}, + "asset": {"url": "example.com/app:latest"}, + "component": "app", + "service_account_name": "", + "pull_secrets": {"ENABLED": false, "SECRETS": []}, + "parameters": { + "results": [ + {"type": "environment", "variable": "MY_VAR", "values": [{"value": "hello"}]}, + {"type": "file", "name": "API P12 Cert!", "destination_path": "/app-data/[2026-05-27] cert.p12", "values": [{"value": "data:application/x-pkcs12;base64,QUFBQkJC"}]} + ] + } +} +JSON +} + +@test "build_deployment: file-type parameter splits binary into a separate Secret" { + unset -f gomplate # use the real gomplate binary + + export CONTEXT="$(_render_context)" + + run bash "$BATS_TEST_DIRNAME/../build_deployment" + [ "$status" -eq 0 ] + + local secret_file="$OUTPUT_DIR/secret-scope-123-deploy-456.yaml" + local secret_files_file="$OUTPUT_DIR/secret-files-scope-123-deploy-456.yaml" + local deploy_file="$OUTPUT_DIR/deployment-scope-123-deploy-456.yaml" + + assert_file_exists "$secret_file" + assert_file_exists "$secret_files_file" + assert_file_exists "$deploy_file" + + # The envFrom Secret must not carry any file-related keys, otherwise the + # binary content would be injected as an env var and runc would reject it. + ! grep -E 'app-(data|file)-' "$secret_file" + + # The files Secret holds only the binary content under a sanitized key. + assert_contains "$(cat "$secret_files_file")" "name: s-scope-123-d-deploy-456-files" + assert_contains "$(cat "$secret_files_file")" "app-file-api-p12-cert: QUFBQkJC" + ! grep -E 'app-data-' "$secret_files_file" + + # The CronJob's application container gets a plain `env:` entry whose value + # is the destination path, plus a volume mount reading from the files Secret. + assert_contains "$(cat "$deploy_file")" "- name: app-data-api-p12-cert" + # Leading `[` in the path makes YAML parse the value as a flow sequence + # unless quoted — the four insertion points below all require quoting. + assert_contains "$(cat "$deploy_file")" 'value: "/app-data/[2026-05-27] cert.p12"' + assert_contains "$(cat "$deploy_file")" 'mountPath: "/app-data/[2026-05-27] cert.p12"' + assert_contains "$(cat "$deploy_file")" 'subPath: "[2026-05-27] cert.p12"' + assert_contains "$(cat "$deploy_file")" 'path: "[2026-05-27] cert.p12"' + + assert_contains "$(cat "$deploy_file")" "secretName: s-scope-123-d-deploy-456-files" + assert_contains "$(cat "$deploy_file")" "key: app-file-api-p12-cert" +} + +@test "build_deployment: secret-files renders empty when no file params" { + unset -f gomplate + + export CONTEXT="$(_render_context | jq '.parameters.results |= map(select(.type != "file"))')" + + run bash "$BATS_TEST_DIRNAME/../build_deployment" + [ "$status" -eq 0 ] + + # gomplate skips writing the output when the template renders empty; + # apply_templates handles missing/empty files gracefully. + local secret_files_file="$OUTPUT_DIR/secret-files-scope-123-deploy-456.yaml" + [ ! -f "$secret_files_file" ] || [ ! -s "$secret_files_file" ] +}