Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Dependabot: keep the pinned Python dependency and the CI actions patched.
# Required by the Wherobots Labs charter (automated dependency scanning).
version: 2
updates:
- package-ecosystem: "pip"
directory: "/aws-step-functions-python-sdk"
schedule:
interval: "weekly"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
35 changes: 29 additions & 6 deletions aws-step-functions-python-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,9 @@ cd labs-reference-implementations/aws-step-functions-python-sdk
```

Use a virtual environment so the Wherobots SDK doesn't pollute your system
Python. `requirements.txt` holds the only local dependency
(`wherobots-python-sdk`, used by the upload step — the dashboard server is
stdlib-only, and the Lambdas install the SDK themselves from PyPI):
Python. `requirements.txt` holds the only dependency, pinned
(`wherobots-python-sdk`, used by the upload step and vendored into the
Lambda zips at deploy time — the dashboard server is stdlib-only):

```bash
python3 -m venv .venv
Expand Down Expand Up @@ -143,7 +143,7 @@ python3 scripts/01_upload_job.py # upload job/hello_wherobots_job.py, save s3:
3. The job runs `try: work; post success` / `except: post failure(traceback)`,
with a daemon thread posting heartbeats every 60 s. `finally` only stops the
heartbeat thread — never posts results.
4. **Happy path**: the success callback (`{building_count: 1084}`) becomes the
4. **Happy path**: the success callback (`{building_count: 1080}`) becomes the
state output and **NextStep** runs immediately. **Soft failure**: the failure
callback fails the task with the traceback as cause → `JobFailed`.
**Hard death (OOM)**: heartbeats stop → `States.Timeout` after
Expand Down Expand Up @@ -182,12 +182,35 @@ python3 scripts/01_upload_job.py # upload job/hello_wherobots_job.py, save s3:
| `scripts/` | Numbered lifecycle scripts (the dashboard runs these) |
| `dashboard/` | Local control-plane server + guided UI |

## Security model (read before exposing anything)

- **The API key never sits in a Lambda env var.** The deploy stores it in
**Secrets Manager**; the Lambdas fetch it at cold start under a role that can
read exactly that one secret. Reading a function's configuration shows only
the secret's ARN.
- **Dependencies are pinned and vendored.** `requirements.txt` pins
`wherobots-python-sdk`, and the deploy vendors it into the Lambda zips —
no network installs at cold start, every instance runs the same code.
- **The callback relay is public by design, throttled, and capability-gated.**
The unguessable single-use task token is the auth; Step Functions rejects
anything else. The token travels as a plain job argument, so anyone who can
read that run's arguments in your Wherobots org (or your Step Functions
execution history) could post a forged callback for that one run — both
surfaces are already inside your trust boundary. The relay caps payload
size, returns generic errors (details go to CloudWatch), and the stage is
throttled (burst 10 / rate 5). For production add an authorizer or WAF.
- **Callback output is treated as untrusted.** The dashboard HTML-escapes
every server-sourced value before rendering it.
- **The dashboard only answers its own page.** Requests must carry a loopback
`Host` and (when present) a same-origin `Origin`; lifecycle scripts run only
via `POST` + `application/json`, so no other website can trigger a deploy,
teardown, or job run while the dashboard is up.

## Production notes (deliberately out of scope here)

- Bake the SDK into a **Lambda layer** (`pip install wherobots-python-sdk -t python/ && zip -r layer.zip python`) instead of the cold-start pip install.
- Put `WHEROBOTS_API_KEY` in **Secrets Manager**, not a Lambda env var.
- Use IaC (Terraform / CDK / SAM) instead of CLI scripts.
- Add `Catch` + alerting states around Submit and Poll.
- Put an authorizer (API key / IAM) or WAF in front of the callback route.

## Cost

