mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
Slice 4 of 4 for runtime experiment rules: an experimental management
API and a hidden CLI, so admins can target, widen, or kill a user-scoped
experiment at runtime without a redeploy. This PR also adds a
startup-default note on the Deployment Overview page and a docs section.
## What's in it
- **API** under `/api/experimental/experiments/rules` (behind
`apiKeyMiddleware` and `httpmw.NoStore`):
- `GET` returns, per user-scoped experiment, the startup default and the
stored rule (mode, condition, revision, actor, time). Stored rules for
other experiments follow, marked `ignored`.
- `PUT /{experiment}` takes `{mode, condition?, expected_revision}`.
- **Write path:**
- It authorizes read and update on `DeploymentConfig` before parsing
anything. A denied request gets 403 and is not audited.
- It validates next. An invalid condition returns 400 with the full CEL
diagnostics, is not audited, and leaves the rule unchanged.
- It then starts the audit and calls `experiments.WriteRule`. A stale
revision returns 409 and a transaction error returns 500; both are
audited with an empty diff.
- An identical write returns 200, creates no audit entry, and keeps the
revision.
- The audit export happens after commit. If it fails, that is logged and
the committed change stands (the existing audit contract).
- **SDK:** `ExperimentalClient.ExperimentRules` and
`ExperimentalClient.PutExperimentRule`.
- **CLI:** hidden `coder exp experiment-rules list|on|off|set|reset`.
- Write commands take `--expected-revision`. Without it they read the
current revision first, which only protects the gap between the two
calls; the help text says so.
- A 409 prints the current rule and exits non-zero, with no retry.
- `reset` warns when the startup default is on, because reset is not a
kill switch.
- **Deployment Overview:** a note says the Experiments values are
startup defaults that runtime rules can override per user.
- **Docs:** a new "Target experiments at runtime" section in
`docs/reference/feature-stages.md` covers the modes, roles, CEL fields
and limits, reset vs. off, revisions and audit, the kill-switch
guarantee (including the rolling-upgrade requirement), frontend
freshness, fail-closed behavior, and that rules are not an authorization
boundary.
## Testing
- **HTTP:** every mode applies on the next request with no restart;
rejected writes keep the rule; RBAC (members 403 on GET and PUT;
auditors can read but not write; an invalid body from an unauthorized
caller still gets 403); malformed and ignored stored rows.
- **HA:** two coderd instances share one database. A PUT through A is
seen by B's `/api/v2/experiments` and by B's evaluator, which is the one
chatd uses.
- **Audit (enterprise, licensed):** an effective change creates one
entry whose diff carries the condition, and the entry is found before
the diff is checked. No-ops, 403s and 400s create no entry. A 409 and an
injected 500 are audited with an empty diff. A failed export keeps the
change.
- **Redaction:** a sentinel condition appears only in the rules API,
audit entries, and the server log's audit lines (Enterprise's slog audit
backend writes every audit diff there). It is absent from every other
log line, `/deployment/config`, the support bundle, and telemetry. The
test wires the slog audit backend so this is checked as stated.
- **CLI:** write and list, a conflict that is not retried, and the reset
warning.
Remote dogfood UAT through Coder Agents passed on a licensed test
deployment:
- the CLI;
- per-user results in the browser;
- 403 for members, including an invalid body;
- group targeting, including removing the user from the group;
- sentinel redaction across logs, `/deployment/config`, the support
bundle and the audit API (at that head the audit still masked the
condition; it is now tracked);
- the audit page showing the condition as changed but masked (same
note);
- the Overview note;
- the static default with `off` and `reset`;
- chatd enforcement toggled between turns.
That round tested `135aaaa41e`. The later commits are a CLI refactor and
a test addition, and a follow-up round will cover the final head.
## Follow-ups (low, not in this PR)
- A broken rule logs one error line per evaluation. Consider
rate-limiting it per experiment and revision.
- There's no API or CLI path to clear a stored rule for an experiment
that isn't user-scoped; those rules are inert and listed as `ignored`.
- A malformed stored rule is listed with zero `updated_by` and
`updated_at` values instead of omitting them.
- CLI wording: the 409 message, conflict output showing a UUID instead
of a username, a no-op write not saying nothing changed, and the parent
help lacking the `user.*` fields.
- `PutExperimentRuleRequest.mode` is not in the Swagger `required` list.
An omitted mode already returns 400.
- The dashboard's experiments query (from #29964) refreshes on mount,
window focus or reconnect once 60 seconds old, not on a timer, and its
comment says it bounds staleness. Correct the comment, or add a refetch
interval if the UI must pick up rule changes without a reload.
## Stack
1. #29869: evaluator library
2. #29898: storage and audit types
3. #29964: enforcement and discovery
4. **This PR:** control plane
> [!NOTE]
> This PR was written by an AI agent (Xum).
---
_Generated with [`xum`](https://github.com/coder/xum) • Model:
`anthropic:claude-opus-5-5` • Thinking: `high`_
---------
Signed-off-by: Thomas Kosiewski <tk@coder.com>
228 lines
8.1 KiB
Go
228 lines
8.1 KiB
Go
package cli
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"time"
|
|
|
|
"golang.org/x/xerrors"
|
|
|
|
"github.com/coder/coder/v2/cli/cliui"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/serpent"
|
|
)
|
|
|
|
const experimentRuleRevisionHelp = "Without --expected-revision, the command reads the current revision " +
|
|
"and writes against it. That protects only against a change made between that read and the write. " +
|
|
"A conflicting change fails the command; it is never retried automatically."
|
|
|
|
func (r *RootCmd) experimentRulesCommand() *serpent.Command {
|
|
return &serpent.Command{
|
|
Use: "experiment-rules",
|
|
Short: "Manage runtime rules for user-scoped experiments",
|
|
Long: "Runtime rules turn user-scoped experiments on or off, or target them with a CEL " +
|
|
"condition over `user`, without a restart. The API and this command are experimental.\n" +
|
|
FormatExamples(
|
|
Example{
|
|
Description: "Enable an experiment for users whose condition is true",
|
|
Command: `coder exp experiment-rules set mcp-tool-search 'user.email.endsWith("@example.com")'`,
|
|
},
|
|
Example{
|
|
Description: "Turn an experiment off for everyone, even if it is enabled at startup",
|
|
Command: "coder exp experiment-rules off mcp-tool-search",
|
|
},
|
|
Example{
|
|
Description: "Restore the startup --experiments default",
|
|
Command: "coder exp experiment-rules reset mcp-tool-search",
|
|
},
|
|
),
|
|
Handler: func(inv *serpent.Invocation) error {
|
|
return inv.Command.HelpHandler(inv)
|
|
},
|
|
Children: []*serpent.Command{
|
|
r.experimentRulesList(),
|
|
r.experimentRuleWrite("on", "Turn an experiment on for every user", codersdk.ExperimentRuleModeOn),
|
|
r.experimentRuleWrite("off", "Turn an experiment off for every user, even if it is enabled at startup", codersdk.ExperimentRuleModeOff),
|
|
r.experimentRuleWrite("set", "Turn an experiment on for users whose CEL condition is true", codersdk.ExperimentRuleModeCondition),
|
|
r.experimentRuleWrite("reset", "Restore the startup --experiments default of an experiment", codersdk.ExperimentRuleModeInherit),
|
|
},
|
|
}
|
|
}
|
|
|
|
type experimentRuleRow struct {
|
|
Experiment codersdk.Experiment `table:"experiment,default_sort"`
|
|
StaticDefault bool `table:"static default"`
|
|
Mode string `table:"mode"`
|
|
Condition string `table:"condition"`
|
|
Revision int64 `table:"revision"`
|
|
UpdatedAt string `table:"updated at"`
|
|
Ignored bool `table:"ignored"`
|
|
}
|
|
|
|
func experimentRuleRows(entries []codersdk.ExperimentRuleEntry) []experimentRuleRow {
|
|
rows := make([]experimentRuleRow, 0, len(entries))
|
|
for _, entry := range entries {
|
|
row := experimentRuleRow{
|
|
Experiment: codersdk.Experiment(entry.Experiment),
|
|
StaticDefault: entry.StaticDefault,
|
|
Mode: "(none)",
|
|
Ignored: entry.Ignored,
|
|
}
|
|
if entry.Rule != nil {
|
|
row.Mode = experimentRuleModeLabel(entry.Rule.Mode)
|
|
row.Condition = entry.Rule.Condition
|
|
row.Revision = entry.Rule.Revision
|
|
if !entry.Rule.UpdatedAt.IsZero() {
|
|
row.UpdatedAt = entry.Rule.UpdatedAt.Format(time.RFC3339)
|
|
}
|
|
}
|
|
rows = append(rows, row)
|
|
}
|
|
return rows
|
|
}
|
|
|
|
func experimentRuleModeLabel(mode string) string {
|
|
if mode == "" {
|
|
return "(malformed)"
|
|
}
|
|
return mode
|
|
}
|
|
|
|
func (r *RootCmd) experimentRulesList() *serpent.Command {
|
|
formatter := cliui.NewOutputFormatter(
|
|
cliui.ChangeFormatterData(
|
|
cliui.TableFormat([]experimentRuleRow{}, []string{"experiment", "static default", "mode", "condition", "revision", "updated at", "ignored"}),
|
|
func(data any) (any, error) {
|
|
entries, ok := data.([]codersdk.ExperimentRuleEntry)
|
|
if !ok {
|
|
return nil, xerrors.Errorf("expected []codersdk.ExperimentRuleEntry, got %T", data)
|
|
}
|
|
return experimentRuleRows(entries), nil
|
|
},
|
|
),
|
|
cliui.JSONFormat(),
|
|
)
|
|
cmd := &serpent.Command{
|
|
Use: "list",
|
|
Short: "List the runtime rules of user-scoped experiments and ignored stored rules",
|
|
Middleware: serpent.RequireNArgs(0),
|
|
Handler: func(inv *serpent.Invocation) error {
|
|
client, err := r.InitClient(inv)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
entries, err := codersdk.NewExperimentalClient(client).ExperimentRules(inv.Context())
|
|
if err != nil {
|
|
return xerrors.Errorf("list experiment rules: %w", err)
|
|
}
|
|
out, err := formatter.Format(inv.Context(), entries)
|
|
if err != nil {
|
|
return xerrors.Errorf("format output: %w", err)
|
|
}
|
|
_, _ = fmt.Fprintln(inv.Stdout, out)
|
|
return nil
|
|
},
|
|
}
|
|
formatter.AttachOptions(&cmd.Options)
|
|
return cmd
|
|
}
|
|
|
|
func (r *RootCmd) experimentRuleWrite(use, short string, mode codersdk.ExperimentRuleMode) *serpent.Command {
|
|
var expectedRevision int64
|
|
args := 1
|
|
usage := use + " <experiment>"
|
|
if mode == codersdk.ExperimentRuleModeCondition {
|
|
args = 2
|
|
usage = use + " <experiment> <condition>"
|
|
}
|
|
long := experimentRuleRevisionHelp
|
|
if mode == codersdk.ExperimentRuleModeInherit {
|
|
long = "Reset is not a kill switch: the startup default may enable the experiment. " +
|
|
"Use off to disable it for everyone.\n\n" + long
|
|
}
|
|
return &serpent.Command{
|
|
Use: usage,
|
|
Short: short,
|
|
Long: long,
|
|
Middleware: serpent.RequireNArgs(args),
|
|
Options: serpent.OptionSet{
|
|
{
|
|
Flag: "expected-revision",
|
|
Description: "Write only if the stored rule is at this revision. Use 0 for an experiment that never had a rule.",
|
|
Value: serpent.Int64Of(&expectedRevision),
|
|
},
|
|
},
|
|
Handler: func(inv *serpent.Invocation) error {
|
|
ctx := inv.Context()
|
|
client, err := r.InitClient(inv)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
exp := codersdk.NewExperimentalClient(client)
|
|
ex := codersdk.Experiment(inv.Args[0])
|
|
req := codersdk.PutExperimentRuleRequest{
|
|
Mode: mode,
|
|
ExpectedRevision: expectedRevision,
|
|
}
|
|
if mode == codersdk.ExperimentRuleModeCondition {
|
|
req.Condition = inv.Args[1]
|
|
}
|
|
|
|
revisionSet := inv.ParsedFlags().Changed("expected-revision")
|
|
var current *codersdk.ExperimentRuleEntry
|
|
if !revisionSet || mode == codersdk.ExperimentRuleModeInherit {
|
|
entries, err := exp.ExperimentRules(ctx)
|
|
if err != nil {
|
|
return xerrors.Errorf("read experiment rules: %w", err)
|
|
}
|
|
current = findExperimentRuleEntry(entries, ex)
|
|
}
|
|
if !revisionSet && current != nil && current.Rule != nil {
|
|
req.ExpectedRevision = current.Rule.Revision
|
|
}
|
|
|
|
rule, err := exp.PutExperimentRule(ctx, ex, req)
|
|
if err != nil {
|
|
var sdkErr *codersdk.Error
|
|
if errors.As(err, &sdkErr) && sdkErr.StatusCode() == http.StatusConflict {
|
|
// Show what changed and stop. Retrying could silently
|
|
// overwrite another administrator's rule.
|
|
if entries, listErr := exp.ExperimentRules(ctx); listErr == nil {
|
|
printCurrentExperimentRule(inv, ex, findExperimentRuleEntry(entries, ex))
|
|
}
|
|
return xerrors.Errorf("rule for experiment %q was not changed: %w", ex, err)
|
|
}
|
|
return xerrors.Errorf("write rule for experiment %q: %w", ex, err)
|
|
}
|
|
_, _ = fmt.Fprintf(inv.Stdout, "Experiment %q rule is now %s (revision %d).\n", ex, experimentRuleModeLabel(rule.Mode), rule.Revision)
|
|
if mode == codersdk.ExperimentRuleModeInherit && current != nil && current.StaticDefault {
|
|
cliui.Warnf(inv.Stderr, "Experiment %q is enabled by the startup --experiments default, so it is now on for every user. Reset is not a kill switch; use off to disable it.", ex)
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
}
|
|
|
|
func findExperimentRuleEntry(entries []codersdk.ExperimentRuleEntry, ex codersdk.Experiment) *codersdk.ExperimentRuleEntry {
|
|
for i := range entries {
|
|
if entries[i].Experiment == string(ex) {
|
|
return &entries[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func printCurrentExperimentRule(inv *serpent.Invocation, ex codersdk.Experiment, entry *codersdk.ExperimentRuleEntry) {
|
|
if entry == nil || entry.Rule == nil {
|
|
_, _ = fmt.Fprintf(inv.Stderr, "Current rule for experiment %q: none (revision 0).\n", ex)
|
|
return
|
|
}
|
|
rule := entry.Rule
|
|
_, _ = fmt.Fprintf(inv.Stderr, "Current rule for experiment %q: %s (revision %d), updated at %s by %s.\n",
|
|
ex, experimentRuleModeLabel(rule.Mode), rule.Revision, rule.UpdatedAt.Format(time.RFC3339), rule.UpdatedBy)
|
|
if rule.Condition != "" {
|
|
_, _ = fmt.Fprintf(inv.Stderr, "Condition: %s\n", rule.Condition)
|
|
}
|
|
}
|