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
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,11 @@ that is not one task (`go tool taskgate`).
characters (it is its line in `llms.txt`), every `SPIN_` variable a page names is one spin's
`hack/env-catalog.txt` lists, and the table of states in `concepts/workspaces.md` is the API's
`WorkspaceState`, both ways. `task reference:check` fails on each.
- **The cost page's numbers are generated and dated**: `task pricing` (`tools/pricing`) writes
`src/data/pricing.json` - the machines the module starts by default, priced on demand in each
region with images, from the lists the AWS pricing page loads - and `task reference:check`
fails on a record a month old or of other machines than the module's defaults at its pin. The
daily sync rewrites it.
- **What the site documents** is `src/data/reference.json`, written by `task reference`. Two
things it keeps apart: the spin release an installation runs (`spin.installs`, the newest
release `images.json` has images of), and the spin commit the reference is generated from
Expand Down
13 changes: 12 additions & 1 deletion Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,18 @@ tasks:
- go run ./tools/sources spin spin-terraform-aws brand

sources:bump:
desc: Move the pins to the newest releases - spin only to one that holds the commit pinned, brand to its main - and fetch them. Then task reference.
desc: Move the pins to the newest releases - spin only to one that holds the commit pinned, brand to its main - and fetch them, and rewrite the prices. Then task reference.
cmds:
- go run ./tools/sources -bump spin spin-terraform-aws brand
- task: pricing

# The cost page's numbers: generated, dated, and refused by reference:check once they are a
# month old or of other machines than the module starts, so a figure on the page is never
# quietly stale. The sync rewrites them every day.
pricing:
desc: Write src/data/pricing.json - what the machines the module starts by default cost an hour, on demand, in each region with images, from the lists the AWS pricing page loads (tools/pricing).
cmds:
- go run ./tools/pricing -terraform {{.TERRAFORM}} -out src/data/pricing.json

sync:pr:
desc: Commit what sources:bump and reference changed to branch sync/sources as the sync App (SYNC_TOKEN, BOT its login), and open its one pull request or update it. sync.yml runs it.
Expand All @@ -56,6 +65,7 @@ tasks:
cmds:
- task: sources
- go run ./tools/refgen -spin {{.SPIN}} -terraform {{.TERRAFORM}} -brand {{.BRAND}} -shots {{.SHOTS}} -check
- go run ./tools/pricing -terraform {{.TERRAFORM}} -check

reference:public:
desc: Fail if what the site has of spin-terraform-aws and brand is not what they say at the commits versions.yaml pins. Needs no spin checkout, nor any credential.
Expand All @@ -64,6 +74,7 @@ tasks:
cmds:
- go run ./tools/sources spin-terraform-aws brand
- go run ./tools/refgen -only terraform,brand -terraform .sources/spin-terraform-aws -brand .sources/brand -check
- go run ./tools/pricing -terraform .sources/spin-terraform-aws -check

build:
desc: Build the site into dist/, with llms.txt, llms-full.txt and a .md twin of every page.
Expand Down
43 changes: 43 additions & 0 deletions src/components/Scenarios.astro
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
// The cost page's three scenarios (src/lib/pricing.ts), a column a region. Its Markdown twin is
// the same table (scenariosMarkdown, src/lib/pages.ts).
import { dollars, regions, scenarios } from '../lib/pricing';

const rows = scenarios();
---

<table class="scenarios">
<thead>
<tr>
<th>Scenario</th>
<th>What runs</th>
{regions.map((r) => <th class="num">{r}<br /><span class="unit">$ a month</span></th>)}
</tr>
</thead>
<tbody>
{
rows.map((s) => (
<tr>
<td><strong>{s.name}</strong></td>
<td>{s.what}</td>
{regions.map((r) => <td class="num">{dollars(s.monthly[r])}</td>)}
</tr>
))
}
</tbody>
</table>

