diff --git a/k8s/README.md b/k8s/README.md index b67915f9..fc33f6e0 100644 --- a/k8s/README.md +++ b/k8s/README.md @@ -186,3 +186,17 @@ 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 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 unconfigured defaults remain unchanged. diff --git a/k8s/deployment/build_context b/k8s/deployment/build_context index c0340487..d0a0756f 100755 --- a/k8s/deployment/build_context +++ b/k8s/deployment/build_context @@ -199,9 +199,24 @@ 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. +# 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') -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..c8aa2a11 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 explicitly selected scope account" { + setup_full_build_context + export IAM='{"ENABLED":false}' + CONTEXT=$(echo "$CONTEXT" | jq '.scope.capabilities.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: 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"') + + source "$SCRIPT" + + assert_equal "$(echo "$CONTEXT" | jq -r .service_account_name)" "" +} + +@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 '.scope.capabilities.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 '.scope.capabilities.service_account_name = "bad\nname"') + export CONTEXT + + run bash -c 'source "$SCRIPT"' + + [ "$status" -ne 0 ] + assert_contains "$output" "Invalid external service_account_name" +} 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":[