coderd/experiments/write.go
Rama main · réplica de lectura del repositorio original.
package experiments
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/database/dbtime"
"github.com/coder/coder/v2/codersdk"
)
// ruleLockPrefix names the advisory lock that serializes writes to one
// experiment's rule. It matches the site_configs key prefix.
const ruleLockPrefix = "experiment_rule:"
// writeLockTimeout bounds how long WriteRule waits for a concurrent writer
// of the same experiment.
const writeLockTimeout = 5 * time.Second
// RevisionConflictError is returned by WriteRule when the expected revision
// is not the current revision of the stored rule.
type RevisionConflictError struct {
Experiment codersdk.Experiment
Expected int64
// Current is the stored revision; zero means no rule is stored or the
// stored value has no readable positive revision.
Current int64
}
func (e *RevisionConflictError) Error() string {
return fmt.Sprintf("experiment %q rule is at revision %d, expected revision %d", e.Experiment, e.Current, e.Expected)
}
// WriteRule stores the mode and condition of rule as the rule for ex when
// the stored revision equals expectedRevision. The Revision, UpdatedBy and
// UpdatedAt fields of rule are ignored: WriteRule sets them.
//
// It returns the stored rule before and after the call, as read and written
// inside one transaction. Before the first write of ex, the old rule is the
// zero Rule (revision 0). When the stored mode and condition already equal
// rule's, nothing is written, changed is false and the new rule equals the
// old one. Writing inherit to an experiment without a row stores revision 1
// and counts as a change.
//
// A stored value without a readable positive revision, which only a write
// outside this API can produce, counts as revision 0: expectedRevision 0
// replaces it and the rule restarts at revision 1.
//
// A stale expectedRevision returns *RevisionConflictError. Validation errors
// can contain CEL diagnostics; return them only to the rule author and never
// log them.
func WriteRule(
ctx context.Context,
db database.Store,
actorID uuid.UUID,
ex codersdk.Experiment,
rule Rule,
expectedRevision int64,
) (oldRule, newRule Rule, changed bool, err error) {
if actorID == uuid.Nil {
return Rule{}, Rule{}, false, xerrors.New("write experiment rule: actor is required")
}
if expectedRevision < 0 {
return Rule{}, Rule{}, false, xerrors.Errorf("write experiment rule: negative expected revision %d", expectedRevision)
}
if err := ValidateRule(ex, rule); err != nil {
return Rule{}, Rule{}, false, xerrors.Errorf("invalid experiment rule: %w", err)
}
lockCtx, cancel := context.WithTimeout(ctx, writeLockTimeout)
defer cancel()
err = db.InTx(func(tx database.Store) error {
if err := tx.AcquireLock(lockCtx, database.GenLockID(ruleLockPrefix+string(ex))); err != nil {
return xerrors.Errorf("acquire experiment rule lock: %w", err)
}
current, err := readRule(ctx, tx, ex)
if err != nil {
return err
}
if current.Revision != expectedRevision {
return &RevisionConflictError{Experiment: ex, Expected: expectedRevision, Current: current.Revision}
}
oldRule = current
if current.Revision > 0 && current.Mode == rule.Mode && current.Condition == rule.Condition {
newRule = current
return nil
}
newRule = Rule{
Mode: rule.Mode,
Condition: rule.Condition,
Revision: current.Revision + 1,
UpdatedBy: actorID,
UpdatedAt: dbtime.Now(),
}
value, err := json.Marshal(newRule)
if err != nil {
return xerrors.Errorf("encode experiment rule: %w", err)
}
if err := tx.UpsertExperimentRule(ctx, database.UpsertExperimentRuleParams{
Experiment: string(ex),
Value: string(value),
}); err != nil {
return xerrors.Errorf("upsert experiment rule: %w", err)
}
changed = true
return nil
}, &database.TxOptions{Isolation: sql.LevelReadCommitted, TxIdentifier: "experiment_rule_write"})
if err != nil {
return Rule{}, Rule{}, false, err
}
return oldRule, newRule, changed, nil
}
// auditIDNamespace derives audit resource IDs from experiment names. It
// must never change: audit history groups entries by these IDs.
var auditIDNamespace = uuid.MustParse("65b7b16d-c22d-4972-adca-014b0a4b77f0")
// AuditRecord returns the audited form of rule for ex. The ID is derived
// from the experiment name, so every audit entry for one experiment shares
// it. A zero Rule (no stored rule) yields empty mode and condition with
// revision 0.
func AuditRecord(ex codersdk.Experiment, rule Rule) database.ExperimentRule {
return database.ExperimentRule{
ID: uuid.NewSHA1(auditIDNamespace, []byte(ex)),
Experiment: string(ex),
Mode: string(rule.Mode),
Condition: rule.Condition,
Revision: rule.Revision,
}
}
// 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
// malformed but whose revision is readable, readRule returns a Rule with
// only Revision set, which never equals a valid rule.
func readRule(ctx context.Context, tx database.Store, ex codersdk.Experiment) (Rule, error) {
value, err := tx.GetExperimentRule(ctx, string(ex))
if errors.Is(err, sql.ErrNoRows) {
return Rule{}, nil
}
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{}
}
var rule Rule
if err := json.Unmarshal([]byte(value), &rule); err != nil || checkShape(rule) != nil {
return Rule{Revision: revision.Revision}
}
return rule
}