From 6852ad74de2ee8d454d974d7f305dddb2350c9a1 Mon Sep 17 00:00:00 2001 From: Manuel de Brito Fontes Date: Sat, 24 Oct 2026 22:14:00 -0300 Subject: [PATCH] refgen: the table of states is the API's, each row its value's comment in spin's proto checkStates held the hand-written table to WorkspaceState's names; now that spin's OpenAPI document carries each value's comment (x-enum-descriptions), the table is written from it, and what a state means is said once, where it is defined. Co-Authored-By: Claude Opus 5.5 --- src/content/docs/concepts/workspaces.md | 25 +++---- tools/refgen/main.go | 6 +- tools/refgen/prose.go | 95 ++++++++++++++++--------- tools/refgen/prose_test.go | 72 +++++++++++++++++++ 4 files changed, 152 insertions(+), 46 deletions(-) create mode 100644 tools/refgen/prose_test.go diff --git a/src/content/docs/concepts/workspaces.md b/src/content/docs/concepts/workspaces.md index 2a9d6c4..2857621 100644 --- a/src/content/docs/concepts/workspaces.md +++ b/src/content/docs/concepts/workspaces.md @@ -46,21 +46,22 @@ space. ## States + + | State | Meaning | |---|---| | `CREATING` | The workspace exists and has not run yet. | -| `WAITING` | No runner has room for it yet. On AWS, one is being started. | -| `STARTING` | A runner is preparing its disk and starting the machine. | -| `RUNNING` | The machine is up. | -| `STOPPING` | The machine is shutting down, and then its disk's last writes are uploaded to the bucket. A stop stays here until they are, and says why. | -| `STOPPED` | Not running. Its disk is kept. | -| `FAILED` | A start or a stop failed. The state says which part failed. | -| `QUARANTINED` | An administrator holds it with `spin workspace quarantine`. It does not run until released. | -| `DELETED` | In the trash, with its disk. `spin workspace undelete` brings it back, stopped. | - -A runner tries a start three times. After that, the workspace is `FAILED`, and it stays that way -until you start it again. `spin workspace status` says why a workspace is not behaving, and -`spin workspace console` shows what its machine printed. +| `STARTING` | A runner is preparing its disk and starting its machine. | +| `RUNNING` | Its machine is up. | +| `STOPPING` | Its machine is shutting down, and then what its disk wrote since its last commit is uploaded to the bucket. A stop stays here until the upload is done, and says why it is not; `spin workspace stop --force` does not wait for it. | +| `STOPPED` | Not running. Its disk is kept, and the next start boots from it. | +| `FAILED` | A start or a stop failed: `spin workspace status` says which part, and why. It stays so until it is started again. | +| `DELETED` | In the trash, with its disk, for the days the installation keeps it there (`workspace_trash_days`): `spin workspace undelete` brings it back, stopped. An installation that keeps no trash erases it at once. | +| `WAITING` | Wanted running, and no runner has room for it yet. On AWS, where runners scale to none, one is being started, and the workspace starts on it once it is up. | +| `QUARANTINED` | Held by an administrator with `spin workspace quarantine`: it cannot start until `spin workspace unquarantine` releases it, and its disk is kept as it was. | + +A runner tries a start three times before the workspace is `FAILED`. `spin workspace status` +says why a workspace is not behaving, and `spin workspace console` shows what its machine printed. ## Stop, suspend, delete diff --git a/tools/refgen/main.go b/tools/refgen/main.go index 3472f82..b33a5ef 100644 --- a/tools/refgen/main.go +++ b/tools/refgen/main.go @@ -260,6 +260,11 @@ func spinHalf(spin, docs, src string, pages map[string][]byte, outputsDir, featu return w, err } w.stale = append(w.stale, printed...) + tabled, err := stateTable(docs, spin, check) + if err != nil { + return w, err + } + w.stale = append(w.stale, tabled...) board, err := json.MarshalIndent(rows, "", "\t") if err != nil { return w, err @@ -283,7 +288,6 @@ func spinHalf(spin, docs, src string, pages map[string][]byte, outputsDir, featu for _, c := range []func() ([]string, error){ func() ([]string, error) { return checkLimits(docs, rows) }, func() ([]string, error) { return checkEnv(docs, spin) }, - func() ([]string, error) { return checkStates(docs, pages) }, func() ([]string, error) { return checkFeatureLinks(docs, rows) }, func() ([]string, error) { return checkCardFeatures(docs, src, rows) }, } { diff --git a/tools/refgen/prose.go b/tools/refgen/prose.go index 13d4244..e7f4534 100644 --- a/tools/refgen/prose.go +++ b/tools/refgen/prose.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "encoding/json" "fmt" "io/fs" "os" @@ -86,51 +87,79 @@ func checkEnv(docs, spin string) ([]string, error) { return bad, err } -var ( - stateRow = regexp.MustCompile("(?m)^\\| `([A-Z_]+)` \\|") - enumValue = regexp.MustCompile("`WORKSPACE_STATE_([A-Z_]+)`") -) +// statesPage is the hand-written page whose table of states refgen writes. +var statesPage = filepath.Join("concepts", "workspaces.md") -// checkStates holds the workspaces page's table of states to the API's WorkspaceState, both -// ways: the table says what each state means, which the API does not, and a state the API adds -// is one the table must explain. -func checkStates(docs string, pages map[string][]byte) ([]string, error) { - page := filepath.Join(docs, "concepts", "workspaces.md") - raw, err := os.ReadFile(page) +// stateTable writes the workspaces page's table of states from the API's WorkspaceState: a row +// a value, in the API's order, saying what spin's proto says of it, which the document carries +// as x-enum-descriptions (spin's buf.gen.openapi.yaml asks for with-enum-value-descriptions). +// A state is explained once, where it is defined, and one the API adds is a row the next sync +// brings. The prose around the table is the page's own. +func stateTable(docs, spin string, check bool) ([]string, error) { + raw, err := os.ReadFile(filepath.Join(spin, openAPIPath)) if err != nil { return nil, err } - types := pages[filepath.Join("api", "types.md")] - enum := section(types, "## WorkspaceState") - if enum == nil { - return []string{"the API reference has no WorkspaceState"}, nil + var doc struct { + Components struct { + Schemas map[string]struct { + Enum []string `json:"enum"` + Descriptions []string `json:"x-enum-descriptions"` + } `json:"schemas"` + } `json:"components"` } - want := map[string]bool{} - for _, m := range enumValue.FindAllSubmatch(enum, -1) { - if s := string(m[1]); s != "UNSPECIFIED" { - want[s] = true - } + if err := json.Unmarshal(raw, &doc); err != nil { + return nil, fmt.Errorf("%s: %w", openAPIPath, err) } - table := section(raw, "## States") - if table == nil { - return []string{page + `: no "## States" section`}, nil + state, ok := doc.Components.Schemas["spin.v1.WorkspaceState"] + if !ok { + return nil, fmt.Errorf("%s has no spin.v1.WorkspaceState", openAPIPath) } - have := map[string]bool{} - for _, m := range stateRow.FindAllSubmatch(table, -1) { - have[string(m[1])] = true + if len(state.Descriptions) != len(state.Enum) { + return nil, fmt.Errorf("%s: WorkspaceState has %d values and %d x-enum-descriptions: spin's buf.gen.openapi.yaml asks for with-enum-value-descriptions", + openAPIPath, len(state.Enum), len(state.Descriptions)) } - var bad []string - for s := range have { - if !want[s] { - bad = append(bad, fmt.Sprintf("%s: the state %s is not one of the API's WorkspaceState", page, s)) + var table strings.Builder + table.WriteString("| State | Meaning |\n|---|---|\n") + for i, v := range state.Enum { + name := strings.TrimPrefix(v, "WORKSPACE_STATE_") + if name == "UNSPECIFIED" { + continue // never a workspace's state } + meaning := strings.Join(strings.Fields(state.Descriptions[i]), " ") + if meaning == "" { + return nil, fmt.Errorf("%s: WorkspaceState %s says nothing of what it means: its comment in spin's proto is the row", openAPIPath, v) + } + fmt.Fprintf(&table, "| `%s` | %s |\n", name, strings.ReplaceAll(meaning, "|", `\|`)) } - for s := range want { - if !have[s] { - bad = append(bad, fmt.Sprintf("%s: the API's WorkspaceState %s has no row in the table of states", page, s)) + + page := filepath.Join(docs, statesPage) + have, err := os.ReadFile(page) + if err != nil { + return nil, err + } + // The table is the run of | lines that starts at the States section's header row. + _, after, ok := bytes.Cut(have, []byte("\n## States\n")) + header := bytes.Index(after, []byte("\n| State | Meaning |\n")) + if !ok || header < 0 || bytes.Contains(after[:header], []byte("\n#")) { + return nil, fmt.Errorf("%s: no table of states (| State | Meaning |) under \"## States\"", page) + } + begin := len(have) - len(after) + header + 1 + end := begin + for end < len(have) && have[end] == '|' { + next := bytes.IndexByte(have[end:], '\n') + if next < 0 { + end = len(have) + break } + end += next + 1 + } + want := append(append(append([]byte{}, have[:begin]...), table.String()...), have[end:]...) + differs, err := syncFile(page, want, check) + if err != nil || !differs { + return nil, err } - return bad, nil + return []string{page}, nil } // publicRepos are the spin-stack repositories a page may link to: each public, or made public diff --git a/tools/refgen/prose_test.go b/tools/refgen/prose_test.go new file mode 100644 index 0000000..1791b56 --- /dev/null +++ b/tools/refgen/prose_test.go @@ -0,0 +1,72 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +const statesDoc = `{"components":{"schemas":{"spin.v1.WorkspaceState":{ + "enum":["WORKSPACE_STATE_UNSPECIFIED","WORKSPACE_STATE_RUNNING","WORKSPACE_STATE_STOPPED"], + "x-enum-descriptions":["Never a state.","Its machine is up,\n and a | is a pipe.","Not running."]}}}}` + +const statesBefore = "---\ntitle: Workspaces\n---\n\n## States\n\nWhat a state is.\n\n" + + "| State | Meaning |\n|---|---|\n| `RUNNING` | Up. |\n| `GONE` | A state the API no longer has. |\n\n" + + "A runner tries a start three times.\n\n## Stop\n\n| Stop | Meaning |\n|---|---|\n" + +// sources is a docs directory holding the workspaces page and a spin checkout holding the API's +// document. +func sources(t *testing.T, doc, page string) (docs, spin string) { + t.Helper() + root := t.TempDir() + docs, spin = filepath.Join(root, "docs"), filepath.Join(root, "spin") + for p, body := range map[string]string{filepath.Join(docs, statesPage): page, filepath.Join(spin, openAPIPath): doc} { + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(p, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + } + return docs, spin +} + +// The table of states is the API's, a row a value but the unspecified one, and only the table: +// the prose around it, and the next section's table, are the page's. -check finds a table the +// API no longer says and leaves it; a write makes it the API's. +func TestTheTableOfStatesIsTheAPIs(t *testing.T) { + docs, spin := sources(t, statesDoc, statesBefore) + page := filepath.Join(docs, statesPage) + if stale, err := stateTable(docs, spin, true); err != nil || len(stale) != 1 { + t.Fatalf("-check: %v, %v; want the page out of date", stale, err) + } + if got, _ := os.ReadFile(page); string(got) != statesBefore { + t.Fatal("-check wrote the page") + } + if _, err := stateTable(docs, spin, false); err != nil { + t.Fatal(err) + } + got, _ := os.ReadFile(page) + want := strings.Replace(statesBefore, "| `RUNNING` | Up. |\n| `GONE` | A state the API no longer has. |\n", + "| `RUNNING` | Its machine is up, and a \\| is a pipe. |\n| `STOPPED` | Not running. |\n", 1) + if string(got) != want { + t.Errorf("the page is\n%s\nwant\n%s", got, want) + } + if stale, err := stateTable(docs, spin, true); err != nil || len(stale) != 0 { + t.Errorf("-check after a write: %v, %v; want it current", stale, err) + } +} + +// A document without each value's description, as protoc-gen-connect-openapi writes one without +// with-enum-value-descriptions, has no table to give: refgen fails rather than keep the old one. +func TestAStateWithoutItsMeaningIsAnError(t *testing.T) { + docs, spin := sources(t, `{"components":{"schemas":{"spin.v1.WorkspaceState":{"enum":["WORKSPACE_STATE_RUNNING"]}}}}`, statesBefore) + if _, err := stateTable(docs, spin, false); err == nil || !strings.Contains(err.Error(), "with-enum-value-descriptions") { + t.Errorf("err = %v; want it to name with-enum-value-descriptions", err) + } + docs, spin = sources(t, statesDoc, "## States\n\nNo table.\n\n## Stop\n\n| State | Meaning |\n") + if _, err := stateTable(docs, spin, false); err == nil { + t.Error("a page whose States section has no table: want an error, not a table under another section") + } +}