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 +}