Files
coder/scripts/configdocgen/main_test.go
T
ba4779fc87 docs: lead with env vars in admin docs and add configuration reference (#26824)
## 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>
2026-08-03 14:33:01 -04:00

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")
}
}