Expand Down
93 changes: 68 additions & 25 deletions aws-step-functions-python-sdk/dashboard/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans+Condensed:wght@500;600;700&family=IBM+Plex+Serif:ital,wght@0,400;0,500;1,400&display=swap">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css"
integrity="sha384-wH75j6z1lH97ZOpMOInqhgKzFkAInZPPSPlZpYKYTOqsaizPvhQZmAtLcPKXpLyH" crossorigin="anonymous">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"
integrity="sha384-F/bZzf7p3Joyp5psL90p/p89AZJsndkSoGwRpXcZhleCWhd8SnRuoYo4d0yirjJp" crossorigin="anonymous"></script>
<style>
:root {
--ground: #F4F6F7;
Expand Down Expand Up @@ -454,15 +456,22 @@ <h1>Wherobots Jobs from Step Functions</h1>

<footer>
<div>Wherobots reference implementation · Step Functions callback pattern with poller fallback</div>
<div>Job: Overture buildings within 1 km of downtown Seattle (geodesic, EPSG:4326) · expected result 1,084</div>
<div>Job: Overture buildings within 1 km of downtown Seattle (geodesic, EPSG:4326) · expected ~1,080 (the exact count tracks the current Overture release)</div>
</footer>
</div>

<script>
const $ = id => document.getElementById(id);
let armedTeardown = false;

function pill(cls, text) { return `<span class="pill ${cls}">${text}</span>`; }
// Every server-sourced value is HTML-escaped before interpolation into
// innerHTML. The success callback's output comes from the public relay, so
// without this, {"x":"<img src=x onerror=...>"} in a job's callback would
// execute in this page — which can call deploy/teardown.
const esc = v => String(v ?? "").replace(/[&<>"']/g,
c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));

function pill(cls, text) { return `<span class="pill ${cls}">${esc(text)}</span>`; }

// Gating greys only the section's action button; the section itself stays
// readable. The disabled button's tooltip names what is blocking it.
Expand All @@ -489,7 +498,7 @@ <h1>Wherobots Jobs from Step Functions</h1>
<tr><td>WHEROBOTS_API_KEY</td><td>${pill(p.WHEROBOTS_API_KEY ? "ok" : "crit", p.WHEROBOTS_API_KEY ? "set (" + p.WHEROBOTS_API_KEY + ")" : "missing")}</td></tr>
<tr><td>AWS credentials</td><td>${pill("info", p.aws_credentials)}</td></tr>
<tr><td>Upload target</td><td>${pill("info", p.storage_target)}</td></tr>
<tr><td>Uploaded script</td><td>${p.uploaded ? pill("ok", "yes") + ` <span style="color:var(--ink-mute)">${p.uploaded}</span>` : pill("warn", "not yet")}</td></tr>
<tr><td>Uploaded script</td><td>${p.uploaded ? pill("ok", "yes") + ` <span style="color:var(--ink-mute)">${esc(p.uploaded)}</span>` : pill("warn", "not yet")}</td></tr>
<tr><td>Infrastructure</td><td>${p.deployed ? pill("ok", "deployed") : pill("warn", "not yet")}</td></tr>`;
$("prov-region").textContent = p.AWS_REGION;
$("prov-target").textContent = p.storage_target;
Expand All @@ -498,33 +507,66 @@ <h1>Wherobots Jobs from Step Functions</h1>

const ready = p.env_file && p.WHEROBOTS_API_KEY;
setLocked("card-upload", !ready);
if (p.uploaded) { markDone("card-upload"); $("upload-status").innerHTML = `<b class="ok">✓ uploaded:</b> ${p.uploaded}`; }
if (p.uploaded) { markDone("card-upload"); $("upload-status").innerHTML = `<b class="ok">✓ uploaded:</b> ${esc(p.uploaded)}`; }
setLocked("card-deploy", !p.uploaded);
if (p.deployed) { markDone("card-deploy"); $("deploy-status").innerHTML = `<b class="ok">✓ deployed</b>`; }
setLocked("card-run", !p.deployed);
setLocked("card-teardown", !p.deployed);
}

// ── lifecycle scripts over SSE ───────────────────────────────────────────────
function runScript(name, btnId, termId, statusId, doneMsg) {
// ── lifecycle scripts, streamed over a POST ──────────────────────────────────
// POST + JSON (not GET/EventSource): running a lifecycle script changes
// state, and a GET was triggerable cross-origin by a plain <img> tag. The
// response body is still SSE-format lines; this parses them from the fetch
// stream.
async function runScript(name, btnId, termId, statusId, doneMsg) {
const btn = $(btnId), term = $(termId), status = $(statusId);
btn.disabled = true; term.classList.add("open"); term.textContent = "";
status.innerHTML = `<span class="spin"></span> running…`;
const es = new EventSource(`/api/exec?script=${name}`);
es.onmessage = e => {
const addLine = text => {
const line = document.createElement("div");
if (e.data.startsWith("▶")) line.className = "cmd";
line.textContent = e.data;
if (text.startsWith("▶")) line.className = "cmd";
line.textContent = text;
term.appendChild(line); term.scrollTop = term.scrollHeight;
};
es.addEventListener("done", e => {
es.close(); btn.disabled = false;
const code = JSON.parse(e.data).exit_code;
const finish = code => {
btn.disabled = false;
status.innerHTML = code === 0 ? `<b class="ok">✓ ${doneMsg}</b>`
: `<b class="err">✗ exited with code ${code} — see the streamed output below</b>`;
: `<b class="err">✗ exited with code ${esc(code)} — see the streamed output below</b>`;
preflight();
});
es.onerror = () => { es.close(); btn.disabled = false; status.innerHTML = `<b class="err">✗ connection lost</b>`; };
};
try {
const resp = await fetch("/api/exec", {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ script: name }),
});
if (!resp.ok || !resp.body) {
const err = await resp.json().catch(() => ({}));
btn.disabled = false;
status.innerHTML = `<b class="err">✗ ${esc(err.error || `HTTP ${resp.status}`)}</b>`;
return;
}
const reader = resp.body.pipeThrough(new TextDecoderStream()).getReader();
let buf = "", exitCode = null;
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buf += value;
let idx;
while ((idx = buf.indexOf("\n\n")) >= 0) { // one SSE event per blank line
const block = buf.slice(0, idx); buf = buf.slice(idx + 2);
const isDone = /^event: done$/m.test(block);
const data = block.split("\n").filter(l => l.startsWith("data: "))
.map(l => l.slice(6)).join("\n");
if (isDone) exitCode = JSON.parse(data).exit_code;
else addLine(data);
}
}
finish(exitCode ?? 1);
} catch (e) {
btn.disabled = false;
status.innerHTML = `<b class="err">✗ connection lost</b>`;
}
}
$("btn-upload").onclick = () => runScript("upload", "btn-upload", "term-upload", "upload-status", "uploaded");
$("btn-deploy").onclick = () => runScript("deploy", "btn-deploy", "term-deploy", "deploy-status", "infrastructure ready — green to run");
Expand Down Expand Up @@ -558,7 +600,8 @@ <h1>Wherobots Jobs from Step Functions</h1>
const MODE_NOTES = {
normal: `<span class="lbl">What a normal run shows</span>
The job counts Overture buildings, then posts a <b>success callback</b> — the paused state
wakes instantly and NextStep receives <code>{"building_count": 1084}</code>. The fallback
wakes instantly and NextStep receives <code>{"building_count": 1080}</code> (the exact
count tracks the current Overture release). The fallback
stages never run: the timeline stays one teal group ending in green, and that absence is
the point. Typically 1–3 minutes end to end — the slowest job start observed in testing was 52 s.`,
soft: `<span class="lbl">What the soft-failure test shows</span>
Expand Down Expand Up @@ -586,10 +629,10 @@ <h1>Wherobots Jobs from Step Functions</h1>
body: JSON.stringify({ force_fail: mode === "soft", hard_fail: mode === "hard" }),
});
const r = await resp.json();
if (r.error) { $("run-status").innerHTML = `<b class="err">✗ ${r.error}</b>`; $("btn-run").disabled = false; return; }
if (r.error) { $("run-status").innerHTML = `<b class="err">✗ ${esc(r.error)}</b>`; $("btn-run").disabled = false; return; }
$("graph-card").style.display = "block";
$("gantt-card").style.display = "block";
$("run-meta").innerHTML = `job ${r.job_name} · <a href="${r.console_url}" target="_blank">AWS console</a>` +
$("run-meta").innerHTML = `job ${esc(r.job_name)} · <a href="${esc(r.console_url)}" target="_blank" rel="noopener">AWS console</a>` +
` · <a href="https://cloud.wherobots.com/workloads" target="_blank">Wherobots Workload History</a>`;
$("run-status").innerHTML = `<span class="spin"></span> running…`;
lastAutoStage = null;
Expand Down Expand Up @@ -713,7 +756,7 @@ <h1>Wherobots Jobs from Step Functions</h1>

