Files
coder/coderd/experiments.go
T
Thomas Kosiewski 12be685b59 feat: add experimental API and CLI to manage experiment rules (#29971)
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>
2026-09-29 12:23:00 +00:00

213 lines
6.9 KiB
Go

package coderd
import (
"errors"
"fmt"
"net/http"
"slices"
"github.com/go-chi/chi/v5"
"github.com/coder/coder/v2/coderd/audit"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
experimentrules "github.com/coder/coder/v2/coderd/experiments"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/coderd/rbac/policy"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/codersdk"
)
// @Summary Get enabled experiments
// @ID get-enabled-experiments
// @Security CoderSessionToken
// @Produce json
// @Tags General
// @Success 200 {array} codersdk.Experiment
// @Router /api/v2/experiments [get]
func (api *API) handleExperimentsGet(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
// The result is personalized by the caller's rules, so it is served
// with no-store caching (see the route registration).
httpapi.Write(ctx, rw, http.StatusOK, api.ExperimentEvaluator.EnabledExperiments(ctx, apiKey.UserID))
}
// @Summary Get safe experiments
// @ID get-safe-experiments
// @Security CoderSessionToken
// @Produce json
// @Tags General
// @Success 200 {array} codersdk.Experiment
// @Router /api/v2/experiments/available [get]
func handleExperimentsAvailable(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
httpapi.Write(ctx, rw, http.StatusOK, codersdk.AvailableExperiments{
Safe: codersdk.ExperimentsSafe,
})
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
//
// @Summary List experiment rules
// @ID list-experiment-rules
// @Security CoderSessionToken
// @Produce json
// @Tags General
// @Success 200 {array} codersdk.ExperimentRuleEntry
// @Router /api/experimental/experiments/rules [get]
// @x-apidocgen {"skip": true}
func (api *API) experimentRules(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if !api.Authorize(r, policy.ActionRead, rbac.ResourceDeploymentConfig) {
httpapi.Forbidden(rw)
return
}
rules, err := experimentrules.ReadRules(ctx, api.Database)
if err != nil {
httpapi.InternalServerError(rw, err)
return
}
entries := make([]codersdk.ExperimentRuleEntry, 0, len(codersdk.ExperimentsUserScoped)+len(rules))
for _, ex := range codersdk.ExperimentsUserScoped {
entry := codersdk.ExperimentRuleEntry{
Experiment: string(ex),
StaticDefault: api.Experiments.Enabled(ex),
}
if rule, ok := rules[ex]; ok {
entry.Rule = ptr.Ref(convertExperimentRule(rule))
}
entries = append(entries, entry)
}
ignored := make([]codersdk.Experiment, 0, len(rules))
for ex := range rules {
if !experimentrules.IsUserScoped(ex) {
ignored = append(ignored, ex)
}
}
slices.Sort(ignored)
for _, ex := range ignored {
entries = append(entries, codersdk.ExperimentRuleEntry{
Experiment: string(ex),
StaticDefault: api.Experiments.Enabled(ex),
Rule: ptr.Ref(convertExperimentRule(rules[ex])),
Ignored: true,
})
}
httpapi.Write(ctx, rw, http.StatusOK, entries)
}
// EXPERIMENTAL: this endpoint is experimental and is subject to change.
//
// @Summary Update experiment rule
// @ID update-experiment-rule
// @Security CoderSessionToken
// @Accept json
// @Produce json
// @Tags General
// @Param experiment path string true "Experiment name"
// @Param request body codersdk.PutExperimentRuleRequest true "Experiment rule"
// @Success 200 {object} codersdk.ExperimentRule
// @Router /api/experimental/experiments/rules/{experiment} [put]
// @x-apidocgen {"skip": true}
func (api *API) putExperimentRule(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
// Authorize before reading the body, so that callers without access
// never reach the CEL compiler. Like other deployment settings, a
// denied request is not audited.
if !api.Authorize(r, policy.ActionRead, rbac.ResourceDeploymentConfig) ||
!api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) {
httpapi.Forbidden(rw)
return
}
ex := codersdk.Experiment(chi.URLParam(r, "experiment"))
var req codersdk.PutExperimentRuleRequest
if !httpapi.Read(ctx, rw, r, &req) {
return
}
if !experimentrules.IsUserScoped(ex) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: fmt.Sprintf("Experiment %q does not accept runtime rules.", ex),
})
return
}
if req.ExpectedRevision < 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Expected revision must not be negative.",
Validations: []codersdk.ValidationError{{
Field: "expected_revision",
Detail: fmt.Sprintf("Got %d.", req.ExpectedRevision),
}},
})
return
}
rule := experimentrules.Rule{
Mode: experimentrules.Mode(req.Mode),
Condition: req.Condition,
}
if err := experimentrules.ValidateRule(ex, rule); err != nil {
// The error carries full CEL diagnostics. Only the author of the
// condition receives them; they are never logged or audited.
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid experiment rule.",
Detail: err.Error(),
})
return
}
// From here on, failures are audited with their status and an empty
// diff. The audit entry is exported after the handler returns, outside
// the write transaction.
aReq, commitAudit := audit.InitRequestWithCancel[database.ExperimentRule](rw, &audit.RequestParams{
Audit: *api.Auditor.Load(),
Log: api.Logger,
Request: r,
Action: database.AuditActionWrite,
})
defer commitAudit(true)
aReq.New = experimentrules.AuditRecord(ex, experimentrules.Rule{})
oldRule, newRule, changed, err := experimentrules.WriteRule(ctx, api.Database, apiKey.UserID, ex, rule, req.ExpectedRevision)
if err != nil {
var conflict *experimentrules.RevisionConflictError
if errors.As(err, &conflict) {
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: fmt.Sprintf("The rule for experiment %q was changed by someone else.", ex),
Detail: fmt.Sprintf("The current revision is %d, but the request expected revision %d. Read the current rule and retry.", conflict.Current, conflict.Expected),
})
return
}
if dbauthz.IsNotAuthorizedError(err) {
httpapi.Forbidden(rw)
return
}
httpapi.InternalServerError(rw, err)
return
}
if !changed {
// An identical rule changes nothing, so nothing is audited.
commitAudit(false)
httpapi.Write(ctx, rw, http.StatusOK, convertExperimentRule(newRule))
return
}
aReq.Old = experimentrules.AuditRecord(ex, oldRule)
aReq.New = experimentrules.AuditRecord(ex, newRule)
httpapi.Write(ctx, rw, http.StatusOK, convertExperimentRule(newRule))
}
func convertExperimentRule(rule experimentrules.Rule) codersdk.ExperimentRule {
return codersdk.ExperimentRule{
Mode: string(rule.Mode),
Condition: rule.Condition,
Revision: rule.Revision,
UpdatedBy: rule.UpdatedBy,
UpdatedAt: rule.UpdatedAt,
}
}