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>
This commit is contained in:
Thomas Kosiewski
2026-09-29 12:23:00 +00:00
committed by GitHub
parent 9aa7130e01
commit 12be685b59
16 changed files with 1915 additions and 3 deletions
+227
View File
@@ -0,0 +1,227 @@
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)
}
}
+135
View File
@@ -0,0 +1,135 @@
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")
})
}
+1
View File
@@ -169,6 +169,7 @@ func (r *RootCmd) AGPLExperimental() []*serpent.Command {
r.rptyCommand(),
r.syncCommand(),
r.updateUserEmail(),
r.experimentRulesCommand(),
}
}
+171
View File
@@ -187,6 +187,86 @@ const docTemplate = `{
]
}
},
"/api/experimental/experiments/rules": {
"get": {
"produces": [
"application/json"
],
"tags": [
"General"
],
"summary": "List experiment rules",
"operationId": "list-experiment-rules",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.ExperimentRuleEntry"
}
}
}
},
"security": [
{
"CoderSessionToken": []
}
],
"x-apidocgen": {
"skip": true
}
}
},
"/api/experimental/experiments/rules/{experiment}": {
"put": {
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"General"
],
"summary": "Update experiment rule",
"operationId": "update-experiment-rule",
"parameters": [
{
"type": "string",
"description": "Experiment name",
"name": "experiment",
"in": "path",
"required": true
},
{
"description": "Experiment rule",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/codersdk.PutExperimentRuleRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.ExperimentRule"
}
}
},
"security": [
{
"CoderSessionToken": []
}
],
"x-apidocgen": {
"skip": true
}
}
},
"/api/experimental/mcp/servers/{mcpServer}/oauth2/callback": {
"get": {
"produces": [
@@ -23975,6 +24055,71 @@ const docTemplate = `{
"ExperimentEnableAIWorkspaceDebug"
]
},
"codersdk.ExperimentRule": {
"type": "object",
"properties": {
"condition": {
"description": "Condition is the CEL expression of a condition rule.",
"type": "string"
},
"mode": {
"description": "Mode is one of the ExperimentRuleMode values, or empty when the\nstored rule is malformed. A malformed rule decides off until it is\nreplaced. It is a plain string so that clients can represent the\nmalformed state.",
"type": "string"
},
"revision": {
"description": "Revision increases on every change and starts at 1. Zero means the\nstored rule has no readable positive revision, so it is malformed.",
"type": "integer"
},
"updated_at": {
"type": "string",
"format": "date-time"
},
"updated_by": {
"type": "string",
"format": "uuid"
}
}
},
"codersdk.ExperimentRuleEntry": {
"type": "object",
"properties": {
"experiment": {
"description": "Experiment is the experiment name. Ignored entries can name\nexperiments this version does not know, so it is a plain string.",
"type": "string"
},
"ignored": {
"description": "Ignored is true for a stored rule of an experiment that does not\naccept runtime rules. Such a rule has no effect.",
"type": "boolean"
},
"rule": {
"description": "Rule is null when no rule was ever stored.",
"allOf": [
{
"$ref": "#/definitions/codersdk.ExperimentRule"
}
]
},
"static_default": {
"description": "StaticDefault reports whether the experiment is in the startup\n--experiments list of the replica that answered.",
"type": "boolean"
}
}
},
"codersdk.ExperimentRuleMode": {
"type": "string",
"enum": [
"inherit",
"on",
"off",
"condition"
],
"x-enum-varnames": [
"ExperimentRuleModeInherit",
"ExperimentRuleModeOn",
"ExperimentRuleModeOff",
"ExperimentRuleModeCondition"
]
},
"codersdk.ExternalAPIKeyScopes": {
"type": "object",
"properties": {
@@ -27820,6 +27965,32 @@ const docTemplate = `{
"ProxyUnregistered"
]
},
"codersdk.PutExperimentRuleRequest": {
"type": "object",
"properties": {
"condition": {
"description": "Condition is required for the condition mode and must be empty\notherwise.",
"type": "string"
},
"expected_revision": {
"description": "ExpectedRevision must equal the current revision of the stored rule,\nor zero when no rule is stored or the stored rule has no readable\npositive revision. A different revision fails with 409 Conflict.",
"type": "integer"
},
"mode": {
"enum": [
"inherit",
"on",
"off",
"condition"
],
"allOf": [
{
"$ref": "#/definitions/codersdk.ExperimentRuleMode"
}
]
}
}
},
"codersdk.PutExtendWorkspaceRequest": {
"type": "object",
"required": [
+151
View File
@@ -156,6 +156,76 @@
]
}
},
"/api/experimental/experiments/rules": {
"get": {
"produces": ["application/json"],
"tags": ["General"],
"summary": "List experiment rules",
"operationId": "list-experiment-rules",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/codersdk.ExperimentRuleEntry"
}
}
}
},
"security": [
{
"CoderSessionToken": []
}
],
"x-apidocgen": {
"skip": true
}
}
},
"/api/experimental/experiments/rules/{experiment}": {
"put": {
"consumes": ["application/json"],
"produces": ["application/json"],
"tags": ["General"],
"summary": "Update experiment rule",
"operationId": "update-experiment-rule",
"parameters": [
{
"type": "string",
"description": "Experiment name",
"name": "experiment",
"in": "path",
"required": true
},
{
"description": "Experiment rule",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/codersdk.PutExperimentRuleRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.ExperimentRule"
}
}
},
"security": [
{
"CoderSessionToken": []
}
],
"x-apidocgen": {
"skip": true
}
}
},
"/api/experimental/mcp/servers/{mcpServer}/oauth2/callback": {
"get": {
"produces": ["text/html"],
@@ -21850,6 +21920,66 @@
"ExperimentEnableAIWorkspaceDebug"
]
},
"codersdk.ExperimentRule": {
"type": "object",
"properties": {
"condition": {
"description": "Condition is the CEL expression of a condition rule.",
"type": "string"
},
"mode": {
"description": "Mode is one of the ExperimentRuleMode values, or empty when the\nstored rule is malformed. A malformed rule decides off until it is\nreplaced. It is a plain string so that clients can represent the\nmalformed state.",
"type": "string"
},
"revision": {
"description": "Revision increases on every change and starts at 1. Zero means the\nstored rule has no readable positive revision, so it is malformed.",
"type": "integer"
},
"updated_at": {
"type": "string",
"format": "date-time"
},
"updated_by": {
"type": "string",
"format": "uuid"
}
}
},
"codersdk.ExperimentRuleEntry": {
"type": "object",
"properties": {
"experiment": {
"description": "Experiment is the experiment name. Ignored entries can name\nexperiments this version does not know, so it is a plain string.",
"type": "string"
},
"ignored": {
"description": "Ignored is true for a stored rule of an experiment that does not\naccept runtime rules. Such a rule has no effect.",
"type": "boolean"
},
"rule": {
"description": "Rule is null when no rule was ever stored.",
"allOf": [
{
"$ref": "#/definitions/codersdk.ExperimentRule"
}
]
},
"static_default": {
"description": "StaticDefault reports whether the experiment is in the startup\n--experiments list of the replica that answered.",
"type": "boolean"
}
}
},
"codersdk.ExperimentRuleMode": {
"type": "string",
"enum": ["inherit", "on", "off", "condition"],
"x-enum-varnames": [
"ExperimentRuleModeInherit",
"ExperimentRuleModeOn",
"ExperimentRuleModeOff",
"ExperimentRuleModeCondition"
]
},
"codersdk.ExternalAPIKeyScopes": {
"type": "object",
"properties": {
@@ -25550,6 +25680,27 @@
"ProxyUnregistered"
]
},
"codersdk.PutExperimentRuleRequest": {
"type": "object",
"properties": {
"condition": {
"description": "Condition is required for the condition mode and must be empty\notherwise.",
"type": "string"
},
"expected_revision": {
"description": "ExpectedRevision must equal the current revision of the stored rule,\nor zero when no rule is stored or the stored rule has no readable\npositive revision. A different revision fails with 409 Conflict.",
"type": "integer"
},
"mode": {
"enum": ["inherit", "on", "off", "condition"],
"allOf": [
{
"$ref": "#/definitions/codersdk.ExperimentRuleMode"
}
]
}
}
},
"codersdk.PutExtendWorkspaceRequest": {
"type": "object",
"required": ["deadline"],
+6
View File
@@ -1384,6 +1384,12 @@ func New(options *Options) *API {
r.Mount("/", api.mcpHTTPHandler())
})
})
r.Route("/experiments/rules", func(r chi.Router) {
// Responses contain condition text, so they are never cached.
r.Use(apiKeyMiddleware, httpmw.NoStore)
r.Get("/", api.experimentRules)
r.Put("/{experiment}", api.putExperimentRule)
})
r.Route("/watch-all-workspacebuilds", func(r chi.Router) {
r.Use(
apiKeyMiddleware,
+300
View File
@@ -0,0 +1,300 @@
package coderd_test
import (
"context"
"fmt"
"net/http"
"testing"
"github.com/stretchr/testify/require"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/rbac"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/testutil"
)
// ruleWriter writes experiment rules through the experimental API and
// tracks the revision of each experiment so that every write expects the
// current revision.
type ruleWriter struct {
t *testing.T
client *codersdk.ExperimentalClient
revisions map[codersdk.Experiment]int64
}
func newRuleWriter(t *testing.T, client *codersdk.Client) *ruleWriter {
return &ruleWriter{t: t, client: codersdk.NewExperimentalClient(client), revisions: map[codersdk.Experiment]int64{}}
}
func (w *ruleWriter) put(ctx context.Context, ex codersdk.Experiment, mode codersdk.ExperimentRuleMode, condition string) codersdk.ExperimentRule {
w.t.Helper()
rule, err := w.client.PutExperimentRule(ctx, ex, codersdk.PutExperimentRuleRequest{
Mode: mode,
Condition: condition,
ExpectedRevision: w.revisions[ex],
})
require.NoError(w.t, err)
require.Equal(w.t, w.revisions[ex]+1, rule.Revision)
w.revisions[ex] = rule.Revision
return rule
}
func requireEnabled(ctx context.Context, t *testing.T, client *codersdk.Client, ex codersdk.Experiment, want bool) {
t.Helper()
got, err := client.Experiments(ctx)
require.NoError(t, err)
require.Equal(t, want, got.Enabled(ex), "experiment %q for the caller", ex)
}
func requireStatus(t *testing.T, err error, status int) *codersdk.Error {
t.Helper()
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, status, sdkErr.StatusCode(), sdkErr.Error())
return sdkErr
}
func ruleEntry(ctx context.Context, t *testing.T, client *codersdk.ExperimentalClient, ex codersdk.Experiment) codersdk.ExperimentRuleEntry {
t.Helper()
entries, err := client.ExperimentRules(ctx)
require.NoError(t, err)
for _, entry := range entries {
if entry.Experiment == string(ex) {
return entry
}
}
t.Fatalf("no rules entry for experiment %q", ex)
return codersdk.ExperimentRuleEntry{}
}
func TestExperimentRules(t *testing.T) {
t.Parallel()
// Each write takes effect on the member's next request, without a
// restart. mcp-tool-search is enabled at startup, example is not.
t.Run("WritesApplyToNextRequest", func(t *testing.T) {
t.Parallel()
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, member := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)
ctx := testutil.Context(t, testutil.WaitLong)
w := newRuleWriter(t, ownerClient)
entries, err := w.client.ExperimentRules(ctx)
require.NoError(t, err)
require.Equal(t, []codersdk.ExperimentRuleEntry{
{Experiment: string(codersdk.ExperimentExample)},
{Experiment: string(codersdk.ExperimentMCPToolSearch), StaticDefault: true},
}, entries)
w.put(ctx, codersdk.ExperimentExample, codersdk.ExperimentRuleModeOn, "")
requireEnabled(ctx, t, memberClient, codersdk.ExperimentExample, true)
w.put(ctx, codersdk.ExperimentExample, codersdk.ExperimentRuleModeCondition, fmt.Sprintf("user.username == %q", member.Username))
requireEnabled(ctx, t, memberClient, codersdk.ExperimentExample, true)
requireEnabled(ctx, t, ownerClient, codersdk.ExperimentExample, false)
w.put(ctx, codersdk.ExperimentExample, codersdk.ExperimentRuleModeOff, "")
requireEnabled(ctx, t, memberClient, codersdk.ExperimentExample, false)
// off is a kill switch for a statically enabled experiment, and
// reset restores the startup default.
w.put(ctx, codersdk.ExperimentMCPToolSearch, codersdk.ExperimentRuleModeOff, "")
requireEnabled(ctx, t, memberClient, codersdk.ExperimentMCPToolSearch, false)
w.put(ctx, codersdk.ExperimentMCPToolSearch, codersdk.ExperimentRuleModeInherit, "")
requireEnabled(ctx, t, memberClient, codersdk.ExperimentMCPToolSearch, true)
entry := ruleEntry(ctx, t, w.client, codersdk.ExperimentMCPToolSearch)
require.True(t, entry.StaticDefault)
require.NotNil(t, entry.Rule)
require.Equal(t, string(codersdk.ExperimentRuleModeInherit), entry.Rule.Mode)
require.Equal(t, int64(2), entry.Rule.Revision)
require.Equal(t, owner.UserID, entry.Rule.UpdatedBy)
require.False(t, entry.Rule.UpdatedAt.IsZero())
// Responses carry condition text and must never be cached.
for _, req := range []struct {
method, path string
body any
}{
{http.MethodGet, "/api/experimental/experiments/rules", nil},
{http.MethodPut, "/api/experimental/experiments/rules/example", codersdk.PutExperimentRuleRequest{Mode: codersdk.ExperimentRuleModeOff, ExpectedRevision: 3}},
} {
res, err := ownerClient.Request(ctx, req.method, req.path, req.body)
require.NoError(t, err)
_ = res.Body.Close()
require.Equal(t, http.StatusOK, res.StatusCode, req.method)
require.Equal(t, "no-store", res.Header.Get("Cache-Control"), req.method)
}
})
t.Run("RejectedWritesKeepRule", func(t *testing.T) {
t.Parallel()
ownerClient := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, ownerClient)
ctx := testutil.Context(t, testutil.WaitLong)
w := newRuleWriter(t, ownerClient)
stored := w.put(ctx, codersdk.ExperimentExample, codersdk.ExperimentRuleModeOn, "")
put := func(ex codersdk.Experiment, req codersdk.PutExperimentRuleRequest) error {
_, err := w.client.PutExperimentRule(ctx, ex, req)
return err
}
// The author of an invalid condition receives the diagnostics.
err := put(codersdk.ExperimentExample, codersdk.PutExperimentRuleRequest{Mode: codersdk.ExperimentRuleModeCondition, Condition: `user.username == "a" &&`, ExpectedRevision: 1})
require.Contains(t, requireStatus(t, err, http.StatusBadRequest).Detail, "Syntax error")
for _, req := range []codersdk.PutExperimentRuleRequest{
{Mode: codersdk.ExperimentRuleModeOff, Condition: "true", ExpectedRevision: 1},
{Mode: codersdk.ExperimentRuleModeCondition, ExpectedRevision: 1},
{Mode: "percent", ExpectedRevision: 1},
{Mode: codersdk.ExperimentRuleModeOff, ExpectedRevision: -1},
} {
requireStatus(t, put(codersdk.ExperimentExample, req), http.StatusBadRequest)
}
for _, ex := range []codersdk.Experiment{codersdk.ExperimentAutoFillParameters, "not-an-experiment"} {
requireStatus(t, put(ex, codersdk.PutExperimentRuleRequest{Mode: codersdk.ExperimentRuleModeOn}), http.StatusBadRequest)
}
// A stale revision conflicts; the same rule at the current
// revision is an accepted no-op.
requireStatus(t, put(codersdk.ExperimentExample, codersdk.PutExperimentRuleRequest{Mode: codersdk.ExperimentRuleModeOff}), http.StatusConflict)
same, err := w.client.PutExperimentRule(ctx, codersdk.ExperimentExample, codersdk.PutExperimentRuleRequest{Mode: codersdk.ExperimentRuleModeOn, ExpectedRevision: 1})
require.NoError(t, err)
require.Equal(t, stored, same)
entries, err := w.client.ExperimentRules(ctx)
require.NoError(t, err)
require.Equal(t, []codersdk.ExperimentRuleEntry{
{Experiment: string(codersdk.ExperimentExample), Rule: &stored},
{Experiment: string(codersdk.ExperimentMCPToolSearch)},
}, entries)
})
// Authorization runs before the body is parsed or compiled: a caller
// without update access gets 403 even for an invalid condition.
t.Run("Authorization", func(t *testing.T) {
t.Parallel()
ownerClient := coderdtest.New(t, nil)
owner := coderdtest.CreateFirstUser(t, ownerClient)
memberClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)
auditorClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleAuditor())
ctx := testutil.Context(t, testutil.WaitLong)
member := codersdk.NewExperimentalClient(memberClient)
_, err := member.ExperimentRules(ctx)
requireStatus(t, err, http.StatusForbidden)
auditor := codersdk.NewExperimentalClient(auditorClient)
_, err = auditor.ExperimentRules(ctx)
require.NoError(t, err)
for _, client := range []*codersdk.ExperimentalClient{member, auditor} {
for _, req := range []codersdk.PutExperimentRuleRequest{
{Mode: codersdk.ExperimentRuleModeOn},
{Mode: codersdk.ExperimentRuleModeCondition, Condition: "user."},
} {
_, err := client.PutExperimentRule(ctx, codersdk.ExperimentExample, req)
requireStatus(t, err, http.StatusForbidden)
}
}
require.Nil(t, ruleEntry(ctx, t, codersdk.NewExperimentalClient(ownerClient), codersdk.ExperimentExample).Rule)
})
// Stored rules the API cannot write are listed but ignored, and a
// malformed rule of a user-scoped experiment stays replaceable, even
// when its revision is unreadable.
t.Run("StoredRulesOutsideTheAPI", func(t *testing.T) {
t.Parallel()
ownerClient, db := coderdtest.NewWithDatabase(t, nil)
owner := coderdtest.CreateFirstUser(t, ownerClient)
memberClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)
ctx := testutil.Context(t, testutil.WaitLong)
sysCtx := dbauthz.AsSystemRestricted(ctx)
for ex, value := range map[codersdk.Experiment]string{
"not-an-experiment": `{"mode":"on","revision":1}`,
codersdk.ExperimentAutoFillParameters: `{"mode":"off","revision":4}`,
codersdk.ExperimentExample: `{"mode":"percent","revision":2}`,
codersdk.ExperimentMCPToolSearch: `{not json`,
} {
require.NoError(t, db.UpsertExperimentRule(sysCtx, database.UpsertExperimentRuleParams{Experiment: string(ex), Value: value}))
}
client := codersdk.NewExperimentalClient(ownerClient)
entries, err := client.ExperimentRules(ctx)
require.NoError(t, err)
require.Equal(t, []codersdk.ExperimentRuleEntry{
{Experiment: string(codersdk.ExperimentExample), Rule: &codersdk.ExperimentRule{Revision: 2}},
{Experiment: string(codersdk.ExperimentMCPToolSearch), Rule: &codersdk.ExperimentRule{}},
{Experiment: string(codersdk.ExperimentAutoFillParameters), Rule: &codersdk.ExperimentRule{Mode: string(codersdk.ExperimentRuleModeOff), Revision: 4}, Ignored: true},
{Experiment: "not-an-experiment", Rule: &codersdk.ExperimentRule{Mode: string(codersdk.ExperimentRuleModeOn), Revision: 1}, Ignored: true},
}, entries)
rule, err := client.PutExperimentRule(ctx, codersdk.ExperimentExample, codersdk.PutExperimentRuleRequest{Mode: codersdk.ExperimentRuleModeOn, ExpectedRevision: 2})
require.NoError(t, err)
require.Equal(t, int64(3), rule.Revision)
_, err = client.PutExperimentRule(ctx, codersdk.ExperimentMCPToolSearch, codersdk.PutExperimentRuleRequest{Mode: codersdk.ExperimentRuleModeOn, ExpectedRevision: 5})
requireStatus(t, err, http.StatusConflict)
rule, err = client.PutExperimentRule(ctx, codersdk.ExperimentMCPToolSearch, codersdk.PutExperimentRuleRequest{Mode: codersdk.ExperimentRuleModeOn, ExpectedRevision: 0})
require.NoError(t, err)
require.Equal(t, int64(1), rule.Revision)
for _, c := range []*codersdk.Client{ownerClient, memberClient} {
requireEnabled(ctx, t, c, codersdk.ExperimentExample, true)
requireEnabled(ctx, t, c, codersdk.ExperimentMCPToolSearch, true)
}
// An on rule without a positive revision is malformed: it is
// listed at revision 0 and decides off for everyone.
for ex, value := range map[codersdk.Experiment]string{
codersdk.ExperimentExample: `{"mode":"on","revision":0}`,
codersdk.ExperimentMCPToolSearch: `{"mode":"on"}`,
} {
require.NoError(t, db.UpsertExperimentRule(sysCtx, database.UpsertExperimentRuleParams{Experiment: string(ex), Value: value}))
require.Equal(t, &codersdk.ExperimentRule{}, ruleEntry(ctx, t, client, ex).Rule, ex)
}
for _, c := range []*codersdk.Client{ownerClient, memberClient} {
requireEnabled(ctx, t, c, codersdk.ExperimentExample, false)
requireEnabled(ctx, t, c, codersdk.ExperimentMCPToolSearch, false)
}
})
}
// TestExperimentRulesReplicas checks that a rule written through one
// replica decides the next evaluation on another replica sharing the
// database, both for the API and for the evaluator chatd uses.
func TestExperimentRulesReplicas(t *testing.T) {
t.Parallel()
db, ps := dbtestutil.NewDB(t)
options := func() *coderdtest.Options {
return &coderdtest.Options{Database: db, Pubsub: ps}
}
clientA := coderdtest.New(t, options())
clientB, _, apiB := coderdtest.NewWithAPI(t, options())
owner := coderdtest.CreateFirstUser(t, clientA)
memberA, member := coderdtest.CreateAnotherUser(t, clientA, owner.OrganizationID)
memberB := codersdk.New(clientB.URL)
memberB.SetSessionToken(memberA.SessionToken())
ctx := testutil.Context(t, testutil.WaitLong)
w := newRuleWriter(t, clientA)
requireB := func(want bool) {
t.Helper()
requireEnabled(ctx, t, memberB, codersdk.ExperimentExample, want)
require.Equal(t, want, apiB.ExperimentEvaluator.Enabled(ctx, member.ID, codersdk.ExperimentExample))
}
requireB(false)
w.put(ctx, codersdk.ExperimentExample, codersdk.ExperimentRuleModeCondition, fmt.Sprintf("user.id == %q", member.ID))
requireB(true)
w.put(ctx, codersdk.ExperimentExample, codersdk.ExperimentRuleModeOff, "")
requireB(false)
}
+174
View File
@@ -1,10 +1,22 @@
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"
)
@@ -36,3 +48,165 @@ func handleExperimentsAvailable(rw http.ResponseWriter, r *http.Request) {
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,
}
}
+26 -3
View File
@@ -136,6 +136,23 @@ func AuditRecord(ex codersdk.Experiment, rule Rule) database.ExperimentRule {
}
}
// ReadRules returns the stored rule of every experiment that has one,
// including experiments that do not accept runtime rules. A stored value
// the Evaluator treats as malformed is returned with an empty Mode and the
// revision WriteRule expects to replace it, which is 0 when the stored
// revision is unreadable or not positive.
func ReadRules(ctx context.Context, db database.Store) (map[codersdk.Experiment]Rule, error) {
rows, err := db.GetExperimentRules(ctx)
if err != nil {
return nil, xerrors.Errorf("get experiment rules: %w", err)
}
rules := make(map[codersdk.Experiment]Rule, len(rows))
for _, row := range rows {
rules[codersdk.Experiment(row.Experiment)] = parseStoredRule(row.Value)
}
return rules, nil
}
// readRule returns the stored rule for ex, or the zero Rule when none is
// stored or the stored revision is unreadable or not positive, so that such
// a row stays replaceable at revision 0. For a value the Evaluator treats as
@@ -149,17 +166,23 @@ func readRule(ctx context.Context, tx database.Store, ex codersdk.Experiment) (R
if err != nil {
return Rule{}, xerrors.Errorf("get experiment rule: %w", err)
}
return parseStoredRule(value), nil
}
// parseStoredRule decodes a stored value with the semantics described on
// readRule.
func parseStoredRule(value string) Rule {
var revision struct {
Revision int64 `json:"revision"`
}
// Decode errors are dropped because they can quote stored content.
// WriteRule always stores revision 1 or higher.
if err := json.Unmarshal([]byte(value), &revision); err != nil || revision.Revision < 1 {
return Rule{}, nil
return Rule{}
}
var rule Rule
if err := json.Unmarshal([]byte(value), &rule); err != nil || checkShape(rule) != nil {
return Rule{Revision: revision.Revision}, nil
return Rule{Revision: revision.Revision}
}
return rule, nil
return rule
}
+22
View File
@@ -576,6 +576,28 @@ func TestTelemetryInstallSource(t *testing.T) {
require.Equal(t, "aws_marketplace", deployment.InstallSource)
}
// TestTelemetryExcludesExperimentRuleConditions guards against condition
// text, which only the rules API may return, leaking into telemetry.
func TestTelemetryExcludesExperimentRuleConditions(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
ctx := testutil.Context(t, testutil.WaitMedium)
sentinel := "telemetry-sentinel-" + uuid.NewString()
value, err := json.Marshal(map[string]any{"mode": "condition", "condition": fmt.Sprintf("user.email == %q", sentinel), "revision": 1})
require.NoError(t, err)
require.NoError(t, db.UpsertExperimentRule(ctx, database.UpsertExperimentRuleParams{
Experiment: string(codersdk.ExperimentExample),
Value: string(value),
}))
deployment, snapshot := collectSnapshot(ctx, t, db, nil)
for _, report := range []any{deployment, snapshot} {
data, err := json.Marshal(report)
require.NoError(t, err)
require.NotContains(t, string(data), sentinel)
}
}
func TestTelemetryItem(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
+101
View File
@@ -0,0 +1,101 @@
package codersdk
import (
"context"
"fmt"
"net/http"
"net/url"
"time"
"github.com/google/uuid"
)
// ExperimentRuleMode selects how a runtime experiment rule decides an
// experiment.
type ExperimentRuleMode string
const (
// ExperimentRuleModeInherit uses the startup --experiments default.
ExperimentRuleModeInherit ExperimentRuleMode = "inherit"
// ExperimentRuleModeOn enables the experiment for every user.
ExperimentRuleModeOn ExperimentRuleMode = "on"
// ExperimentRuleModeOff disables the experiment for every user, even
// when it is enabled at startup.
ExperimentRuleModeOff ExperimentRuleMode = "off"
// ExperimentRuleModeCondition enables the experiment for users whose
// CEL condition is true.
ExperimentRuleModeCondition ExperimentRuleMode = "condition"
)
// ExperimentRule is the stored runtime rule of one experiment.
type ExperimentRule struct {
// Mode is one of the ExperimentRuleMode values, or empty when the
// stored rule is malformed. A malformed rule decides off until it is
// replaced. It is a plain string so that clients can represent the
// malformed state.
Mode string `json:"mode"`
// Condition is the CEL expression of a condition rule.
Condition string `json:"condition,omitempty"`
// Revision increases on every change and starts at 1. Zero means the
// stored rule has no readable positive revision, so it is malformed.
Revision int64 `json:"revision"`
UpdatedBy uuid.UUID `json:"updated_by" format:"uuid"`
UpdatedAt time.Time `json:"updated_at" format:"date-time"`
}
// ExperimentRuleEntry describes the runtime rule state of one experiment.
type ExperimentRuleEntry struct {
// Experiment is the experiment name. Ignored entries can name
// experiments this version does not know, so it is a plain string.
Experiment string `json:"experiment"`
// StaticDefault reports whether the experiment is in the startup
// --experiments list of the replica that answered.
StaticDefault bool `json:"static_default"`
// Rule is null when no rule was ever stored.
Rule *ExperimentRule `json:"rule"`
// Ignored is true for a stored rule of an experiment that does not
// accept runtime rules. Such a rule has no effect.
Ignored bool `json:"ignored"`
}
// PutExperimentRuleRequest replaces the runtime rule of one experiment.
type PutExperimentRuleRequest struct {
Mode ExperimentRuleMode `json:"mode" enums:"inherit,on,off,condition"`
// Condition is required for the condition mode and must be empty
// otherwise.
Condition string `json:"condition,omitempty"`
// ExpectedRevision must equal the current revision of the stored rule,
// or zero when no rule is stored or the stored rule has no readable
// positive revision. A different revision fails with 409 Conflict.
ExpectedRevision int64 `json:"expected_revision"`
}
// ExperimentRules returns the runtime rule state of every experiment that
// accepts runtime rules, followed by ignored stored rules.
func (c *ExperimentalClient) ExperimentRules(ctx context.Context) ([]ExperimentRuleEntry, error) {
res, err := c.Request(ctx, http.MethodGet, "/api/experimental/experiments/rules", nil)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return nil, ReadBodyAsError(res)
}
var entries []ExperimentRuleEntry
return entries, ReadBodyAsJSON(res, &entries)
}
// PutExperimentRule replaces the runtime rule of ex and returns the stored
// rule. A stale expected revision returns an *Error with status 409.
func (c *ExperimentalClient) PutExperimentRule(ctx context.Context, ex Experiment, req PutExperimentRuleRequest) (ExperimentRule, error) {
res, err := c.Request(ctx, http.MethodPut, fmt.Sprintf("/api/experimental/experiments/rules/%s", url.PathEscape(string(ex))), req)
if err != nil {
return ExperimentRule{}, err
}
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
return ExperimentRule{}, ReadBodyAsError(res)
}
var rule ExperimentRule
return rule, ReadBodyAsJSON(res, &rule)
}
+86
View File
@@ -9241,6 +9241,68 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o
|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `agent-lifecycle-hooks`, `ai-gateway-reverse-proxy`, `ai-gateway-seat-exclusion`, `auto-fill-parameters`, `chat-advisor`, `chat-inline-mcp-servers`, `chat-virtual-desktop`, `enable-ai-workspace-debug`, `example`, `mcp-server-http`, `mcp-tool-search`, `no_nats_pubsub`, `notifications`, `workspace-build-updates`, `workspace-capable-licensing`, `workspace-usage` |
## codersdk.ExperimentRule
```json
{
"condition": "string",
"mode": "string",
"revision": 0,
"updated_at": "2019-08-24T14:15:22Z",
"updated_by": "deea00dc-b6b6-4412-a483-26ac61e1f6fe"
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
|--------------|---------|----------|--------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `condition` | string | false | | Condition is the CEL expression of a condition rule. |
| `mode` | string | false | | Mode is one of the ExperimentRuleMode values, or empty when the stored rule is malformed. A malformed rule decides off until it is replaced. It is a plain string so that clients can represent the malformed state. |
| `revision` | integer | false | | Revision increases on every change and starts at 1. Zero means the stored rule has no readable positive revision, so it is malformed. |
| `updated_at` | string | false | | |
| `updated_by` | string | false | | |
## codersdk.ExperimentRuleEntry
```json
{
"experiment": "string",
"ignored": true,
"rule": {
"condition": "string",
"mode": "string",
"revision": 0,
"updated_at": "2019-08-24T14:15:22Z",
"updated_by": "deea00dc-b6b6-4412-a483-26ac61e1f6fe"
},
"static_default": true
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
|------------------|----------------------------------------------------|----------|--------------|------------------------------------------------------------------------------------------------------------------------------|
| `experiment` | string | false | | Experiment is the experiment name. Ignored entries can name experiments this version does not know, so it is a plain string. |
| `ignored` | boolean | false | | Ignored is true for a stored rule of an experiment that does not accept runtime rules. Such a rule has no effect. |
| `rule` | [codersdk.ExperimentRule](#codersdkexperimentrule) | false | | Rule is null when no rule was ever stored. |
| `static_default` | boolean | false | | Static default reports whether the experiment is in the startup --experiments list of the replica that answered. |
## codersdk.ExperimentRuleMode
```json
"inherit"
```
### Properties
#### Enumerated Values
| Value(s) |
|-------------------------------------|
| `condition`, `inherit`, `off`, `on` |
## codersdk.ExternalAPIKeyScopes
```json
@@ -13440,6 +13502,30 @@ Git clone makes use of this by parsing the URL from: 'Username for "https://gith
|--------------------------------------------------|
| `ok`, `unhealthy`, `unreachable`, `unregistered` |
## codersdk.PutExperimentRuleRequest
```json
{
"condition": "string",
"expected_revision": 0,
"mode": "inherit"
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
|---------------------|------------------------------------------------------------|----------|--------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `condition` | string | false | | Condition is required for the condition mode and must be empty otherwise. |
| `expected_revision` | integer | false | | Expected revision must equal the current revision of the stored rule, or zero when no rule is stored or the stored rule has no readable positive revision. A different revision fails with 409 Conflict. |
| `mode` | [codersdk.ExperimentRuleMode](#codersdkexperimentrulemode) | false | | |
#### Enumerated Values
| Property | Value(s) |
|----------|-------------------------------------|
| `mode` | `condition`, `inherit`, `off`, `on` |
## codersdk.PutExtendWorkspaceRequest
```json
+90
View File
@@ -60,6 +60,96 @@ You can opt-out of a feature after you've enabled it.
</details>
### Target experiments at runtime
Some early access features accept runtime rules.
A rule turns the experiment on or off for every user, or targets it to some users, without a restart.
Only user-scoped experiments accept rules.
Other experiments, such as `no_nats_pubsub` or `workspace-capable-licensing`, read only the startup `--experiments` list and need a restart to change.
> [!NOTE]
> The rules API and the hidden `coder exp experiment-rules` command are experimental.
> They can change or be removed without notice.
> There is no UI editor yet.
You need the Owner role to change a rule.
The Auditor role can list rules.
Run `coder exp experiment-rules list` to show each user-scoped experiment, its startup default, and its rule.
The list also shows stored rules that target other experiments as `ignored`, because they have no effect.
#### Rule modes
Each user-scoped experiment has at most one rule:
| Rule | Effect | Command |
|-----------------------|----------------------------------------------------------------------------|-----------------------------------------------------------|
| No rule, or `inherit` | Uses the startup `--experiments` default | `coder exp experiment-rules reset <experiment>` |
| `on` | On for every user | `coder exp experiment-rules on <experiment>` |
| `off` | Off for every user, even if the experiment is enabled at startup | `coder exp experiment-rules off <experiment>` |
| `condition` | On for users whose [CEL](https://cel.dev) condition is true, off otherwise | `coder exp experiment-rules set <experiment> <condition>` |
For example, to turn on an experiment for users in one group, widen it to every member of the `coder` organization, and then turn it off for everyone:
```sh
coder exp experiment-rules set mcp-tool-search '"coder/beta-testers" in user.groups'
coder exp experiment-rules set mcp-tool-search '"coder/Everyone" in user.groups'
coder exp experiment-rules off mcp-tool-search
```
Reset doesn't turn an experiment off.
It restores the startup default, which can enable the experiment for every user.
The command prints a warning when that happens.
To turn an experiment off for everyone, use `off`.
Each change increases the rule's revision.
Changing commands accept `--expected-revision` and fail when the stored rule has a different revision.
Without the flag, the command reads the current revision and writes against it, which protects only against a change made between that read and the write.
The command never retries a conflict: it prints the current rule and exits with an error.
If you use [audit logs](../admin/security/audit-logs.md), each change creates an entry with the `experiment_rule` resource type.
The entry records who changed the rule, when, and the old and new condition.
Audit lines in the server log carry the same diff.
#### Condition variables
A condition is a CEL expression that returns a boolean.
It can read one variable, `user`, with these fields:
| Field | Type | Value |
|----------------------|--------------|-------------------------------------------------------------------------------------------------------------------|
| `user.id` | string | The user's ID |
| `user.username` | string | The user's username |
| `user.email` | string | The user's email address, compared case-sensitively |
| `user.roles` | list(string) | The user's explicit site roles, such as `owner`. The implied member role and organization roles are not included. |
| `user.organizations` | list(string) | Names of the organizations the user belongs to |
| `user.groups` | list(string) | Groups the user belongs to, as `<organization>/<group>`, including `<organization>/Everyone` |
A condition can be at most 4,096 characters long, and each evaluation has a CEL cost limit of 10,000.
Coder rejects a condition that fails to compile and returns the error to the person who wrote it.
Log lines about a failed condition record only the experiment, the rule revision, the error category, and the line and column, never the condition text.
#### When a change takes effect
On supporting replicas, a fresh authoritative database read after the update commits observes the new rule.
In-flight evaluations and work already using an earlier decision are not cancelled.
All serving replicas must run a supporting version before you rely on this control.
Replicas that run an earlier version ignore rules and use their startup `--experiments` list.
Every replica also falls back to its own startup list for experiments without a rule or with `inherit`, so give all replicas the same `--experiments` value.
The dashboard fetches the enabled experiments again when a page loads, when the browser window regains focus, and when the connection is restored, if its copy is more than 60 seconds old.
It doesn't refresh on a timer, so a tab that stays focused can show an out-of-date view.
The server applies the current rule to each request regardless of what the dashboard shows.
#### Failure behavior
If Coder can't read the rules, a stored rule is malformed, or a condition fails to compile or evaluate, the experiment is off for that decision, even if it is enabled at startup.
You can't change this behavior.
Rules gate features; they are not an authorization boundary.
Don't use a rule to restrict access to data or actions.
## Beta
- **Stable**: No
+331
View File
@@ -0,0 +1,331 @@
package coderd_test
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"sync/atomic"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"golang.org/x/xerrors"
"cdr.dev/slog/v3"
"github.com/coder/coder/v2/coderd/audit"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbauthz"
"github.com/coder/coder/v2/coderd/database/dbtestutil"
"github.com/coder/coder/v2/coderd/experiments"
"github.com/coder/coder/v2/codersdk"
entaudit "github.com/coder/coder/v2/enterprise/audit"
"github.com/coder/coder/v2/enterprise/audit/backends"
"github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
"github.com/coder/coder/v2/enterprise/coderd/license"
"github.com/coder/coder/v2/support"
"github.com/coder/coder/v2/testutil"
)
type experimentRulesFixture struct {
db database.Store
owner *codersdk.Client
ownerID uuid.UUID
orgID uuid.UUID
client *codersdk.ExperimentalClient
logs *testutil.FakeSink
failUpsert *atomic.Bool
}
// newExperimentRulesFixture starts a licensed deployment that audits into
// the database, plus extra audit backends built with the server logger,
// and captures server logs.
func newExperimentRulesFixture(t *testing.T, extraBackends ...func(slog.Logger) entaudit.Backend) experimentRulesFixture {
t.Helper()
db, ps := dbtestutil.NewDB(t)
failUpsert := &atomic.Bool{}
logs := testutil.NewFakeSink(t)
logger := logs.Logger()
auditBackends := []entaudit.Backend{backends.NewPostgres(db, true)}
for _, backend := range extraBackends {
auditBackends = append(auditBackends, backend(logger))
}
auditor := entaudit.NewAuditor(db, entaudit.DefaultFilter, auditBackends...)
ownerClient, owner := coderdenttest.New(t, &coderdenttest.Options{
AuditLogging: true,
Options: &coderdtest.Options{
Database: upsertFaultStore{Store: db, fail: failUpsert},
Pubsub: ps,
Auditor: auditor,
Logger: &logger,
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{codersdk.FeatureAuditLog: 1},
},
})
return experimentRulesFixture{
db: db,
owner: ownerClient,
ownerID: owner.UserID,
orgID: owner.OrganizationID,
client: codersdk.NewExperimentalClient(ownerClient),
logs: logs,
failUpsert: failUpsert,
}
}
func (f experimentRulesFixture) auditLogs(ctx context.Context, t *testing.T) []database.AuditLog {
t.Helper()
rows, err := f.db.GetAuditLogsOffset(dbauthz.AsSystemRestricted(ctx), database.GetAuditLogsOffsetParams{
ResourceType: string(database.ResourceTypeExperimentRule),
LimitOpt: 50,
})
require.NoError(t, err)
logs := make([]database.AuditLog, 0, len(rows))
for _, row := range rows {
logs = append(logs, row.AuditLog)
}
return logs
}
// waitAuditLogs waits until n experiment rule audit entries exist. Entries
// are exported after the response is written, so they can trail it.
func (f experimentRulesFixture) waitAuditLogs(ctx context.Context, t *testing.T, n int) []database.AuditLog {
t.Helper()
var logs []database.AuditLog
testutil.Eventually(ctx, t, func(ctx context.Context) bool {
logs = f.auditLogs(ctx, t)
return len(logs) >= n
}, testutil.IntervalFast)
require.Len(t, logs, n)
return logs
}
func (f experimentRulesFixture) put(ctx context.Context, mode codersdk.ExperimentRuleMode, condition string, expected int64) (codersdk.ExperimentRule, error) {
return f.client.PutExperimentRule(ctx, codersdk.ExperimentExample, codersdk.PutExperimentRuleRequest{
Mode: mode, Condition: condition, ExpectedRevision: expected,
})
}
func (f experimentRulesFixture) storedRule(ctx context.Context, t *testing.T) *codersdk.ExperimentRule {
t.Helper()
entries, err := f.client.ExperimentRules(ctx)
require.NoError(t, err)
for _, entry := range entries {
if entry.Experiment == string(codersdk.ExperimentExample) {
return entry.Rule
}
}
t.Fatal("no rules entry for the example experiment")
return nil
}
func requireStatusCode(t *testing.T, err error, status int) {
t.Helper()
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, status, sdkErr.StatusCode(), sdkErr.Error())
}
func TestExperimentRuleAudit(t *testing.T) {
t.Parallel()
t.Run("WritePath", func(t *testing.T) {
t.Parallel()
f := newExperimentRulesFixture(t)
ctx := testutil.Context(t, testutil.WaitLong)
const condition = `user.username == "audited-condition"`
stored, err := f.put(ctx, codersdk.ExperimentRuleModeCondition, condition, 0)
require.NoError(t, err)
// Require the entry before inspecting it, so the diff checks
// cannot pass because nothing was audited.
entry := f.waitAuditLogs(ctx, t, 1)[0]
require.Equal(t, int32(http.StatusOK), entry.StatusCode)
require.Equal(t, database.AuditActionWrite, entry.Action)
require.Equal(t, string(codersdk.ExperimentExample), entry.ResourceTarget)
require.Equal(t, experiments.AuditRecord(codersdk.ExperimentExample, experiments.Rule{}).ID, entry.ResourceID)
require.Equal(t, f.ownerID, entry.UserID)
var diff audit.Map
require.NoError(t, json.Unmarshal(entry.Diff, &diff))
require.Equal(t, audit.OldNew{Old: "", New: condition}, diff["condition"])
require.Equal(t, audit.OldNew{Old: "", New: "condition"}, diff["mode"])
require.Equal(t, audit.OldNew{Old: float64(0), New: float64(1)}, diff["revision"])
// An identical write is a no-op: 200 with the unchanged rule and
// no entry. The conflict below is audited after it, so a stray
// no-op entry would show up in the count.
same, err := f.put(ctx, codersdk.ExperimentRuleModeCondition, condition, 1)
require.NoError(t, err)
require.Equal(t, stored, same)
// Denied and invalid writes are rejected before the audit starts,
// so they add no entry to the count either.
memberClient, _ := coderdtest.CreateAnotherUser(t, f.owner, f.orgID)
_, err = codersdk.NewExperimentalClient(memberClient).PutExperimentRule(ctx, codersdk.ExperimentExample, codersdk.PutExperimentRuleRequest{Mode: codersdk.ExperimentRuleModeOff, ExpectedRevision: 1})
requireStatusCode(t, err, http.StatusForbidden)
_, err = f.put(ctx, codersdk.ExperimentRuleModeCondition, "user.", 1)
requireStatusCode(t, err, http.StatusBadRequest)
_, err = f.put(ctx, codersdk.ExperimentRuleModeOff, "", 0)
requireStatusCode(t, err, http.StatusConflict)
conflict := f.waitAuditLogs(ctx, t, 2)[0]
require.Equal(t, int32(http.StatusConflict), conflict.StatusCode)
require.JSONEq(t, "{}", string(conflict.Diff))
// A database error inside the transaction rolls the write back
// and is audited with its status.
f.failUpsert.Store(true)
_, err = f.put(ctx, codersdk.ExperimentRuleModeOff, "", 1)
requireStatusCode(t, err, http.StatusInternalServerError)
f.failUpsert.Store(false)
failed := f.waitAuditLogs(ctx, t, 3)[0]
require.Equal(t, int32(http.StatusInternalServerError), failed.StatusCode)
require.JSONEq(t, "{}", string(failed.Diff))
require.Equal(t, &stored, f.storedRule(ctx, t))
})
// The audit entry is exported after the rule commits. When the export
// fails, the change stands and the failure is logged.
t.Run("FailedExportKeepsChange", func(t *testing.T) {
t.Parallel()
f := newExperimentRulesFixture(t, func(slog.Logger) entaudit.Backend { return failingAuditBackend{} })
ctx := testutil.Context(t, testutil.WaitLong)
stored, err := f.put(ctx, codersdk.ExperimentRuleModeOn, "", 0)
require.NoError(t, err)
testutil.Eventually(ctx, t, func(context.Context) bool {
return len(f.logs.Entries(func(e slog.SinkEntry) bool {
return e.Level == slog.LevelError && e.Message == "export audit log"
})) > 0
}, testutil.IntervalFast)
require.Equal(t, &stored, f.storedRule(ctx, t))
})
}
// TestExperimentRuleRedaction checks where condition text can appear. The
// rules API, audit entries and the server log's audit lines carry it. No
// other log line, /deployment/config or a support bundle may, even for
// invalid conditions and conditions that fail during evaluation.
func TestExperimentRuleRedaction(t *testing.T) {
t.Parallel()
// Enterprise servers always export audit entries to the server log.
f := newExperimentRulesFixture(t, backends.NewSlog)
memberClient, _ := coderdtest.CreateAnotherUser(t, f.owner, f.orgID)
ctx := testutil.Context(t, testutil.WaitLong)
sentinel := "redaction-sentinel-" + uuid.NewString()
// The author of an invalid condition gets diagnostics quoting it.
_, err := f.put(ctx, codersdk.ExperimentRuleModeCondition, fmt.Sprintf("user.email == %q &&", sentinel), 0)
var sdkErr *codersdk.Error
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode())
require.Contains(t, sdkErr.Detail, sentinel)
// A valid condition, then one that fails while the member's
// experiments are evaluated.
validCondition := fmt.Sprintf("user.email == %q", sentinel)
failingCondition := fmt.Sprintf("user.groups[7] == %q", sentinel)
_, err = f.put(ctx, codersdk.ExperimentRuleModeCondition, validCondition, 0)
require.NoError(t, err)
_, err = memberClient.Experiments(ctx)
require.NoError(t, err)
_, err = f.put(ctx, codersdk.ExperimentRuleModeCondition, failingCondition, 1)
require.NoError(t, err)
_, err = memberClient.Experiments(ctx)
require.NoError(t, err)
testutil.Eventually(ctx, t, func(context.Context) bool {
return len(f.logs.Entries(func(e slog.SinkEntry) bool {
return e.Message == "experiment condition failed; experiment is off"
})) > 0
}, testutil.IntervalFast)
require.Contains(t, f.storedRule(ctx, t).Condition, sentinel)
// The invalid write is not audited. The newest entry records the
// condition change with both texts.
latest := f.waitAuditLogs(ctx, t, 2)[0]
var diff audit.Map
require.NoError(t, json.Unmarshal(latest.Diff, &diff))
require.Equal(t, audit.OldNew{Old: validCondition, New: failingCondition}, diff["condition"])
res, err := f.owner.Request(ctx, http.MethodGet, "/api/v2/deployment/config", nil)
require.NoError(t, err)
body, err := io.ReadAll(res.Body)
_ = res.Body.Close()
require.NoError(t, err)
require.Equal(t, http.StatusOK, res.StatusCode)
require.NotContains(t, string(body), sentinel)
bundle, err := support.Run(ctx, &support.Deps{Client: f.owner, Log: slog.Make()})
require.NoError(t, err)
bundleJSON, err := json.Marshal(bundle)
require.NoError(t, err)
require.NotContains(t, string(bundleJSON), sentinel)
// Audit lines are exported after the handler returns. Requiring one
// with the condition keeps the check below from passing vacuously.
auditLine := func(e slog.SinkEntry) bool { return e.Message == "audit_log" || e.Message == "export audit log" }
testutil.Eventually(ctx, t, func(context.Context) bool {
for _, entry := range f.logs.Entries(func(e slog.SinkEntry) bool { return e.Message == "audit_log" }) {
if strings.Contains(logText(entry), sentinel) {
return true
}
}
return false
}, testutil.IntervalFast)
for _, entry := range f.logs.Entries(func(e slog.SinkEntry) bool { return !auditLine(e) }) {
require.False(t, strings.Contains(logText(entry), sentinel), "log entry contains condition text: %s", entry.Message)
}
}
// logText renders a log entry's message and field values. Byte slices,
// such as audit diffs, are rendered as text.
func logText(entry slog.SinkEntry) string {
var b strings.Builder
_, _ = b.WriteString(entry.Message)
for _, field := range entry.Fields {
_, _ = b.WriteString(" ")
switch v := field.Value.(type) {
case json.RawMessage:
_, _ = b.Write(v)
case []byte:
_, _ = b.Write(v)
default:
_, _ = fmt.Fprintf(&b, "%+v", v)
}
}
return b.String()
}
// upsertFaultStore fails experiment rule upserts while fail is set.
type upsertFaultStore struct {
database.Store
fail *atomic.Bool
}
func (s upsertFaultStore) InTx(fn func(database.Store) error, opts *database.TxOptions) error {
return s.Store.InTx(func(tx database.Store) error {
return fn(upsertFaultStore{Store: tx, fail: s.fail})
}, opts)
}
func (s upsertFaultStore) UpsertExperimentRule(ctx context.Context, arg database.UpsertExperimentRuleParams) error {
if s.fail.Load() {
return xerrors.New("injected upsert failure")
}
return s.Store.UpsertExperimentRule(ctx, arg)
}
// failingAuditBackend fails every export.
type failingAuditBackend struct{}
func (failingAuditBackend) Decision() entaudit.FilterDecision { return entaudit.FilterDecisionStore }
func (failingAuditBackend) Export(context.Context, database.AuditLog, entaudit.BackendDetails) error {
return xerrors.New("injected audit export failure")
}
+80
View File
@@ -5304,6 +5304,67 @@ export type Experiment =
| "workspace-capable-licensing"
| "workspace-usage";
// From codersdk/experimentrules.go
/**
* ExperimentRule is the stored runtime rule of one experiment.
*/
export interface ExperimentRule {
/**
* Mode is one of the ExperimentRuleMode values, or empty when the
* stored rule is malformed. A malformed rule decides off until it is
* replaced. It is a plain string so that clients can represent the
* malformed state.
*/
readonly mode: string;
/**
* Condition is the CEL expression of a condition rule.
*/
readonly condition?: string;
/**
* Revision increases on every change and starts at 1. Zero means the
* stored rule has no readable positive revision, so it is malformed.
*/
readonly revision: number;
readonly updated_by: string;
readonly updated_at: string;
}
// From codersdk/experimentrules.go
/**
* ExperimentRuleEntry describes the runtime rule state of one experiment.
*/
export interface ExperimentRuleEntry {
/**
* Experiment is the experiment name. Ignored entries can name
* experiments this version does not know, so it is a plain string.
*/
readonly experiment: string;
/**
* StaticDefault reports whether the experiment is in the startup
* --experiments list of the replica that answered.
*/
readonly static_default: boolean;
/**
* Rule is null when no rule was ever stored.
*/
readonly rule: ExperimentRule | null;
/**
* Ignored is true for a stored rule of an experiment that does not
* accept runtime rules. Such a rule has no effect.
*/
readonly ignored: boolean;
}
// From codersdk/experimentrules.go
export type ExperimentRuleMode = "condition" | "inherit" | "off" | "on";
export const ExperimentRuleModes: ExperimentRuleMode[] = [
"condition",
"inherit",
"off",
"on",
];
export const Experiments: Experiment[] = [
"ai-gateway-reverse-proxy",
"ai-gateway-seat-exclusion",
@@ -8386,6 +8447,25 @@ export const ProxyHealthStatuses: ProxyHealthStatus[] = [
"unregistered",
];
// From codersdk/experimentrules.go
/**
* PutExperimentRuleRequest replaces the runtime rule of one experiment.
*/
export interface PutExperimentRuleRequest {
readonly mode: ExperimentRuleMode;
/**
* Condition is required for the condition mode and must be empty
* otherwise.
*/
readonly condition?: string;
/**
* ExpectedRevision must equal the current revision of the stored rule,
* or zero when no rule is stored or the stored rule has no readable
* positive revision. A different revision fails with 409 Conflict.
*/
readonly expected_revision: number;
}
// From codersdk/workspaces.go
/**
* PutExtendWorkspaceRequest is a request to extend the deadline of
@@ -77,6 +77,20 @@ export const OverviewPageView: React.FC<OverviewPageViewProps> = ({
)}
additionalValues={safeExperiments}
/>
<p className="m-0 text-sm text-content-secondary">
Experiments lists the startup defaults. Runtime experiment rules can
override them per user without a restart. See{" "}
<Link
href={docs(
"/reference/feature-stages#target-experiments-at-runtime",
)}
target="_blank"
rel="noreferrer"
>
the documentation
</Link>
.
</p>
</div>
</>
);