From 1213ffa1708bd19ef38264aef0a4a9755c97729b Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Mon, 26 Oct 2026 13:57:26 -0300 Subject: [PATCH] cost: three months priced, from the module's defaults and the lists the AWS pricing page loads The cost page named the parts and sent the reader to the calculator with no figure to start from. tools/pricing reads what the module starts by default at the pinned commit - the control plane's and the proxy's machine, the runners' types, the most runners - and prices them on demand in each region Spin OS has images in, from the per-region lists the AWS pricing page itself loads, dated by AWS's publication and by the day written. The page draws three months from it: nothing running, one runner on working hours, the most runners around the clock. The daily sync rewrites the record and reference:check refuses one a month old or of other machines than the module's, so a figure on the page is never quietly stale. Spot, storage and transfer stay the calculator's: no public list prices a spot hour, and the other two depend on use. Co-Authored-By: Claude Fable 5.1 --- README.md | 5 + Taskfile.yml | 13 +- src/components/Scenarios.astro | 43 +++++ src/content/docs/aws/cost.mdx | 16 ++ src/data/pricing.json | 93 ++++++++++ src/lib/pages.ts | 4 + src/lib/pricing.ts | 74 ++++++++ tools/pricing/pricing.go | 312 +++++++++++++++++++++++++++++++++ tools/pricing/pricing_test.go | 135 ++++++++++++++ 9 files changed, 694 insertions(+), 1 deletion(-) create mode 100644 src/components/Scenarios.astro create mode 100644 src/data/pricing.json create mode 100644 src/lib/pricing.ts create mode 100644 tools/pricing/pricing.go create mode 100644 tools/pricing/pricing_test.go 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(); +--- + + + + + + + {regions.map((r) => )} + + + + { + rows.map((s) => ( + + + + {regions.map((r) => )} + + )) + } + +
ScenarioWhat runs{r}
$ a month
{s.name}{s.what}{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 +}