Files
coder/cli/exp_experimentrules_test.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

136 lines
4.9 KiB
Go

package cli_test
import (
"bytes"
"context"
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/cli/clitest"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
)
func TestExperimentRules(t *testing.T) {
t.Parallel()
// mcp-tool-search is enabled at startup; example is not.
setup := func(t *testing.T) (*codersdk.Client, *codersdk.Client, func(args ...string) (string, string, error)) {
t.Helper()
ownerClient := coderdtest.New(t, &coderdtest.Options{
DeploymentValues: coderdtest.DeploymentValues(t, func(v *codersdk.DeploymentValues) {
v.Experiments = []string{string(codersdk.ExperimentMCPToolSearch)}
}),
})
owner := coderdtest.CreateFirstUser(t, ownerClient)
memberClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)
run := func(args ...string) (string, string, error) {
t.Helper()
inv, root := clitest.New(t, append([]string{"exp", "experiment-rules"}, args...)...)
clitest.SetupConfig(t, ownerClient, root)
var stdout, stderr bytes.Buffer
inv.Stdout = &stdout
inv.Stderr = &stderr
err := inv.WithContext(testutil.Context(t, testutil.WaitLong)).Run()
return stdout.String(), stderr.String(), err
}
return ownerClient, memberClient, run
}
storedRule := func(ctx context.Context, t *testing.T, client *codersdk.Client, ex codersdk.Experiment) *codersdk.ExperimentRule {
t.Helper()
entries, err := codersdk.NewExperimentalClient(client).ExperimentRules(ctx)
require.NoError(t, err)
for _, entry := range entries {
if entry.Experiment == string(ex) {
return entry.Rule
}
}
t.Fatalf("no rules entry for %q", ex)
return nil
}
requireMemberEnabled := func(ctx context.Context, t *testing.T, member *codersdk.Client, ex codersdk.Experiment, want bool) {
t.Helper()
got, err := member.Experiments(ctx)
require.NoError(t, err)
require.Equal(t, want, got.Enabled(ex))
}
// Without --expected-revision, each command writes against the
// current revision, and the change applies to the next request.
t.Run("WriteAndList", func(t *testing.T) {
t.Parallel()
ownerClient, memberClient, run := setup(t)
ctx := testutil.Context(t, testutil.WaitLong)
stdout, _, err := run("on", "example")
require.NoError(t, err)
require.Contains(t, stdout, `Experiment "example" rule is now on (revision 1).`)
requireMemberEnabled(ctx, t, memberClient, codersdk.ExperimentExample, true)
_, _, err = run("set", "example", `user.username == "owner-only"`)
require.NoError(t, err)
requireMemberEnabled(ctx, t, memberClient, codersdk.ExperimentExample, false)
_, _, err = run("off", "example")
require.NoError(t, err)
_, _, err = run("reset", "example")
require.NoError(t, err)
rule := storedRule(ctx, t, ownerClient, codersdk.ExperimentExample)
require.Equal(t, string(codersdk.ExperimentRuleModeInherit), rule.Mode)
require.Equal(t, int64(4), rule.Revision)
stdout, _, err = run("list", "-o", "json")
require.NoError(t, err)
var entries []codersdk.ExperimentRuleEntry
require.NoError(t, json.Unmarshal([]byte(stdout), &entries))
require.Len(t, entries, 2)
require.Equal(t, rule, entries[0].Rule)
stdout, _, err = run("list")
require.NoError(t, err)
require.Regexp(t, `example\s+false\s+inherit\s+4`, stdout)
require.Regexp(t, `mcp-tool-search\s+true\s+\(none\)\s+0`, stdout)
})
// A stale --expected-revision fails with the current state and is not
// retried: the stored rule keeps its revision.
t.Run("ConflictNotRetried", func(t *testing.T) {
t.Parallel()
ownerClient, _, run := setup(t)
ctx := testutil.Context(t, testutil.WaitLong)
_, _, err := run("on", "example", "--expected-revision", "0")
require.NoError(t, err)
_, stderr, err := run("set", "example", `user.username == "x"`, "--expected-revision", "0")
require.Error(t, err)
require.Contains(t, err.Error(), "409")
require.Contains(t, stderr, `Current rule for experiment "example": on (revision 1)`)
rule := storedRule(ctx, t, ownerClient, codersdk.ExperimentExample)
require.Equal(t, string(codersdk.ExperimentRuleModeOn), rule.Mode)
require.Equal(t, int64(1), rule.Revision)
})
// Reset restores the startup default, which may be on, so it warns
// that it is not a kill switch.
t.Run("ResetWarnsWhenStaticDefaultOn", func(t *testing.T) {
t.Parallel()
_, memberClient, run := setup(t)
ctx := testutil.Context(t, testutil.WaitLong)
_, _, err := run("off", "mcp-tool-search")
require.NoError(t, err)
requireMemberEnabled(ctx, t, memberClient, codersdk.ExperimentMCPToolSearch, false)
_, stderr, err := run("reset", "mcp-tool-search")
require.NoError(t, err)
require.Contains(t, stderr, "Reset is not a kill switch")
requireMemberEnabled(ctx, t, memberClient, codersdk.ExperimentMCPToolSearch, true)
_, stderr, err = run("reset", "example")
require.NoError(t, err)
require.NotContains(t, stderr, "Reset is not a kill switch")
})
}