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
71 changes: 56 additions & 15 deletions docs/integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,22 +49,63 @@ it does write: they are byte-for-byte what `spored` (and `spawn list`) expect, e
for behaviors spawn-ts can't run itself. The docs are careful to say which tier a
feature is in so nothing over-promises.

**Wire-compatible is not yet wire-complete.** Go's `buildLaunchTags`
(`spawn/pkg/aws/tags.go:32`) stamps 55 tags at launch; spawn-ts stamps 33. The
absent ones are not all tier D — the base-identity block is tier **B**, and its
absence has real consumers:

| absent tag | tier | consequence |
### Wire-compatible, and what that does *not* cover

"Wire-compatible" is a claim about the tags spawn-ts writes, so it's only as
useful as the list of tags it doesn't. `buildTags`
(`spawn/pkg/aws/tags.go:32`) can write 54 distinct keys; `buildLaunchTags`
(`src/core/tags.ts`) writes 43 — 13 of Go's absent, 2 of spawn-ts's own not in
Go. Every difference below is classified in the same A–E tiers, because "absent"
alone doesn't say whether it's a gap or a decision.

Since [#51](https://github.com/spore-host/spawn-ts/issues/51), the **base-identity
block is stamped**: `managed`, `root`, `created-by`, `version`, `account-id`,
`account-base36`, `iam-user`, `account-name`, plus `os` and `local-username`. That
block is what makes an instance *ownable* — the portal filters its list, its
single-instance lookup and its terminate on `spawn:iam-user`
(`lambda/dashboard-api/instances.go:60`/`:168`/`:285`, the last 403ing on a
mismatch), while `spawn list` filters on `spawn:managed` alone. An instance
missing it was therefore visible in the CLI yet invisible *and unterminatable*
in the portal, so `EC2Provider` now **refuses to launch** when it can't resolve
the identity rather than omitting the tag: an orphaned billable instance is worse
than a failed launch.

What Go writes and spawn-ts still does not:

| absent tag | tier | why |
|---|---|---|
| `spawn:iam-user` | **B** | the portal can neither list nor terminate a spawn-ts instance (`lambda/dashboard-api/instances.go:285` → 403); `cleanup --only-mine` skips it |
| `spawn:account-base36` | **B** | `spored`'s notifier can't build the instance FQDN |
| `spawn:os`, `spawn:local-username` | **B** | `spawn connect` can't infer the SSH user |
| `spawn:version` | **B** | AMI management can't tell which launcher wrote the instance |
| `spawn:active-ports` | **C** | `spored` writes this one itself; not a launcher gap |
| FSx / EFS / DCV tags | **D** | the provisioning they describe isn't browser-reachable |

Tracked in [#51](https://github.com/spore-host/spawn-ts/issues/51); the full
50-command tier matrix is [#57](https://github.com/spore-host/spawn-ts/issues/57).
| `spawn:fsx-*` (7 keys), `spawn:efs-id`, `spawn:efs-mount-point` | **D** | describe filesystem provisioning + on-node mounts the browser can't perform |
| `spawn:dcv-session-id`, `spawn:app-name` | **D** | DCV streaming sessions; no browser path to create one |
| `spawn:command` | **B** | observability only — spawn-ts already delivers the command via user-data (`src/aws/userdata.ts`), which is the load-bearing half. If added it must keep Go's `len(cmd) <= 256` guard, or `RunInstances` fails outright (spawn#214/#246) |
| `spawn:slack-workspace-id` | **B** | only meaningful once the notify path carries a workspace binding |
| `spawn:job-array-created` | **E** | deliberate: non-deterministic, no reader exists, and `spawn:launch-time` already records it (`src/core/tags.ts`) |

And two spawn-ts writes that Go's launcher doesn't, both read by Go:

| extra tag | why it's correct |
|---|---|
| `spawn:compute-seconds` | seeded at `0` so cost accounting has a defined starting point. Go leaves it absent and `spored` creates it (`pkg/agent/agent.go:404`); `pkg/provider/ec2.go:486` reads it either way |
| `spawn:idle-cpu` | Go's launcher never writes it although `pkg/provider/ec2.go:508` decodes it — so a Go-launched instance's `--idle-cpu` threshold is unreachable by the reader that wants it. spawn-ts writing it is the fix, not the divergence |

One more difference is deliberate and visible: `spawn:created-by` is
`"spawn-ts"`, not Go's `"spawn"`. No reader compares the value, and an operator
benefits from knowing which launcher produced an instance.

The full 50-command tier matrix is
[#57](https://github.com/spore-host/spawn-ts/issues/57).

### The tag budget is a real constraint

AWS caps a resource at **50 tags**, and exceeding it fails `RunInstances`
outright — it doesn't truncate. A fully configured non-sweep launch already
stamps 39, so the per-member `spawn:param:*` tags of a sweep cannot be a fixed
allowance. Go uses a flat 35 with a comment claiming it stays "under AWS 50-tag
limit" (`pkg/aws/tags.go:247`), which doesn't hold: 35 params on a maximal launch
reaches ~73 tags. spawn-ts computes the remaining budget instead
(`AWS_TAG_LIMIT` minus what the launch has already consumed) and drops the
surplus in sorted key order, so the surviving subset is deterministic. Dropping
parameters is itself lossy — they're how a sweep member records which point in
the space it *is* — but a truncated tag set beats a launch that fails.

## Why the catalog is offline (and where live data goes)

Expand Down
78 changes: 78 additions & 0 deletions src/aws/ec2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,22 @@ afterEach(() => {
vi.restoreAllMocks();
});

/** The identity a caller would hand in (federated path) — no STS call needed. */
const testIdentity = {
accountId: "123456789012",
userArn: "arn:aws:sts::123456789012:assumed-role/spore-portal/alice",
};

function provider(overrides: Partial<ConstructorParameters<typeof EC2Provider>[0]> = {}) {
return new EC2Provider({
region: "us-east-1",
accessKeyId: "AKIA_TEST",
secretAccessKey: "secret",
endpoint: "http://localhost:4566",
// Supplied so these tests exercise the launch, not GetCallerIdentity. The
// refuse-to-launch path when it's absent and STS fails is covered separately
// below — that behaviour is deliberate, not incidental.
identity: testIdentity,
...overrides,
});
}
Expand Down Expand Up @@ -116,6 +126,18 @@ describe("EC2Provider.launch", () => {
expect(byKey[tag("managed")]).toBe("true");
expect(byKey[tag("ttl")]).toBe("4h");

// Base identity. spawn:iam-user is the load-bearing one: the portal filters
// its instance list on it, and terminate 403s without a match — so an
// instance launched without it is visible in `spawn list` yet unterminatable
// from the portal.
expect(byKey[tag("iam-user")]).toBe(testIdentity.userArn);
expect(byKey[tag("account-id")]).toBe("123456789012");
expect(byKey[tag("account-base36")]).toBe("1kpqzg2c"); // base36(123456789012)
expect(byKey[tag("created-by")]).toBe("spawn-ts"); // not "spawn" — provenance
expect(byKey[tag("root")]).toBe("true");
expect(byKey[tag("os")]).toBe("linux");
expect(byKey[tag("local-username")]).toBe("ec2-user");

// Returned instance trusts the tags we sent, decoding TTL deadline from them.
expect(inst.instanceId).toBe("i-abc");
expect(inst.name).toBe("job");
Expand Down Expand Up @@ -151,6 +173,62 @@ describe("EC2Provider.launch", () => {
await expect(provider().launch(baseSpec, T0)).rejects.toThrow(/no instance/);
});

it("REFUSES to launch when the identity can't be resolved", async () => {
// The tempting alternative — omit spawn:iam-user and launch anyway — produces
// an instance that is invisible in the portal's list, 403s on terminate there
// (lambda/dashboard-api/instances.go:285), and bills the whole time. An
// orphaned billable instance is strictly worse than a failed launch, so the
// error must be loud rather than a quietly missing tag.
const { STSClient } = await import("@aws-sdk/client-sts");
vi.spyOn(STSClient.prototype, "send").mockRejectedValue(new Error("access denied"));
const p = provider({ identity: undefined });
await expect(p.launch(baseSpec, T0)).rejects.toThrow(/cannot determine the launching identity/);
// And nothing was launched — it fails BEFORE RunInstances, not after.
expect(sent.filter((c) => c instanceof RunInstancesCommand)).toHaveLength(0);
});

it("REFUSES to launch when GetCallerIdentity returns no Arn", async () => {
// A 200 with empty fields is the sneakier case: it isn't an error, so a
// `try/catch` alone would sail past it and stamp `undefined`.
const { STSClient } = await import("@aws-sdk/client-sts");
vi.spyOn(STSClient.prototype, "send").mockResolvedValue({ Account: "1234" } as never);
const p = provider({ identity: undefined });
await expect(p.launch(baseSpec, T0)).rejects.toThrow(/no Account\/Arn/);
expect(sent.filter((c) => c instanceof RunInstancesCommand)).toHaveLength(0);
});

it("resolves the identity via STS once and reuses it across launches", async () => {
handler = () => ({ Instances: [{ InstanceId: "i-1", State: { Name: "pending" } }] });
const { STSClient } = await import("@aws-sdk/client-sts");
const stsSend = vi
.spyOn(STSClient.prototype, "send")
.mockResolvedValue({ Account: "210987654321", Arn: "arn:aws:iam::210987654321:user/bob" } as never);

const p = provider({ identity: undefined });
await p.launch(baseSpec, T0);
await p.launch(baseSpec, T0);

// Credentials can't change under a provider, so one call serves both launches.
expect(stsSend).toHaveBeenCalledTimes(1);
const tags = lastOf(RunInstancesCommand).input.TagSpecifications![0].Tags!;
const byKey = Object.fromEntries(tags.map((t: any) => [t.Key, t.Value]));
expect(byKey[tag("iam-user")]).toBe("arn:aws:iam::210987654321:user/bob");
});

it("records the local username actually created on the box", async () => {
// These must agree or `spawn connect` SSHes to a user that doesn't exist:
// Go prefers spawn:local-username and falls back to ec2-user
// (cmd/connect.go:135), so a custom username with no tag sends it to ec2-user.
handler = () => ({ Instances: [{ InstanceId: "i-1", State: { Name: "pending" } }] });
await provider({ username: "researcher" }).launch(baseSpec, T0);
const tags = lastOf(RunInstancesCommand).input.TagSpecifications![0].Tags!;
const byKey = Object.fromEntries(tags.map((t: any) => [t.Key, t.Value]));
expect(byKey[tag("local-username")]).toBe("researcher");
// The same value reached user-data, not just the tag.
const userData = Buffer.from(lastOf(RunInstancesCommand).input.UserData!, "base64").toString();
expect(userData).toContain("researcher");
});

it("attaches the IAM instance profile (name or ARN) when configured", async () => {
handler = () => ({ Instances: [{ InstanceId: "i-1", State: { Name: "pending" } }] });

Expand Down
70 changes: 67 additions & 3 deletions src/aws/ec2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import type {
ManagedInstance,
} from "../core/types.js";
import { validateDeclarations } from "../core/plugins.js";
import { buildLaunchTags, decodeConfigTags, decodeSweepTags, decodeJobArrayTags, decodeHookTags, isManaged, tag } from "../core/tags.js";
import { buildLaunchTags, decodeConfigTags, decodeSweepTags, decodeJobArrayTags, decodeHookTags, isManaged, tag, type LaunchIdentity } from "../core/tags.js";
import { buildLinuxBootstrap, encodeUserData } from "./userdata.js";

export interface EC2ProviderOptions {
Expand All @@ -53,13 +53,22 @@ export interface EC2ProviderOptions {
* bootstrap relies on the SHA256 checksum only. See userdata.ts.
*/
sporedSigningPublicKey?: string;
/**
* Who is launching, for the spawn:* base-identity tags. Supply it when the
* caller already knows (the federated BYOA path gets
* `AssumedRoleUser.Arn` + the account id straight back from
* AssumeRoleWithWebIdentity, so no extra call is needed); otherwise the
* provider resolves it once via GetCallerIdentity on first launch.
*/
identity?: LaunchIdentity;
}

export class EC2Provider implements Provider {
readonly label: string;
readonly isReal: boolean;
private client: EC2Client;
private opts: EC2ProviderOptions;
private cachedIdentity?: LaunchIdentity;

constructor(opts: EC2ProviderOptions) {
this.opts = opts;
Expand All @@ -76,13 +85,68 @@ export class EC2Provider implements Provider {
});
}

/**
* Resolve (and cache) the launching identity. Cached for the provider's
* lifetime because credentials don't change under it.
*
* Throws rather than degrading. A launch that can't stamp spawn:iam-user
* produces an instance the portal can neither list nor terminate (it 403s on
* the owner mismatch — lambda/dashboard-api/instances.go:285), and it still
* accrues cost. Failing at launch is recoverable; an orphaned billable
* instance is the #63 invariant in its most expensive form, so the error must
* not look like an absence of identity.
*/
private async resolveIdentity(): Promise<LaunchIdentity> {
if (this.opts.identity) return this.opts.identity;
if (this.cachedIdentity) return this.cachedIdentity;
// Imported lazily: only the real launch path needs STS, so the pure/offline
// consumers of this module don't pay for the client.
const { STSClient, GetCallerIdentityCommand } = await import("@aws-sdk/client-sts");
const sts = new STSClient({
region: this.opts.region,
endpoint: this.opts.endpoint || undefined,
credentials: {
accessKeyId: this.opts.accessKeyId,
secretAccessKey: this.opts.secretAccessKey,
sessionToken: this.opts.sessionToken,
},
});
let out;
try {
out = await sts.send(new GetCallerIdentityCommand({}));
} catch (err) {
throw new Error(
`cannot determine the launching identity (sts:GetCallerIdentity failed: ${
(err as Error).message
}). Refusing to launch: without spawn:iam-user the instance would be ` +
`invisible to the portal and impossible to terminate there, while still costing money.`,
);
}
if (!out.Account || !out.Arn) {
throw new Error(
"sts:GetCallerIdentity returned no Account/Arn. Refusing to launch: " +
"without spawn:iam-user the instance would be unterminatable from the portal.",
);
}
this.cachedIdentity = { accountId: out.Account, userArn: out.Arn };
return this.cachedIdentity;
}

async launch(spec: LaunchSpec, launchTimeMs: number): Promise<ManagedInstance> {
const tags = buildLaunchTags(spec, launchTimeMs);
const identity = await this.resolveIdentity();
const tags = buildLaunchTags(spec, launchTimeMs, identity);

// The tag must name the user userdata actually creates, so resolve once and
// use the same value for both — otherwise `spawn connect` SSHes to a user
// that doesn't exist. Set before tagList is snapshotted below.
const localUsername = spec.localUsername ?? this.opts.username ?? "ec2-user";
tags[tag("local-username")] = localUsername;

const tagList: AwsTag[] = Object.entries(tags).map(([Key, Value]) => ({ Key, Value }));

const userData = encodeUserData(
buildLinuxBootstrap({
username: this.opts.username ?? "ec2-user",
username: localUsername,
publicKey: this.opts.publicKey,
command: spec.onComplete ? undefined : undefined, // workload wiring is a later feature
sessionTimeoutMs: spec.sessionTimeoutMs,
Expand Down
Loading
Loading