feat!: seed ai_providers from env on server startup (#24895)

_Disclaimer: implemented by a Coder Agent using Claude Opus 4.7_

Part of the implementation of [RFC: Common AI Provider Configs](https://www.notion.so/coderhq/RFC-Common-AI-Provider-Configs-34bd579be59280ed958feffb82024797) (AIGOV-201).

## Note

This change can cause a previously working installation to fail to start should a conflict exist between the providers configured in the environment & those now migrated to the database.

I'll raise a PR upstack to document this process and workarounds should a startup fail.

## What this PR does

Reconciles environment-derived AI provider configuration with the `ai_providers` table at server startup. The seed runs **before** the aibridged daemon is initialized, so the runtime always reads providers from the database; the legacy `CODER_AIBRIDGE_*` environment variables become a one-shot migration source.

### Behavior

- Concurrent server starts are serialized through a Postgres advisory lock (`LockIDAIProvidersEnvSeed`).
- Missing rows are inserted with an audit entry attributed to the system actor.
- Existing rows whose canonical hash matches the env-derived hash are left alone (the common no-op restart path).
- Existing rows whose canonical hash does **not** match cause server startup to fail with a descriptive error so the operator can explicitly resolve the conflict in either env or DB.
- Soft-deleted rows are NOT resurrected from env; an explicit operator deletion is sticky across restarts.
- Indexed providers whose name conflicts with a legacy env var fail startup with a clear remediation message.
- Unknown provider types (e.g. `copilot`, until the DB enum is widened) are skipped with a log entry rather than failing startup.

### Canonical hashing

The `canonicalAIProvider` shape captures exactly the fields that determine runtime behavior — `type`, `base_url`, and the Bedrock subset of settings (access key, access key secret, region, model, small fast model) — and is hashed with SHA-256. The hash is **computed on demand from the row + env**, never persisted, so the database does not need a new column for it. API keys live in the separate `ai_provider_keys` table and are intentionally excluded from the hash so operators can rotate keys via the API without forcing a server restart.

<details>
<summary>Decision log</summary>

- The hash is intentionally not persisted in the database. The RFC discussed this trade-off; computing on demand keeps the schema minimal and lets the canonical shape evolve without a migration.
- The lock uses an `iota` slot in `coderd/database/lock.go` rather than `GenLockID` so it's stable, easy to audit, and matches the convention used for every other startup lock.
- A bearer-token Anthropic provider whose env vars also set Bedrock metadata but no AWS credentials does NOT store the Bedrock fields. Without credentials the discriminated settings would misrepresent the row as Bedrock auth.
- We deliberately do NOT publish to the `ai_providers_changed` pubsub channel from the seed because the seed completes before any subscriber is started; the follow-up PR introduces that channel.

</details>
This commit is contained in:
Danny Kopping
2026-05-22 08:37:27 +02:00
committed by GitHub
parent 06526a5822
commit 9341efec9f
9 changed files with 1170 additions and 18 deletions
+56
View File
@@ -32,6 +32,62 @@ type AIProviderBedrockSettings struct {
AccessKeySecret *string `json:"access_key_secret,omitempty"`
}
// IsConfigured reports whether any load-bearing Bedrock field is set,
// indicating that the operator wants the provider to authenticate via
// AWS Bedrock rather than as a bearer-token Anthropic provider.
//
// Model and SmallFastModel are intentionally excluded: they have
// deployment-level defaults declared in codersdk/deployment.go, so
// they're always non-empty in a real deployment and cannot serve as
// a detection signal. Region and credentials have no defaults and
// therefore reliably indicate operator intent. Credentials alone are
// not required because Bedrock can also authenticate via the AWS
// environment (instance profile, AWS_PROFILE, IRSA, etc.).
func (b AIProviderBedrockSettings) IsConfigured() bool {
if b.Region != "" {
return true
}
if b.AccessKey != nil && *b.AccessKey != "" {
return true
}
if b.AccessKeySecret != nil && *b.AccessKeySecret != "" {
return true
}
return false
}
// NewAIProviderBedrockSettings builds an AIProviderBedrockSettings,
// promoting non-empty credential strings to pointers so callers don't
// have to repeat the "set field iff non-empty" boilerplate. Empty
// credentials are left nil, matching the PATCH-omit semantics of the
// pointer-typed fields.
func NewAIProviderBedrockSettings(region, accessKey, accessKeySecret, model, smallFastModel string) AIProviderBedrockSettings {
s := AIProviderBedrockSettings{
Region: region,
Model: model,
SmallFastModel: smallFastModel,
}
if accessKey != "" {
s.AccessKey = &accessKey
}
if accessKeySecret != "" {
s.AccessKeySecret = &accessKeySecret
}
return s
}
// IsBedrockConfigured reports whether the combination of the parent
// provider's BaseURL and AIProviderBedrockSettings indicates a Bedrock
// provider. BaseURL alone (e.g. a custom VPC or FIPS endpoint with
// credentials resolved via the AWS environment) is sufficient.
//
// Use this rather than AIProviderBedrockSettings.IsConfigured() when
// BaseURL is available; the seed, the runtime config builder, and the
// legacy validator must all agree on what counts as a Bedrock provider.
func IsBedrockConfigured(baseURL string, b AIProviderBedrockSettings) bool {
return baseURL != "" || b.IsConfigured()
}
func (AIProviderBedrockSettings) settingsType() string {
return AIProviderSettingsTypeBedrock
}