Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 13 additions & 12 deletions src/content/docs/concepts/workspaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,21 +46,22 @@ space.

## States

<!-- The table is written by tools/refgen from the API's WorkspaceState, each row from its value's comment in spin's api/proto/spin/v1/common.proto: edit that, not the table. -->

| 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

Expand Down
6 changes: 5 additions & 1 deletion tools/refgen/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) },
} {
Expand Down
95 changes: 62 additions & 33 deletions tools/refgen/prose.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"bytes"
"encoding/json"
"fmt"
"io/fs"
"os"
Expand Down Expand Up @@ -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
Expand Down
72 changes: 72 additions & 0 deletions tools/refgen/prose_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading