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>
510 lines
14 KiB
Go
510 lines
14 KiB
Go
// Command configdocgen generates the Coder server configuration reference at
|
|
// docs/admin/setup/configuration-reference.md from codersdk.DeploymentValues.
|
|
// It lists every visible deployment option grouped by serpent group. Each
|
|
// option is rendered as a heading with its description followed by the
|
|
// environment variable, CLI flag, YAML key, and default that apply to it.
|
|
// Because the source is DeploymentValues, the page stays in sync as options
|
|
// change.
|
|
package main
|
|
|
|
import (
|
|
"cmp"
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
"slices"
|
|
"strings"
|
|
"unicode"
|
|
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/coder/v2/scripts/atomicwrite"
|
|
"github.com/coder/flog"
|
|
"github.com/coder/serpent"
|
|
)
|
|
|
|
const header = `<!-- DO NOT EDIT | GENERATED CONTENT -->
|
|
# Configuration reference
|
|
|
|
Coder server is configured primarily through environment variables. This page
|
|
lists every option so you can search by environment variable name, CLI flag, or
|
|
YAML key. For first-time setup guidance and worked examples, see
|
|
[Configure Control Plane Access](./index.md).
|
|
|
|
Each option can be set through one or more of the methods below. An option lists
|
|
only the methods that apply to it.
|
|
|
|
- An environment variable (recommended for production deployments running as a
|
|
system service, container, or Helm chart).
|
|
- A CLI flag passed to ` + "`coder server`" + ` (useful for one-off invocations
|
|
and local development).
|
|
- A key in a YAML configuration file passed with ` + "`--config`" + `.
|
|
|
|
For a full description of each option's accepted values and behavior, follow the
|
|
flag link into the [` + "`coder server`" + ` CLI reference](../../reference/cli/server.md).
|
|
|
|
Deprecated options are listed at the end of each section.
|
|
|
|
`
|
|
|
|
// generalSection holds options that do not belong to a serpent group.
|
|
const generalSection = "General"
|
|
|
|
// option is the normalized data needed to render one deployment option.
|
|
type option struct {
|
|
title string // short, sentence-case heading text
|
|
env string
|
|
flagName string
|
|
flagAnchor string
|
|
yaml string
|
|
defValue string
|
|
desc string
|
|
deprecated bool
|
|
sortKey string // original serpent name, for stable ordering
|
|
}
|
|
|
|
// node is one section of the reference: a serpent group (or the synthetic
|
|
// "General" group) with its direct options and any child sections.
|
|
type node struct {
|
|
name string // raw group name (leaf); sentence-cased at render time
|
|
intro string // group description, if any
|
|
options []option
|
|
children []*node
|
|
childIdx map[string]*node
|
|
}
|
|
|
|
func newNode(name string) *node {
|
|
return &node{name: name, childIdx: map[string]*node{}}
|
|
}
|
|
|
|
// child returns the named child section, creating it on first use.
|
|
func (n *node) child(name string) *node {
|
|
if c, ok := n.childIdx[name]; ok {
|
|
return c
|
|
}
|
|
c := newNode(name)
|
|
n.childIdx[name] = c
|
|
n.children = append(n.children, c)
|
|
return c
|
|
}
|
|
|
|
// prepareEnv mirrors scripts/clidocgen so the generated defaults do not
|
|
// depend on the generating host. Without it, defaults derived from
|
|
// os.UserCacheDir and the config dir embed the local home directory.
|
|
func prepareEnv() {
|
|
for _, env := range os.Environ() {
|
|
if strings.HasPrefix(env, "CODER_") {
|
|
name, _, _ := strings.Cut(env, "=")
|
|
if err := os.Unsetenv(name); err != nil {
|
|
panic(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
err := os.Setenv("CLIDOCGEN_CACHE_DIRECTORY", "~/.cache")
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
err = os.Setenv("CLIDOCGEN_CONFIG_DIRECTORY", "~/.config/coderv2")
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
err = os.Setenv("TMPDIR", "/tmp")
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
}
|
|
|
|
func main() {
|
|
prepareEnv()
|
|
|
|
out := flag.String("out", "docs/admin/setup/configuration-reference.md", "path to write the generated reference page")
|
|
flag.Parse()
|
|
|
|
var vals codersdk.DeploymentValues
|
|
opts := vals.Options()
|
|
|
|
root := buildTree(opts)
|
|
body := render(root)
|
|
|
|
content := header + body
|
|
content = strings.TrimRight(content, "\n") + "\n"
|
|
if err := atomicwrite.File(*out, []byte(content)); err != nil {
|
|
flog.Fatalf("write %s: %v", *out, err)
|
|
}
|
|
flog.Successf("wrote %s", *out)
|
|
}
|
|
|
|
// buildTree groups options into a section tree, skipping hidden options and
|
|
// options that have no environment variable, flag, or YAML key (those cannot
|
|
// be set by an operator).
|
|
func buildTree(opts serpent.OptionSet) *node {
|
|
root := newNode("")
|
|
for _, opt := range opts {
|
|
if opt.Hidden {
|
|
continue
|
|
}
|
|
if opt.Env == "" && opt.Flag == "" && opt.YAML == "" {
|
|
continue
|
|
}
|
|
sec := sectionFor(root, opt.Group)
|
|
sec.options = append(sec.options, toOption(opt))
|
|
}
|
|
sortTree(root)
|
|
return root
|
|
}
|
|
|
|
// sectionFor returns the section node for an option's group, creating the
|
|
// chain of ancestor sections as needed. Options with no group (or an unnamed
|
|
// group) live in the General section.
|
|
func sectionFor(root *node, g *serpent.Group) *node {
|
|
if g == nil {
|
|
return root.child(generalSection)
|
|
}
|
|
cur := root
|
|
for _, ancestor := range g.Ancestry() {
|
|
if ancestor.Name == "" {
|
|
return root.child(generalSection)
|
|
}
|
|
cur = cur.child(ancestor.Name)
|
|
if cur.intro == "" {
|
|
cur.intro = collapse(ancestor.Description)
|
|
}
|
|
}
|
|
return cur
|
|
}
|
|
|
|
func toOption(opt serpent.Option) option {
|
|
var flagName, flagAnchor string
|
|
if opt.Flag != "" {
|
|
flagName = "--" + opt.Flag
|
|
// clidocgen renders a flag heading as "### -s, --flag" when it has a
|
|
// shorthand and "### --flag" otherwise, so the anchor must include the
|
|
// shorthand to match.
|
|
flagAnchor = "--" + opt.Flag
|
|
if opt.FlagShorthand != "" {
|
|
flagAnchor = "-" + opt.FlagShorthand + "---" + opt.Flag
|
|
}
|
|
}
|
|
|
|
def := opt.Default
|
|
if def == "" && opt.DefaultFn != nil {
|
|
// DefaultFn results depend on the host environment, so evaluating them
|
|
// here would leak host-specific values. Send the reader to the CLI
|
|
// reference for the resolved default instead.
|
|
def = "(computed at runtime)"
|
|
}
|
|
|
|
return option{
|
|
title: shortTitle(opt),
|
|
env: opt.Env,
|
|
flagName: flagName,
|
|
flagAnchor: flagAnchor,
|
|
yaml: opt.YAMLPath(),
|
|
defValue: def,
|
|
desc: collapse(opt.Description),
|
|
deprecated: isDeprecated(opt),
|
|
sortKey: opt.Name,
|
|
}
|
|
}
|
|
|
|
// isDeprecated reports whether an option is deprecated. serpent tracks
|
|
// replacements in UseInstead, and codersdk also marks some options by leading
|
|
// the description with "Deprecated".
|
|
func isDeprecated(opt serpent.Option) bool {
|
|
if len(opt.UseInstead) > 0 {
|
|
return true
|
|
}
|
|
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(opt.Description)), "deprecated")
|
|
}
|
|
|
|
// sortTree orders sections and their options. General sorts first and
|
|
// Dangerous last among top-level sections; every other section is
|
|
// alphabetical. Within a section, active options come before deprecated ones,
|
|
// each alphabetical by their original name.
|
|
func sortTree(n *node) {
|
|
slices.SortStableFunc(n.children, func(a, b *node) int {
|
|
if c := cmp.Compare(sectionRank(a.name), sectionRank(b.name)); c != 0 {
|
|
return c
|
|
}
|
|
return strings.Compare(a.name, b.name)
|
|
})
|
|
for _, c := range n.children {
|
|
slices.SortStableFunc(c.options, func(a, b option) int {
|
|
if a.deprecated != b.deprecated {
|
|
if a.deprecated {
|
|
return 1
|
|
}
|
|
return -1
|
|
}
|
|
return strings.Compare(a.sortKey, b.sortKey)
|
|
})
|
|
sortTree(c)
|
|
}
|
|
}
|
|
|
|
// sectionRank orders top-level sections: General first, Dangerous last
|
|
// (regardless of its emoji prefix), everything else alphabetical.
|
|
func sectionRank(name string) int {
|
|
switch {
|
|
case name == generalSection:
|
|
return -1
|
|
case strings.HasSuffix(name, "Dangerous"):
|
|
return 1
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func render(root *node) string {
|
|
var b strings.Builder
|
|
for _, sec := range root.children {
|
|
renderNode(&b, sec, 2)
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
func renderNode(b *strings.Builder, n *node, level int) {
|
|
_, _ = fmt.Fprintf(b, "%s %s\n\n", strings.Repeat("#", level), sentenceCase(n.name))
|
|
if n.intro != "" {
|
|
_, _ = b.WriteString(n.intro)
|
|
_, _ = b.WriteString("\n\n")
|
|
}
|
|
for _, opt := range n.options {
|
|
renderOption(b, opt, level+1)
|
|
}
|
|
for _, c := range n.children {
|
|
renderNode(b, c, level+1)
|
|
}
|
|
}
|
|
|
|
func renderOption(b *strings.Builder, opt option, level int) {
|
|
_, _ = fmt.Fprintf(b, "%s %s\n\n", strings.Repeat("#", level), opt.title)
|
|
|
|
desc := opt.desc
|
|
if opt.deprecated {
|
|
desc = emphasizeDeprecation(desc)
|
|
}
|
|
if desc != "" {
|
|
_, _ = b.WriteString(desc)
|
|
_, _ = b.WriteString("\n\n")
|
|
}
|
|
|
|
if opt.env != "" {
|
|
_, _ = fmt.Fprintf(b, "- Environment variable: `%s`\n", opt.env)
|
|
}
|
|
if opt.flagName != "" {
|
|
_, _ = fmt.Fprintf(b, "- CLI flag: [`%s`](../../reference/cli/server.md#%s)\n", opt.flagName, opt.flagAnchor)
|
|
}
|
|
if opt.yaml != "" {
|
|
_, _ = fmt.Fprintf(b, "- YAML key: `%s`\n", opt.yaml)
|
|
}
|
|
if opt.defValue != "" {
|
|
_, _ = fmt.Fprintf(b, "- Default value: `%s`\n", opt.defValue)
|
|
}
|
|
_, _ = b.WriteString("\n")
|
|
}
|
|
|
|
// emphasizeDeprecation bolds the leading "Deprecated" marker in a description
|
|
// so a deprecated option reads clearly. Trailing text is left unbolded so the
|
|
// paragraph is not a lone emphasis span (markdownlint MD036).
|
|
func emphasizeDeprecation(desc string) string {
|
|
const marker = "Deprecated"
|
|
if len(desc) >= len(marker) && strings.EqualFold(desc[:len(marker)], marker) {
|
|
if strings.TrimSpace(desc[len(marker):]) != "" {
|
|
return "**" + desc[:len(marker)] + "**" + desc[len(marker):]
|
|
}
|
|
return desc
|
|
}
|
|
if desc == "" {
|
|
return "Deprecated."
|
|
}
|
|
return "**Deprecated.** " + desc
|
|
}
|
|
|
|
// shortTitle strips the redundant group prefix from an option name and returns
|
|
// it in sentence case, e.g. "AI Gateway Send Actor Headers" becomes
|
|
// "Send actor headers".
|
|
func shortTitle(opt serpent.Option) string {
|
|
name := opt.Name
|
|
if opt.Group != nil {
|
|
name = stripGroupPrefix(name, opt.Group)
|
|
}
|
|
return sentenceCase(name)
|
|
}
|
|
|
|
// stripGroupPrefix removes the group name that many option names repeat. For
|
|
// space-prefixed names like "AI Gateway Send Actor Headers" it drops the
|
|
// longest matching ancestor chain ("AI Gateway"). For colon-prefixed names
|
|
// like "Notifications: Email TLS: StartTLS" it drops every segment up to the
|
|
// last ": " once the leading segment belongs to the top-level group. Names
|
|
// that do not repeat the group are returned unchanged.
|
|
func stripGroupPrefix(name string, g *serpent.Group) string {
|
|
ancestry := g.Ancestry()
|
|
if len(ancestry) == 0 {
|
|
return name
|
|
}
|
|
names := make([]string, len(ancestry))
|
|
for i, a := range ancestry {
|
|
names[i] = a.Name
|
|
}
|
|
|
|
if before, _, ok := strings.Cut(name, ": "); ok {
|
|
// Only treat the colon as a group separator when the leading segment
|
|
// belongs to the top-level group. This avoids mangling meaningful
|
|
// colons such as "Health Check Threshold: Database".
|
|
if top := normalize(names[0]); top != "" && strings.HasPrefix(normalize(before), top) {
|
|
if idx := strings.LastIndex(name, ": "); idx >= 0 {
|
|
if rest := strings.TrimSpace(name[idx+len(": "):]); rest != "" {
|
|
return rest
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Try the longest ancestor suffix chain first (start == 0 is the full
|
|
// path) so the most specific prefix wins.
|
|
for start := range names {
|
|
prefix := strings.Join(names[start:], " ") + " "
|
|
if rest, ok := cutFold(name, prefix); ok {
|
|
if rest = strings.TrimSpace(rest); rest != "" {
|
|
return rest
|
|
}
|
|
}
|
|
}
|
|
return name
|
|
}
|
|
|
|
// properNouns lists words that keep their capitalization in sentence case.
|
|
// They have ordinary title-case shape, so keepWord's acronym check would not
|
|
// otherwise catch them.
|
|
var properNouns = map[string]bool{
|
|
"anthropic": true,
|
|
"bedrock": true,
|
|
"claude": true,
|
|
"coder": true,
|
|
"google": true,
|
|
"helm": true,
|
|
"honeycomb": true,
|
|
"maven": true,
|
|
"postgres": true,
|
|
"prometheus": true,
|
|
"stackdriver": true,
|
|
"tailscale": true,
|
|
"terraform": true,
|
|
"wireguard": true,
|
|
}
|
|
|
|
// featureNames are multi-word names whose exact casing is restored after
|
|
// sentence-casing. A name that prefixes another comes after the longer one.
|
|
var featureNames = []string{
|
|
"AI Gateway Proxy",
|
|
"AI Gateway",
|
|
"OpenID Connect",
|
|
"Template Builder",
|
|
}
|
|
|
|
// sentenceCase lowercases a heading's words after the first, preserving the
|
|
// first word, acronyms and mixed-case tokens, proper nouns, and feature names.
|
|
func sentenceCase(s string) string {
|
|
words := strings.Fields(s)
|
|
seenFirst := false
|
|
for i, w := range words {
|
|
if !seenFirst {
|
|
// Keep any leading symbols (e.g. an emoji) and the first real word.
|
|
if hasLetter(w) {
|
|
seenFirst = true
|
|
}
|
|
continue
|
|
}
|
|
if !keepWord(w) {
|
|
words[i] = strings.ToLower(w)
|
|
}
|
|
}
|
|
return restoreFeatureNames(strings.Join(words, " "))
|
|
}
|
|
|
|
// restoreFeatureNames rewrites any case-insensitive occurrence of a feature
|
|
// name with its canonical casing.
|
|
func restoreFeatureNames(s string) string {
|
|
for _, name := range featureNames {
|
|
s = replaceFold(s, name)
|
|
}
|
|
return s
|
|
}
|
|
|
|
// replaceFold replaces case-insensitive occurrences of canonical in s with
|
|
// canonical's exact casing. It assumes canonical is ASCII, which holds for the
|
|
// feature names above.
|
|
func replaceFold(s, canonical string) string {
|
|
lower := strings.ToLower(canonical)
|
|
var b strings.Builder
|
|
for {
|
|
idx := strings.Index(strings.ToLower(s), lower)
|
|
if idx < 0 {
|
|
_, _ = b.WriteString(s)
|
|
return b.String()
|
|
}
|
|
_, _ = b.WriteString(s[:idx])
|
|
_, _ = b.WriteString(canonical)
|
|
s = s[idx+len(canonical):]
|
|
}
|
|
}
|
|
|
|
// keepWord reports whether a word must keep its capitalization: proper nouns,
|
|
// all-caps or mixed-case acronyms (URL, GitHub), and tokens with digits
|
|
// (OAuth2).
|
|
func keepWord(w string) bool {
|
|
core := strings.Trim(w, "()[]{}:;,.\"'")
|
|
if core == "" {
|
|
return true
|
|
}
|
|
if properNouns[strings.ToLower(core)] {
|
|
return true
|
|
}
|
|
for i, r := range core {
|
|
if i == 0 {
|
|
continue
|
|
}
|
|
if unicode.IsUpper(r) || unicode.IsDigit(r) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func hasLetter(s string) bool {
|
|
for _, r := range s {
|
|
if unicode.IsLetter(r) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// collapse trims a string and collapses internal runs of whitespace to a
|
|
// single space so multi-line source text renders as one paragraph.
|
|
func collapse(s string) string {
|
|
return strings.Join(strings.Fields(s), " ")
|
|
}
|
|
|
|
// normalize lowercases a string and drops everything but letters and digits,
|
|
// so prefixes can be compared regardless of spacing, case, or punctuation.
|
|
func normalize(s string) string {
|
|
var b strings.Builder
|
|
for _, r := range s {
|
|
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
|
_, _ = b.WriteRune(unicode.ToLower(r))
|
|
}
|
|
}
|
|
return b.String()
|
|
}
|
|
|
|
// cutFold trims prefix from s using a case-insensitive comparison, reporting
|
|
// whether it was present.
|
|
func cutFold(s, prefix string) (string, bool) {
|
|
if len(s) >= len(prefix) && strings.EqualFold(s[:len(prefix)], prefix) {
|
|
return s[len(prefix):], true
|
|
}
|
|
return s, false
|
|
}
|