async function poll(arn) {
const d = await (await fetch(`/api/execution?arn=${encodeURIComponent(arn)}`)).json();
if (d.error) { $("run-status").innerHTML = `<b class="err">poll error: ${d.error}</b>`; return; }
if (d.error) { $("run-status").innerHTML = `<b class="err">poll error: ${esc(d.error)}</b>`; return; }
updateGraph(d);
renderTimeline(d);
syncLiveFile(d.stages);
Expand All @@ -722,15 +765,15 @@ <h1>Wherobots Jobs from Step Functions</h1>
if (d.run_id || d.poller) {
wb.style.display = "block";
const p = d.poller || {};
wb.innerHTML = `<span class="lbl">Fallback engaged</span>Wherobots run <code>${d.run_id || "…"}</code>` +
` · status <b>${p.wherobots_status || "…"}</b> · poller checks so far: ${p.checks ?? 0}`;
wb.innerHTML = `<span class="lbl">Fallback engaged</span>Wherobots run <code>${esc(d.run_id || "…")}</code>` +
` · status <b>${esc(p.wherobots_status || "…")}</b> · poller checks so far: ${esc(p.checks ?? 0)}`;
}
const terminal = ["SUCCEEDED", "FAILED", "TIMED_OUT", "ABORTED"].includes(d.status);
if (terminal) {
clearInterval(pollTimer); $("btn-run").disabled = false;
document.querySelectorAll("#card-run ol.files li").forEach(li => li.classList.remove("live"));
$("run-status").innerHTML = d.status === "SUCCEEDED"
? `<b class="ok">✓ pipeline SUCCEEDED — NextStep received: </b><code>${JSON.stringify(d.output && d.output.callback || d.output)}</code>`
? `<b class="ok">✓ pipeline SUCCEEDED — NextStep received: </b><code>${esc(JSON.stringify(d.output && d.output.callback || d.output))}</code>`
: `<b class="err">✗ pipeline ${d.status} — open the AWS console link for the cause (the job's traceback on a failure callback)</b>`;
}
}
Expand Down
Loading
Loading