Skip to content

Commit a35fa46

Browse files
authored
fix(operator): apply_ecs missing taskRoleArn/executionRoleArn/logConfiguration (#1280)
* fix(operator): apply_ecs never set taskRoleArn, executionRoleArn, or logConfiguration oabctl apply_ecs registered ECS task definitions with none of taskRoleArn, executionRoleArn, or a log driver, and set an unused CONFIG_S3_PATH env var that nothing ever read. Found and fixed while deploying a Telegram bot with spec.ingress + spec.secrets end-to-end against a real AWS account (crates/openab/oab cluster, not the separate Terraform-managed openab-chaodu fleet, which already sets these correctly and is unaffected by this change). Five fixes, in the order they were uncovered: 1. Missing executionRoleArn. Any manifest using spec.secrets failed task registration outright: "you must also specify a value for 'executionRoleArn'". Resolved from bootstrap state (S3 bootstrap/state.json), matching how cluster/subnets/task role are already sourced from bootstrap rather than the manifest. 2. Execution role missing secretsmanager:GetSecretValue. ECS uses the EXECUTION role, not the task role, to fetch spec.secrets values before the container starts. bootstrap.rs only granted this policy to the task role, so step 1's fix alone still failed with AccessDeniedException. Added the same oab-secrets policy to oab-task-execution in bootstrap.rs, applied unconditionally (not just on first creation) so `oabctl bootstrap` self-heals existing installs too -- verified by detaching the policy and re-running bootstrap, which reattached it automatically. 3. No logConfiguration. Container failures were completely opaque -- not even an empty CloudWatch log stream was created, which is what made the next two bugs so hard to diagnose. Wired the container to the /oab/agents log group bootstrap already creates. 4. CONFIG_S3_PATH was dead code. apply_ecs set this env var but nothing in the codebase ever reads it -- the image's default CMD points openab at a local /etc/openab/config.toml that nothing populates. openab actually has native s3:// config-source support (config-s3 feature, in the default feature set + unified), so the real fix is passing configFrom directly as the `-c` argument to `openab run`, no download step needed. 5. Missing taskRoleArn -- the actual root cause of the remaining crash loop after 1-4. ECS only provisions the AWS_CONTAINER_CREDENTIALS_RELATIVE_URI credentials endpoint (and injects that env var into the container) when a task role is set on the task definition. Without it the running `openab` process has no AWS credentials for its own SDK calls at all -- it fell through env/profile/webidentity/ECS credential providers and finally tried IMDS, which doesn't exist on Fargate, failing with an opaque "dispatch failure". Confirmed via two controlled debug ECS tasks (one plain, one wrapped in tini matching the real image's entrypoint) that environment variable inheritance through fork() is NOT gated on PID 1 -- ruling out an initial incorrect hypothesis -- then confirmed the actual difference by diffing describe-task-definition JSON between a working debug task and the failing one: taskRoleArn was null. Resolved from bootstrap state like executionRoleArn. Also added a "no bootstrap task role found" guard mirroring the existing executionRoleArn guard, and documented both IAM roles' permissions in operator/README.md (previously only the task role's policies were listed; the execution role's requirements were entirely undocumented). Verified end-to-end against a live AWS account: - Applied a Telegram bot manifest with spec.ingress + spec.secrets. - After fixes 1-4: task registered and ran, but crash-looped with "failed to fetch S3 config ... dispatch failure". - Enabled RUST_LOG=debug via a temporary task-def revision to see the AWS credential chain fall through every provider. - After fix 5: `oabctl apply --wait` reported "is stable"; service reached steady state and stayed at runningCount=1 for 90+ seconds (previously crash-looped within 10-90s every time); curl through the printed webhook URL reached the running task. - Cleaned up all test/debug ECS task definitions and scaled the test service to 0 afterward. build + clippy --all-targets -D warnings + cargo test (15 passed) on an M4 (macmini), in a nested workspace layout mirroring the CI `operator` job. * fix(review): address PR review findings - F1: load_bootstrap_state now resolves bucket via OabConfig (respects custom bucket config/env var) before falling back to STS-derived name - F2: Add explicit eprintln warnings for each failure mode in load_bootstrap_state (STS failure, missing account, state not found, S3 read error) instead of silently returning None - F3: Compute effective_region that falls back to bootstrap_state.region when config.region() is None — ensures AWS_REGION env var and logConfiguration are set even without explicit SDK region config --------- Co-authored-by: chaodu-agent <chaodu-agent@users.noreply.github.com>
1 parent 33610f5 commit a35fa46

3 files changed

Lines changed: 157 additions & 13 deletions

File tree

operator/README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,12 +374,26 @@ oabctl bootstrap --cluster my-cluster # import existing resources
374374

375375
### IAM Task Role Permissions
376376

377+
Attached to `oab-task-role` — the identity the *running container* assumes.
378+
377379
| Policy | Permissions | Resource |
378380
|--------|------------|----------|
379381
| `oab-ecs-exec` | ssmmessages:* | * (ECS Exec requirement) |
380382
| `oab-s3-artifacts` | s3:GetObject, s3:PutObject | `{bucket}/artifacts/*` |
381383
| `oab-secrets` | secretsmanager:GetSecretValue | `arn:aws:secretsmanager:*:*:secret:oab/*` |
382384

385+
### IAM Execution Role Permissions
386+
387+
Attached to `oab-task-execution` — the identity **ECS itself** assumes to pull
388+
the image and resolve `spec.secrets` values *before* the container starts.
389+
This is a different role from the task role above; a manifest's `spec.secrets`
390+
values are fetched by this role, not by the running container.
391+
392+
| Policy | Permissions | Resource |
393+
|--------|------------|----------|
394+
| `AmazonECSTaskExecutionRolePolicy` (AWS managed) | ECR image pulls, CloudWatch log delivery | * |
395+
| `oab-secrets` | secretsmanager:GetSecretValue | `arn:aws:secretsmanager:*:*:secret:oab/*` |
396+
383397
### Import Existing Resources
384398

385399
```bash

operator/src/apply.rs

Lines changed: 128 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,44 @@ use aws_sdk_ecs::types::{
88
use aws_sdk_s3::primitives::ByteStream;
99
use std::path::Path;
1010

11-
/// Try to load bootstrap state for networking defaults (used in future for auto-fill)
12-
#[allow(dead_code)]
11+
/// Load bootstrap state to resolve the task execution role ARN (and other
12+
/// networking defaults). Required on `register_task_definition` whenever the
13+
/// task uses ECS-injected secrets (or pulls from a private registry) — ECS
14+
/// rejects the request with "you must also specify a value for
15+
/// 'executionRoleArn'" otherwise.
1316
async fn load_bootstrap_state(config: &aws_config::SdkConfig) -> Option<BootstrapState> {
14-
let sts = aws_sdk_sts::Client::new(config);
15-
let account = sts.get_caller_identity().send().await.ok()?
16-
.account()?.to_string();
17-
let bucket = format!("oab-control-plane-{account}");
17+
let bucket = if let Some(b) = crate::config::OabConfig::load().ok().and_then(|c| c.bucket()) {
18+
b
19+
} else {
20+
let sts = aws_sdk_sts::Client::new(config);
21+
let identity = match sts.get_caller_identity().send().await {
22+
Ok(id) => id,
23+
Err(e) => {
24+
eprintln!(" ⚠ load_bootstrap_state: STS get_caller_identity failed: {e}");
25+
return None;
26+
}
27+
};
28+
let account = match identity.account() {
29+
Some(a) => a.to_string(),
30+
None => {
31+
eprintln!(" ⚠ load_bootstrap_state: STS response missing account field");
32+
return None;
33+
}
34+
};
35+
format!("oab-control-plane-{account}")
36+
};
1837
let s3 = aws_sdk_s3::Client::new(config);
19-
crate::bootstrap::load_state_pub(&s3, &bucket).await.ok().flatten()
38+
match crate::bootstrap::load_state_pub(&s3, &bucket).await {
39+
Ok(Some(state)) => Some(state),
40+
Ok(None) => {
41+
eprintln!(" ⚠ load_bootstrap_state: no bootstrap state found in s3://{bucket}/bootstrap-state.json (run `oabctl bootstrap` first)");
42+
None
43+
}
44+
Err(e) => {
45+
eprintln!(" ⚠ load_bootstrap_state: failed to read bootstrap state from s3://{bucket}: {e}");
46+
None
47+
}
48+
}
2049
}
2150

2251
pub async fn run(aws_config: &aws_config::SdkConfig, file_path: &str, sync_config: bool, wait: bool) -> Result<()> {
@@ -216,9 +245,12 @@ async fn apply_ecs(
216245
KeyValuePair::builder().name("NAMESPACE").value(&m.metadata.namespace).build(),
217246
KeyValuePair::builder().name("NAME").value(&m.metadata.name).build(),
218247
];
219-
if !m.spec.config_from.is_empty() {
220-
env_vars.push(KeyValuePair::builder().name("CONFIG_S3_PATH").value(&m.spec.config_from).build());
221-
}
248+
// openab's own AWS SDK calls (config-s3 loading, secrets resolution, etc.)
249+
// resolve region via the standard chain: AWS_REGION env var → profile →
250+
// IMDS. Fargate tasks have no EC2 instance metadata to fall back to, so
251+
// without this the SDK can fail to resolve an endpoint at all.
252+
// Region is injected below after bootstrap_state is loaded (to allow
253+
// fallback to bootstrap_state.region when config.region() is None).
222254
if let Some(ref bootstrap) = m.spec.bootstrap_from {
223255
env_vars.push(KeyValuePair::builder().name("BOOTSTRAP_FROM").value(bootstrap).build());
224256
}
@@ -233,14 +265,64 @@ async fn apply_ecs(
233265
})
234266
.collect();
235267

236-
// 4. Register task definition
268+
// 4. Register task definition. Resolve bootstrap state once up front — it
269+
// supplies both the CloudWatch log group (for logConfiguration below) and
270+
// the execution role ARN (further down), neither of which the manifest
271+
// can or should specify directly (bootstrap owns these, not the manifest —
272+
// see operator/README.md's "Resources Created" section).
273+
let bootstrap_state = load_bootstrap_state(config).await;
274+
275+
// Resolve effective region: prefer SDK config, fall back to bootstrap
276+
// state's recorded region. Fargate has no IMDS, so without AWS_REGION the
277+
// container's SDK calls will fail to resolve endpoints entirely.
278+
let effective_region: Option<String> = config.region()
279+
.map(|r| r.as_ref().to_string())
280+
.or_else(|| bootstrap_state.as_ref().map(|s| s.region.clone()));
281+
if let Some(ref region) = effective_region {
282+
env_vars.push(KeyValuePair::builder().name("AWS_REGION").value(region).build());
283+
}
284+
237285
let mut container = ContainerDefinition::builder()
238286
.name("openab")
239287
.image(&m.spec.image)
240288
.essential(true)
241289
.set_environment(Some(env_vars))
242290
.set_secrets(if secrets.is_empty() { None } else { Some(secrets) });
243291

292+
// The image's default CMD points `openab` at a local
293+
// /etc/openab/config.toml that nothing populates. openab has native
294+
// s3:// config-source support (built with the `config-s3` feature,
295+
// included in the default feature set + `unified`), so override the
296+
// command to load configFrom directly instead — no download step,
297+
// sidecar, or entrypoint script needed. Uses the task role's existing
298+
// s3:GetObject grant on `{bucket}/artifacts/*`.
299+
if !m.spec.config_from.is_empty() {
300+
container = container.set_command(Some(vec![
301+
"openab".to_string(),
302+
"run".to_string(),
303+
"-c".to_string(),
304+
m.spec.config_from.clone(),
305+
]));
306+
}
307+
308+
// Ship container stdout/stderr to the log group bootstrap created, so a
309+
// crashing/misbehaving container is actually diagnosable. Without this,
310+
// ECS uses no log driver and task failures are opaque (no log stream at
311+
// all, not even an empty one).
312+
if let Some(log_group) = bootstrap_state.as_ref().map(|s| &s.resources.log_group) {
313+
if let Some(ref region) = effective_region {
314+
container = container.log_configuration(
315+
aws_sdk_ecs::types::LogConfiguration::builder()
316+
.log_driver(aws_sdk_ecs::types::LogDriver::Awslogs)
317+
.options("awslogs-group", log_group.as_str())
318+
.options("awslogs-region", region.as_str())
319+
.options("awslogs-stream-prefix", &service_name)
320+
.options("awslogs-create-group", "true")
321+
.build()?,
322+
);
323+
}
324+
}
325+
244326
// Ingress needs the container port exposed so ECS can register an SRV record
245327
// (Cloud Map + API Gateway learn the target port from it).
246328
if let Some(ingress) = &m.spec.ingress {
@@ -254,14 +336,47 @@ async fn apply_ecs(
254336

255337
let container = container.build();
256338

257-
let task_def = ecs
339+
// ECS requires executionRoleArn whenever the task definition uses
340+
// container secrets (or a private registry) — resolve it from bootstrap
341+
// state rather than requiring it in the manifest, matching how the
342+
// task role / cluster / subnets are already sourced from bootstrap.
343+
//
344+
// taskRoleArn is separate and equally required: ECS only provisions the
345+
// AWS_CONTAINER_CREDENTIALS_RELATIVE_URI endpoint (and injects that env
346+
// var into the container) when a task role is set on the task
347+
// definition. Without it, the running `openab` process has no AWS
348+
// credentials at all for its own SDK calls (fetching configFrom from S3,
349+
// resolving spec.secrets values via aws-sm:// refs, etc.) — it falls
350+
// through envvar/profile/webidentity/ECS providers and finally tries
351+
// IMDS, which doesn't exist on Fargate, and fails with a generic
352+
// "dispatch failure". This was previously never set at all.
353+
let execution_role_arn = bootstrap_state.as_ref().map(|s| s.resources.execution_role_arn.clone());
354+
let task_role_arn = bootstrap_state.as_ref().map(|s| s.resources.task_role_arn.clone());
355+
356+
let mut register_req = ecs
258357
.register_task_definition()
259358
.family(&service_name)
260359
.requires_compatibilities(aws_sdk_ecs::types::Compatibility::Fargate)
261360
.network_mode(aws_sdk_ecs::types::NetworkMode::Awsvpc)
262361
.cpu(&m.spec.resources.cpu)
263362
.memory(&m.spec.resources.memory)
264-
.container_definitions(container)
363+
.container_definitions(container);
364+
if let Some(arn) = &execution_role_arn {
365+
register_req = register_req.execution_role_arn(arn);
366+
} else if !m.spec.secrets.is_empty() {
367+
anyhow::bail!(
368+
"spec.secrets is set but no bootstrap execution role was found — run `oabctl bootstrap` first, or ECS will reject task registration"
369+
);
370+
}
371+
if let Some(arn) = &task_role_arn {
372+
register_req = register_req.task_role_arn(arn);
373+
} else {
374+
anyhow::bail!(
375+
"no bootstrap task role was found — run `oabctl bootstrap` first, or the running container will have no AWS credentials"
376+
);
377+
}
378+
379+
let task_def = register_req
265380
.send()
266381
.await
267382
.context("failed to register task definition")?;

operator/src/bootstrap.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,21 @@ async fn create(config: &aws_config::SdkConfig, imports: ImportOptions) -> Resul
273273
managed.execution_role = true;
274274
arn
275275
};
276+
if imports.execution_role.is_none() {
277+
// ECS uses the EXECUTION role (not the task role) to fetch
278+
// `spec.secrets` values before the container starts. The managed
279+
// AmazonECSTaskExecutionRolePolicy above covers ECR pulls and log
280+
// delivery but not Secrets Manager, so without this, any manifest
281+
// using `spec.secrets` fails at task launch with an AccessDenied on
282+
// secretsmanager:GetSecretValue. Applied unconditionally (not just on
283+
// first creation) so it self-heals existing bootstrap installs too —
284+
// `put_role_policy` is idempotent.
285+
iam.put_role_policy()
286+
.role_name(EXECUTION_ROLE)
287+
.policy_name("oab-secrets")
288+
.policy_document(r#"{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["secretsmanager:GetSecretValue"],"Resource":"arn:aws:secretsmanager:*:*:secret:oab/*"}]}"#)
289+
.send().await.ok();
290+
}
276291

277292
// Save partial state (in case subsequent steps fail)
278293
let mut state = BootstrapState {

0 commit comments

Comments
 (0)