diff --git a/README.md b/README.md
index 44c848d..58493f7 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/Taskfile.yml b/Taskfile.yml
index 929df55..fbab97e 100644
--- a/Taskfile.yml
+++ b/Taskfile.yml
@@ -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.
@@ -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.
@@ -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.
diff --git a/src/components/Scenarios.astro b/src/components/Scenarios.astro
new file mode 100644
index 0000000..d46fb84
--- /dev/null
+++ b/src/components/Scenarios.astro
@@ -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();
+---
+
+
+
+
+ | Scenario |
+ What runs |
+ {regions.map((r) => {r} $ a month | )}
+
+
+
+ {
+ rows.map((s) => (
+
+ | {s.name} |
+ {s.what} |
+ {regions.map((r) => {dollars(s.monthly[r])} | )}
+
+ ))
+ }
+
+
+
+
diff --git a/src/content/docs/aws/cost.mdx b/src/content/docs/aws/cost.mdx
index 4d62c4f..ba58320 100644
--- a/src/content/docs/aws/cost.mdx
+++ b/src/content/docs/aws/cost.mdx
@@ -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):
+
+
+
+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 |
diff --git a/src/data/pricing.json b/src/data/pricing.json
new file mode 100644
index 0000000..9f5fef1
--- /dev/null
+++ b/src/data/pricing.json
@@ -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"
+ }
+ }
+ }
+}
diff --git a/src/lib/pages.ts b/src/lib/pages.ts
index e12caa5..3df02c6 100644
--- a/src/lib/pages.ts
+++ b/src/lib/pages.ts
@@ -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';
@@ -106,6 +107,9 @@ export function toMarkdown(d: Doc, base: string): string {
});
// A figure the code decides is its text.
body = body.replace(//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(//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.
diff --git a/src/lib/pricing.ts b/src/lib/pricing.ts
new file mode 100644
index 0000000..c524977
--- /dev/null
+++ b/src/lib/pricing.ts
@@ -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;
+}
+
+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>)[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');
+}
diff --git a/tools/pricing/pricing.go b/tools/pricing/pricing.go
new file mode 100644
index 0000000..980189d
--- /dev/null
+++ b/tools/pricing/pricing.go
@@ -0,0 +1,312 @@
+// Command pricing writes src/data/pricing.json: what the machines an installation starts by
+// default cost an hour, on demand, in each region Spin OS has images in, read from the price list
+// the AWS pricing page itself loads (one JSON a region and an operating system, dated by AWS) and
+// the module's defaults at the commit the site pins. The cost page draws its scenarios from it,
+// each number with its region and the date the list was published; `-check` fails on a record
+// older than the window or of other machines than the module's, so a number on the page is never
+// quietly a month old.
+//
+// Spot prices are not here: AWS publishes none without credentials, and a runner's is the hour's.
+package main
+
+import (
+ "encoding/json"
+ "errors"
+ "flag"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "path/filepath"
+ "regexp"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+)
+
+// listURL is the on-demand Linux price list of one region, as the AWS pricing page loads it:
+// %s is the region's name as AWS writes it, percent-encoded.
+const listURL = "https://b0.p.awsstatic.com/pricing/2.0/meteredUnitMaps/ec2/USD/current/ec2-ondemand-without-sec-sel/%s/Linux/index.json"
+
+// regionNames is each region code as the list names it: the list is keyed by name, not code.
+var regionNames = map[string]string{
+ "us-east-1": "US East (N. Virginia)",
+ "us-west-2": "US West (Oregon)",
+}
+
+// window is how old the record may be before -check refuses it: the daily sync rewrites it,
+// and a month without a sync merged is a site nobody is keeping.
+const window = 30 * 24 * time.Hour
+
+// Record is src/data/pricing.json.
+type Record struct {
+ // Generated is the day this was written, UTC.
+ Generated string `json:"generated"`
+ // Published is when AWS published the lists read, as each list's manifest says.
+ Published string `json:"published"`
+ // Source is where the lists come from, for a reader.
+ Source string `json:"source"`
+ Machines Machines `json:"machines"`
+ Regions map[string]Region `json:"regions"`
+}
+
+// Machines is what the module starts by default, at the commit the site pins: the control
+// plane's and the proxy's machine, the types a runner may be, and the most runners at once.
+type Machines struct {
+ ControlPlane string `json:"controlPlane"`
+ Proxy string `json:"proxy"`
+ Runners []string `json:"runners"`
+ MaxRunners int `json:"maxRunners"`
+}
+
+// Region is each machine's price in one region.
+type Region map[string]Price
+
+// Price is one instance type on demand, an hour, with what it is.
+type Price struct {
+ Hourly float64 `json:"hourly"`
+ VCPU int `json:"vcpu"`
+ Memory string `json:"memory"`
+}
+
+func main() {
+ if err := run(os.Args[1:], os.Stdout); err != nil {
+ fmt.Fprintln(os.Stderr, "pricing:", err)
+ os.Exit(1)
+ }
+}
+
+func run(args []string, stdout io.Writer) error {
+ fs := flag.NewFlagSet("pricing", flag.ContinueOnError)
+ terraform := fs.String("terraform", ".sources/spin-terraform-aws", "a checkout of spin-terraform-aws at the commit the site pins")
+ out := fs.String("out", "src/data/pricing.json", "the record")
+ check := fs.Bool("check", false, "write nothing; fail when the record is older than the window or of other machines than the module's defaults")
+ lists := fs.String("lists", "", "test: a directory holding .json in place of the lists on the web")
+ if err := fs.Parse(args); err != nil {
+ return err
+ }
+ machines, err := defaults(*terraform)
+ if err != nil {
+ return err
+ }
+ if *check {
+ return checkRecord(*out, machines, time.Now())
+ }
+ r, err := build(machines, *lists, time.Now())
+ if err != nil {
+ return err
+ }
+ b, err := json.MarshalIndent(r, "", "\t")
+ if err != nil {
+ return err
+ }
+ if err := os.WriteFile(*out, append(b, '\n'), 0o644); err != nil { //nolint:gosec // the site's data file
+ return err
+ }
+ fmt.Fprintf(stdout, "pricing: %d regions, lists published %s\n", len(r.Regions), r.Published)
+ return nil
+}
+
+var (
+ variable = regexp.MustCompile(`(?m)^variable "([a-z_]+)" \{$`)
+ stringValue = regexp.MustCompile(`(?m)^\s*default\s*=\s*"([^"]+)"`)
+ listValue = regexp.MustCompile(`(?m)^\s*default\s*=\s*\[([^\]]*)\]`)
+ intValue = regexp.MustCompile(`(?m)^\s*default\s*=\s*(\d+)`)
+)
+
+// defaults reads what the module starts by default: the control plane's and the proxy's
+// instance type from modules/controlplane, the runners' types and the most runners from
+// modules/runners (max_hosts, which the root calls max_runners). A variable without a default
+// is a mistake here, not a value to guess.
+func defaults(terraform string) (Machines, error) {
+ cp, err := os.ReadFile(filepath.Join(terraform, "modules", "controlplane", "variables.tf"))
+ if err != nil {
+ return Machines{}, err
+ }
+ runners, err := os.ReadFile(filepath.Join(terraform, "modules", "runners", "variables.tf"))
+ if err != nil {
+ return Machines{}, err
+ }
+ var m Machines
+ if m.ControlPlane, err = stringDefault(cp, "instance_type"); err != nil {
+ return Machines{}, err
+ }
+ if m.Proxy, err = stringDefault(cp, "proxy_instance_type"); err != nil {
+ return Machines{}, err
+ }
+ block, err := variableBlock(runners, "instance_types")
+ if err != nil {
+ return Machines{}, err
+ }
+ l := listValue.FindSubmatch(block)
+ if l == nil {
+ return Machines{}, errors.New("modules/runners instance_types: no list default")
+ }
+ for _, item := range strings.Split(string(l[1]), ",") {
+ if item = strings.Trim(strings.TrimSpace(item), `"`); item != "" {
+ m.Runners = append(m.Runners, item)
+ }
+ }
+ if len(m.Runners) == 0 {
+ return Machines{}, errors.New("modules/runners instance_types: an empty default")
+ }
+ block, err = variableBlock(runners, "max_hosts")
+ if err != nil {
+ return Machines{}, err
+ }
+ n := intValue.FindSubmatch(block)
+ if n == nil {
+ return Machines{}, errors.New("modules/runners max_hosts: no number default")
+ }
+ m.MaxRunners, _ = strconv.Atoi(string(n[1])) // digits, by the expression
+ return m, nil
+}
+
+func stringDefault(src []byte, name string) (string, error) {
+ block, err := variableBlock(src, name)
+ if err != nil {
+ return "", err
+ }
+ m := stringValue.FindSubmatch(block)
+ if m == nil {
+ return "", fmt.Errorf("%s: no string default", name)
+ }
+ return string(m[1]), nil
+}
+
+// variableBlock is the text of variable "name" up to the next variable.
+func variableBlock(src []byte, name string) ([]byte, error) {
+ all := variable.FindAllSubmatchIndex(src, -1)
+ for i, m := range all {
+ if string(src[m[2]:m[3]]) != name {
+ continue
+ }
+ end := len(src)
+ if i+1 < len(all) {
+ end = all[i+1][0]
+ }
+ return src[m[1]:end], nil
+ }
+ return nil, fmt.Errorf("no variable %q", name)
+}
+
+// list is one region's price list as the pricing page loads it: a manifest with its
+// publication date, and the region's prices keyed " Linux".
+type list struct {
+ Manifest struct {
+ Published string `json:"hawkFilePublicationDate"`
+ } `json:"manifest"`
+ Regions map[string]map[string]struct {
+ Price string `json:"price"`
+ VCPU string `json:"vCPU"`
+ Memory string `json:"Memory"`
+ Type string `json:"Instance Type"`
+ } `json:"regions"`
+}
+
+// build reads each region's list and keeps the machines' prices. A machine the list does not
+// price fails: a type the module starts that a region lacks is for the module to fix, not a
+// blank on the page.
+func build(m Machines, lists string, now time.Time) (Record, error) {
+ r := Record{Generated: now.UTC().Format("2006-01-02"), Source: fmt.Sprintf(listURL, ""), Machines: m, Regions: map[string]Region{}}
+ codes := make([]string, 0, len(regionNames))
+ for code := range regionNames {
+ codes = append(codes, code)
+ }
+ sort.Strings(codes)
+ for _, code := range codes {
+ raw, err := fetch(code, lists)
+ if err != nil {
+ return Record{}, err
+ }
+ var l list
+ if err := json.Unmarshal(raw, &l); err != nil {
+ return Record{}, fmt.Errorf("%s's list: %w", code, err)
+ }
+ if l.Manifest.Published == "" {
+ return Record{}, fmt.Errorf("%s's list: no publication date in its manifest", code)
+ }
+ if r.Published == "" || l.Manifest.Published > r.Published {
+ r.Published = l.Manifest.Published
+ }
+ prices, ok := l.Regions[regionNames[code]]
+ if !ok {
+ return Record{}, fmt.Errorf("%s's list holds no region %q", code, regionNames[code])
+ }
+ region := Region{}
+ for _, t := range append([]string{m.ControlPlane, m.Proxy}, m.Runners...) {
+ p, ok := prices[key(t, regionNames[code])]
+ if !ok {
+ return Record{}, fmt.Errorf("%s: no on-demand price for %s, which the module starts by default", code, t)
+ }
+ hourly, err := strconv.ParseFloat(p.Price, 64)
+ if err != nil {
+ return Record{}, fmt.Errorf("%s %s: price %q: %w", code, t, p.Price, err)
+ }
+ vcpu, err := strconv.Atoi(p.VCPU)
+ if err != nil {
+ return Record{}, fmt.Errorf("%s %s: vCPU %q: %w", code, t, p.VCPU, err)
+ }
+ region[t] = Price{Hourly: hourly, VCPU: vcpu, Memory: p.Memory}
+ }
+ r.Regions[code] = region
+ }
+ return r, nil
+}
+
+// key is how the list names an instance type's row: the family and size apart, the region's
+// name without its parentheses, then the operating system.
+func key(instanceType, region string) string {
+ family, size, _ := strings.Cut(instanceType, ".")
+ clean := strings.NewReplacer("(", "", ")", "").Replace(region)
+ return family + " " + size + " " + clean + " Linux"
+}
+
+func fetch(code, lists string) ([]byte, error) {
+ if lists != "" {
+ return os.ReadFile(filepath.Join(lists, code+".json"))
+ }
+ u := fmt.Sprintf(listURL, strings.ReplaceAll(strings.ReplaceAll(regionNames[code], " ", "%20"), "(", "%28"))
+ u = strings.ReplaceAll(u, ")", "%29")
+ resp, err := http.Get(u) //nolint:gosec,noctx // a public list, one request
+ if err != nil {
+ return nil, err
+ }
+ defer func() { _ = resp.Body.Close() }() // read-only
+ if resp.StatusCode != http.StatusOK {
+ return nil, fmt.Errorf("GET %s: %s", u, resp.Status)
+ }
+ return io.ReadAll(resp.Body)
+}
+
+// checkRecord holds the record to the window and to the module's defaults: one older than the
+// window, or of other machines than the module starts at the pinned commit, fails.
+func checkRecord(path string, m Machines, now time.Time) error {
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ return err
+ }
+ var r Record
+ if err := json.Unmarshal(raw, &r); err != nil {
+ return fmt.Errorf("%s: %w", path, err)
+ }
+ day, err := time.Parse("2006-01-02", r.Generated)
+ if err != nil {
+ return fmt.Errorf("%s: generated %q: %w", path, r.Generated, err)
+ }
+ if now.Sub(day) > window {
+ return fmt.Errorf("%s was generated on %s, more than %d days ago: run task pricing", path, r.Generated, int(window.Hours()/24))
+ }
+ have, _ := json.Marshal(r.Machines)
+ want, _ := json.Marshal(m)
+ if string(have) != string(want) {
+ return fmt.Errorf("%s prices %s, and the module starts %s by default: run task pricing", path, have, want)
+ }
+ for code := range regionNames {
+ if _, ok := r.Regions[code]; !ok {
+ return fmt.Errorf("%s has no prices for %s: run task pricing", path, code)
+ }
+ }
+ return nil
+}
diff --git a/tools/pricing/pricing_test.go b/tools/pricing/pricing_test.go
new file mode 100644
index 0000000..36377fa
--- /dev/null
+++ b/tools/pricing/pricing_test.go
@@ -0,0 +1,135 @@
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+// module is a spin-terraform-aws checkout with the three defaults the record prices.
+func module(t *testing.T, runnerTypes string) string {
+ t.Helper()
+ root := t.TempDir()
+ write := func(path, text string) {
+ t.Helper()
+ if err := os.MkdirAll(filepath.Dir(filepath.Join(root, path)), 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(root, path), []byte(text), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ write("modules/controlplane/variables.tf", "variable \"domain\" {\n type = string\n}\n\nvariable \"instance_type\" {\n description = \"The machine.\"\n type = string\n default = \"t8i.medium\"\n}\n\nvariable \"proxy_instance_type\" {\n default = \"t8i.micro\"\n}\n")
+ write("modules/runners/variables.tf", "variable \"instance_types\" {\n type = list(string)\n default = ["+runnerTypes+"]\n}\n\nvariable \"max_hosts\" {\n type = number\n default = 3\n}\n")
+ return root
+}
+
+// lists is a directory of each region's list as the pricing page loads it, with the machines'
+// rows and others.
+func lists(t *testing.T, published string) string {
+ t.Helper()
+ dir := t.TempDir()
+ row := func(instanceType, price, vcpu, memory string) map[string]string {
+ return map[string]string{"price": price, "vCPU": vcpu, "Memory": memory, "Instance Type": instanceType}
+ }
+ for code, name := range regionNames {
+ clean := strings.NewReplacer("(", "", ")", "").Replace(name)
+ l := map[string]any{
+ "manifest": map[string]string{"hawkFilePublicationDate": published},
+ "regions": map[string]any{name: map[string]any{
+ "t8i medium " + clean + " Linux": row("t8i.medium", "0.0499200000", "2", "4.0 GiB"),
+ "t8i micro " + clean + " Linux": row("t8i.micro", "0.0124800000", "2", "1.0 GiB"),
+ "m8id 8xlarge " + clean + " Linux": row("m8id.8xlarge", "2.0883200000", "32", "128 GiB"),
+ "g6 16xlarge " + clean + " Linux": row("g6.16xlarge", "9", "64", "256 GiB"),
+ }},
+ }
+ b, err := json.Marshal(l)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, code+".json"), b, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ }
+ return dir
+}
+
+// The record prices the machines the module starts by default, in each region, dated by the
+// day it was written and by AWS's publication; a machine the lists do not price fails rather
+// than leaving a blank.
+func TestTheRecordPricesTheModulesDefaults(t *testing.T) {
+ out := filepath.Join(t.TempDir(), "pricing.json")
+ var stdout bytes.Buffer
+ if err := run([]string{"-terraform", module(t, `"m8id.8xlarge"`), "-lists", lists(t, "2026-10-23T22:13:55Z"), "-out", out}, &stdout); err != nil {
+ t.Fatal(err)
+ }
+ if got := stdout.String(); got != "pricing: 2 regions, lists published 2026-10-23T22:13:55Z\n" {
+ t.Errorf("said %q", got)
+ }
+ raw, err := os.ReadFile(out)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var r Record
+ if err := json.Unmarshal(raw, &r); err != nil {
+ t.Fatal(err)
+ }
+ if r.Generated != time.Now().UTC().Format("2006-01-02") || r.Published != "2026-10-23T22:13:55Z" {
+ t.Errorf("dated %s, lists %s", r.Generated, r.Published)
+ }
+ want := Machines{ControlPlane: "t8i.medium", Proxy: "t8i.micro", Runners: []string{"m8id.8xlarge"}, MaxRunners: 3}
+ if got, _ := json.Marshal(r.Machines); string(got) != string(must(json.Marshal(want))) {
+ t.Errorf("machines %s", got)
+ }
+ for _, code := range []string{"us-east-1", "us-west-2"} {
+ if p := r.Regions[code]["m8id.8xlarge"]; p.Hourly != 2.08832 || p.VCPU != 32 || p.Memory != "128 GiB" {
+ t.Errorf("%s m8id.8xlarge = %+v", code, p)
+ }
+ if _, ok := r.Regions[code]["g6.16xlarge"]; ok {
+ t.Errorf("%s prices a machine the module does not start", code)
+ }
+ }
+
+ err = run([]string{"-terraform", module(t, `"m8id.8xlarge", "c8id.8xlarge"`), "-lists", lists(t, "2026-10-23T22:13:55Z"), "-out", out}, &stdout)
+ if err == nil || !strings.Contains(err.Error(), "no on-demand price for c8id.8xlarge") {
+ t.Errorf("a machine the list does not price: %v", err)
+ }
+}
+
+// -check holds the record to the window and to the module: older than the window, or of other
+// machines than the module starts now, is a record to rewrite.
+func TestAStaleOrMovedRecordFails(t *testing.T) {
+ out := filepath.Join(t.TempDir(), "pricing.json")
+ tf := module(t, `"m8id.8xlarge"`)
+ if err := run([]string{"-terraform", tf, "-lists", lists(t, "2026-10-23T22:13:55Z"), "-out", out}, &bytes.Buffer{}); err != nil {
+ t.Fatal(err)
+ }
+ if err := run([]string{"-terraform", tf, "-out", out, "-check"}, &bytes.Buffer{}); err != nil {
+ t.Errorf("a record written today, of the module's machines: %v", err)
+ }
+ moved := module(t, `"c8id.8xlarge"`)
+ err := run([]string{"-terraform", moved, "-out", out, "-check"}, &bytes.Buffer{})
+ if err == nil || !strings.Contains(err.Error(), "the module starts") {
+ t.Errorf("the module's defaults moved: %v", err)
+ }
+
+ m, err := defaults(tf)
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = checkRecord(out, m, time.Now().Add(window+24*time.Hour))
+ if err == nil || !strings.Contains(err.Error(), "more than 30 days ago") {
+ t.Errorf("a record past the window: %v", err)
+ }
+}
+
+func must(b []byte, err error) []byte {
+ if err != nil {
+ panic(err)
+ }
+ return b
+}