<style>
.num {
text-align: right;
font-family: var(--sl-font-mono);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.unit {
font-family: var(--sl-font);
font-weight: normal;
font-size: var(--sl-text-xs);
color: var(--sl-color-gray-3);
}
</style>
16 changes: 16 additions & 0 deletions src/content/docs/aws/cost.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,28 @@ title: Cost
description: "What an installation costs on AWS: the small part that runs all the time, the runners that run only while workspaces do, and the settings that change it."
---

import Scenarios from '../../../components/Scenarios.astro';
import Value from '../../../components/Value.astro';
import { generated, published } from '../../../lib/pricing';

An installation has two kinds of cost: a small one that runs all the time, and runners, which
run only while workspaces do. Prices depend on your region and change; use the
[AWS Pricing Calculator](https://calculator.aws/) with the sizes below.

## Three months, priced

The module's default machines, on demand, in the regions Spin OS has images in, at the prices
the AWS pricing page listed on {published} (the record was written on {generated}, and the
daily sync rewrites it):

<Scenarios />

Runners are spot by default, and a spot runner costs a fraction of the on-demand hour shown:
the calculator has your region's spot price, and `aws ec2 describe-spot-price-history` today's.
Not in the table: what the volumes bucket holds, at S3 Standard's price per GB a month, and
what leaves the region, at EC2's price per GB - both depend on what your workspaces do, and the
calculator has both.

## All the time

| Resource | Default size |
Expand Down
93 changes: 93 additions & 0 deletions src/data/pricing.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
{
"generated": "2026-10-26",
"published": "2026-10-23T22:13:55Z",
"source": "https://b0.p.awsstatic.com/pricing/2.0/meteredUnitMaps/ec2/USD/current/ec2-ondemand-without-sec-sel/\u003cregion\u003e/Linux/index.json",
"machines": {
"controlPlane": "t8i.medium",
"proxy": "t8i.micro",
"runners": [
"m8id.8xlarge",
"c8id.8xlarge",
"r8id.8xlarge",
"m8id.12xlarge",
"c8id.12xlarge"
],
"maxRunners": 1
},
"regions": {
"us-east-1": {
"c8id.12xlarge": {
"hourly": 2.66112,
"vcpu": 48,
"memory": "96 GiB"
},
"c8id.8xlarge": {
"hourly": 1.77408,
"vcpu": 32,
"memory": "64 GiB"
},
"m8id.12xlarge": {
"hourly": 3.13248,
"vcpu": 48,
"memory": "192 GiB"
},
"m8id.8xlarge": {
"hourly": 2.08832,
"vcpu": 32,
"memory": "128 GiB"
},
"r8id.8xlarge": {
"hourly": 2.66112,
"vcpu": 32,
"memory": "256 GiB"
},
"t8i.medium": {
"hourly": 0.04992,
"vcpu": 2,
"memory": "4.0 GiB"
},
"t8i.micro": {
"hourly": 0.01248,
"vcpu": 2,
"memory": "1.0 GiB"
}
},
"us-west-2": {
"c8id.12xlarge": {
"hourly": 2.66112,
"vcpu": 48,
"memory": "96 GiB"
},
"c8id.8xlarge": {
"hourly": 1.77408,
"vcpu": 32,
"memory": "64 GiB"
},
"m8id.12xlarge": {
"hourly": 3.13248,
"vcpu": 48,
"memory": "192 GiB"
},
"m8id.8xlarge": {
"hourly": 2.08832,
"vcpu": 32,
"memory": "128 GiB"
},
"r8id.8xlarge": {
"hourly": 2.66112,
"vcpu": 32,
"memory": "256 GiB"
},
"t8i.medium": {
"hourly": 0.04992,
"vcpu": 2,
"memory": "4.0 GiB"
},
"t8i.micro": {
"hourly": 0.01248,
"vcpu": 2,
"memory": "1.0 GiB"
}
}
}
}
4 changes: 4 additions & 0 deletions src/lib/pages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { featureHref } from './feature-links.mjs';
import { nav } from './nav.mjs';
import figures from '../data/figures.json';
import { output } from './output';
import { generated, published, scenariosMarkdown } from './pricing';
import { statusLine } from './reference';
import { storyMarkdown } from './story';
import { twinPath } from './twin';
Expand Down Expand Up @@ -106,6 +107,9 @@ export function toMarkdown(d: Doc, base: string): string {
});
// A figure the code decides is its text.
body = body.replace(/<Value\s+name="(\w+)"\s*\/>/g, (_, name: string) => value(name));
// The cost page's scenarios are the same table, and the dates it names are the record's.
body = body.replace(/<Scenarios\s*\/>/g, () => scenariosMarkdown());
body = body.replace(/\{published\}/g, published).replace(/\{generated\}/g, generated);
// A screenshot is its alt text: a twin has no picture to show.
body = body.replace(/!\[([^\]]*)\]\([^)]*\.png\)/g, '*Screenshot: $1.*');
// What a command prints is the same block of output.
Expand Down
74 changes: 74 additions & 0 deletions src/lib/pricing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// The cost page's scenarios, from src/data/pricing.json (tools/pricing): the machines the module
// starts by default, priced on demand in each region Spin OS has images in, as the lists the AWS
// pricing page loads had them on the record's date. Three months, each the one before plus
// something: nothing running, one runner on working hours, the most runners around the clock.
// Scenarios.astro draws them; the page's Markdown twin is scenariosMarkdown.
import pricing from '../data/pricing.json';

/** A month as AWS bills it. */
const hours = 730;
/** Working hours in a month: eight a day, twenty-two days. */
const workingHours = 8 * 22;
// What the installation's smaller parts are billed at, from AWS's pricing pages as of the
// record's date: the public IPv4 address the proxy's elastic IP holds (an hour), and a Route 53
// hosted zone (a month, and the installation has two).
const ipv4Hourly = 0.005;
const zoneMonthly = 0.5;
const zones = 2;

export interface Scenario {
name: string;
what: string;
/** Dollars a month, by region code. */
monthly: Record<string, number>;
}

export const regions = Object.keys(pricing.regions).sort();
export const generated = pricing.generated;
export const published = pricing.published.slice(0, 10);
export const machines = pricing.machines;

function price(region: string, type: string): number {
const p = (pricing.regions as Record<string, Record<string, { hourly: number }>>)[region]?.[type];
if (!p) throw new Error(`pricing.json: no price for ${type} in ${region}`);
return p.hourly;
}

/** The three scenarios, each a month in dollars per region. */
export function scenarios(): Scenario[] {
const { controlPlane, proxy, runners, maxRunners } = machines;
const runner = runners[0];
const perRegion = (f: (region: string) => number) => Object.fromEntries(regions.map((r) => [r, f(r)]));
const idle = (r: string) => (price(r, controlPlane) + price(r, proxy) + ipv4Hourly) * hours + zones * zoneMonthly;
const n = maxRunners;
return [
{
name: 'Nothing running',
what: `The control plane (${controlPlane}) and the proxy (${proxy}) all month, the proxy's public IPv4 address, and two Route 53 zones. No runner is up.`,
monthly: perRegion(idle),
},
{
name: 'One runner, working hours',
what: `The above, and one ${runner} up ${workingHours} hours: eight a day, twenty-two days, on demand.`,
monthly: perRegion((r) => idle(r) + price(r, runner) * workingHours),
},
{
name: n === 1 ? 'One runner around the clock' : `${n} runners around the clock`,
what: `The above with ${n === 1 ? 'the runner' : `${n} runners (max_runners' default)`} up all month, on demand: the most the default installation can spend on runners.`,
monthly: perRegion((r) => idle(r) + n * price(r, runner) * hours),
},
];
}

/** A dollar figure as the table shows it. */
export function dollars(n: number): string {
return `$${n.toLocaleString('en-US', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`;
}

/** The scenarios as a Markdown table, for the page's twin. */
export function scenariosMarkdown(): string {
const head = `| Scenario | What runs | ${regions.map((r) => `${r} ($/month)`).join(' | ')} |`;
const rule = `|---|---|${regions.map(() => '---').join('|')}|`;
const rows = scenarios().map((s) => `| **${s.name}** | ${s.what} | ${regions.map((r) => dollars(s.monthly[r])).join(' | ')} |`);
return [head, rule, ...rows].join('\n');
}
Loading
Loading