From 7868e107bb10d71e910669e414467c8271424144 Mon Sep 17 00:00:00 2001 From: Gabriel Eisbruch Date: Mon, 7 Sep 2026 09:32:12 -0300 Subject: [PATCH 1/2] fix(k8s): honor externally managed service accounts for deployments --- k8s/README.md | 13 +++++ k8s/deployment/build_context | 21 +++++++- k8s/deployment/tests/build_context.bats | 65 +++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 2 deletions(-) diff --git a/k8s/README.md b/k8s/README.md index b67915f9..7b8fcc98 100644 --- a/k8s/README.md +++ b/k8s/README.md @@ -186,3 +186,16 @@ Advanced configuration options. | Variable | Description | Scope Configuration Property | |----------|-------------|------------------------------| | **K8S_MODIFIERS** | JSON string with dynamic modifications to Kubernetes objects | `object_modifiers` | + +### ServiceAccounts managed outside the scope + +To run pods with an existing ServiceAccount (for example IRSA managed by +OpenTofu), set `security.service_account_name` in the application's +`container-orchestration` provider. A `scope-configurations` value at that path +has precedence. Keep managed IAM disabled; configuring both modes is rejected. + +The ServiceAccount must already exist in the scope's Kubernetes namespace and +carry the desired IAM role annotation. The deployment builder only selects it +through `spec.template.spec.serviceAccountName`; the scope does not create or +delete the external ServiceAccount or its IAM role. Existing managed-IAM naming +and the unconfigured pod default remain unchanged. diff --git a/k8s/deployment/build_context b/k8s/deployment/build_context index c0340487..057ad4a4 100755 --- a/k8s/deployment/build_context +++ b/k8s/deployment/build_context @@ -199,9 +199,26 @@ fi IAM_ENABLED=$(echo "$IAM" | jq -r '.ENABLED // false') -SERVICE_ACCOUNT_NAME="" +# Externally managed ServiceAccounts (for example, IRSA declared in IaC) are +# selected for the pod without enabling the scope's role/SA lifecycle manager. +EXTERNAL_SERVICE_ACCOUNT_NAME=$(get_config_value \ + --provider '.providers["scope-configurations"].security.service_account_name' \ + --provider '.providers["container-orchestration"].security.service_account_name' \ + --default "" +) -if [[ "$IAM_ENABLED" == "true" ]]; then +SERVICE_ACCOUNT_NAME="" +if [[ -n "$EXTERNAL_SERVICE_ACCOUNT_NAME" ]]; then + if [[ "$IAM_ENABLED" == "true" ]]; then + log error "Configure either an external service_account_name or managed IAM, not both" + exit 1 + fi + if [[ ${#EXTERNAL_SERVICE_ACCOUNT_NAME} -gt 253 || ! "$EXTERNAL_SERVICE_ACCOUNT_NAME" =~ ^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$ ]]; then + log error "Invalid external service_account_name: expected a Kubernetes DNS subdomain name" + exit 1 + fi + SERVICE_ACCOUNT_NAME="$EXTERNAL_SERVICE_ACCOUNT_NAME" +elif [[ "$IAM_ENABLED" == "true" ]]; then SERVICE_ACCOUNT_NAME=$(echo "$IAM" | jq -r .PREFIX)-"$SCOPE_ID" fi diff --git a/k8s/deployment/tests/build_context.bats b/k8s/deployment/tests/build_context.bats index 72f95cc6..e1c048ee 100644 --- a/k8s/deployment/tests/build_context.bats +++ b/k8s/deployment/tests/build_context.bats @@ -1302,3 +1302,68 @@ EOF assert_equal "$(echo "$CONTEXT" | jq -r '.main_traffic_manager_port')" "10080" } + +# External ServiceAccounts are selected by the real context builder, while IAM +# role creation/deletion stays disabled for resources owned by IaC. +@test "external service account: uses the container-orchestration provider" { + setup_full_build_context + export IAM='{"ENABLED":false}' + CONTEXT=$(echo "$CONTEXT" | jq '.providers["container-orchestration"].security.service_account_name = "ui-plugins-stage"') + + source "$SCRIPT" + + assert_equal "$(echo "$CONTEXT" | jq -r .service_account_name)" "ui-plugins-stage" + assert_equal "$IAM_ENABLED" "false" +} + +@test "external service account: scope-configurations override the orchestrator default" { + setup_full_build_context + export IAM='{"ENABLED":false}' + CONTEXT=$(echo "$CONTEXT" | jq '.providers["container-orchestration"].security.service_account_name = "base-sa" | .providers["scope-configurations"].security.service_account_name = "scope-sa"') + + source "$SCRIPT" + + assert_equal "$(echo "$CONTEXT" | jq -r .service_account_name)" "scope-sa" +} + +@test "external service account: keeps managed IAM name when no external name is configured" { + setup_full_build_context + export IAM='{"ENABLED":true,"PREFIX":"managed"}' + + source "$SCRIPT" + + assert_equal "$(echo "$CONTEXT" | jq -r .service_account_name)" "managed-test-scope-123" +} + +@test "external service account: leaves the pod default when neither mode is configured" { + setup_full_build_context + export IAM='{"ENABLED":false}' + + source "$SCRIPT" + + assert_equal "$(echo "$CONTEXT" | jq -r .service_account_name)" "" +} + +@test "external service account: rejects conflicting lifecycle ownership" { + setup_full_build_context + export IAM='{"ENABLED":true,"PREFIX":"managed"}' + CONTEXT=$(echo "$CONTEXT" | jq '.providers["container-orchestration"].security.service_account_name = "external-sa"') + export CONTEXT + + run bash -c 'source "$SCRIPT"' + + [ "$status" -ne 0 ] + assert_contains "$output" "either an external service_account_name or managed IAM" +} + +@test "external service account: rejects names that could inject YAML" { + setup_full_build_context + export IAM='{"ENABLED":false}' + CONTEXT=$(echo "$CONTEXT" | jq '.providers["container-orchestration"].security.service_account_name = "bad\nname"') + export CONTEXT + + run bash -c 'source "$SCRIPT"' + + [ "$status" -ne 0 ] + assert_contains "$output" "Invalid external service_account_name" +} From 4f3acd1b89716ef4ca48c8db594439ca65ac58f5 Mon Sep 17 00:00:00 2001 From: Gabriel Eisbruch Date: Mon, 7 Sep 2026 09:43:28 -0300 Subject: [PATCH 2/2] fix(k8s): make external service accounts an explicit scope opt-in --- k8s/README.md | 9 +++++---- k8s/deployment/build_context | 8 +++----- k8s/deployment/tests/build_context.bats | 14 +++++++------- k8s/specs/service-spec.json.tpl | 18 ++++++++++++++++++ 4 files changed, 33 insertions(+), 16 deletions(-) diff --git a/k8s/README.md b/k8s/README.md index 7b8fcc98..fc33f6e0 100644 --- a/k8s/README.md +++ b/k8s/README.md @@ -190,12 +190,13 @@ Advanced configuration options. ### ServiceAccounts managed outside the scope To run pods with an existing ServiceAccount (for example IRSA managed by -OpenTofu), set `security.service_account_name` in the application's -`container-orchestration` provider. A `scope-configurations` value at that path -has precedence. Keep managed IAM disabled; configuring both modes is rejected. +OpenTofu), set the scope capability `service_account_name`. Leave it empty to +preserve current behavior. Account-level provider defaults are not consulted +for this opt-in, so other scopes keep their existing identity. Keep managed IAM +disabled; configuring both ownership modes is rejected. The ServiceAccount must already exist in the scope's Kubernetes namespace and carry the desired IAM role annotation. The deployment builder only selects it through `spec.template.spec.serviceAccountName`; the scope does not create or delete the external ServiceAccount or its IAM role. Existing managed-IAM naming -and the unconfigured pod default remain unchanged. +and unconfigured defaults remain unchanged. diff --git a/k8s/deployment/build_context b/k8s/deployment/build_context index 057ad4a4..d0a0756f 100755 --- a/k8s/deployment/build_context +++ b/k8s/deployment/build_context @@ -201,11 +201,9 @@ IAM_ENABLED=$(echo "$IAM" | jq -r '.ENABLED // false') # Externally managed ServiceAccounts (for example, IRSA declared in IaC) are # selected for the pod without enabling the scope's role/SA lifecycle manager. -EXTERNAL_SERVICE_ACCOUNT_NAME=$(get_config_value \ - --provider '.providers["scope-configurations"].security.service_account_name' \ - --provider '.providers["container-orchestration"].security.service_account_name' \ - --default "" -) +# Explicit scope opt-in: do not start using inherited account-level defaults +# that older deployments previously ignored. +EXTERNAL_SERVICE_ACCOUNT_NAME=$(echo "$CONTEXT" | jq -r '.scope.capabilities.service_account_name // empty') SERVICE_ACCOUNT_NAME="" if [[ -n "$EXTERNAL_SERVICE_ACCOUNT_NAME" ]]; then diff --git a/k8s/deployment/tests/build_context.bats b/k8s/deployment/tests/build_context.bats index e1c048ee..c8aa2a11 100644 --- a/k8s/deployment/tests/build_context.bats +++ b/k8s/deployment/tests/build_context.bats @@ -1305,10 +1305,10 @@ EOF # External ServiceAccounts are selected by the real context builder, while IAM # role creation/deletion stays disabled for resources owned by IaC. -@test "external service account: uses the container-orchestration provider" { +@test "external service account: uses the explicitly selected scope account" { setup_full_build_context export IAM='{"ENABLED":false}' - CONTEXT=$(echo "$CONTEXT" | jq '.providers["container-orchestration"].security.service_account_name = "ui-plugins-stage"') + CONTEXT=$(echo "$CONTEXT" | jq '.scope.capabilities.service_account_name = "ui-plugins-stage"') source "$SCRIPT" @@ -1316,14 +1316,14 @@ EOF assert_equal "$IAM_ENABLED" "false" } -@test "external service account: scope-configurations override the orchestrator default" { +@test "external service account: ignores inherited account defaults without scope opt-in" { setup_full_build_context export IAM='{"ENABLED":false}' - CONTEXT=$(echo "$CONTEXT" | jq '.providers["container-orchestration"].security.service_account_name = "base-sa" | .providers["scope-configurations"].security.service_account_name = "scope-sa"') + CONTEXT=$(echo "$CONTEXT" | jq '.providers["container-orchestration"].security.service_account_name = "base-sa"') source "$SCRIPT" - assert_equal "$(echo "$CONTEXT" | jq -r .service_account_name)" "scope-sa" + assert_equal "$(echo "$CONTEXT" | jq -r .service_account_name)" "" } @test "external service account: keeps managed IAM name when no external name is configured" { @@ -1347,7 +1347,7 @@ EOF @test "external service account: rejects conflicting lifecycle ownership" { setup_full_build_context export IAM='{"ENABLED":true,"PREFIX":"managed"}' - CONTEXT=$(echo "$CONTEXT" | jq '.providers["container-orchestration"].security.service_account_name = "external-sa"') + CONTEXT=$(echo "$CONTEXT" | jq '.scope.capabilities.service_account_name = "external-sa"') export CONTEXT run bash -c 'source "$SCRIPT"' @@ -1359,7 +1359,7 @@ EOF @test "external service account: rejects names that could inject YAML" { setup_full_build_context export IAM='{"ENABLED":false}' - CONTEXT=$(echo "$CONTEXT" | jq '.providers["container-orchestration"].security.service_account_name = "bad\nname"') + CONTEXT=$(echo "$CONTEXT" | jq '.scope.capabilities.service_account_name = "bad\nname"') export CONTEXT run bash -c 'source "$SCRIPT"' diff --git a/k8s/specs/service-spec.json.tpl b/k8s/specs/service-spec.json.tpl index 60763b89..516338c2 100644 --- a/k8s/specs/service-spec.json.tpl +++ b/k8s/specs/service-spec.json.tpl @@ -61,6 +61,16 @@ } }, "elements":[ + { + "type":"Category", + "label":"ServiceAccount", + "elements":[ + { + "type":"Control", + "scope":"#/properties/service_account_name" + } + ] + }, { "type":"Category", "label":"Resources", @@ -341,6 +351,14 @@ "export":false, "default":"docker-image" }, + "service_account_name": { + "type": "string", + "title": "Existing ServiceAccount", + "default": "", + "maxLength": 253, + "pattern": "^$|^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$", + "description": "Use an existing Kubernetes ServiceAccount managed outside this scope. Leave empty for the default. Requires managed IAM to remain disabled." + }, "ram_memory":{ "type":"integer", "oneOf":[