mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
## What & why Admin/setup docs lead with `coder server --flag` examples, but most operators configure Coder through `CODER_*` environment variables (system service, container, or Helm chart). There is no single page mapping a setting to its env var, CLI flag, YAML key, and default, so searching the docs for an env var name such as `CODER_PG_CONNECTION_URL` returns nothing. This adds a generated configuration reference and begins shifting admin docs to lead with the environment-variable form. ## Changes - **Generated configuration reference** (`docs/admin/setup/configuration-reference.md`): a searchable, per-setting list of every visible deployment option. Each option is a heading (grouped and nested by serpent group) followed by its description and the environment variable, CLI flag, YAML key, and default that apply to it. Generated from `codersdk.DeploymentValues` so it stays in sync. - **Generator + `make gen` wiring** (`scripts/configdocgen/`): new binary plus a Makefile target and `GEN_FILES` entry, mirroring the existing `clidocgen` / `auditdocgen` pattern. Output is host-independent (same env normalization as `clidocgen`). - **Demo conversion** (`docs/admin/users/github-auth.md`): inverted to lead with the `/etc/coder.d/coder.env` env-var form; the CLI-flag form becomes a closing note that links to the reference. H2 slugs preserved. - **Style guide** (`.claude/docs/DOCS_STYLE_GUIDE.md`): documents the env-var-first convention for admin/setup docs. - **Navigation**: manifest entry under Administration → Setup, plus a TIP callout on the setup index. ## Risk Docs + gen pipeline only; no runtime change. The page is regenerated by `make gen`; the `gen` and `check-docs` CI checks pass. ## Follow-up Several other admin pages still lead with flag walls. Recommend sweeping them incrementally in separate PRs rather than expanding scope here. <details> <summary>Implementation notes (provenance, conflict resolution, verification)</summary> - Continues prior work by @aslilac and @bpmct from the `kayla/docs-env-vars-first` branch. Both original commits are cherry-picked here with authorship preserved. - Rebased onto current `main`. Resolved two `Makefile` conflicts where `main` had since added the `feature-stages.md` gen target at the same locations; kept both targets (union) in `GEN_FILES`, `gen/mark-fresh`, and the recipe block. - The original branch's checked-in page predated recent `codersdk.DeploymentValues` changes, so it was **regenerated** against current `main` (adds `CODER_SCIM_USE_LEGACY`, the `Networking / Cluster` section with `CODER_CLUSTER_HOST`, `CODER_BOUNDARY_LOG_RETENTION`, and the AI Gateway description rename). The `gen` CI check enforces this stays current. - Fixed flag-link anchors for short-form flags (`--config`, `--log-filter`): the generator derives the anchor from `FlagShorthand` to match `clidocgen`'s heading (e.g. `#-l---log-filter`). - `linkspector` ignores the AWS Bedrock base URL that appears as an illustrative `<region>` placeholder in an option description, consistent with the existing `openai.com` ignore patterns. </details> <details> <summary>Configuration reference layout (2026-07-08 update)</summary> Reworked the reference from a wide table into a nested, per-setting list so it fits without horizontal scrolling and stops repeating the group name in every heading: - **List, not table.** Each option renders as a heading, its description, and a bullet list of only the configuration methods that apply to it (non-applicable methods are omitted instead of shown as `-`). - **Nested sections.** Sections nest by the serpent group hierarchy, so `Email / Email Authentication` becomes `Email` (h2) with an `Email authentication` (h3) subsection instead of a redundant flat title. - **Shorter, sentence-case headings.** The redundant group prefix is stripped from each option name and the remainder is lowercased to sentence case, preserving acronyms and mixed-case tokens (`URL`, `TLS`, `OAuth2`, `GitHub`) plus a small proper-noun allowlist (`Coder`, `Terraform`, `Honeycomb`, `Anthropic`, `Bedrock`, ...). Example: `AI Gateway Send Actor Headers` becomes `Send actor headers`. - **Deprecated options** sort to the end of each section and lead with an emphasized **Deprecated** marker. Headings stay clean (no `(deprecated)` suffix) so their anchors remain stable. - **Section intros** render from a group's `Description` when the source defines one (e.g. DERP); no hand-maintained prose or links are introduced. All transformations run in pure Go at `make gen` time (no AI at generation time). Generation is idempotent, and `markdownlint` and `golangci-lint` both pass. </details> --- 🤖 Opened by Coder Agents on behalf of @nickvigilante. Continues work by @aslilac and @bpmct. --------- Co-authored-by: Kayla (via Coder Agents) <kayla@coder.com> Co-authored-by: Coder Agents <noreply@coder.com> Co-authored-by: Ben Potter <me@bpmct.net>
240 lines
9.4 KiB
Go
240 lines
9.4 KiB
Go
package main
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/coder/serpent"
|
|
)
|
|
|
|
func TestSentenceCase(t *testing.T) {
|
|
t.Parallel()
|
|
cases := []struct {
|
|
name string
|
|
in string
|
|
want string
|
|
}{
|
|
{"lowercases trailing words", "Send Actor Headers", "Send actor headers"},
|
|
{"keeps trailing acronym", "Anthropic Base URL", "Anthropic base URL"},
|
|
{"keeps all-caps token", "Allow BYOK", "Allow BYOK"},
|
|
{"lowercases ordinary word", "Email Authentication", "Email authentication"},
|
|
{"keeps proper noun", "Trace Honeycomb API Key", "Trace Honeycomb API key"},
|
|
{"restores OpenID Connect", "OpenID Connect sign in text", "OpenID Connect sign in text"},
|
|
{"keeps leading mixed-case token", "SSH Keygen Algorithm", "SSH keygen algorithm"},
|
|
{"single lowercase word", "pprof", "pprof"},
|
|
{"feature name", "AI Gateway", "AI Gateway"},
|
|
{"longer feature name wins", "AI Gateway Proxy", "AI Gateway Proxy"},
|
|
{"feature name as whole title", "Template Builder", "Template Builder"},
|
|
{"feature name after leading word", "Disable Template Builder", "Disable Template Builder"},
|
|
{"leading symbol is not the first word", "⚠️ Dangerous", "⚠️ Dangerous"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
if got := sentenceCase(tc.in); got != tc.want {
|
|
t.Errorf("sentenceCase(%q) = %q, want %q", tc.in, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestStripGroupPrefix(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
aiGateway := serpent.Group{Name: "AI Gateway"}
|
|
email := serpent.Group{Name: "Email"}
|
|
emailAuth := serpent.Group{Name: "Email Authentication", Parent: &email}
|
|
introspection := serpent.Group{Name: "Introspection"}
|
|
healthCheck := serpent.Group{Name: "Health Check", Parent: &introspection}
|
|
networking := serpent.Group{Name: "Networking"}
|
|
derp := serpent.Group{Name: "DERP", Parent: &networking}
|
|
oauth2 := serpent.Group{Name: "OAuth2"}
|
|
github := serpent.Group{Name: "GitHub", Parent: &oauth2}
|
|
dangerous := serpent.Group{Name: "⚠️ Dangerous"}
|
|
|
|
cases := []struct {
|
|
name string
|
|
group *serpent.Group
|
|
want string
|
|
}{
|
|
// Space-prefixed names drop the group path.
|
|
{"AI Gateway Send Actor Headers", &aiGateway, "Send Actor Headers"},
|
|
{"DERP Config Path", &derp, "Config Path"},
|
|
{"OAuth2 GitHub Allow Everyone", &github, "Allow Everyone"},
|
|
// Colon-prefixed names drop up to the last ": ".
|
|
{"Email Auth: Identity", &emailAuth, "Identity"},
|
|
// A meaningful colon that is not a group separator is preserved.
|
|
{"Health Check Threshold: Database", &healthCheck, "Threshold: Database"},
|
|
// The Dangerous group's emoji name still matches its "DANGEROUS:" prefix.
|
|
{"DANGEROUS: Allow Path App Sharing", &dangerous, "Allow Path App Sharing"},
|
|
// Names that do not repeat the group are unchanged.
|
|
{"Access URL", &networking, "Access URL"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
if got := stripGroupPrefix(tc.name, tc.group); got != tc.want {
|
|
t.Errorf("stripGroupPrefix(%q) = %q, want %q", tc.name, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestShortTitle(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
aiGateway := serpent.Group{Name: "AI Gateway"}
|
|
cases := []struct {
|
|
opt serpent.Option
|
|
want string
|
|
}{
|
|
{serpent.Option{Name: "AI Gateway Send Actor Headers", Group: &aiGateway}, "Send actor headers"},
|
|
{serpent.Option{Name: "AI Gateway Anthropic Base URL", Group: &aiGateway}, "Anthropic base URL"},
|
|
// No group: only sentence case applies.
|
|
{serpent.Option{Name: "Cache Directory"}, "Cache directory"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.opt.Name, func(t *testing.T) {
|
|
t.Parallel()
|
|
if got := shortTitle(tc.opt); got != tc.want {
|
|
t.Errorf("shortTitle(%q) = %q, want %q", tc.opt.Name, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIsDeprecated(t *testing.T) {
|
|
t.Parallel()
|
|
cases := []struct {
|
|
name string
|
|
opt serpent.Option
|
|
want bool
|
|
}{
|
|
{"description prefix", serpent.Option{Description: "Deprecated: use X instead."}, true},
|
|
{"description sentence", serpent.Option{Description: "Deprecated and ignored."}, true},
|
|
{"use instead", serpent.Option{UseInstead: []serpent.Option{{Name: "X"}}}, true},
|
|
{"active", serpent.Option{Description: "A normal option."}, false},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
if got := isDeprecated(tc.opt); got != tc.want {
|
|
t.Errorf("isDeprecated(%s) = %v, want %v", tc.name, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEmphasizeDeprecation(t *testing.T) {
|
|
t.Parallel()
|
|
cases := []struct {
|
|
name string
|
|
in string
|
|
want string
|
|
}{
|
|
// Description already starts with the marker: only the marker is bolded.
|
|
{"marker with sentence", "Deprecated and ignored.", "**Deprecated** and ignored."},
|
|
{"marker with colon", "Deprecated: use X.", "**Deprecated**: use X."},
|
|
// Description does not start with the marker (the UseInstead path): the
|
|
// marker is prepended.
|
|
{"no marker", "A normal description.", "**Deprecated.** A normal description."},
|
|
{"empty description", "", "Deprecated."},
|
|
// A bare marker with no trailing text is left unbolded (markdownlint MD036).
|
|
{"bare marker", "Deprecated", "Deprecated"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
if got := emphasizeDeprecation(tc.in); got != tc.want {
|
|
t.Errorf("emphasizeDeprecation(%q) = %q, want %q", tc.in, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCollapse(t *testing.T) {
|
|
t.Parallel()
|
|
if got := collapse("a\n b\tc "); got != "a b c" {
|
|
t.Errorf("collapse() = %q, want %q", got, "a b c")
|
|
}
|
|
}
|
|
|
|
// TestRenderPipeline exercises buildTree and render end to end: section
|
|
// nesting and ordering, option skipping, deprecated sinking, and the per-option
|
|
// bullet list (environment variable, CLI flag anchor, YAML key, default).
|
|
func TestRenderPipeline(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
email := serpent.Group{Name: "Email", YAML: "email"}
|
|
emailAuth := serpent.Group{Name: "Email Authentication", YAML: "emailAuth", Parent: &email}
|
|
dangerous := serpent.Group{Name: "⚠️ Dangerous", YAML: "dangerous"}
|
|
|
|
opts := serpent.OptionSet{
|
|
// Hidden options and options with no env/flag/YAML are skipped.
|
|
{Name: "Hidden Option", Env: "CODER_HIDDEN", Hidden: true},
|
|
{Name: "Unsettable Option"},
|
|
// General section (no group).
|
|
{Name: "Access URL", Env: "CODER_ACCESS_URL", Flag: "access-url", Default: "https://example.com", Description: "The access URL."},
|
|
// A DefaultFn with no static Default renders the computed-at-runtime label.
|
|
{Name: "Cache Directory", Env: "CODER_CACHE_DIRECTORY", Flag: "cache-dir", DefaultFn: func() string { return "~/.cache/coder" }, Description: "The cache directory."},
|
|
// Deprecated via UseInstead: description does not start with "Deprecated".
|
|
{Name: "Email From", Env: "CODER_EMAIL_FROM", Flag: "email-from", YAML: "from", Group: &email, Description: "The sender address.", UseInstead: []serpent.Option{{Name: "Notifications Email From"}}},
|
|
// Active option with a flag shorthand.
|
|
{Name: "Email Smarthost", Env: "CODER_EMAIL_SMARTHOST", Flag: "email-smarthost", FlagShorthand: "s", YAML: "smarthost", Group: &email, Description: "The SMTP host."},
|
|
// Nested child section.
|
|
{Name: "Email Authentication Identity", Env: "CODER_EMAIL_AUTH_IDENTITY", YAML: "identity", Group: &emailAuth, Description: "The identity."},
|
|
// A Dangerous group sorts last regardless of alphabetical order.
|
|
{Name: "DANGEROUS: Allow All Cors", Env: "CODER_DANGEROUS_ALLOW_ALL_CORS", Flag: "dangerous-allow-all-cors", Group: &dangerous, Description: "Allow all cross-origin requests."},
|
|
}
|
|
|
|
got := render(buildTree(opts))
|
|
|
|
wantContains := []string{
|
|
"## General",
|
|
"### Access URL",
|
|
"- Environment variable: `CODER_ACCESS_URL`",
|
|
"- CLI flag: [`--access-url`](../../reference/cli/server.md#--access-url)",
|
|
"- Default value: `https://example.com`",
|
|
"## Email",
|
|
"### Smarthost",
|
|
// Flag shorthand is folded into the anchor to match the CLI reference.
|
|
"- CLI flag: [`--email-smarthost`](../../reference/cli/server.md#-s---email-smarthost)",
|
|
// YAML key is the dotted group path.
|
|
"- YAML key: `email.from`",
|
|
// Deprecated marker is prepended for the UseInstead path.
|
|
"**Deprecated.** The sender address.",
|
|
// A DefaultFn with no static Default is labeled, not evaluated.
|
|
"- Default value: `(computed at runtime)`",
|
|
"### Email authentication",
|
|
"#### Identity",
|
|
"- YAML key: `email.emailAuth.identity`",
|
|
// The Dangerous group renders as its own section.
|
|
"## ⚠️ Dangerous",
|
|
}
|
|
for _, w := range wantContains {
|
|
if !strings.Contains(got, w) {
|
|
t.Errorf("render() missing %q\n---\n%s", w, got)
|
|
}
|
|
}
|
|
|
|
// General (rank -1) sorts before every other top-level section.
|
|
if i, j := strings.Index(got, "## General"), strings.Index(got, "## Email"); i < 0 || j < 0 || i > j {
|
|
t.Errorf("General should render before Email (got indexes %d, %d)", i, j)
|
|
}
|
|
// Active options sort before deprecated ones within a section.
|
|
if i, j := strings.Index(got, "### Smarthost"), strings.Index(got, "### From"); i < 0 || j < 0 || i > j {
|
|
t.Errorf("active option should render before deprecated option (got indexes %d, %d)", i, j)
|
|
}
|
|
// The Dangerous section sorts last among top-level sections.
|
|
if i, j := strings.Index(got, "## Email"), strings.Index(got, "## ⚠️ Dangerous"); i < 0 || j < 0 || i > j {
|
|
t.Errorf("Dangerous section should render last (got indexes %d, %d)", i, j)
|
|
}
|
|
// Hidden and unsettable options never render.
|
|
if strings.Contains(got, "Hidden") {
|
|
t.Error("hidden option should be skipped")
|
|
}
|
|
if strings.Contains(got, "Unsettable") {
|
|
t.Error("option with no env/flag/YAML should be skipped")
|
|
}
|
|
}
|