feat(aws-pcs): cluster-scoped Name tag + PCS-API login lookup in docs - #1183
DaisukeMiyamoto merged 6 commits into
Conversation
…E walkthrough test
Two coupled changes so multi-cluster deployments in one account/VPC stop
colliding, plus a walkthrough test so this class of docs regression fails
CI instead of the customer.
1) Cluster-scoped Name tag on every CNG instance
PCS-${CngName} -> ${ClusterName}-${CngName}
Applied lock-step in all four hand-maintained CNG templates (add-cng,
add-cng-p5, add-cng-p6-b200, add-cng-p6-b300). Two deploy-all stacks
in the same VPC no longer both show PCS-login / PCS-cpu1 in the EC2
console.
2) cluster-user-iam.yaml SSM resourceTag scope
"PCS-login*" -> "*-login"
Same intent (login CNGs only, not compute) under the new Name tag.
3) Docs derive LOGIN_INSTANCE_ID from the stack via the PCS API, not tags
README §6 (Accessing the Cluster), §8.2 (Grafana port-forward, admin
password retrieval, public IP lookup) and docs/IAM.md now resolve the
login node in one line:
CFN Outputs.ClusterId -> pcs list-compute-node-groups (name=login)
-> ec2 describe-instances by aws:pcs:compute-node-group-id
Tag-naming conventions no longer matter to the docs; only the PCS API
contract (which is stable) does. Every code block is self-contained so
it can be copy-pasted as one unit from GitHub.
4) lint-docs.sh banned patterns
- aws:pcs:compute-node-group-name (tag key that PCS never emits — a
doc filter using it returns zero rows silently)
- Name=tag:Name,Values=PCS- (the old flat naming, superseded)
5) tests/README-walkthrough-test.md (new, Test 15)
Pre-merge test: follow README §3 -> §6 -> §8.2 as written, every
command must return a real value, Grafana must load and accept
admin+password. Catches the tag/CLI/permission drift that stalls a
first-time user at the connect step.
Verified live on a fresh us-east-2 cluster (pcs-walkthrough): new Name
tag emitted (pcs-walkthrough-login), one-line login lookup returned the
correct instance id, SSM start-session opened, Grafana port-forward
opened, admin+SSM-retrieved password authenticated, and all twelve
provisioned dashboards (Cluster Summary, Slurm Detail, GPU Node List, …)
are listed by the Grafana API.
KeitaW
left a comment
There was a problem hiding this comment.
Review Batch 1/3 — IAM & Access Scoping
| "Condition": { | ||
| "StringLike": { | ||
| "ssm:resourceTag/Name": "PCS-login*" | ||
| "ssm:resourceTag/Name": "*-login" |
There was a problem hiding this comment.
ssm:resourceTag/Name: *-login is a leading-wildcard grant — it doesn't scope to the cluster, and it can match compute nodes — 🔴 (security) [confirmed]
Observation: The cluster-user SSM condition goes from PCS-login* (anchored prefix) to *-login (leading wildcard), while Resource stays arn:aws:ec2:*:*:instance/*. A leading * in StringLike matches any instance Name ending in -login, and cluster-user-iam.yaml has no ClusterName/ClusterId parameter to narrow it. So a cluster-user can ssm:StartSession (shell / port-forward / SSH-over-SSM) on:
- Every other cluster's login node — this PR names them
<ClusterName>-login, so all of them match*-login. The change makes the Name cluster-unique but the policy discards that uniqueness. - Any unrelated account instance ending in
-login(bastion-login,vpn-login, …) — strictly broader than the oldPCS-login*, which at least required thePCS-prefix. - A compute CNG whose name ends in
login(e.g. CNGdata-login→Name=<cluster>-data-login) — this breaks the template's own stated guarantee ("cannot open shells on compute nodes"); the oldPCS-login*prefix would not have matched it.
The IAM.md prose ("so any login CNG matches; compute/GPU CNGs do not", "the most stable signal available") reassures where it should warn — it omits the account-wide breadth.
Impact: In the very multi-cluster/one-VPC scenario this PR targets, the IAM layer provides no per-cluster isolation and can reach unrelated instances. I confirmed this with iam simulate-custom-policy on the exact statement — under *-login, ssm:StartSession is allowed on myclusterB-login (another cluster), bastion-login / vpn-login (unrelated), and myclusterA-data-login (a compute CNG ending in login), and correctly denied only on myclusterA-cpu1 / -gpu-p5. Every one of those four unintended allows was implicitDeny under the old PCS-login* — so this is a demonstrated widening, not a theoretical one. (Today's real blast radius is small — 1 *-login instance live in the test region — but the glob is unbounded by design.)
Suggestion: Scope by the AWS-managed, operator-immutable aws:pcs:cluster-id tag — the same lever the §8.2 monitoring path in this PR already uses. Add a ClusterId parameter to this template and AND both keys in one StringLike (keys AND together), so it's PCS-instances-of-this-cluster-whose-role-is-login:
"StringLike": {
"ssm:resourceTag/aws:pcs:cluster-id": "<ClusterId param>",
"ssm:resourceTag/Name": "*login*"
}(cluster-id alone would expose compute nodes; Name alone is account-wide — you need both.) If *-login stays for now, please make the IAM.md / description prose state plainly that it's an account-wide leading-wildcard match, so an operator tightening for production isn't misled. — verified live 2026-07-11: cluster-user-iam.yaml params are only GroupName/AttachUsers; instances carry an immutable aws:pcs:cluster-id tag.
KeitaW
left a comment
There was a problem hiding this comment.
Review Batch 2/3 — Docs, Tags & Maintainability
The 4-way byte-identical UserData duplication keeps forcing lock-step edits — 🟢 (observation)
Observation: This is the third change in recent memory that had to be applied identically to the four hand-maintained CNG templates (the needrestart guard, the cross-region-S3 --region fix, and now the Name/CngName retag) — I diffed the four added blocks here and they're byte-identical (good, no drift this time).
Impact: None today; it's a standing fragility — the next edit that lands in three of four is where a real divergence appears.
Suggestion: The new tests/lint-docs.sh is a natural home for a cross-file assertion that the four Tags: blocks stay byte-identical, so drift fails CI. Or factor the shared UserData into one source. (Not a blocker for this PR.)
| STACK_NAME=pcs-ml-cluster # your CloudFormation stack name | ||
| AWS_REGION=us-east-1 # your region | ||
|
|
||
| LOGIN_INSTANCE_ID=$(aws ec2 describe-instances --region "$AWS_REGION" --filters "Name=tag:aws:pcs:compute-node-group-id,Values=$(aws pcs list-compute-node-groups --cluster-identifier $(aws cloudformation describe-stacks --stack-name "$STACK_NAME" --region "$AWS_REGION" --query 'Stacks[0].Outputs[?OutputKey==`ClusterId`].OutputValue' --output text) --region "$AWS_REGION" --query 'computeNodeGroups[?name==`login`].id' --output text)" "Name=instance-state-name,Values=running" --query 'Reservations[0].Instances[0].InstanceId' --output text) |
There was a problem hiding this comment.
The nested LOGIN_INSTANCE_ID=$(…) one-liner fails silently to None, and --cluster-identifier is unquoted — 🟢 [confirmed]
Observation: The triple-nested substitution (this line in §6, and the §8.2 copy) is functionally correct — the JMESPath backticks are protected by single quotes, and nested $(…) parses fine. But there's no error path: a wrong STACK_NAME/AWS_REGION or a missing ClusterId output makes the inner describe-stacks return empty → list-compute-node-groups runs with an empty identifier → the Values= filter matches nothing → --output text prints literal None → aws ssm start-session --target None fails opaquely. Separately, --cluster-identifier $(…) is unquoted, so on an empty ClusterId the token vanishes and the following --region is consumed as the identifier value — a different, more confusing error than a clean "empty identifier."
Impact: A first-time copy-paste into the wrong stack/region gets None with no hint why. (Test 15's "no None/empty" gate is exactly the right guard — but the snippet itself gives the user no diagnostic.)
Suggestion: Split into named steps with a guard, and quote the identifier — IAM.md already splits out CLUSTER_ID for the Grafana step, so the pattern exists:
CLUSTER_ID=$(aws cloudformation describe-stacks --stack-name "$STACK_NAME" --region "$AWS_REGION" \
--query 'Stacks[0].Outputs[?OutputKey==`ClusterId`].OutputValue' --output text)
[ -n "$CLUSTER_ID" ] && [ "$CLUSTER_ID" != "None" ] || { echo "No ClusterId — check STACK_NAME/AWS_REGION"; return 1; }
CNG_ID=$(aws pcs list-compute-node-groups --cluster-identifier "$CLUSTER_ID" --region "$AWS_REGION" \
--query 'computeNodeGroups[?name==`login`].id' --output text)
LOGIN_INSTANCE_ID=$(aws ec2 describe-instances --region "$AWS_REGION" \
--filters "Name=tag:aws:pcs:compute-node-group-id,Values=$CNG_ID" "Name=instance-state-name,Values=running" \
--query 'Reservations[0].Instances[0].InstanceId' --output text)| Value: !Sub '${ClusterName}-${CngName}' | ||
| - Key: CngName | ||
| Value: !Sub 'PCS-${CngName}' | ||
| Value: !Sub '${ClusterName}-${CngName}' |
There was a problem hiding this comment.
The CngName tag no longer holds the CNG name — 🟢 [confirmed]
Observation: The custom tag Key: CngName now gets Value: !Sub '${ClusterName}-${CngName}' — identical to Name. A tag keyed CngName whose value is <cluster>-<cng> is misleading; the bare CNG name is !Ref CngName. (It was already mildly off as PCS-${CngName}, but this PR edits the line and could set it right.)
Impact: Redundant with Name; anything grouping/filtering by tag:CngName (dashboards, cost allocation) now sees a cluster-prefixed value, not the CNG name.
Suggestion:
| Value: !Sub '${ClusterName}-${CngName}' | |
| Value: !Ref CngName |
(or drop the duplicate tag, since Name already carries the console-readable value). Applies to the same line in all four add-cng*.yaml.
KeitaW
left a comment
There was a problem hiding this comment.
Review Batch 3/3 — Positives & Sources
Things That Look Great
- The PCS-API login lookup is the right approach, and I verified it works live. On a real cluster (
pcs_3duasoz99i),list-compute-node-groups→computeNodeGroups[?name=='login'].id→describe-instancesbyaws:pcs:compute-node-group-idresolves the login node correctly. Moving off the human-facingNametag to the PCS API (and the immutableaws:pcs:*tags) is exactly the robust, naming-independent path. - The new walkthrough test (Test 15) is excellent. A pre-merge test that follows README §3 → §6 → §8.2 as written, gating on "every command returns a real value (no
None/Not found)", is precisely the guard for the copy-paste-first-step regressions this doc set has hit before. The failure-classification section (None → stale snippet; AccessDenied → IAM scope drift; Grafana 403 → monitoring) is genuinely useful. - The lint bans are well-formed —
aws:pcs:compute-node-group-name(bare, notag:prefix, so it catches both the CLI-filter and console-hint forms) plusName=tag:Name,Values=PCS-to retire the old flat naming. This is the correct, complete guard. - The monitoring path (§8.2) is properly per-cluster scoped (
aws:pcs:cluster-id+monitoring-role=login) — that same immutable-tag pattern is the fix the SSM IAM policy above wants. - Applied consistently — the Name/CngName change is byte-identical across all four CNG templates, and the README/IAM.md/lint changes move in lock-step.
- Self-contained copy-paste blocks with
STACK_NAME/AWS_REGIONset once per block — a real usability improvement over cross-block variable threading.
Sources
- Live verification (2026-07-11, acct 159553542841): PCS cluster
pcs_3duasoz99i(us-east-1) —aws pcs list-compute-node-groupsreturns aloginCNGpcs_y20b00q2o6, anddescribe-instancesbytag:aws:pcs:compute-node-group-idresolves login nodei-0ca17ac97d8d60571.cluster-user-iam.yamlparameters confirmed to be onlyGroupName/AttachUsers(no cluster identifier). - IAM
StringLikeleading-wildcard semantics: a*-prefixed pattern matches any suffix — IAM policy element: condition operators. Demonstrated live (aws iam simulate-custom-policy, 2026-07-11) on the PR's exactssm:StartSessionstatement:*-loginallowsmyclusterB-login,bastion-login,vpn-login,myclusterA-data-login(allimplicitDenyunderPCS-login*); both correctly denymyclusterA-cpu1/-gpu-p5. - PCS instance tags (
aws:pcs:cluster-id,aws:pcs:compute-node-group-id) are AWS-applied/immutable — AWS PCS docs.
The custom tag `CngName` had value `!Sub '${ClusterName}-${CngName}'`
— identical to the new `Name` tag introduced by this PR. A tag keyed
`CngName` whose value carries the cluster prefix is misleading and
duplicates `Name`; anything filtering / grouping by `tag:CngName`
(dashboards, cost allocation) would see a cluster-prefixed value, not
the CNG name.
Same value change in all four `add-cng*.yaml` — this custom tag
survives on every CNG (login/compute/GPU families).
Reported by KeitaW during PR review.
…op *login glob Reported by KeitaW on this PR. The triple-nested substitution used in README §6, IAM.md, and (from awslabs#1172) JUPYTER.md was functionally correct but had two footguns: 1. **Fail-silent to `None`.** A wrong STACK_NAME/AWS_REGION or a missing ClusterId stack output made the inner describe-stacks return empty → the outer AWS calls printed the literal string "None" → `aws ssm start-session --target None` failed opaquely. 2. **Unquoted `--cluster-identifier $(…)`.** On an empty ClusterId the token disappeared and `--region` was consumed as the identifier value — a more confusing error than a clean "empty identifier". Split the pipeline into three named steps (CLUSTER_ID, LOGIN_CNG_ID, LOGIN_INSTANCE_ID), quote every substitution, and add an early guard that stops with a clear message on missing/None CLUSTER_ID. Same change in all three snippets (README §6, README §8.2, IAM.md "What the cluster user can do", JUPYTER.md Step 3). JUPYTER.md previously used `Name=tag:Name,Values=*login` — a leading-wildcard match that this PR's Item 1 review calls out for matching any `*-login` in the account (other clusters, `bastion-login`, compute CNGs ending in `login`, …). Replaced with the same PCS-API + `aws:pcs:compute-node-group-id` resolver used elsewhere in the patch, which is scoped to the login CNG of the named stack.
Reported by KeitaW on this PR. The SSMSessionToLoginNodeInstance
condition used `ssm:resourceTag/Name: "*-login"` — a leading
wildcard that matches ANY instance in the account whose Name tag
ends with `-login`:
* every other cluster's login node (this PR names them
<ClusterName>-login, so all of them satisfy `*-login`);
* unrelated `-login` instances (`bastion-login`, `vpn-login`, …)
that were denied under the previous `PCS-login*` prefix;
* compute CNGs whose own name ends in `login` (e.g. CNG named
`data-login` → Name=<cluster>-data-login), breaking the
template's own "cannot open shells on compute nodes" guarantee.
Add a required `ClusterStackName` parameter and switch the Condition
to `StringEquals` with `!Sub "${ClusterStackName}-login"` — the exact
Name tag the login CNG carries (add-cng*.yaml Name value =
<ClusterName>-<CngName>, deploy-all passes StackName as ClusterName).
Also rewrite the PolicyDocument from a JSON-string literal to
native YAML so the Sub reference is legible.
Verified via `aws iam simulate-custom-policy` on the deployed
policy — the four unintended-allow cases in the original report all
flip to `implicitDeny`, and the legitimate target-cluster login
stays `allowed`:
Name=<target>-login → allowed
Name=<other>-login → implicitDeny
Name=bastion-login → implicitDeny
Name=<target>-cpu1 → implicitDeny
Name=<target>-data-login → implicitDeny
Trade-off: one deploy of `cluster-user-iam.yaml` now grants access to
exactly one cluster. Deploy it once per target cluster. Docs (IAM.md
scope wording + deploy example, iam-test.md Test B expectations and
`simulate-custom-policy` recipe) updated to reflect this.
|
Thanks for the security catch — Item 1 is exactly the kind of thing this PR should have gotten right the first time. All three should-fixes are addressed in 4b52e3e / 9ecd426 / b9ee204 (three separate commits, one per Item). Item 1 —
|
ssm:resourceTag/Name context |
Result |
|---|---|
pcs-um-acct-login (target) |
allowed |
other-cluster-login |
implicitDeny |
bastion-login |
implicitDeny |
pcs-um-acct-cpu1 (compute) |
implicitDeny |
pcs-um-acct-data-login (compute w/ login suffix) |
implicitDeny |
Trade-off: one deploy of cluster-user-iam.yaml now grants access to exactly one cluster — deploy it once per target cluster. The template Description, IAM.md's scope wording, the deploy example, and iam-test.md Test B expectations + a simulate-custom-policy recipe are all updated to reflect this. I also rewrote the PolicyDocument from a JSON-string literal to native YAML so the !Sub reference is legible.
Item 2 — split login-lookup snippet (9ecd426)
Adopted your suggestion. All three copies (README §6, README §8.2, IAM.md "What the cluster user can do") now split into three named steps, quote every substitution, and add an early guard:
```bash
CLUSTER_ID=$(aws cloudformation describe-stacks ... --query 'Stacks[0].Outputs[?OutputKey==`ClusterId`].OutputValue' --output text)
[ -n "$CLUSTER_ID" ] && [ "$CLUSTER_ID" != "None" ] || { echo "No ClusterId — check STACK_NAME/AWS_REGION"; return 1; }
LOGIN_CNG_ID=$(aws pcs list-compute-node-groups --cluster-identifier "$CLUSTER_ID" ...)
LOGIN_INSTANCE_ID=$(aws ec2 describe-instances ... "Name=tag:aws:pcs:compute-node-group-id,Values=$LOGIN_CNG_ID" ...)
```
While in there I also fixed the same fail-silent + Name=*login glob in the already-merged docs/JUPYTER.md from #1172 — it was the same pattern, and Item 1's exact-tag argument applies to it too. So JUPYTER.md Step 3 now uses the same PCS-API resolver instead of "Name=tag:Name,Values=*login".
Item 3 — bare CngName tag (b9ee204)
Taken as-is: the CngName custom tag now holds !Ref CngName in all four add-cng*.yaml. As you noted, prefixed values were already off-spec pre-PR (commit 10613e3, #1120, meant the tag to "preserve the per-node-group name" but coded PCS-${CngName}); this PR was widening the drift, and the fix restores the original intent.
9ecd426 bundled a JUPYTER.md change into what should have been a README/IAM.md-only fix. Two reasons to back it out: 1. JUPYTER.md's `--filters "Name=tag:Name,Values=*login"` is a client- side discovery filter for an end user's own SSM session, not an IAM Condition. Item 1's exact-tag argument is about who a policy grants session-start rights to; a shell filter that returns the wrong login just leads to `ssm start-session` failing under the IAM scope the principal actually has. Not a security issue for JUPYTER.md. 2. The replacement pipeline was not run verbatim on a live cluster — only the equivalent shape in README/IAM.md was. Landing an unverified pipeline in Jupyter's connect step (the load-bearing step of that guide) is worse than the original three-line filter, which shipped in awslabs#1172 with KeitaW's live-cluster verification. Keep 9ecd426's README §6 / §8.2 / IAM.md changes — those are the ones KeitaW asked for.
|
Correction on my previous comment. The JUPYTER.md change I bundled into 9ecd426 was scope-creep on my part — I saw the same
Reverted in 43a54fc. The README §6, README §8.2, and IAM.md parts of 9ecd426 — the ones you actually asked for in Item 2 — stay. |
KeitaW
left a comment
There was a problem hiding this comment.
Re-review (round 2) — resolution scoreboard
Thorough response round — all three round-1 findings are addressed with dedicated commits, and I verified the fixes rather than trusting the messages. Headline: the login-node IAM scope is now correct and I re-verified it live — the old *-login leading-wildcard is gone, replaced by StringEquals ${ClusterStackName}-login, and running the PR's own iam-test.md matrix against the resolved policy gives exactly allowed for <stack>-login and implicitDeny for other-cluster-login / bastion-login / <stack>-cpu1 / <stack>-data-login (round 1 those were allowed).
| # | Round-1 finding | Status |
|---|---|---|
| 1 | ssm:resourceTag/Name: *-login leading-wildcard SSM grant |
✅ Resolved (4b52e3e) — ClusterStackName param + StringEquals ${ClusterStackName}-login; verified live via simulate-custom-policy (allowed + implicitDeny×4). deploy-all wiring (ClusterName=${AWS::StackName}, login CngName: login) confirms the real login node matches. |
| 2 | nested LOGIN_INSTANCE_ID=$(…) fails silently to None |
✅ Resolved (mostly) (9ecd426) — split, quoted, guarded on CLUSTER_ID. One residual last-hop, inline below. |
| 3 | CngName tag held PCS-<name> not the CNG name |
✅ Resolved (b9ee204) — !Ref CngName, byte-identical across all four templates. |
| — | (obs) 4-way byte-identical UserData duplication | ➖ Unchanged / informational — still 4 copies; the new lint doesn't assert Tags:-block identity (non-blocking). |
Three round-2 comments inline: one 🟡 (the new lint gives a false all-clear) and two 🟢 doc-accuracy notes. Details on each line.
| $'S3 (public )?hosting is not allowed\tNEVERMATCH\tPostInstallScriptUrl now accepts s3:// URLs' | ||
| $'architectures/aws-pcs/iam/\tNEVERMATCH\tthe iam/ directory was removed; use docs/IAM.md + assets/cluster-*-iam.yaml' | ||
| $'aws:pcs:compute-node-group-name\tNEVERMATCH\ttag key does not exist — use `aws pcs list-compute-node-groups` + `tag:aws:pcs:compute-node-group-id`' | ||
| $'Name=tag:Name,Values=PCS-\tNEVERMATCH\tthe Name tag is <ClusterName>-<CngName>; resolve the login node via the PCS API instead' |
There was a problem hiding this comment.
The new lint gives a false all-clear — two Jupyter docs still carry the patterns it's meant to eradicate
This PR replaces the mutable-Name-tag login discovery and the PCS-login* scope across README / IAM.md / iam-test.md, and adds these two BANNED patterns to stop them creeping back. The gap: two sibling docs describing the same login-node flow still carry the pre-PR forms, and both sit inside the lint's own DOC_GLOBS (docs/*.md tests/*.md):
docs/JUPYTER.md:120andtests/jupyter-notebook-test.md:119— login discovery via--filters "Name=tag:Name,Values=*login"+Reservations[0].Instances[0](un-cluster-scoped, arbitrary first match — the exact shared-VPC "wrong cluster" hazard §6/§8/IAM.md now avoid).docs/JUPYTER.md:144— describes the SSM condition asssm:resourceTag/Name = PCS-login*(the old StringLike). The claim it supports is still true, but the named mechanism is stale and misleads anyone auditing a restricted role from that doc.
I checked live that these two new patterns (aws:pcs:compute-node-group-name, Name=tag:Name,Values=PCS-) match none of those forms, so the lint passes green while the drift survives in-tree — it reads as "no stale lookups" when three remain. I see you deliberately reverted the JUPYTER.md scope change (43a54fc, "out of scope, untested") — deferring the Jupyter rewrite is a reasonable call for a PR already carrying a security fix. The narrower ask is to keep the lint honest about it: either widen the two patterns so they actually catch Values=*login / PCS-login* (which forces the small Jupyter fix now), or add an explicit "Jupyter docs deferred to #NNNN" note so a green lint isn't read as full coverage. I'd lean toward widening the patterns — making this class fail CI is the whole point of adding them.
| [ -n "$CLUSTER_ID" ] && [ "$CLUSTER_ID" != "None" ] || { echo "No ClusterId — check STACK_NAME/AWS_REGION"; return 1; } | ||
|
|
||
| LOGIN_CNG_ID=$(aws pcs list-compute-node-groups --cluster-identifier "$CLUSTER_ID" --region "$AWS_REGION" --query 'computeNodeGroups[?name==`login`].id' --output text) | ||
| LOGIN_INSTANCE_ID=$(aws ec2 describe-instances --region "$AWS_REGION" --filters "Name=tag:aws:pcs:compute-node-group-id,Values=$LOGIN_CNG_ID" "Name=instance-state-name,Values=running" --query 'Reservations[0].Instances[0].InstanceId' --output text) |
There was a problem hiding this comment.
The split login-lookup guards only the first of three hops — a renamed login CNG still lands on --target None
The split + CLUSTER_ID guard is a solid fix for the round-1 silent-None. The residual: the guard covers CLUSTER_ID, but LOGIN_CNG_ID and LOGIN_INSTANCE_ID are unguarded. If list-compute-node-groups finds no CNG named login (renamed, or not yet running), LOGIN_CNG_ID comes back empty → the describe-instances filter becomes Values= → LOGIN_INSTANCE_ID=None → aws ssm start-session --target None fails with an opaque SSM error — the round-1 class, one hop down. A single guard right before the session covers all three hops:
[ -n "$LOGIN_INSTANCE_ID" ] && [ "$LOGIN_INSTANCE_ID" != "None" ] || { echo "No running login node for cluster $CLUSTER_ID"; return 1; }Two asides: the existing CLUSTER_ID guard uses return 1, which only halts inside a function or a sourced script — pasted at an interactive prompt (which the README instructs) return prints an error and execution continues anyway, so the guard's "stop here" intent isn't enforced interactively. And credit due — the new Test 15 gate (echo $LOGIN_INSTANCE_ID must print an i-…) already catches this before a customer would, so this is polish, not a hole. docs/IAM.md's equivalent block has the same shape and would take the same one-line guard.
| **CLI** (AWS CloudShell has the required permissions): | ||
| **CLI** (AWS CloudShell has the required permissions). Set `STACK_NAME` / | ||
| `AWS_REGION` once, then a single command resolves the login node's | ||
| instance ID — it works no matter what your CNG is named because the |
There was a problem hiding this comment.
§6 rationale is slightly self-contradictory — the resilience is against the Name tag, not the CNG name
Small wording thing: the §6 intro says the lookup "works no matter what your CNG is named because the lookup goes through the PCS API." But the lookup hardcodes computeNodeGroups[?name==\login`], so it *does* depend on the login CNG being named login(which deploy-all guarantees). What the PCS-API path actually buys you is independence from the mutable EC2Name` tag — that's the real win worth stating.
| instance ID — it works no matter what your CNG is named because the | |
| instance ID — it works no matter how instances are Name-tagged because the |
KeitaW
left a comment
There was a problem hiding this comment.
Walkthrough Test 15, Step 4 — "the same way" conflates two lookup mechanisms
Minor nit in the new (excellent) walkthrough test: Step 4 says §8.2 Option A "produces LOGIN_INSTANCE_ID the same way" as Step 2 (§6). They're different paths — §6 uses the PCS API + aws:pcs:compute-node-group-id, while §8.2 filters on monitoring-role=login + aws:pcs:cluster-id. Both valid, but a reader following the gates literally will notice the commands differ. Dropping "the same way" (or noting the different tag path) keeps the "as written" contract precise.
Things that look great (round 2)
- The IAM scope fix is exactly right and I verified it live.
ClusterStackName+StringEquals(notStringLike) fails closed — a wrong stack name yields AccessDenied, not an over-grant — and the inline comment enumerating what's excluded (bastion-login, other clusters,*login-suffixed compute CNGs) matches the simulated reality one-for-one. - The JSON→YAML policy rewrite is faithful. All seven statements preserved, and — the easy CFN foot-gun here —
${aws:username}inSSMSessionTerminateOwnis correctly left outside!Sub, so CloudFormation doesn't try to resolve it as a template parameter (a cleanvalidate-templateconfirms). The only!Subis the intended${ClusterStackName}-login. - You turned my round-1 demonstration into a durable test.
iam-test.mdnow scopes the check withsimulate-custom-policyacross the five representative names with the exact expectedallowed / implicitDeny×4— regression guard institutionalized, and its expectations match my live run precisely. ClusterStackNameis properly constrained —AllowedPattern+ a clearConstraintDescription, so a bad value fails at deploy with a readable message rather than silently mis-scoping.- Scope discipline — the self-revert of the out-of-scope
JUPYTER.mdchange (43a54fc) is the right call for a PR already carrying a security fix.
Sources
- Verified live (2026-07-13, acct 159553542841):
validate-templateon the rewrittencluster-user-iam.yaml→ valid (GroupName/AttachUsers/ClusterStackName,CAPABILITY_NAMED_IAM);simulate-custom-policyon the resolvedssm:StartSessionstatement →pcs-ml-cluster-login=allowed,other-cluster-login/bastion-login/pcs-ml-cluster-cpu1/pcs-ml-cluster-data-login=implicitDeny;bash tests/lint-docs.sh→ PASS. - deploy-all wiring:
pcs-ml-cluster-deploy-all.yamlpassesClusterName: !Sub '${AWS::StackName}'to every CNG stack and sets loginCngName: login, so the login node'sName=<stack>-login. - PCS-emitted instance tags (docs + live EC2, 3 regions):
aws:pcs:cluster-idandaws:pcs:compute-node-group-idexist;aws:pcs:compute-node-group-namedoes not (0 instances) — confirming the lint ban and the id-based lookups. Finding CNG instances, Connect to your cluster. - IAM
StringEqualsexact-match (no wildcards): IAM condition operators.
Bring in upstream/main (up to awslabs#1179 apt-lock fix + awslabs#1172 Jupyter guide + several 3.test_cases fixes) so this PR fast-forwards cleanly again. Only real conflict: tests/README.md added a Test 15 on both sides — upstream added the Jupyter test (from awslabs#1172), this PR added a README walkthrough test. Keep both: Jupyter is Test 15, README walkthrough becomes Test 16. readme-walkthrough-test.md's title updated to match. The add-cng*.yaml auto-merges — awslabs#1179 (DPkg::Lock::Timeout) applies around this PR's Name/CngName tag edits without conflict.
…verbatim-verified) (#1222) * docs(aws-pcs): improve JUPYTER + USER-MANAGEMENT walkthroughs (verbatim-verified) Two doc rewrites bundled to save review roundtrips — both fell out of end-to-end verbatim replays on live Tokyo/us-east-2 clusters, so every recipe below has been executed from the doc text alone. ## docs/USER-MANAGEMENT.md — full rewrite - Frame the doc around the single-user default first; multi-user becomes an opt-in section, so readers who don't need OpenLDAP can close the tab immediately. - New §2 "Adding your first LDAP user" walkthrough: recover admin password (§2.1) → add user with SSH key (§2.2) → log in (§2.3, both direct SSH and SSH-over-SSM via the PCS-API login lookup) → verify on a compute node as the user (§2.4). - §2.1 admin-password recipe now takes CLUSTER_ID/AWS_REGION from IMDS on the login node (no reader-supplied "pcs_xxxxxxxx" placeholder). - §3 "Detailed operations" restructured as a complete operator toolkit (add / list / delete / reset-password / create-group / add-member), all using `-W` / `-W -S` prompt paths so nothing sensitive lands in shell history. - §4 "Slurm accounting" split into 4.1 Register / 4.2 Limits and enforcement / 4.3 Reporting. §4.1 reproduces the two-project scenario from AWS's "Introducing managed accounting for AWS PCS" blog; §4.2 documents `associations,limits,safe` accepts+pends rather than rejects (contra the blog); §4.3 keeps the two `sacct`/`sreport` tool-behaviour notes verified in earlier live testing. - Explicit callouts: console vs CLI deploy paths, submit-from-$HOME, `--time=<mm>` required when a GrpTRESRunMins quota is set. ## assets/scripts/ldap-add-user.sh — reject duplicate UID/username Same helper the walkthrough drives. LDAP does NOT enforce uidNumber uniqueness (it's not part of the DN), so `ldap-add-user.sh bob 10001 …` after alice at 10001 silently produced two users sharing UID 10001 = same POSIX principal on shared /home + /fsx. Pre-check with an unauthenticated ldapsearch; refuse the add with a clear stderr message. Live-verified: dup UID and dup name both trip the guard; a distinct UID creates cleanly. ## assets/cluster.yaml — ClusterName default `pcs-ml-cluster` Aligns with the README/quick-create link's stack name so a first-time user gets the same identifier whichever deploy path they follow. deploy-all continues to pass `${AWS::StackName}` through, so this default only affects direct `cluster.yaml` stacks. ## docs/JUPYTER.md — preinstall torch, GPU verify cell, tokenless mode, safer login lookup - Step 1 also installs `torch --index-url .../cu124`, so the first notebook can `import torch` without an install/restart roundtrip. - Step 2 explicitly `cd $HOME` before `sbatch`. Slurm's job CWD is the submitter's, and `--output=%u-jupyter-%j.log` resolves against that; submitting from an SSM shell (`/var/…`) sent the log where Step 3 couldn't find it (reproduced live). - Step 3 replaces the leading-wildcard `Name=*login` login filter and stale `ssm:resourceTag/Name = PCS-login*` IAM callout with the cluster-scoped PCS-API resolver (matches README §6 / #1183 pattern). - New "Verify GPU visibility from the notebook" cell prints CUDA_VISIBLE_DEVICES / SLURM_JOB_GPUS, enumerates PyTorch device properties, runs a matmul. Verified live on g6.12xlarge: device_count()=1, NVIDIA L4 22 GiB SM 8.9, matmul completes. - New "Running without a token (single-user clusters only)" section documents the `--ServerApp.token='' --ServerApp.password=''` form with a prominent DO-NOT-USE-ON-MULTI-USER callout — Jupyter binds to the compute node's private IP, so the token is what separates users on multi-user clusters. ## tests/multi-user-test.md — regression coverage for the above - B5a: duplicate uidNumber / username rejected by ldap-add-user.sh (guards commit 6f3b6ad). - B7: password reset via `ldappasswd -W -S` interactive prompt. - B8: group create + `add: memberUid` via ldapadd/ldapmodify -W. - B9: end-to-end SSH-over-SSM as an LDAP user with PCS-API instance lookup, then `srun` on a compute node. - Part F: reproduces the blog scenario (project accounts, per-user cap, --account=, safe-enforcement PD, `sacct`/`sreport` recipes). ## Verified Doc-only re-run on a fresh Tokyo (ap-northeast-1) `pcs-ml-cluster` stack deployed from the public bucket with `DirectoryService=OpenLDAP-LoginNode`, `ManagedAccounting=enabled`, `MonitoringStack=Prometheus-LoginNode`. Every fenced block above executed as written (§6 login lookup, §7 srun container, §8.2 Grafana password + Option A monitoring-role lookup; USER-MANAGEMENT §2/§3/§4/§5; JUPYTER Step 1/2/3 auth gates, 403/200/200, token file mode 600). GPU verify cell exercised on a separate g6.12xlarge cluster. * docs(aws-pcs): tighten IAM.md — collapse duplicate tables, trim redundancy Two rounds of tightening; content preserved, ~30 lines of overlap removed. - Merge the header "roles" table and the "Deploying" stacks table: they keyed the same two rows to different columns (What can do / Creates, Template link / Launch button). Fold into one "Role · Can do · Deploy" table where each row carries both the YAML link and the Launch button, and inline "broad — do not hand out" / "safe to hand out widely" callouts absorb the standalone splitting-the-roles paragraph. - §Deploying the policies: shorten the AttachUsers / AttachImageBuilderPolicy paragraph — the details are already in the CLI example above and the template's parameter descriptions. - §What the cluster user can do: drop the second CLUSTER_ID resolve (already resolved once at the top of the block). - §Considerations "Login-node access is scoped": trim implementation detail about the Name-tag emission chain (readers can check the template); keep the exact-match scope and the operator-mutable caveat. - Merge "admin policy split" and "no AmazonPCSFullAccess" bullets — same message (why the customer-managed policies look the way they do). - §Verifying: shorten the private-bucket callout to one sentence. * docs(aws-pcs): JUPYTER.md — decouple torch install from GPU-work framing The torch install in Step 1 is prerequisite for the GPU-visibility verify cell below, not for running Jupyter on a GPU queue (the queue picks the queue; the kernel doesn't need torch to launch). Reframe: - Step 1: "For GPU work, also install PyTorch now" → "To run the GPU-visibility verify cell below, also install PyTorch". - Step 2: drop the "For GPU work, the venv already has torch" note (misleading — a GPU job doesn't require torch). Keep the pointer to Using GPUs for allocation behaviour. * docs(aws-pcs): tighten USER-MANAGEMENT.md — drop duplicated sections Post-review pass. Content preserved, ~90 lines of redundancy removed. - §5.3 "Invalid credentials from ldap*": deleted — it was a one-liner redirecting to §2.1. Folded the redirect into §2.1's admin-password fallback callout so a reader who mistypes at a -W prompt still sees the recovery path in situ. - §5.4/§5.5 renumbered to §5.3/§5.4. - §6 "How it works": collapsed the ASCII diagram plus the separate "tag-based discovery" sub-section into one bullet list plus a login-node-replacement recovery snippet. Roughly a third of the previous size, same operational information. - §8 "UID / GID conventions": absorbed into §3.1 (Add a user), which was already pointing at it for the allocation policy; the table now sits next to the invocation it informs. - §9 "Template structure": deleted — its ASCII diagram restated §1 "modular deployment" (which already documents DirectoryRole / IamProfileArn per CNG type). - §10 "Upgrading to AWS Simple AD (future)": deleted — placeholder, no operational content, still tracked in ROADMAP.md. * docs(aws-pcs): tighten JUPYTER.md Using-GPUs section, drop Notes - Using GPUs: put "How the GPU allocation behaves" before the verify cell (allocation is the frame, verify is the check). Collapse the bullets: --gres is the only knob (merges the old "Slurm enforces" and "Multi-GPU works" points), drop scontrol-example detail, drop the p5.48xlarge measurement note, keep containerized-kernel pointer as one line. - Notes section removed. The Multi-user/token bullet duplicated the Step 2 comment on the token being the user boundary; the Multiple-clusters bullet was stale (the Step 3 lookup no longer uses Name=*login); the SSH-tunnel alternative and /fsx-execve caveat are out of the load-bearing flow. "Do not run Jupyter on the login node" moved up into Prerequisites where readers will hit it before submitting. * docs(aws-pcs): tighten IAM.md — one column for launch, drop CLI mirror + verify section Post-review pass, ~80 more lines out. - Table: collapse "Template" and "Launch" into one Launch column; the Launch button already resolves to the same YAML, listing the file path next to it was redundant. Move "Can do" into the Role cell so the table stays two-column. - §Deploying the policies (CLI example) removed. The Launch buttons in the table cover the deploy path; the parameter-choice notes (AttachUsers, AttachImageBuilderPolicy, ClusterStackName) fit in one paragraph under the table. - §What the cluster user can do (~25-line CLI example) removed. The same session / port-forward / Grafana-password recipes live in README §6 / §8.2, and this doc's job is the policies, not a copy of the user runbook. - §Considerations: drop the "Combined CRUD is intentional" paragraph (internal design detail; only matters when tweaking the admin policy, in which case reading the template beats reading prose). - §Refining to least-privilege via CloudTrail removed. It's an advanced follow-on with its own tooling, not a policy-reference concern. - §Verifying the policies removed. The one-liner pointer to iam-test.md was thin, and the private-bucket note gets absorbed into §Considerations. - §Not covered by these policies: fold into §Considerations as a short bullet list. * docs(aws-pcs): trim IAM.md Considerations to what needs reader action - Drop "Login-node access is scoped to one stack": the exact-match ssm:resourceTag condition + operator-mutable Name caveat is already documented in cluster-user-iam.yaml's Description and inline comments where a reader tightening the policy will actually read it. - Drop "The admin policy is split into core + Image Builder": internal reason (IAM 6,144-char limit) doesn't lead to a reader action — AttachImageBuilderPolicy handling is already noted under the table. - Drop "Private template bucket": edge case for private-bucket hosting; those users hit the missing s3:GetObject themselves and it's unrelated to reference-policy design. Keeps: sample-grade disclaimer + AWS reference link, AWS-managed pairing hints (with the AmazonEC2FullAccess warning), and the list of roles this template doesn't cover. * docs(aws-pcs): add PCS-Ready DLAMI version history Document every published x86_64 PCS-Ready DLAMI build with its PCS Agent, Slurm, base DLAMI, driver/CUDA, DCGM, EFA and containerd versions, so readers can pick a build (and know whether node lifecycle actions, which need agent 1.5.0-1, are available). * docs(aws-pcs): note enforcement-required accounting registration in README §7 Adding a short pointer so the srun example in §7 does not silently fail on clusters deployed with AccountingPolicyEnforcement set — the submitter (including ubuntu) has to be registered in sacctmgr first. Details stay in USER-MANAGEMENT.md §4. * docs(aws-pcs): address PR #1222 review feedback (blocking + should-fix + nit) USER-MANAGEMENT.md: - §3 note on Slurm binary path swap when SlurmVersion=25.05 - §4.1 sacctmgr uses $S alias to absolute path (secure_path bypass) - §4.2 hold demo now creates myjob.sh/smalljob.sh, adds --partition, tightens the cap so 4-task job trips AssocGrpCPURunMinutesLimit - §4.2 use --ntasks-per-node=4 to match sinfo-reported CPUs on cpu1 - §4 accounting-enforcement wording: templates offer ,safe only - sreport end date off-by-one (2026-04-30 → 2026-05-01, ...-03-31 → -04-01) - README anchor #quick-start → #3-quick-start multi-user-test.md: - Part B: reorder — new B7 group add uses testuser2 still present, B9 deletes testuser2 at the end - Part B8 (was B9) restructures SSH-over-SSM into three explicit steps (laptop keygen → login-node authorized_keys append → laptop ssh) - Part F1 use sudo $S with absolute path, add carol UID 10004 - Part F2 batch script gets a shebang (printf '#!/bin/bash\n...\n') - Part F3 --ntasks-per-node=4 matches sinfo CPUs on cpu1 - Part F3 anchor #41-worked-example... → #41-register-users-to-accounts - Part F WithAssoc on F1 sacctmgr show; sreport end=now JUPYTER.md: - Step 1 pin PyTorch cu130 (matches DLAMI's CUDA 13.2 stack) - Tokenless mode drop --ServerApp.disable_check_xsrf=True; note Step 3 sub-step 2 is skipped and the banner heredoc's token line removed - Multi-user warning reworded to trusted-same-principal formulation - Verify GPU cell no longer flags all-visible nvidia-smi as a bug IAM.md: - Cluster-user row narrows claim: no AWS-API mutations, no compute-node sessions; login-node SSM shell is ssm-user with passwordless sudo - Restore Name-tag operator-mutable caveat on the scoping description PCS-READY-DLAMI.md: - Reframe intro as a dated snapshot (as of 2026-08-05); drop the "describe-images below" reference (command was never added) assets/scripts/ldap-add-user.sh: - Duplicate-detection extended: getent passwd for username and UID (catches local /etc/passwd shadowing, e.g. `ubuntu` UID 1000), plus hard-reject UID outside the 10001-59999 LDAP-user range Verified end-to-end via pcs-ml-cluster-deploy-all.yaml on a fresh Tokyo cluster (DirectoryService=OpenLDAP-LoginNode + ManagedAccounting= enabled + AccountingPolicyEnforcement=associations,limits,safe): §2.1 password recovery, §2.2 add first user (alice), §4.1 register users, §4.2 hold demo (limit trips), Part F1/F2/F3 all pass verbatim.
What
Two coupled changes so multi-cluster deployments in one account/VPC stop
colliding at the tag layer.
1. Cluster-scoped Name tag on every CNG instance
PCS-${CngName}→${ClusterName}-${CngName}in all four hand-maintainedCNG UserData templates (add-cng, add-cng-p5, add-cng-p6-b200,
add-cng-p6-b300). Two deploy-all stacks in the same VPC no longer both
show
PCS-login/PCS-cpu1in the EC2 console; each is prefixed bythe stack name.
2. Docs derive
LOGIN_INSTANCE_IDfrom the stack — by purposeCNG): resolve via the PCS API —
CFN Outputs.ClusterId→pcs list-compute-node-groups (name==login)→
ec2 describe-instances by aws:pcs:compute-node-group-id.Tag-naming conventions no longer matter.
tag:monitoring-role=login+tag:aws:pcs:cluster-id=<the cluster>.Same instance today but a different intent — the tag is what the
monitoring stack itself uses to identify its host.
Every code block is self-contained so it can be copy-pasted as one unit
from GitHub — no cross-block variable threading.
Ancillary changes (in support of the above)
cluster-user-iam.yamlSSM scope updated in lock-step with (1) —ssm:resourceTag/Name: PCS-login*→*-login, so cluster-user'slogin-only SSM scope still resolves under the new Name tag. Same
intent, adjusted for the new naming.
tests/lint-docs.shbanned patterns to prevent regressionof (2) —
aws:pcs:compute-node-group-name(a tag PCS never emits;a doc filter using it returns zero rows silently) and
Name=tag:Name,Values=PCS-(the old flat naming, superseded).tests/readme-walkthrough-test.md(Test 15) — a pre-merge testthat follows README §3 → §6 → §8.2 as written; every command must
return a real value, Grafana must load and accept admin +
SSM-retrieved password. Runs on every PR touching those sections or
the tags/policies they depend on. Formalises the walkthrough
performed for this PR's Testing section so the same class of
copy-paste-first-step regression can't slip through again.
Testing (Test 15 executed on this PR's tree)
Deployed a fresh
pcs-walkthroughcluster in us-east-2 from the modifiedtemplates. Every README code block executed verbatim as one paste:
i-06f3486a5eefaf3b0pcs-walkthrough-login(wasPCS-login)monitoring-role=login+aws:pcs:cluster-idlookupreturned the same instance
localhost:8443→/grafana/= 302,/grafana/api/health= 200 with{"database":"ok","version":"13.0.2"}adminuser JSONSummary, Slurm Detail, GPU Node List, GPU Health, Cluster Costs,
Storage, …)
Also passes locally:
bash tests/lint-docs.shaws cloudformation validate-templateon all four CNG templates pluscluster-user-iam.yaml