diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 1296712..94fe655 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -3,6 +3,10 @@ name: CI
on:
pull_request:
branches: [main]
+ # Drafts skip every job, so marking a PR ready must start a run; without
+ # ready_for_review the required checks stay "skipped", which a ruleset
+ # counts as passing.
+ types: [opened, synchronize, reopened, ready_for_review]
# Re-run by hand, from the Actions tab or `gh workflow run ci.yml --repo simtabi/cloudkit`.
workflow_dispatch:
# Nothing re-runs after a merge, so this weekly run on main is what catches
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 86f7945..a03e252 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -20,9 +20,29 @@ All notable changes to this project are documented here. The format follows
- CI on pull requests, by dispatch and weekly; a release workflow that runs only on a confirmed
dispatch against a `v` tag.
- A weekly and on-demand CI leg on macOS and Windows; pull requests still test on Linux only.
+- `Options`, `CredentialSource` and `Registry` (`NewRegistry`, `Open`, `Descriptors`): the caller
+ builds the registry from one `Factory` per provider; it refuses invalid options, incomplete
+ descriptors, empty or non-slug provider IDs and duplicate IDs, and keeps no package-level state.
+- `Provider`, `Descriptor`, `TokenModel`, `Module`, `Resource`, `Filter` and `Account`: each `Open`
+ gives an adapter a fresh client that is limited to its declared hosts, fetches the account's token
+ per request, sends it only to API hosts and never on a redirect, and retries.
+- `Capability`, `Access` (`Allowed`, `Denied`, `Unknown`) and `CapSet`, with the capability catalogue;
+ an unknown capability counts as a write.
+- `Plan`, `Change`, `Money`, `NewPlan` and a SHA-256 digest over the provider, account, changes, what a
+ destroy loses and the live state; plans expire after `PlanTTL`.
+- `Registry.Apply` with the `Applier` interface: refuses an altered or expired plan and a plan whose
+ live state moved (`ErrConflict`, `PlanError`), and a destroy whose resource names were not typed back
+ (`ErrConfirmationRequired`, `Confirmation`), before anything is sent.
+- `RetryPolicy` and `RateLimitError`: jittered backoff, `Retry-After` honoured up to `MaxDelay`, and no
+ retry of a non-idempotent request after an ambiguous failure.
+- `UnsupportedError`, matching `ErrUnsupported`.
+- Policy tests: context first on every interface and registry method (C10), no package-level client
+ or registry (C9), and no shipped exported name removed.
### Changed
- The architecture guide states the scope split: cloudkit finds and connects to managed databases
but never administers them, and REST descriptors (planned) support writes behind signing or
explicit enablement, plans and the contract suite.
+- The architecture guide explains the plan digest and the client's layering, and the getting-started
+ guide covers the registry, plans, `Apply` and retries.
diff --git a/README.md b/README.md
index d594769..7f7f403 100644
--- a/README.md
+++ b/README.md
@@ -62,7 +62,7 @@ Hosted at .
### Guides
- [Installation](docs/installation.md) — the module path, Go version and pinning before `v0.1.0`
-- [Getting started](docs/getting-started.md) — transports, egress policies, secrets and errors
+- [Getting started](docs/getting-started.md) — transports, the registry, plans and applying them, retries, secrets and errors
- [Architecture](docs/architecture.md) — the rules every adapter follows, and why
- [Release](docs/release.md) — how a version is tagged and released
diff --git a/docs/architecture.md b/docs/architecture.md
index 1395d6a..9ae4af8 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -35,9 +35,12 @@ its own engine adapters.
| C9 | No global state: no `init()` registration, no package-level clients |
| C10 | `context.Context` first on every call; the logger is injected |
-C1, C3, C4 and C5 are implemented in this release. A policy test in `src/internal/policy` scans
-every Go file for the symbols that would break C1, C4, C8, C9 and C10, with an exemption list that
-records the exact count it allows.
+C1 to C7, C9 and C10 are implemented in this release; C8 arrives with the SSH path. A policy test
+in `src/internal/policy` scans every Go file for the symbols that would break C1, C4, C8, C9 and
+C10, with an exemption list that records the exact count it allows. Three more policy tests read
+the package's syntax: every interface method, `*Registry` method and exported function field takes
+a `context.Context` first (C10); no package-level client or registry exists (C9); and no exported
+name the package has shipped disappears.
## Why the caller owns the transport
@@ -47,6 +50,31 @@ a library that accepted nil, or built its own client, would route around that gu
noticing. Refusing nil and refusing the default transport makes the bypass impossible to write by
accident.
+## Why plans carry a digest
+
+Both consumers show a plan to a person, collect an approval, and apply it later, possibly minutes
+later and from another process. The digest is SHA-256 over the provider, the account, the changes,
+what a destroy loses, and the live state the plan was built against. `Apply` reads the live state
+again through the adapter and recomputes it, so one comparison refuses a plan whose infrastructure
+moved, a plan edited after approval (a trimmed `Loses` list, another target), and a plan replayed
+against another account. The plan ID is derived from the digest, so an approval recorded against an
+ID cannot be reused for different content. `Cost` and `Expires` are outside the digest: the first is
+informational, and the second is checked on its own.
+
+A destroy is applied only when the consumer passes back the deleted resources' names exactly as
+`Plan.ConfirmText` gives them. Approval levels and step-up remain the consumer's; cloudkit checks
+only the typed name, and does so before it opens the provider, so a refusal makes no request.
+
+## Why the client is layered as it is
+
+The client an adapter receives puts the egress guard outermost, then retries, then the token, then
+the caller's round tripper. A refused host therefore never reaches the credential source or the
+retry loop. The token is added only to requests for the descriptor's API hosts, and never to a
+request that follows a redirect, even to an allowed redirect host: a token for the API is not a
+token for the provider's object store. Retries never resend a non-idempotent request after an
+ambiguous failure, so a create is not sent twice; a 429 is retried because the provider did not act
+on it.
+
## Why it is a library without a CLI framework
Each consumer has its own command tree and configuration loader. A library that brought cobra or a
@@ -68,7 +96,7 @@ before it loads.
| Path | Holds |
|---|---|
-| `src/` | package `cloudkit`: transport, egress policy, secrets, errors, redaction |
+| `src/` | package `cloudkit`: transport, egress policy, secrets, errors, redaction, the registry, capabilities, plans and `Apply`, retries |
| `src/internal/policy/` | repository-wide tests: forbidden symbols, workflow guards |
Planned packages are added when they have code: `providers/` (wave 1: Hetzner, DigitalOcean,
diff --git a/docs/getting-started.md b/docs/getting-started.md
index 45486b0..7de2952 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -1,7 +1,7 @@
# Getting started
-A walkthrough of the four things this release provides: the transport, the egress policy, secrets
-and the error set.
+A walkthrough of what this release provides: the transport, the egress policy, the registry, plans
+and applying them, retries, secrets and the error set.
## The transport
@@ -46,6 +46,156 @@ client, err := cloudkit.NewHTTPClient(transport, cloudkit.EgressPolicy{
| Redirect-only hosts | a host listed only in `RedirectHosts` is reachable by redirect, never directly |
| Refusal | before your round tripper sees the request, as a `*cloudkit.EgressError` matching `cloudkit.ErrEgressDenied` |
+## The registry
+
+`cloudkit.Registry` is built by you, from `cloudkit.Options` and one `cloudkit.Factory` per provider.
+Nothing registers itself, and there is no package-level registry or client.
+
+```go
+reg, err := cloudkit.NewRegistry(cloudkit.Options{
+ Transport: transport, // required; nil or the default transport is refused
+ Credentials: vaultTokens, // required; a cloudkit.CredentialSource
+ Logger: logger, // required; slog.New(slog.DiscardHandler) to silence
+ Retry: cloudkit.RetryPolicy{}, // zero value: 3 tries, jittered backoff
+ UserAgent: "dbpantry/1.4.0",
+}, hetznerFactory, digitaloceanFactory)
+```
+
+`NewRegistry` refuses invalid options, a factory whose descriptor is incomplete, a factory with no
+`New` function, and two factories with the same ID. A descriptor ID is lower-case words joined by
+hyphens (`hetzner`, `digitalocean`), never empty.
+
+| `Options` field | Required | Notes |
+|---|---|---|
+| `Transport` | yes | both `HTTP` and `Dial`; see above |
+| `Credentials` | yes | `Token(ctx, account)` is called for every request, so a rotated token is used on the next one |
+| `Logger` | yes | cloudkit never uses the default logger |
+| `Retry` | no | see [Retries](#retries) |
+| `UserAgent` | no | sent as `User-Agent` when set |
+
+### Credentials
+
+```go
+type vault struct{ store *Vault }
+
+func (v vault) Token(ctx context.Context, account string) (cloudkit.Secret, error) {
+ b, err := v.store.Read(ctx, "providers/"+account)
+ if err != nil {
+ return cloudkit.Secret{}, err
+ }
+ return cloudkit.NewSecret(b), nil
+}
+```
+
+An empty token is refused rather than sent, and a failing source is reported without repeating its
+message, so nothing the vault says about a token reaches an error string.
+
+### Opening a provider
+
+```go
+p, err := reg.Open(ctx, "hetzner", "production")
+if err != nil {
+ return err // cloudkit.ErrNotFound for an unknown provider
+}
+caps, err := p.Probe(ctx)
+if caps.Allowed(cloudkit.CapServerCreate) {
+ // offer "create server"
+}
+servers, err := p.Inventory(ctx, cloudkit.Filter{Kinds: []string{"server"}})
+```
+
+Each `Open` builds a fresh client, so two accounts never share one. The adapter receives a
+`cloudkit.Account` whose `HTTP` client:
+
+| Layer, outermost first | Does |
+|---|---|
+| Egress guard | refuses any host outside the descriptor's `APIHosts`, and redirects outside `RedirectHosts` |
+| Retry | the registry's `RetryPolicy` |
+| Authenticator | adds `Authorization: Bearer ` from the credential source, only for API hosts and never on a redirect |
+| Your round tripper | everything else: proxy, TLS, timeouts, your own egress controls |
+
+### Capabilities
+
+`Probe` returns a `cloudkit.CapSet`: each capability it tested and whether the token is `Allowed`
+or `Denied`. A capability it did not test reads as `Unknown`, which is not allowed. A read-only
+token shows every write as `Denied`. `Capability.IsWrite` says which capabilities change anything;
+a name cloudkit does not know counts as a write.
+
+## Plans and applying them
+
+Every write is planned first. An adapter builds a plan with `cloudkit.NewPlan` from the changes,
+what a destroy loses, and the live state it read:
+
+```go
+plan, err := lifecycle.PlanDestroy(ctx, "srv-4821")
+// plan.Changes: delete server web-1
+// plan.Loses: server web-1, volume data-1, ipv4 203.0.113.7
+// plan.ConfirmText(): "web-1"
+```
+
+| `Plan` field | Holds |
+|---|---|
+| `ID` | stable, derived from the digest; approvals refer to it |
+| `Provider`, `Account` | where it applies; `Apply` opens the same account |
+| `Changes` | each create, update or delete, with the resource before and after |
+| `Loses` | for a destroy, every resource deleted and everything that goes with it |
+| `Cost` | monthly delta, when the provider reports one; not covered by the digest |
+| `Digest` | SHA-256 over the provider, account, changes, loses and the live state |
+| `Expires` | 15 minutes after the plan was built (`cloudkit.PlanTTL`) |
+
+Show the plan, collect the approval your product requires, then apply it:
+
+```go
+result, err := reg.Apply(ctx, plan, cloudkit.Confirmation{Typed: typedByThePerson})
+switch {
+case errors.Is(err, cloudkit.ErrConfirmationRequired):
+ // a destroy, and the name was not typed back exactly
+case errors.Is(err, cloudkit.ErrConflict):
+ // the live state moved, the plan expired, or it was altered: plan again
+}
+```
+
+`Apply` refuses before anything changes, in this order:
+
+| Refusal | Error |
+|---|---|
+| the plan is malformed, was altered after `NewPlan`, or has expired | `ErrConflict` (`*PlanError`) |
+| a destroy whose `ConfirmText` was not typed back exactly | `ErrConfirmationRequired` |
+| the provider cannot apply plans | `ErrUnsupported` (`*UnsupportedError`) |
+| the live state the adapter reads again no longer matches the digest | `ErrConflict` (`*PlanError`) |
+
+`Result` comes back only when the provider reports the work done, not merely accepted.
+
+### Writing the apply side of an adapter
+
+A provider that applies plans implements `cloudkit.Applier`:
+
+```go
+type Applier interface {
+ LiveState(ctx context.Context, p Plan) ([]Resource, error) // same shape as passed to NewPlan
+ Execute(ctx context.Context, p Plan) (Result, error) // waits for the provider's actions
+}
+```
+
+`NewPlan` refuses a delete that is not listed in `loses`, or whose resource has no name to type
+back.
+
+## Retries
+
+The registry's clients retry by `cloudkit.RetryPolicy`. The zero value is 3 tries, jittered
+exponential backoff from 500 ms, and `Retry-After` honoured up to 30 s; `MaxAttempts: 1` turns
+retries off.
+
+| Response | Retried for |
+|---|---|
+| 429 | any request whose body can be replayed; the provider did not act on it |
+| 502, 503, 504, or a transport error | idempotent methods only (GET, HEAD, OPTIONS, PUT, DELETE), so a create is never sent twice |
+| anything else | not retried |
+
+A 429 that outlasts the budget, or asks to wait longer than `MaxDelay`, ends in a
+`*cloudkit.RateLimitError` matching `cloudkit.ErrRateLimited`, with the `RetryAfter` the provider
+asked for.
+
## Secrets
`cloudkit.Secret` holds a credential and prints as `[REDACTED]` in every fmt verb, in `log/slog`,
@@ -69,6 +219,7 @@ req.Header.Set("Authorization", "Bearer "+string(token.Reveal()))
| `ErrRateLimited` | throttled after the retry budget |
| `ErrEgressDenied` | the egress policy refused a request or redirect |
| `ErrUnsupported` | the adapter does not implement the capability |
+| `ErrConfirmationRequired` | a destroy plan was applied without its names typed back |
Match them with `errors.Is`. `cloudkit.Redact` strips Authorization-style headers, bearer
credentials, credential query parameters and URL userinfo from a string, and `cloudkit.RedactBody`
@@ -77,9 +228,9 @@ URL with them before logging it.
## What comes next
-The registry, capability probes, plans with drift refusal, the contract test kit, the SSH path, the
-cloud-init renderer, the first provider adapters (Hetzner, DigitalOcean, Linode, Vultr), managed
-database discovery and REST descriptors are planned; see [Architecture](architecture.md).
+The contract test kit, the hardened transport, the SSH path, the cloud-init renderer, the first
+provider adapters (Hetzner, DigitalOcean, Linode, Vultr), managed database discovery and REST
+descriptors are planned; see [Architecture](architecture.md).
---
diff --git a/src/capability.go b/src/capability.go
new file mode 100644
index 0000000..b4da679
--- /dev/null
+++ b/src/capability.go
@@ -0,0 +1,100 @@
+package cloudkit
+
+import (
+ "fmt"
+ "maps"
+ "slices"
+)
+
+// Capability names one thing a token may be allowed to do, as ".".
+type Capability string
+
+// The capabilities a probe reports on. Reads need no plan; every other one is
+// a write and goes through [Plan] and [Registry.Apply].
+const (
+ CapInventory Capability = "inventory.read"
+
+ CapServerCreate Capability = "server.create"
+ CapServerResize Capability = "server.resize"
+ CapServerDestroy Capability = "server.destroy"
+ CapServerPower Capability = "server.power" // power and reboot
+ CapServerUpdate Capability = "server.update" // rename, labels, protection
+
+ CapFirewallRead Capability = "firewall.read"
+ CapFirewallWrite Capability = "firewall.write"
+
+ CapNetworkRead Capability = "network.read"
+ CapNetworkWrite Capability = "network.write"
+
+ CapVolumeRead Capability = "volume.read"
+ CapVolumeWrite Capability = "volume.write"
+
+ CapSnapshotRead Capability = "snapshot.read"
+ CapSnapshotWrite Capability = "snapshot.write"
+
+ CapObjectStorageRead Capability = "object-storage.read"
+ CapObjectStorageWrite Capability = "object-storage.write"
+
+ CapDNSRead Capability = "dns.read"
+ CapDNSWrite Capability = "dns.write"
+
+ CapHostKeysRead Capability = "host-keys.read"
+ CapCostRead Capability = "cost.read"
+
+ CapManagedDatabasesRead Capability = "managed-databases.read"
+ CapManagedDatabasesConnect Capability = "managed-databases.connect" // mints a short-lived connect credential; changes nothing
+)
+
+// reads lists the capabilities that change nothing. Anything else, including
+// a capability this release does not know, counts as a write, so an unknown
+// name fails safe.
+var reads = []Capability{
+ CapInventory, CapFirewallRead, CapNetworkRead, CapVolumeRead, CapSnapshotRead,
+ CapObjectStorageRead, CapDNSRead, CapHostKeysRead, CapCostRead,
+ CapManagedDatabasesRead, CapManagedDatabasesConnect,
+}
+
+// IsWrite reports whether c changes anything. An unknown capability is a write.
+func (c Capability) IsWrite() bool { return !slices.Contains(reads, c) }
+
+// Access is what a probe found for one capability.
+type Access int
+
+// The three answers a probe gives. The zero value is Unknown, so a capability
+// a probe did not test is never mistaken for an allowed one.
+const (
+ Unknown Access = iota // not tested, or the provider would not say
+ Allowed // the token may do it
+ Denied // the token may not; neither product offers it
+)
+
+func (a Access) String() string {
+ switch a {
+ case Unknown:
+ return "unknown"
+ case Allowed:
+ return "allowed"
+ case Denied:
+ return "denied"
+ }
+ return fmt.Sprintf("Access(%d)", int(a))
+}
+
+// CapSet is a probe's answer: each capability it tested and what it found.
+// A capability missing from the set is Unknown. A nil CapSet reads as all
+// Unknown; Set needs one made with make or a literal.
+type CapSet map[Capability]Access
+
+// Get returns what the probe found for c, Unknown when it did not say.
+func (s CapSet) Get(c Capability) Access { return s[c] }
+
+// Allowed reports whether the probe found c allowed. Unknown is not allowed.
+func (s CapSet) Allowed(c Capability) bool { return s[c] == Allowed }
+
+// Set records a for c.
+func (s CapSet) Set(c Capability, a Access) { s[c] = a }
+
+// Capabilities returns the capabilities in the set, sorted.
+func (s CapSet) Capabilities() []Capability {
+ return slices.Sorted(maps.Keys(s))
+}
diff --git a/src/capability_test.go b/src/capability_test.go
new file mode 100644
index 0000000..fab40cf
--- /dev/null
+++ b/src/capability_test.go
@@ -0,0 +1,67 @@
+package cloudkit
+
+import (
+ "slices"
+ "testing"
+)
+
+func TestCapabilityIsWrite(t *testing.T) {
+ for _, c := range []Capability{CapInventory, CapFirewallRead, CapDNSRead, CapHostKeysRead, CapCostRead, CapManagedDatabasesRead, CapManagedDatabasesConnect} {
+ if c.IsWrite() {
+ t.Errorf("%s counts as a write", c)
+ }
+ }
+ for _, c := range []Capability{CapServerCreate, CapServerResize, CapServerDestroy, CapServerPower, CapServerUpdate, CapFirewallWrite, CapNetworkWrite, CapVolumeWrite, CapSnapshotWrite, CapObjectStorageWrite, CapDNSWrite} {
+ if !c.IsWrite() {
+ t.Errorf("%s counts as a read", c)
+ }
+ }
+ // Fail safe: a name this release does not know is a write.
+ if !Capability("volume.read-ish").IsWrite() || !Capability("").IsWrite() {
+ t.Error("an unknown capability counts as a read")
+ }
+}
+
+func TestCapSet(t *testing.T) {
+ var none CapSet
+ if none.Get(CapInventory) != Unknown || none.Allowed(CapInventory) {
+ t.Fatal("a nil CapSet is not all Unknown")
+ }
+ s := CapSet{}
+ s.Set(CapServerCreate, Denied)
+ s.Set(CapInventory, Allowed)
+ if !s.Allowed(CapInventory) || s.Allowed(CapServerCreate) || s.Get(CapDNSWrite) != Unknown {
+ t.Fatalf("CapSet = %v", s)
+ }
+ if got := s.Capabilities(); !slices.Equal(got, []Capability{CapInventory, CapServerCreate}) {
+ t.Fatalf("Capabilities = %v", got)
+ }
+ for a, want := range map[Access]string{Unknown: "unknown", Allowed: "allowed", Denied: "denied", Access(9): "Access(9)"} {
+ if a.String() != want {
+ t.Errorf("%d.String() = %q", int(a), a.String())
+ }
+ }
+ if Unknown != Access(0) {
+ t.Fatal("the zero Access is not Unknown")
+ }
+}
+
+func TestTokenModelString(t *testing.T) {
+ for m, want := range map[TokenModel]string{Scoped: "scoped", ReadOrReadWrite: "read-or-read-write", PerProduct: "per-product", Unscoped: "unscoped", 0: "TokenModel(0)"} {
+ if m.String() != want {
+ t.Errorf("%d.String() = %q", int(m), m.String())
+ }
+ }
+}
+
+func TestDescriptorEgressIsACopy(t *testing.T) {
+ d := fakeDescriptor()
+ e := d.Egress()
+ e.APIHosts[0] = "evil.test"
+ if d.APIHosts[0] != fakeHost {
+ t.Fatal("Egress shares the descriptor's slice")
+ }
+ if err := d.Validate(); err != nil {
+ t.Fatal(err)
+ }
+}
diff --git a/src/client.go b/src/client.go
new file mode 100644
index 0000000..2b2a8b4
--- /dev/null
+++ b/src/client.go
@@ -0,0 +1,84 @@
+package cloudkit
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "slices"
+)
+
+// authenticator adds the account's token to each request bound for one of the
+// descriptor's API hosts, fetching it from the CredentialSource every time, so
+// a rotated token is used on the next request and none is kept (C5).
+type authenticator struct {
+ next http.RoundTripper
+ creds CredentialSource
+ account string
+ hosts []string // normalised API hosts
+ userAgent string
+}
+
+// RoundTrip never modifies req; it sends a clone. A request that follows a
+// redirect, or goes to any host but an API host, carries no credential, even
+// when the redirect target is allowed: a token for the API is not a token for
+// the provider's object store or CDN.
+func (a *authenticator) RoundTrip(req *http.Request) (*http.Response, error) {
+ out := req.Clone(req.Context())
+ if a.userAgent != "" {
+ out.Header.Set("User-Agent", a.userAgent)
+ }
+ out.Header.Del("Authorization")
+ if req.Response == nil && slices.Contains(a.hosts, hostOf(req)) {
+ tok, err := a.creds.Token(req.Context(), a.account)
+ if err == nil && tok.IsZero() {
+ err = errEmptyToken
+ }
+ if err != nil {
+ if req.Body != nil {
+ _ = req.Body.Close() // RoundTrip must close the body, even on error
+ }
+ return nil, &credentialError{account: a.account, err: err}
+ }
+ v := tok.Reveal()
+ out.Header.Set("Authorization", "Bearer "+string(v))
+ clear(v)
+ }
+ return a.next.RoundTrip(out)
+}
+
+// errEmptyToken is the cause when a credential source returns no token: a
+// request is never sent unauthenticated instead.
+var errEmptyToken = errors.New("cloudkit: the credential source returned an empty token")
+
+// credentialError reports a failed credential source without repeating its
+// message: whatever a vault or keychain says about a token stays out of the
+// error string, while errors.Is and errors.As still reach the cause.
+type credentialError struct {
+ account string
+ err error
+}
+
+func (e *credentialError) Error() string {
+ return fmt.Sprintf("cloudkit: the credential source failed for account %q", e.account)
+}
+
+func (e *credentialError) Unwrap() error { return e.err }
+
+// redactedError carries an error whose message has been through [Redact],
+// while errors.Is and errors.As still see the original chain.
+type redactedError struct {
+ msg string
+ err error
+}
+
+func (e *redactedError) Error() string { return e.msg }
+func (e *redactedError) Unwrap() error { return e.err }
+
+// redactErr is how an error from outside the module (a provider, an adapter,
+// a credential source) leaves it.
+func redactErr(err error) error {
+ if err == nil {
+ return nil
+ }
+ return &redactedError{msg: Redact(err.Error()), err: err}
+}
diff --git a/src/cloudkit.go b/src/cloudkit.go
new file mode 100644
index 0000000..a4c432e
--- /dev/null
+++ b/src/cloudkit.go
@@ -0,0 +1,286 @@
+package cloudkit
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log/slog"
+ "net/http"
+ "net/url"
+ "regexp"
+ "slices"
+ "time"
+)
+
+// CredentialSource returns the token for one provider account. It is called
+// per request, so rotation needs no restart, and the module never keeps the
+// value (C5).
+type CredentialSource interface {
+ Token(ctx context.Context, account string) (Secret, error)
+}
+
+// Options configures a [Registry]. Transport, Credentials and Logger are
+// required; Retry and UserAgent may be left zero.
+type Options struct {
+ Transport Transport // required (C1, C2)
+ Credentials CredentialSource // required (C5)
+ Logger *slog.Logger // required; slog.New(slog.DiscardHandler) to silence (C10)
+ Retry RetryPolicy // zero value: 3 tries, jittered backoff, honours Retry-After
+ UserAgent string // e.g. "dbpantry/1.4.0" or "mesobia/0.3.0"; empty leaves the header alone
+}
+
+// Validate reports whether o can build a [Registry].
+func (o Options) Validate() error {
+ if err := o.Transport.Validate(); err != nil {
+ return err
+ }
+ if o.Credentials == nil {
+ return errors.New("cloudkit: options: Credentials is nil")
+ }
+ if o.Logger == nil {
+ return errors.New("cloudkit: options: Logger is nil; pass slog.New(slog.DiscardHandler) to silence it")
+ }
+ return o.Retry.Validate()
+}
+
+// TokenModel says how a provider scopes its API tokens.
+type TokenModel int
+
+// The token models. The zero value is not one of them, so a descriptor that
+// forgets to say is refused.
+const (
+ Scoped TokenModel = iota + 1 // scoped tokens per endpoint or permission
+ ReadOrReadWrite // one token per project, read or read and write
+ PerProduct // per product: none, read only, read and write
+ Unscoped // one key reaches everything
+)
+
+func (m TokenModel) String() string {
+ switch m {
+ case Scoped:
+ return "scoped"
+ case ReadOrReadWrite:
+ return "read-or-read-write"
+ case PerProduct:
+ return "per-product"
+ case Unscoped:
+ return "unscoped"
+ }
+ return fmt.Sprintf("TokenModel(%d)", int(m))
+}
+
+// Module names the SDK an adapter is built on, for the consumers' inventory
+// screens.
+type Module struct {
+ Path string // e.g. "github.com/hetznercloud/hcloud-go/v2"
+ Version string // e.g. "v2.53.0"
+ License string // SPDX identifier, e.g. "MIT"
+}
+
+// Descriptor is what an adapter says about itself.
+type Descriptor struct {
+ ID, Title, DocsURL string
+ APIHosts []string // C3; e.g. "api.hetzner.cloud"
+ RedirectHosts []string // C4; redirect targets the adapter expects, if any
+ TokenModel TokenModel // Scoped, ReadOrReadWrite, PerProduct, Unscoped
+ UserDataLimit int // bytes the provider accepts; 0 means no user data
+ SDK Module // path, version and licence, for the inventory screen
+}
+
+// reID is the shape of a provider ID: lower-case words joined by hyphens.
+var reID = regexp.MustCompile(`^[a-z][a-z0-9]*(-[a-z0-9]+)*$`)
+
+const maxIDLen = 64
+
+func validID(id string) bool { return len(id) <= maxIDLen && reID.MatchString(id) }
+
+// Validate reports whether d is complete: a lower-case, hyphenated ID, a
+// title, an HTTPS docs URL if any, a valid egress policy and a token model.
+func (d Descriptor) Validate() error {
+ if !validID(d.ID) {
+ return fmt.Errorf("cloudkit: descriptor ID %q must be lower-case words joined by hyphens, at most %d bytes", d.ID, maxIDLen)
+ }
+ if d.Title == "" {
+ return fmt.Errorf("cloudkit: descriptor %s has no title", d.ID)
+ }
+ if d.DocsURL != "" {
+ if u, err := url.Parse(d.DocsURL); err != nil || u.Scheme != "https" || u.Host == "" {
+ return fmt.Errorf("cloudkit: descriptor %s: DocsURL must be an https URL", d.ID)
+ }
+ }
+ if err := d.Egress().Validate(); err != nil {
+ return fmt.Errorf("cloudkit: descriptor %s: %w", d.ID, err)
+ }
+ if d.TokenModel < Scoped || d.TokenModel > Unscoped {
+ return fmt.Errorf("cloudkit: descriptor %s has no token model", d.ID)
+ }
+ if d.UserDataLimit < 0 {
+ return fmt.Errorf("cloudkit: descriptor %s: UserDataLimit is negative", d.ID)
+ }
+ return nil
+}
+
+// Egress returns the egress policy the descriptor declares.
+func (d Descriptor) Egress() EgressPolicy {
+ return EgressPolicy{APIHosts: slices.Clone(d.APIHosts), RedirectHosts: slices.Clone(d.RedirectHosts)}
+}
+
+// Resource is one thing a provider holds: a server, volume, IP, firewall,
+// snapshot, bucket or DNS record.
+type Resource struct {
+ Kind string `json:"kind"` // e.g. "server", "volume", "ipv4"
+ ID string `json:"id,omitempty"` // the provider's ID; empty before a create
+ Name string `json:"name,omitempty"` // the name a person types back to destroy it
+ Region string `json:"region,omitempty"` // the provider's region or location code
+ Labels map[string]string `json:"labels,omitempty"` // provider labels or tags
+ Attrs map[string]string `json:"attrs,omitempty"` // facts a plan depends on: size, image, status
+}
+
+// Filter narrows an inventory. A zero Filter matches everything.
+type Filter struct {
+ Kinds []string // match any of these kinds; empty matches all
+ Region string // empty matches all
+ Labels map[string]string // every label must be present with this value
+}
+
+// Matches reports whether r passes the filter.
+func (f Filter) Matches(r Resource) bool {
+ if len(f.Kinds) > 0 && !slices.Contains(f.Kinds, r.Kind) {
+ return false
+ }
+ if f.Region != "" && f.Region != r.Region {
+ return false
+ }
+ for k, v := range f.Labels {
+ if got, ok := r.Labels[k]; !ok || got != v {
+ return false
+ }
+ }
+ return true
+}
+
+// Provider is the only interface every adapter implements. The capability
+// interfaces are optional, found by type assertion.
+type Provider interface {
+ Descriptor() Descriptor
+ Probe(ctx context.Context) (CapSet, error) // with the real token
+ Inventory(ctx context.Context, f Filter) ([]Resource, error)
+}
+
+// Account is what an adapter gets for one provider account when the registry
+// opens it. HTTP is the only client the adapter may use: it is limited to the
+// descriptor's hosts (C3, C4), adds the account's token to each request from
+// the CredentialSource (C5), and retries by the registry's RetryPolicy.
+type Account struct {
+ Provider string // the descriptor ID
+ Name string // the account name the caller passed to Open
+ HTTP *http.Client // guarded and authenticated; never replace its Transport
+ Logger *slog.Logger // the registry's logger, with provider and account attributes
+}
+
+// Factory builds a provider for one account. The caller passes factories to
+// [NewRegistry]; nothing registers itself (C9).
+type Factory struct {
+ Descriptor Descriptor
+ New func(ctx context.Context, a Account) (Provider, error)
+}
+
+// Registry opens providers and applies plans. The caller builds it with
+// [NewRegistry]; there is no package-level registry (C9). It is safe for
+// concurrent use and holds no client between calls, so two accounts never
+// share one.
+type Registry struct {
+ opts Options
+ factories map[string]Factory
+ order []string
+ now func() time.Time // replaced in tests
+ sleep func(ctx context.Context, d time.Duration) error // replaced in tests
+}
+
+// NewRegistry builds a registry over the given factories. It refuses invalid
+// options (a nil or default transport among them, C2), a factory whose
+// descriptor is invalid or whose New is nil, and two factories with one ID.
+func NewRegistry(o Options, factories ...Factory) (*Registry, error) {
+ if err := o.Validate(); err != nil {
+ return nil, err
+ }
+ r := &Registry{opts: o, factories: make(map[string]Factory, len(factories)), now: time.Now, sleep: sleepCtx}
+ for _, f := range factories {
+ if err := f.Descriptor.Validate(); err != nil {
+ return nil, err
+ }
+ if f.New == nil {
+ return nil, fmt.Errorf("cloudkit: factory %s has no New function", f.Descriptor.ID)
+ }
+ if _, dup := r.factories[f.Descriptor.ID]; dup {
+ return nil, fmt.Errorf("cloudkit: two factories have the ID %q", f.Descriptor.ID)
+ }
+ f.Descriptor = cloneDescriptor(f.Descriptor)
+ r.factories[f.Descriptor.ID] = f
+ r.order = append(r.order, f.Descriptor.ID)
+ }
+ return r, nil
+}
+
+// Descriptors returns the descriptor of every factory, in the order given to
+// [NewRegistry].
+func (r *Registry) Descriptors() []Descriptor {
+ out := make([]Descriptor, 0, len(r.order))
+ for _, id := range r.order {
+ out = append(out, cloneDescriptor(r.factories[id].Descriptor))
+ }
+ return out
+}
+
+// Open builds a provider for one account, with a fresh guarded and
+// authenticated client. It returns [ErrNotFound] for an unknown provider.
+func (r *Registry) Open(ctx context.Context, provider, account string) (Provider, error) {
+ f, ok := r.factories[provider]
+ if !ok {
+ return nil, fmt.Errorf("%w: no provider %q in the registry", ErrNotFound, provider)
+ }
+ if account == "" {
+ return nil, fmt.Errorf("cloudkit: open %s: account is empty", provider)
+ }
+ client, err := r.client(f.Descriptor, account)
+ if err != nil {
+ return nil, err
+ }
+ p, err := f.New(ctx, Account{
+ Provider: provider,
+ Name: account,
+ HTTP: client,
+ Logger: r.opts.Logger.With("provider", provider, "account", account),
+ })
+ if err != nil {
+ return nil, redactErr(fmt.Errorf("cloudkit: open %s: %w", provider, err))
+ }
+ if p == nil {
+ return nil, fmt.Errorf("cloudkit: open %s: factory returned no provider", provider)
+ }
+ if got := p.Descriptor().ID; got != provider {
+ return nil, fmt.Errorf("cloudkit: open %s: provider describes itself as %q", provider, got)
+ }
+ return p, nil
+}
+
+// client builds the http.Client an adapter uses: the egress guard outermost,
+// so a refused host never reaches the retry loop or the credential source,
+// then retries, then the token, then the caller's round tripper.
+func (r *Registry) client(d Descriptor, account string) (*http.Client, error) {
+ auth := &authenticator{
+ next: r.opts.Transport.HTTP,
+ creds: r.opts.Credentials,
+ account: account,
+ hosts: normalise(d.APIHosts),
+ userAgent: r.opts.UserAgent,
+ }
+ retry := &retrier{next: auth, policy: r.opts.Retry, sleep: r.sleep}
+ return NewHTTPClient(Transport{HTTP: retry, Dial: r.opts.Transport.Dial}, d.Egress())
+}
+
+func cloneDescriptor(d Descriptor) Descriptor {
+ d.APIHosts = slices.Clone(d.APIHosts)
+ d.RedirectHosts = slices.Clone(d.RedirectHosts)
+ return d
+}
diff --git a/src/core_test.go b/src/core_test.go
new file mode 100644
index 0000000..5cc9977
--- /dev/null
+++ b/src/core_test.go
@@ -0,0 +1,330 @@
+package cloudkit
+
+import (
+ "context"
+ "crypto/rand"
+ "errors"
+ "log/slog"
+ "net/http"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+)
+
+func validOptions() Options {
+ return Options{
+ Transport: Transport{HTTP: newWorld(), Dial: noDial},
+ Credentials: newCreds(),
+ Logger: slog.New(slog.DiscardHandler),
+ }
+}
+
+// C2, C5, C10: a registry is never built over a missing or default
+// transport, a missing credential source or a missing logger.
+func TestNewRegistryRefusesBadOptions(t *testing.T) {
+ cases := map[string]func(*Options){
+ "nil HTTP": func(o *Options) { o.Transport.HTTP = nil },
+ "default transport": func(o *Options) { o.Transport.HTTP = http.DefaultTransport },
+ "nil Dial": func(o *Options) { o.Transport.Dial = nil },
+ "nil credentials": func(o *Options) { o.Credentials = nil },
+ "nil logger": func(o *Options) { o.Logger = nil },
+ "negative retries": func(o *Options) { o.Retry.MaxAttempts = -1 },
+ "base above max": func(o *Options) { o.Retry = RetryPolicy{BaseDelay: time.Minute, MaxDelay: time.Second} },
+ }
+ for name, mutate := range cases {
+ o := validOptions()
+ mutate(&o)
+ if _, err := NewRegistry(o, fakeFactory(nil)); err == nil {
+ t.Errorf("%s: accepted", name)
+ }
+ }
+ if _, err := NewRegistry(validOptions(), fakeFactory(nil)); err != nil {
+ t.Fatalf("valid options refused: %v", err)
+ }
+ if _, err := NewRegistry(Options{Transport: Transport{Dial: noDial}}); !errors.Is(err, ErrInvalidTransport) {
+ t.Errorf("nil HTTP: got %v, want ErrInvalidTransport", err)
+ }
+}
+
+// C9: the registry is the only place providers are known, so it refuses two
+// factories with one ID and any ID that is empty or not a lower-case slug.
+func TestNewRegistryRefusesBadFactories(t *testing.T) {
+ with := func(mutate func(*Factory)) Factory {
+ f := fakeFactory(nil)
+ mutate(&f)
+ return f
+ }
+ cases := map[string][]Factory{
+ "duplicate ID": {fakeFactory(nil), fakeFactory(nil)},
+ "empty ID": {with(func(f *Factory) { f.Descriptor.ID = "" })},
+ "blank ID": {with(func(f *Factory) { f.Descriptor.ID = " " })},
+ "upper-case ID": {with(func(f *Factory) { f.Descriptor.ID = "Hetzner" })},
+ "ID with a slash": {with(func(f *Factory) { f.Descriptor.ID = "simtabi/fake" })},
+ "ID with a space": {with(func(f *Factory) { f.Descriptor.ID = "fake cloud" })},
+ "trailing hyphen": {with(func(f *Factory) { f.Descriptor.ID = "fake-" })},
+ "ID too long": {with(func(f *Factory) { f.Descriptor.ID = strings.Repeat("a", maxIDLen+1) })},
+ "no title": {with(func(f *Factory) { f.Descriptor.Title = "" })},
+ "http docs": {with(func(f *Factory) { f.Descriptor.DocsURL = "http://docs.fake.test" })},
+ "no API hosts": {with(func(f *Factory) { f.Descriptor.APIHosts = nil })},
+ "wildcard host": {with(func(f *Factory) { f.Descriptor.APIHosts = []string{"*.fake.test"} })},
+ "bad redirect host": {with(func(f *Factory) { f.Descriptor.RedirectHosts = []string{"https://x.test"} })},
+ "no token model": {with(func(f *Factory) { f.Descriptor.TokenModel = 0 })},
+ "unknown token": {with(func(f *Factory) { f.Descriptor.TokenModel = Unscoped + 1 })},
+ "negative user data": {with(func(f *Factory) { f.Descriptor.UserDataLimit = -1 })},
+ "nil New": {with(func(f *Factory) { f.New = nil })},
+ }
+ for name, fs := range cases {
+ if _, err := NewRegistry(validOptions(), fs...); err == nil {
+ t.Errorf("%s: accepted", name)
+ }
+ }
+ other := with(func(f *Factory) { f.Descriptor.ID = "fake-two" })
+ r, err := NewRegistry(validOptions(), fakeFactory(nil), other)
+ if err != nil {
+ t.Fatalf("two distinct factories refused: %v", err)
+ }
+ ds := r.Descriptors()
+ if len(ds) != 2 || ds[0].ID != "fake" || ds[1].ID != "fake-two" {
+ t.Fatalf("Descriptors = %+v", ds)
+ }
+ ds[0].APIHosts[0] = "evil.test"
+ if r.Descriptors()[0].APIHosts[0] != fakeHost {
+ t.Fatal("Descriptors hands out the registry's own slices")
+ }
+}
+
+// C9: nothing is shared between registries or between Opens.
+func TestRegistryHoldsNoSharedClient(t *testing.T) {
+ h := newHarness(t, Options{})
+ a, b := h.open(), h.open()
+ if a.acct.HTTP == b.acct.HTTP {
+ t.Fatal("two Opens share one http.Client")
+ }
+ if a.acct.Provider != "fake" || a.acct.Name != "production" || a.acct.Logger == nil {
+ t.Fatalf("account = %+v", a.acct)
+ }
+ h2 := newHarness(t, Options{})
+ if h2.open().acct.HTTP == a.acct.HTTP {
+ t.Fatal("two registries share one http.Client")
+ }
+}
+
+func TestOpenRefuses(t *testing.T) {
+ h := newHarness(t, Options{})
+ ctx := context.Background()
+ if _, err := h.reg.Open(ctx, "nope", "production"); !errors.Is(err, ErrNotFound) {
+ t.Errorf("unknown provider: %v", err)
+ }
+ if _, err := h.reg.Open(ctx, "fake", ""); err == nil {
+ t.Error("empty account accepted")
+ }
+
+ token := rand.Text()
+ cases := map[string]func(context.Context, Account) (Provider, error){
+ "nil provider": func(context.Context, Account) (Provider, error) { return nil, nil },
+ "wrong ID": func(context.Context, Account) (Provider, error) {
+ d := fakeDescriptor()
+ d.ID = "other"
+ return readOnlyProvider{d}, nil
+ },
+ "factory error": func(context.Context, Account) (Provider, error) {
+ return nil, errors.New("dial failed: Authorization: Bearer " + token)
+ },
+ }
+ for name, newFn := range cases {
+ r, err := NewRegistry(validOptions(), Factory{Descriptor: fakeDescriptor(), New: newFn})
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, err = r.Open(ctx, "fake", "production")
+ if err == nil {
+ t.Errorf("%s: accepted", name)
+ continue
+ }
+ if strings.Contains(err.Error(), token) {
+ t.Errorf("%s: error leaks the token: %v", name, err)
+ }
+ }
+}
+
+// C5: the token is fetched for every request, so rotation needs no restart,
+// and it travels only in the Authorization header.
+func TestClientAuthenticatesEveryRequest(t *testing.T) {
+ h := newHarness(t, Options{UserAgent: "dbpantry/1.4.0"})
+ p := h.open()
+ ctx := context.Background()
+ if _, err := p.Inventory(ctx, Filter{}); err != nil {
+ t.Fatal(err)
+ }
+ first := h.creds.token
+ second := h.creds.rotate()
+ if _, err := p.Inventory(ctx, Filter{}); err != nil {
+ t.Fatal(err)
+ }
+ reqs := h.world.requests()
+ if len(reqs) != 2 || h.creds.count() != 2 {
+ t.Fatalf("requests %d, token fetches %d; want 2 and 2", len(reqs), h.creds.count())
+ }
+ if reqs[0].Auth != "Bearer "+first || reqs[1].Auth != "Bearer "+second {
+ t.Fatal("the rotated token was not used on the next request")
+ }
+ if reqs[0].UserAgent != "dbpantry/1.4.0" {
+ t.Errorf("User-Agent = %q", reqs[0].UserAgent)
+ }
+}
+
+// C5 with C4: a redirect to an allowed object host is followed, but carries
+// no credential, even one the adapter set by hand.
+func TestTokenNeverFollowsARedirect(t *testing.T) {
+ h := newHarness(t, Options{})
+ inner := h.world
+ rt := roundTripFunc(func(req *http.Request) (*http.Response, error) {
+ if req.URL.Host == fakeHost && req.URL.Path == "/v1/download" {
+ resp := reply(req, http.StatusFound, nil)
+ resp.Header.Set("Location", "https://objects.fake.test/blob")
+ _, _ = inner.RoundTrip(req) // record it
+ return resp, nil
+ }
+ if req.URL.Host == "objects.fake.test" {
+ _, _ = inner.RoundTrip(req)
+ return reply(req, http.StatusOK, nil), nil
+ }
+ return inner.RoundTrip(req)
+ })
+ o := Options{Transport: Transport{HTTP: rt, Dial: noDial}, Credentials: h.creds, Logger: slog.New(slog.DiscardHandler)}
+ r, err := NewRegistry(o, fakeFactory(nil))
+ if err != nil {
+ t.Fatal(err)
+ }
+ prov, err := r.Open(context.Background(), "fake", "production")
+ if err != nil {
+ t.Fatal(err)
+ }
+ c := prov.(*fakeProvider).acct.HTTP
+ req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "https://"+fakeHost+"/v1/download", nil)
+ req.Header.Set("Authorization", "Bearer set-by-the-adapter")
+ resp, err := c.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ _ = resp.Body.Close()
+ reqs := inner.requests()
+ if len(reqs) != 2 {
+ t.Fatalf("saw %d requests, want the API call and the redirect", len(reqs))
+ }
+ if !strings.HasPrefix(reqs[0].Auth, "Bearer ") || reqs[0].Auth == "Bearer set-by-the-adapter" {
+ t.Errorf("API request auth = %q, want the source's token", reqs[0].Auth)
+ }
+ if reqs[1].Auth != "" {
+ t.Fatalf("the redirect to the object host carried %q", reqs[1].Auth)
+ }
+}
+
+type roundTripFunc func(*http.Request) (*http.Response, error)
+
+func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
+
+// C3 before C5: a refused host never reaches the credential source.
+func TestEgressRefusalPrecedesCredentials(t *testing.T) {
+ h := newHarness(t, Options{})
+ c := h.open().acct.HTTP
+ for _, u := range []string{"https://evil.test/v1", "http://" + fakeHost + "/v1", "https://objects.fake.test/blob"} {
+ if err := get(t, c, u); !errors.Is(err, ErrEgressDenied) {
+ t.Errorf("%s: %v", u, err)
+ }
+ }
+ if h.creds.count() != 0 || len(h.world.requests()) != 0 {
+ t.Fatalf("credential fetches %d, requests %d; want 0 and 0", h.creds.count(), len(h.world.requests()))
+ }
+}
+
+func TestCredentialFailuresAreRedacted(t *testing.T) {
+ h := newHarness(t, Options{})
+ p := h.open()
+ leak := rand.Text()
+ h.creds.err = errors.New("vault said: token=" + leak + " expired")
+ _, err := p.Inventory(context.Background(), Filter{})
+ if err == nil || strings.Contains(err.Error(), leak) {
+ t.Fatalf("error = %v", err)
+ }
+ h.creds.err = nil
+ h.creds.token = ""
+ if _, err := p.Inventory(context.Background(), Filter{}); !errors.Is(err, errEmptyToken) {
+ t.Fatalf("empty token: %v", err)
+ }
+ if n := len(h.world.requests()); n != 0 {
+ t.Fatalf("%d requests went out without a credential", n)
+ }
+}
+
+func TestProbeReportsWritesDeniedForAReadOnlyToken(t *testing.T) {
+ h := newHarness(t, Options{})
+ h.world.readOnly = true
+ p := h.open()
+ caps, err := p.Probe(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, c := range caps.Capabilities() {
+ if c.IsWrite() && caps.Get(c) != Denied {
+ t.Errorf("%s = %s for a read-only token", c, caps.Get(c))
+ }
+ }
+ if !caps.Allowed(CapInventory) {
+ t.Error("a read-only token cannot read")
+ }
+ if _, err := p.PlanCreate(context.Background(), Resource{Kind: "server", Name: "web-1"}); !errors.Is(err, ErrDenied) {
+ t.Errorf("PlanCreate with a read-only token: %v", err)
+ }
+}
+
+func TestInventoryFilters(t *testing.T) {
+ h := newHarness(t, Options{},
+ Resource{Kind: "server", ID: "s1", Name: "web-1", Region: "fsn1", Labels: map[string]string{"env": "prod"}},
+ Resource{Kind: "server", ID: "s2", Name: "web-2", Region: "nbg1"},
+ Resource{Kind: "volume", ID: "v1", Name: "data", Region: "fsn1"},
+ )
+ p := h.open()
+ cases := []struct {
+ f Filter
+ want int
+ }{
+ {Filter{}, 3},
+ {Filter{Kinds: []string{"server"}}, 2},
+ {Filter{Region: "fsn1"}, 2},
+ {Filter{Labels: map[string]string{"env": "prod"}}, 1},
+ {Filter{Labels: map[string]string{"env": "dev"}}, 0},
+ {Filter{Kinds: []string{"volume", "server"}, Region: "nbg1"}, 1},
+ }
+ for _, c := range cases {
+ got, err := p.Inventory(context.Background(), c.f)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(got) != c.want {
+ t.Errorf("%+v: %d resources, want %d", c.f, len(got), c.want)
+ }
+ }
+}
+
+func TestOpenIsSafeConcurrently(t *testing.T) {
+ r, err := NewRegistry(validOptions(), fakeFactory(nil))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var wg sync.WaitGroup
+ for range 8 {
+ wg.Go(func() {
+ p, err := r.Open(context.Background(), "fake", "production")
+ if err != nil {
+ t.Error(err)
+ return
+ }
+ if _, err := p.Inventory(context.Background(), Filter{}); err != nil {
+ t.Error(err)
+ }
+ })
+ }
+ wg.Wait()
+}
diff --git a/src/doc.go b/src/doc.go
index a6b9e8b..1ae942f 100644
--- a/src/doc.go
+++ b/src/doc.go
@@ -9,6 +9,11 @@
// credentials travel as [Secret] values that redact themselves in fmt,
// log/slog and JSON.
//
-// This release carries the contracts that need no provider. The registry,
-// plans, provider adapters, the SSH path and the cloud-init renderer follow.
+// The caller builds a [Registry] from [Options] and the [Factory] of each
+// provider it wants; nothing registers itself. [Registry.Open] hands an
+// adapter a guarded, authenticated client for one account. Every write is a
+// [Plan] first: [Registry.Apply] refuses a plan whose live state moved, and a
+// destroy whose resource names were not typed back.
+//
+// Provider adapters, the SSH path and the cloud-init renderer follow.
package cloudkit
diff --git a/src/errors.go b/src/errors.go
index 76f11e9..915c7ec 100644
--- a/src/errors.go
+++ b/src/errors.go
@@ -26,6 +26,9 @@ var (
ErrEgressDenied = errors.New("cloudkit: egress denied")
// ErrUnsupported means the adapter does not implement the capability.
ErrUnsupported = errors.New("cloudkit: unsupported")
+ // ErrConfirmationRequired means a destroy plan was applied without its
+ // resource names typed back (C7).
+ ErrConfirmationRequired = errors.New("cloudkit: confirmation required")
)
// EgressError names the host an [EgressPolicy] refused. It matches
@@ -46,3 +49,32 @@ func (e *EgressError) Error() string {
// Unwrap lets errors.Is match [ErrEgressDenied].
func (e *EgressError) Unwrap() error { return ErrEgressDenied }
+
+// PlanError says why [Registry.Apply] refused a plan before changing anything:
+// its live state moved, it expired, or it was altered. It matches
+// [ErrConflict].
+type PlanError struct {
+ PlanID string
+ Reason string
+}
+
+func (e *PlanError) Error() string {
+ return fmt.Sprintf("cloudkit: conflict: plan %s: %s", e.PlanID, e.Reason)
+}
+
+// Unwrap lets errors.Is match [ErrConflict].
+func (e *PlanError) Unwrap() error { return ErrConflict }
+
+// UnsupportedError names the capability interface a provider does not
+// implement. It matches [ErrUnsupported].
+type UnsupportedError struct {
+ Provider string // descriptor ID
+ Interface string // e.g. "Applier", "ServerLifecycle"
+}
+
+func (e *UnsupportedError) Error() string {
+ return fmt.Sprintf("cloudkit: unsupported: provider %s does not implement %s", e.Provider, e.Interface)
+}
+
+// Unwrap lets errors.Is match [ErrUnsupported].
+func (e *UnsupportedError) Unwrap() error { return ErrUnsupported }
diff --git a/src/fake_test.go b/src/fake_test.go
new file mode 100644
index 0000000..9b9555c
--- /dev/null
+++ b/src/fake_test.go
@@ -0,0 +1,409 @@
+package cloudkit
+
+import (
+ "bytes"
+ "cmp"
+ "context"
+ "crypto/rand"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "log/slog"
+ "net"
+ "net/http"
+ "slices"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+)
+
+// The fake provider used by the core tests. It reaches its "API" only through
+// Account.HTTP, so every call it makes crosses the egress guard, the retry
+// loop and the authenticator before it reaches world, which plays the
+// caller's round tripper and the provider's servers at once.
+
+const fakeHost = "api.fake.test"
+
+func fakeDescriptor() Descriptor {
+ return Descriptor{
+ ID: "fake",
+ Title: "Fake Cloud",
+ DocsURL: "https://docs.fake.test/api",
+ APIHosts: []string{fakeHost},
+ RedirectHosts: []string{"objects.fake.test"},
+ TokenModel: ReadOrReadWrite,
+ UserDataLimit: 32 << 10,
+ }
+}
+
+// world is the provider's state and the caller's transport.
+type world struct {
+ mu sync.Mutex
+ resources map[string]Resource
+ next int
+ readOnly bool // the token may only read
+ fail []int // status codes to answer before serving normally
+ reqs []seenReq // every request that reached the transport
+ hook func(*http.Request) // runs before serving, outside the lock
+}
+
+type seenReq struct {
+ Method, URL, Auth, UserAgent, Body string
+}
+
+func newWorld(rs ...Resource) *world {
+ w := &world{resources: map[string]Resource{}}
+ for _, r := range rs {
+ w.resources[r.ID] = r
+ }
+ return w
+}
+
+func (w *world) writes() int {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ n := 0
+ for _, r := range w.reqs {
+ if r.Method != http.MethodGet {
+ n++
+ }
+ }
+ return n
+}
+
+func (w *world) requests() []seenReq {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ return slices.Clone(w.reqs)
+}
+
+func (w *world) RoundTrip(req *http.Request) (*http.Response, error) {
+ if w.hook != nil {
+ w.hook(req)
+ }
+ var body []byte
+ if req.Body != nil {
+ b, err := io.ReadAll(req.Body)
+ _ = req.Body.Close()
+ if err != nil {
+ return nil, err
+ }
+ body = b
+ }
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ w.reqs = append(w.reqs, seenReq{req.Method, req.URL.String(), req.Header.Get("Authorization"), req.Header.Get("User-Agent"), string(body)})
+ if len(w.fail) > 0 {
+ code := w.fail[0]
+ w.fail = w.fail[1:]
+ resp := reply(req, code, nil)
+ if code == http.StatusTooManyRequests {
+ resp.Header.Set("Retry-After", "2")
+ }
+ return resp, nil
+ }
+ if !strings.HasPrefix(req.Header.Get("Authorization"), "Bearer ") {
+ return reply(req, http.StatusUnauthorized, nil), nil
+ }
+ id := strings.TrimPrefix(req.URL.Path, "/v1/resources/")
+ switch {
+ case req.Method == http.MethodGet && req.URL.Path == "/v1/token":
+ return reply(req, http.StatusOK, map[string]bool{"write": !w.readOnly}), nil
+ case req.Method == http.MethodGet && req.URL.Path == "/v1/resources":
+ return reply(req, http.StatusOK, w.list()), nil
+ case w.readOnly:
+ return reply(req, http.StatusForbidden, nil), nil
+ case req.Method == http.MethodPost && req.URL.Path == "/v1/resources":
+ var r Resource
+ if err := json.Unmarshal(body, &r); err != nil {
+ return reply(req, http.StatusBadRequest, nil), nil
+ }
+ w.next++
+ r.ID = fmt.Sprintf("r%d", w.next)
+ w.resources[r.ID] = r
+ return reply(req, http.StatusCreated, r), nil
+ case req.Method == http.MethodPut && id != req.URL.Path:
+ var r Resource
+ if err := json.Unmarshal(body, &r); err != nil {
+ return reply(req, http.StatusBadRequest, nil), nil
+ }
+ w.resources[id] = r
+ return reply(req, http.StatusOK, r), nil
+ case req.Method == http.MethodDelete && id != req.URL.Path:
+ if _, ok := w.resources[id]; !ok {
+ return reply(req, http.StatusNotFound, nil), nil
+ }
+ delete(w.resources, id)
+ for k, r := range w.resources { // attached things go with it
+ if r.Attrs["attached_to"] == id {
+ delete(w.resources, k)
+ }
+ }
+ return reply(req, http.StatusNoContent, nil), nil
+ }
+ return reply(req, http.StatusNotFound, nil), nil
+}
+
+func (w *world) list() []Resource {
+ out := make([]Resource, 0, len(w.resources))
+ for _, r := range w.resources {
+ out = append(out, r)
+ }
+ slices.SortFunc(out, func(a, b Resource) int { return cmp.Compare(a.ID, b.ID) })
+ return out
+}
+
+// set changes the world behind the plan's back.
+func (w *world) set(r Resource) {
+ w.mu.Lock()
+ defer w.mu.Unlock()
+ w.resources[r.ID] = r
+}
+
+func reply(req *http.Request, code int, v any) *http.Response {
+ var b []byte
+ if v != nil {
+ b, _ = json.Marshal(v)
+ }
+ return &http.Response{StatusCode: code, Header: http.Header{}, Body: io.NopCloser(bytes.NewReader(b)), Request: req}
+}
+
+// fakeProvider implements Provider and Applier over the world's API.
+type fakeProvider struct {
+ acct Account
+}
+
+func (p *fakeProvider) Descriptor() Descriptor { return fakeDescriptor() }
+
+func (p *fakeProvider) Probe(ctx context.Context) (CapSet, error) {
+ var tok struct{ Write bool }
+ if err := p.call(ctx, http.MethodGet, "/v1/token", nil, &tok); err != nil {
+ return nil, err
+ }
+ write := Denied
+ if tok.Write {
+ write = Allowed
+ }
+ return CapSet{
+ CapInventory: Allowed,
+ CapServerCreate: write,
+ CapServerDestroy: write,
+ CapVolumeWrite: write,
+ }, nil
+}
+
+func (p *fakeProvider) Inventory(ctx context.Context, f Filter) ([]Resource, error) {
+ var all []Resource
+ if err := p.call(ctx, http.MethodGet, "/v1/resources", nil, &all); err != nil {
+ return nil, err
+ }
+ return slices.DeleteFunc(all, func(r Resource) bool { return !f.Matches(r) }), nil
+}
+
+// PlanCreate and PlanDestroy are what a ServerLifecycle adapter would offer.
+func (p *fakeProvider) PlanCreate(ctx context.Context, r Resource) (Plan, error) {
+ if err := p.writable(ctx); err != nil {
+ return Plan{}, err
+ }
+ live, err := p.Inventory(ctx, Filter{})
+ if err != nil {
+ return Plan{}, err
+ }
+ return NewPlan(p.acct.Provider, p.acct.Name, []Change{{Action: Create, After: &r}}, nil, live)
+}
+
+func (p *fakeProvider) PlanDestroy(ctx context.Context, id string) (Plan, error) {
+ if err := p.writable(ctx); err != nil {
+ return Plan{}, err
+ }
+ live, err := p.Inventory(ctx, Filter{})
+ if err != nil {
+ return Plan{}, err
+ }
+ i := slices.IndexFunc(live, func(r Resource) bool { return r.ID == id })
+ if i < 0 {
+ return Plan{}, fmt.Errorf("%w: %s", ErrNotFound, id)
+ }
+ target := live[i]
+ loses := []Resource{target}
+ for _, r := range live {
+ if r.Attrs["attached_to"] == id {
+ loses = append(loses, r)
+ }
+ }
+ return NewPlan(p.acct.Provider, p.acct.Name, []Change{{Action: Delete, Before: &target}}, loses, live)
+}
+
+func (p *fakeProvider) writable(ctx context.Context) error {
+ caps, err := p.Probe(ctx)
+ if err != nil {
+ return err
+ }
+ if caps.Get(CapServerCreate) != Allowed {
+ return fmt.Errorf("%w: the token is read only", ErrDenied)
+ }
+ return nil
+}
+
+func (p *fakeProvider) LiveState(ctx context.Context, _ Plan) ([]Resource, error) {
+ return p.Inventory(ctx, Filter{})
+}
+
+func (p *fakeProvider) Execute(ctx context.Context, pl Plan) (Result, error) {
+ var res Result
+ for _, c := range pl.Changes {
+ switch c.Action {
+ case Create:
+ var r Resource
+ if err := p.call(ctx, http.MethodPost, "/v1/resources", c.After, &r); err != nil {
+ return Result{}, err
+ }
+ res.Resources = append(res.Resources, r)
+ case Update:
+ if err := p.call(ctx, http.MethodPut, "/v1/resources/"+c.Before.ID, c.After, nil); err != nil {
+ return Result{}, err
+ }
+ res.Resources = append(res.Resources, *c.After)
+ case Delete:
+ if err := p.call(ctx, http.MethodDelete, "/v1/resources/"+c.Before.ID, nil, nil); err != nil {
+ return Result{}, err
+ }
+ }
+ }
+ return res, nil
+}
+
+func (p *fakeProvider) call(ctx context.Context, method, path string, in, out any) error {
+ var body io.Reader
+ if in != nil {
+ b, err := json.Marshal(in)
+ if err != nil {
+ return err
+ }
+ body = bytes.NewReader(b)
+ }
+ req, err := http.NewRequestWithContext(ctx, method, "https://"+fakeHost+path, body)
+ if err != nil {
+ return err
+ }
+ resp, err := p.acct.HTTP.Do(req)
+ if err != nil {
+ return err
+ }
+ defer func() { _ = resp.Body.Close() }()
+ b, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
+ switch {
+ case resp.StatusCode == http.StatusForbidden:
+ return fmt.Errorf("%w: %s %s", ErrDenied, method, path)
+ case resp.StatusCode == http.StatusNotFound:
+ return fmt.Errorf("%w: %s %s", ErrNotFound, method, path)
+ case resp.StatusCode >= 300:
+ return fmt.Errorf("fake: %s %s: %d %s", method, path, resp.StatusCode, RedactBody(b))
+ }
+ if out != nil && len(b) > 0 {
+ return json.Unmarshal(b, out)
+ }
+ return nil
+}
+
+// fakeFactory builds fake providers; opened counts the accounts it saw.
+func fakeFactory(opened *[]Account) Factory {
+ return Factory{Descriptor: fakeDescriptor(), New: func(_ context.Context, a Account) (Provider, error) {
+ if opened != nil {
+ *opened = append(*opened, a)
+ }
+ return &fakeProvider{acct: a}, nil
+ }}
+}
+
+// readOnlyProvider implements Provider and nothing else.
+type readOnlyProvider struct{ d Descriptor }
+
+func (p readOnlyProvider) Descriptor() Descriptor { return p.d }
+func (p readOnlyProvider) Probe(context.Context) (CapSet, error) {
+ return CapSet{CapInventory: Allowed}, nil
+}
+func (p readOnlyProvider) Inventory(context.Context, Filter) ([]Resource, error) {
+ return nil, nil
+}
+
+// creds is a CredentialSource whose token changes when rotate is called.
+type creds struct {
+ mu sync.Mutex
+ token string
+ calls int
+ err error
+}
+
+func newCreds() *creds { return &creds{token: rand.Text()} }
+
+func (c *creds) Token(_ context.Context, _ string) (Secret, error) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.calls++
+ if c.err != nil {
+ return Secret{}, c.err
+ }
+ return NewSecret([]byte(c.token)), nil
+}
+
+func (c *creds) rotate() string {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.token = rand.Text()
+ return c.token
+}
+
+func (c *creds) count() int {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return c.calls
+}
+
+func noDial(context.Context, string, string) (net.Conn, error) { return nil, errors.New("no SSH here") }
+
+// harness is one registry over the fake provider.
+type harness struct {
+ t *testing.T
+ world *world
+ creds *creds
+ reg *Registry
+ logs *bytes.Buffer
+ slept []time.Duration
+ opened []Account
+}
+
+func newHarness(t *testing.T, o Options, rs ...Resource) *harness {
+ t.Helper()
+ h := &harness{t: t, world: newWorld(rs...), creds: newCreds(), logs: &bytes.Buffer{}}
+ if o.Transport.HTTP == nil {
+ o.Transport = Transport{HTTP: h.world, Dial: noDial}
+ }
+ if o.Credentials == nil {
+ o.Credentials = h.creds
+ }
+ if o.Logger == nil {
+ o.Logger = slog.New(slog.NewJSONHandler(h.logs, nil))
+ }
+ reg, err := NewRegistry(o, fakeFactory(&h.opened))
+ if err != nil {
+ t.Fatal(err)
+ }
+ reg.sleep = func(ctx context.Context, d time.Duration) error {
+ h.slept = append(h.slept, d)
+ return ctx.Err()
+ }
+ h.reg = reg
+ return h
+}
+
+func (h *harness) open() *fakeProvider {
+ h.t.Helper()
+ p, err := h.reg.Open(context.Background(), "fake", "production")
+ if err != nil {
+ h.t.Fatal(err)
+ }
+ return p.(*fakeProvider)
+}
diff --git a/src/internal/policy/policy_test.go b/src/internal/policy/policy_test.go
index 412b005..2845b1c 100644
--- a/src/internal/policy/policy_test.go
+++ b/src/internal/policy/policy_test.go
@@ -4,6 +4,9 @@
package policy
import (
+ "go/ast"
+ "go/parser"
+ "go/token"
"io/fs"
"os"
"path/filepath"
@@ -15,7 +18,7 @@ import (
// minGoFiles is a floor on how many Go files the scan reads. A walk that
// stops matching would otherwise report a clean module. Raise it as the
// module grows; never lower it to make a failure go away.
-const minGoFiles = 7
+const minGoFiles = 17
// forbidden are the symbols that would let a request or a session bypass the
// caller's transport, egress controls or host-key checks, or that would give
@@ -33,6 +36,7 @@ var forbidden = []struct {
{regexp.MustCompile(`\bInsecureIgnoreHostKey\b`), "C8: SSH host keys are always verified"},
{regexp.MustCompile(`\bPasswordCallback\b|\bssh\.Password\(`), "C8: no SSH password auth"},
{regexp.MustCompile(`(?m)^func init\(\)`), "C9: no init registration"},
+ {regexp.MustCompile(`(?m)^var\s+\w+\s*(=\s*)?[&*]?(http\.Client|Registry|NewRegistry\(|NewHTTPClient\()`), "C9: no package-level clients or registries"},
{regexp.MustCompile(`\bslog\.(Default|SetDefault|Info|Warn|Error|Debug)\(|\blog\.(Print|Fatal|Panic)`), "C10: log through the injected *slog.Logger"},
}
@@ -44,6 +48,8 @@ var exempt = map[string]map[string]int{
"transport.go": {`\bhttp\.DefaultTransport\b`: 2},
// NewHTTPClient's tests hand it in to prove it is refused.
"cloudkit_test.go": {`\bhttp\.DefaultTransport\b`: 2},
+ // NewRegistry's tests hand it in to prove it is refused (C2).
+ "core_test.go": {`\bhttp\.DefaultTransport\b`: 1},
}
func moduleRoot(t *testing.T) string {
@@ -149,6 +155,12 @@ func TestCIRunsOnPullRequestsNotPushes(t *testing.T) {
if n := strings.Count(wf, "cron:"); n != 1 {
t.Errorf("ci.yml has %d crons, want exactly one weekly", n)
}
+ // Jobs skip drafts, so a PR marked ready must start a run, or its required
+ // checks report "skipped" and the ruleset lets it merge untested.
+ if strings.Contains(wf, "pull_request.draft") &&
+ !regexp.MustCompile(`(?m)^[ \t]*types:\s*\[[^\]]*\bready_for_review\b`).MatchString(wf) {
+ t.Error("ci.yml skips drafts but its pull_request trigger lacks ready_for_review")
+ }
}
// Third-party actions are pinned to a commit SHA with a version comment;
@@ -179,3 +191,189 @@ func TestActionPins(t *testing.T) {
t.Fatalf("read %d action references, below the floor of 3", seen)
}
}
+
+// parsePackage parses the non-test files of package cloudkit, at the module
+// root, and fails below a floor so a parse that finds nothing cannot pass.
+func parsePackage(t *testing.T) []*ast.File {
+ t.Helper()
+ root := moduleRoot(t)
+ matches, err := filepath.Glob(filepath.Join(root, "*.go"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ fset := token.NewFileSet()
+ var files []*ast.File
+ for _, m := range matches {
+ if strings.HasSuffix(m, "_test.go") {
+ continue
+ }
+ f, err := parser.ParseFile(fset, m, nil, parser.SkipObjectResolution)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if f.Name.Name == "cloudkit" {
+ files = append(files, f)
+ }
+ }
+ const minSourceFiles = 11
+ if len(files) < minSourceFiles {
+ t.Fatalf("parsed %d source files of package cloudkit, below the floor of %d", len(files), minSourceFiles)
+ }
+ return files
+}
+
+// C10: every method of an exported interface, every method of *Registry and
+// every exported func-typed field takes a context.Context first, so the
+// caller owns cancellation. The exemptions are pure accessors that reach
+// nothing, each with its reason.
+var contextExempt = map[string]string{
+ "Provider.Descriptor": "returns a constant description; no I/O",
+ "Registry.Descriptors": "returns the factories' descriptions; no I/O",
+}
+
+func TestContextFirst(t *testing.T) {
+ checked := 0
+ check := func(name string, ft *ast.FuncType) {
+ checked++
+ if _, ok := contextExempt[name]; ok {
+ return
+ }
+ ps := ft.Params.List
+ if len(ps) == 0 || !isContext(ps[0].Type) {
+ t.Errorf("%s does not take context.Context first (C10)", name)
+ }
+ }
+ for _, f := range parsePackage(t) {
+ for _, decl := range f.Decls {
+ switch d := decl.(type) {
+ case *ast.FuncDecl:
+ if d.Recv != nil && d.Name.IsExported() && recvName(d.Recv) == "Registry" {
+ check("Registry."+d.Name.Name, d.Type)
+ }
+ case *ast.GenDecl:
+ for _, spec := range d.Specs {
+ ts, ok := spec.(*ast.TypeSpec)
+ if !ok || !ts.Name.IsExported() {
+ continue
+ }
+ switch tt := ts.Type.(type) {
+ case *ast.InterfaceType:
+ for _, m := range tt.Methods.List {
+ if ft, ok := m.Type.(*ast.FuncType); ok {
+ check(ts.Name.Name+"."+m.Names[0].Name, ft)
+ }
+ }
+ case *ast.StructType:
+ for _, fld := range tt.Fields.List {
+ ft, ok := fld.Type.(*ast.FuncType)
+ if !ok {
+ continue
+ }
+ for _, n := range fld.Names {
+ if n.IsExported() {
+ check(ts.Name.Name+"."+n.Name, ft)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ // Measured 2026-11-03: Registry.{Open,Apply,Descriptors}, Provider's 3,
+ // Applier's 2, CredentialSource.Token, Factory.New and Transport.Dial.
+ if checked < 10 {
+ t.Fatalf("checked %d signatures, below the floor of 10", checked)
+ }
+ for name := range contextExempt {
+ if !strings.Contains(name, ".") {
+ t.Errorf("exemption %q is not Type.Method", name)
+ }
+ }
+}
+
+func isContext(e ast.Expr) bool {
+ sel, ok := e.(*ast.SelectorExpr)
+ if !ok {
+ return false
+ }
+ pkg, ok := sel.X.(*ast.Ident)
+ return ok && pkg.Name == "context" && sel.Sel.Name == "Context"
+}
+
+func recvName(fl *ast.FieldList) string {
+ e := fl.List[0].Type
+ if st, ok := e.(*ast.StarExpr); ok {
+ e = st.X
+ }
+ if id, ok := e.(*ast.Ident); ok {
+ return id.Name
+ }
+ return ""
+}
+
+// shipped is every exported name package cloudkit has carried. Removing one
+// breaks a caller this repository cannot see, so it is deprecated in place
+// instead; this test fails if one disappears. Append, never delete.
+var shipped = []string{
+ // Phase 0, c1a6a98.
+ "Transport", "Transport.Validate", "EgressPolicy", "EgressPolicy.Validate", "NewHTTPClient",
+ "Secret", "NewSecret", "Secret.Reveal", "Secret.IsZero", "Secret.String", "Secret.GoString",
+ "Secret.Format", "Secret.LogValue", "Secret.MarshalJSON", "Secret.MarshalText",
+ "ErrInvalidTransport", "ErrDenied", "ErrNotFound", "ErrConflict", "ErrRateLimited",
+ "ErrEgressDenied", "ErrUnsupported", "EgressError", "EgressError.Error", "EgressError.Unwrap",
+ "Redact", "RedactBody", "MaxBodyBytes",
+ // Phase 1, core.
+ "CredentialSource", "Options", "Options.Validate", "TokenModel", "TokenModel.String",
+ "Scoped", "ReadOrReadWrite", "PerProduct", "Unscoped", "Module", "Descriptor",
+ "Descriptor.Validate", "Descriptor.Egress", "Resource", "Filter", "Filter.Matches", "Provider",
+ "Account", "Factory", "Registry", "NewRegistry", "Registry.Descriptors", "Registry.Open",
+ "Registry.Apply", "Capability", "Capability.IsWrite", "Access", "Access.String", "Unknown",
+ "Allowed", "Denied", "CapSet", "CapSet.Get", "CapSet.Allowed", "CapSet.Set",
+ "CapSet.Capabilities", "CapInventory", "CapServerCreate", "CapServerResize", "CapServerDestroy",
+ "CapServerPower", "CapServerUpdate", "CapFirewallRead", "CapFirewallWrite", "CapNetworkRead",
+ "CapNetworkWrite", "CapVolumeRead", "CapVolumeWrite", "CapSnapshotRead", "CapSnapshotWrite",
+ "CapObjectStorageRead", "CapObjectStorageWrite", "CapDNSRead", "CapDNSWrite", "CapHostKeysRead",
+ "CapCostRead", "CapManagedDatabasesRead", "CapManagedDatabasesConnect", "PlanTTL",
+ "ChangeAction", "Create", "Update", "Delete", "Change", "Money", "Plan", "NewPlan",
+ "Plan.IsDestroy", "Plan.ConfirmText", "Confirmation", "Result", "Applier",
+ "ErrConfirmationRequired", "PlanError", "PlanError.Error", "PlanError.Unwrap",
+ "UnsupportedError", "UnsupportedError.Error", "UnsupportedError.Unwrap", "RetryPolicy",
+ "RetryPolicy.Validate", "RetryPolicy.Backoff", "RateLimitError", "RateLimitError.Error",
+ "RateLimitError.Unwrap", "DefaultMaxAttempts", "DefaultBaseDelay", "DefaultMaxDelay",
+}
+
+func TestShippedSurfaceIsPreserved(t *testing.T) {
+ have := map[string]bool{}
+ for _, f := range parsePackage(t) {
+ for _, decl := range f.Decls {
+ switch d := decl.(type) {
+ case *ast.FuncDecl:
+ name := d.Name.Name
+ if d.Recv != nil {
+ name = recvName(d.Recv) + "." + name
+ }
+ have[name] = true
+ case *ast.GenDecl:
+ for _, spec := range d.Specs {
+ switch sp := spec.(type) {
+ case *ast.TypeSpec:
+ have[sp.Name.Name] = true
+ case *ast.ValueSpec:
+ for _, n := range sp.Names {
+ have[n.Name] = true
+ }
+ }
+ }
+ }
+ }
+ }
+ for _, name := range shipped {
+ if !have[name] {
+ t.Errorf("exported %s has gone; deprecate it in place instead of removing it", name)
+ }
+ }
+ if len(have) < len(shipped) {
+ t.Fatalf("found %d declarations, fewer than the %d shipped names", len(have), len(shipped))
+ }
+}
diff --git a/src/plan.go b/src/plan.go
new file mode 100644
index 0000000..5de2621
--- /dev/null
+++ b/src/plan.go
@@ -0,0 +1,257 @@
+package cloudkit
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "strings"
+ "time"
+)
+
+// PlanTTL is how long a plan stays applicable after [NewPlan] builds it.
+const PlanTTL = 15 * time.Minute
+
+// digestVersion is hashed first, so a change to what the digest covers can
+// never make an old plan match a new state.
+const digestVersion = "cloudkit/plan/v1"
+
+// ChangeAction is what a [Change] does.
+type ChangeAction string
+
+// The three kinds of change.
+const (
+ Create ChangeAction = "create" // After only
+ Update ChangeAction = "update" // Before and After
+ Delete ChangeAction = "delete" // Before only
+)
+
+// Change is one create, update or delete, with the resource before and after.
+type Change struct {
+ Action ChangeAction `json:"action"`
+ Before *Resource `json:"before,omitempty"` // nil for a create
+ After *Resource `json:"after,omitempty"` // nil for a delete
+}
+
+func (c Change) validate() error {
+ switch c.Action {
+ case Create:
+ if c.Before != nil || c.After == nil {
+ return fmt.Errorf("cloudkit: a create has an After and no Before")
+ }
+ case Update:
+ if c.Before == nil || c.After == nil {
+ return fmt.Errorf("cloudkit: an update has a Before and an After")
+ }
+ case Delete:
+ if c.Before == nil || c.After != nil {
+ return fmt.Errorf("cloudkit: a delete has a Before and no After")
+ }
+ default:
+ return fmt.Errorf("cloudkit: unknown change action %q", c.Action)
+ }
+ for _, r := range []*Resource{c.Before, c.After} {
+ if r != nil && r.Kind == "" {
+ return fmt.Errorf("cloudkit: a %s names a resource with no kind", c.Action)
+ }
+ }
+ if c.Before != nil && c.Before.ID == "" {
+ return fmt.Errorf("cloudkit: a %s of a %s names no resource ID", c.Action, c.Before.Kind)
+ }
+ return nil
+}
+
+// Money is an amount in a currency's minor unit (cents for USD and EUR).
+type Money struct {
+ Minor int64 // e.g. 499 for 4.99
+ Currency string // ISO 4217, e.g. "EUR"
+}
+
+// Plan is a write that has not happened yet: the changes, what a destroy
+// loses, and a digest over both and the live state they were planned against.
+// Build one with [NewPlan]; run it with [Registry.Apply].
+type Plan struct {
+ ID string // stable; derived from the digest. The API and approvals refer to it
+ Provider string // descriptor ID
+ Account string // the account the plan was made for; Apply opens the same one
+ Changes []Change // create, update, delete, each with before and after
+ Loses []Resource // for destroy: disks, volumes, IPs (C7)
+ Cost *Money // monthly delta, when the API reports it; not covered by the digest
+ Digest [32]byte // over Provider, Account, Changes, Loses and the live state
+ Expires time.Time
+}
+
+// NewPlan builds a plan. live is the state the changes were planned against,
+// read by the adapter; [Registry.Apply] reads it again through the adapter's
+// [Applier.LiveState] and refuses the plan if it moved (C6).
+//
+// A plan that deletes anything must list in loses every resource it deletes,
+// plus whatever goes with them, such as attached volumes and IPs (C7).
+func NewPlan(provider, account string, changes []Change, loses, live []Resource) (Plan, error) {
+ p := Plan{Provider: provider, Account: account, Changes: changes, Loses: loses}
+ if err := p.validate(); err != nil {
+ return Plan{}, err
+ }
+ d, err := digest(p, live)
+ if err != nil {
+ return Plan{}, err
+ }
+ p.Digest = d
+ p.ID = planID(d)
+ p.Expires = time.Now().Add(PlanTTL)
+ return p, nil
+}
+
+func (p Plan) validate() error {
+ if !validID(p.Provider) {
+ return fmt.Errorf("cloudkit: plan names an invalid provider %q", p.Provider)
+ }
+ if p.Account == "" {
+ return fmt.Errorf("cloudkit: plan for %s names no account", p.Provider)
+ }
+ if len(p.Changes) == 0 {
+ return fmt.Errorf("cloudkit: plan for %s has no changes", p.Provider)
+ }
+ for _, c := range p.Changes {
+ if err := c.validate(); err != nil {
+ return err
+ }
+ if c.Action != Delete {
+ continue
+ }
+ if !listed(p.Loses, *c.Before) {
+ return fmt.Errorf("cloudkit: plan deletes %s %s but does not list it in Loses", c.Before.Kind, c.Before.ID)
+ }
+ if c.Before.Name == "" {
+ return fmt.Errorf("cloudkit: plan deletes %s %s, which has no name to type back", c.Before.Kind, c.Before.ID)
+ }
+ }
+ return nil
+}
+
+func listed(loses []Resource, r Resource) bool {
+ for _, l := range loses {
+ if l.Kind == r.Kind && l.ID == r.ID {
+ return true
+ }
+ }
+ return false
+}
+
+// IsDestroy reports whether the plan deletes anything.
+func (p Plan) IsDestroy() bool {
+ for _, c := range p.Changes {
+ if c.Action == Delete {
+ return true
+ }
+ }
+ return false
+}
+
+// ConfirmText is what a person must type back before a destroy plan applies:
+// the name of every resource it deletes, in the order of its changes, joined
+// by ", ". It is empty for a plan that deletes nothing.
+func (p Plan) ConfirmText() string {
+ var names []string
+ for _, c := range p.Changes {
+ if c.Action == Delete && c.Before != nil {
+ names = append(names, c.Before.Name)
+ }
+ }
+ return strings.Join(names, ", ")
+}
+
+// digest covers everything the person approving the plan was shown, except
+// Cost and Expires, and the live state it was planned against. encoding/json
+// writes struct fields in order and map keys sorted, so the bytes are
+// canonical.
+func digest(p Plan, live []Resource) ([32]byte, error) {
+ b, err := json.Marshal(struct {
+ Version string `json:"v"`
+ Provider string `json:"provider"`
+ Account string `json:"account"`
+ Changes []Change `json:"changes"`
+ Loses []Resource `json:"loses"`
+ Live []Resource `json:"live"`
+ }{digestVersion, p.Provider, p.Account, p.Changes, p.Loses, live})
+ if err != nil {
+ return [32]byte{}, fmt.Errorf("cloudkit: digest: %w", err)
+ }
+ return sha256.Sum256(b), nil
+}
+
+func planID(d [32]byte) string { return "plan_" + hex.EncodeToString(d[:12]) }
+
+// Confirmation is what the consumer collected from the person approving a
+// plan. Approval levels and step-up stay in the consumer; cloudkit only checks
+// the typed name.
+type Confirmation struct {
+ Typed string // must equal Plan.ConfirmText for a destroy plan; ignored otherwise
+}
+
+// Result is what an applied plan produced. Apply returns only once the
+// provider reports the work done, not merely accepted.
+type Result struct {
+ PlanID string
+ Resources []Resource // the resources as they are after the changes
+}
+
+// Applier is implemented by a provider that can apply plans. LiveState reads
+// the current state of everything the plan touches, in the same shape the
+// adapter passed to [NewPlan]. Execute makes the changes and waits for the
+// provider's actions to finish.
+type Applier interface {
+ LiveState(ctx context.Context, p Plan) ([]Resource, error)
+ Execute(ctx context.Context, p Plan) (Result, error)
+}
+
+// Apply runs a plan. Before anything changes it refuses, in this order: a
+// plan that is malformed, was altered after it was built or has expired
+// ([ErrConflict]); a destroy whose ConfirmText was not typed back
+// ([ErrConfirmationRequired]); a provider that cannot apply plans
+// ([ErrUnsupported]); and a plan whose live state moved since it was made
+// ([ErrConflict], C6). None of those refusals reaches Execute.
+func (r *Registry) Apply(ctx context.Context, p Plan, confirm Confirmation) (Result, error) {
+ if err := p.validate(); err != nil {
+ return Result{}, &PlanError{PlanID: p.ID, Reason: err.Error()}
+ }
+ if p.Digest == ([32]byte{}) || p.ID != planID(p.Digest) {
+ return Result{}, &PlanError{PlanID: p.ID, Reason: "the plan was not built by NewPlan, or its ID was changed"}
+ }
+ if p.Expires.IsZero() || !r.now().Before(p.Expires) {
+ return Result{}, &PlanError{PlanID: p.ID, Reason: "the plan has expired; plan again"}
+ }
+ if p.IsDestroy() && confirm.Typed != p.ConfirmText() {
+ return Result{}, fmt.Errorf("%w: plan %s deletes %q; type the name back to apply it", ErrConfirmationRequired, p.ID, p.ConfirmText())
+ }
+ prov, err := r.Open(ctx, p.Provider, p.Account)
+ if err != nil {
+ return Result{}, err
+ }
+ ap, ok := prov.(Applier)
+ if !ok {
+ return Result{}, &UnsupportedError{Provider: p.Provider, Interface: "Applier"}
+ }
+ live, err := ap.LiveState(ctx, p)
+ if err != nil {
+ return Result{}, redactErr(fmt.Errorf("cloudkit: plan %s: read live state: %w", p.ID, err))
+ }
+ now, err := digest(p, live)
+ if err != nil {
+ return Result{}, err
+ }
+ if now != p.Digest {
+ return Result{}, &PlanError{PlanID: p.ID, Reason: "the live state moved since the plan was made; plan again"}
+ }
+ logger := r.opts.Logger.With("provider", p.Provider, "account", p.Account, "plan", p.ID)
+ logger.InfoContext(ctx, "cloudkit: applying plan", "changes", len(p.Changes), "destroy", p.IsDestroy())
+ res, err := ap.Execute(ctx, p)
+ if err != nil {
+ logger.WarnContext(ctx, "cloudkit: plan failed", "error", Redact(err.Error()))
+ return Result{}, redactErr(fmt.Errorf("cloudkit: plan %s: %w", p.ID, err))
+ }
+ res.PlanID = p.ID
+ logger.InfoContext(ctx, "cloudkit: plan applied")
+ return res, nil
+}
diff --git a/src/plan_test.go b/src/plan_test.go
new file mode 100644
index 0000000..6fce40b
--- /dev/null
+++ b/src/plan_test.go
@@ -0,0 +1,335 @@
+package cloudkit
+
+import (
+ "context"
+ "crypto/rand"
+ "errors"
+ "log/slog"
+ "strings"
+ "testing"
+ "time"
+)
+
+func server(id, name string) Resource {
+ return Resource{Kind: "server", ID: id, Name: name, Region: "fsn1", Attrs: map[string]string{"size": "cx22"}}
+}
+
+func attachedVolume(id, to string) Resource {
+ return Resource{Kind: "volume", ID: id, Name: id, Attrs: map[string]string{"attached_to": to}}
+}
+
+func TestNewPlanRefusesMalformedPlans(t *testing.T) {
+ s := server("s1", "web-1")
+ nameless := server("s1", "")
+ cases := map[string]struct {
+ provider, account string
+ changes []Change
+ loses []Resource
+ }{
+ "bare provider": {"", "production", []Change{{Action: Create, After: &s}}, nil},
+ "bad provider": {"Fake", "production", []Change{{Action: Create, After: &s}}, nil},
+ "no account": {"fake", "", []Change{{Action: Create, After: &s}}, nil},
+ "no changes": {"fake", "production", nil, nil},
+ "unknown action": {"fake", "production", []Change{{Action: "replace", After: &s}}, nil},
+ "create with before": {"fake", "production", []Change{{Action: Create, Before: &s, After: &s}}, nil},
+ "create without": {"fake", "production", []Change{{Action: Create}}, nil},
+ "update without after": {"fake", "production", []Change{{Action: Update, Before: &s}}, nil},
+ "delete with after": {"fake", "production", []Change{{Action: Delete, Before: &s, After: &s}}, []Resource{s}},
+ "kindless resource": {"fake", "production", []Change{{Action: Create, After: &Resource{Name: "x"}}}, nil},
+ "before without ID": {"fake", "production", []Change{{Action: Update, Before: &Resource{Kind: "server"}, After: &s}}, nil},
+ // C7: a destroy names what it loses, and something to type back.
+ "delete, no loses": {"fake", "production", []Change{{Action: Delete, Before: &s}}, nil},
+ "delete, target not in loses": {"fake", "production", []Change{{Action: Delete, Before: &s}}, []Resource{attachedVolume("v1", "s1")}},
+ "delete, no name": {"fake", "production", []Change{{Action: Delete, Before: &nameless}}, []Resource{nameless}},
+ }
+ for name, c := range cases {
+ if _, err := NewPlan(c.provider, c.account, c.changes, c.loses, nil); err == nil {
+ t.Errorf("%s: accepted", name)
+ }
+ }
+}
+
+// C6: the digest covers what was planned and the state it was planned
+// against, so any difference in either gives a different digest, and the
+// same inputs always give the same digest and ID.
+func TestPlanDigestCoversEverythingApproved(t *testing.T) {
+ s, v := server("s1", "web-1"), attachedVolume("v1", "s1")
+ live := []Resource{s, v}
+ base := func() (string, string, []Change, []Resource, []Resource) {
+ target := s
+ return "fake", "production", []Change{{Action: Delete, Before: &target}}, []Resource{s, v}, live
+ }
+ p0, err := NewPlan(base())
+ if err != nil {
+ t.Fatal(err)
+ }
+ again, _ := NewPlan(base())
+ if again.Digest != p0.Digest || again.ID != p0.ID {
+ t.Fatal("the same plan gave a different digest or ID")
+ }
+ if !strings.HasPrefix(p0.ID, "plan_") || time.Until(p0.Expires) <= 0 || time.Until(p0.Expires) > PlanTTL {
+ t.Fatalf("ID %q, expires in %s", p0.ID, time.Until(p0.Expires))
+ }
+ variants := map[string]func(*string, *string, *[]Change, *[]Resource, *[]Resource){
+ "provider": func(p, _ *string, _ *[]Change, _, _ *[]Resource) { *p = "fake-two" },
+ "account": func(_, a *string, _ *[]Change, _, _ *[]Resource) { *a = "staging" },
+ "loses": func(_, _ *string, _ *[]Change, l, _ *[]Resource) { *l = []Resource{s} },
+ "live": func(_, _ *string, _ *[]Change, _, lv *[]Resource) { *lv = []Resource{s} },
+ "live attr": func(_, _ *string, _ *[]Change, _, lv *[]Resource) {
+ x := s
+ x.Attrs = map[string]string{"size": "cx32"}
+ *lv = []Resource{x, v}
+ },
+ "change": func(_, _ *string, c *[]Change, _, _ *[]Resource) {
+ other := s
+ other.Name = "web-2"
+ *c = []Change{{Action: Delete, Before: &other}}
+ },
+ }
+ for name, mutate := range variants {
+ p, a, c, l, lv := base()
+ mutate(&p, &a, &c, &l, &lv)
+ got, err := NewPlan(p, a, c, l, lv)
+ if err != nil {
+ t.Fatalf("%s: %v", name, err)
+ }
+ if got.Digest == p0.Digest {
+ t.Errorf("changing the %s did not change the digest", name)
+ }
+ }
+}
+
+func TestConfirmText(t *testing.T) {
+ a, b := server("s1", "web-1"), server("s2", "web-2")
+ p, err := NewPlan("fake", "production", []Change{{Action: Delete, Before: &a}, {Action: Delete, Before: &b}}, []Resource{a, b}, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !p.IsDestroy() || p.ConfirmText() != "web-1, web-2" {
+ t.Fatalf("IsDestroy %v, ConfirmText %q", p.IsDestroy(), p.ConfirmText())
+ }
+ c, err := NewPlan("fake", "production", []Change{{Action: Create, After: &a}}, nil, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if c.IsDestroy() || c.ConfirmText() != "" {
+ t.Fatalf("create: IsDestroy %v, ConfirmText %q", c.IsDestroy(), c.ConfirmText())
+ }
+}
+
+func TestApplyCreates(t *testing.T) {
+ h := newHarness(t, Options{}, server("s1", "web-1"))
+ ctx := context.Background()
+ p, err := h.open().PlanCreate(ctx, Resource{Kind: "server", Name: "web-2"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ res, err := h.reg.Apply(ctx, p, Confirmation{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if res.PlanID != p.ID || len(res.Resources) != 1 || res.Resources[0].Name != "web-2" || res.Resources[0].ID == "" {
+ t.Fatalf("result = %+v", res)
+ }
+ if h.world.writes() != 1 {
+ t.Fatalf("%d writes, want 1", h.world.writes())
+ }
+ if !strings.Contains(h.logs.String(), p.ID) || strings.Contains(h.logs.String(), h.creds.token) {
+ t.Fatalf("logs do not name the plan, or carry the token: %s", h.logs)
+ }
+}
+
+// C6: a plan whose live state moved is refused, and nothing is written.
+func TestApplyRefusesDrift(t *testing.T) {
+ h := newHarness(t, Options{}, server("s1", "web-1"))
+ ctx := context.Background()
+ p, err := h.open().PlanCreate(ctx, Resource{Kind: "server", Name: "web-2"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ moved := server("s1", "web-1")
+ moved.Attrs["size"] = "cx32" // someone resized it in the console
+ h.world.set(moved)
+ _, err = h.reg.Apply(ctx, p, Confirmation{})
+ var pe *PlanError
+ if !errors.Is(err, ErrConflict) || !errors.As(err, &pe) || pe.PlanID != p.ID {
+ t.Fatalf("got %v, want a PlanError for %s", err, p.ID)
+ }
+ if h.world.writes() != 0 {
+ t.Fatalf("%d writes after a refused plan", h.world.writes())
+ }
+}
+
+// C7: a destroy applies only with every deleted name typed back, and a
+// refusal makes no request at all.
+func TestApplyDestroyNeedsTheNameTypedBack(t *testing.T) {
+ h := newHarness(t, Options{}, server("s1", "web-1"), attachedVolume("v1", "s1"), server("s2", "web-2"))
+ ctx := context.Background()
+ p, err := h.open().PlanDestroy(ctx, "s1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(p.Loses) != 2 {
+ t.Fatalf("the plan loses %+v, want the server and its volume", p.Loses)
+ }
+ before := len(h.world.requests())
+ for _, typed := range []string{"", "web-2", "WEB-1", "web-1 ", "s1"} {
+ _, err := h.reg.Apply(ctx, p, Confirmation{Typed: typed})
+ if !errors.Is(err, ErrConfirmationRequired) {
+ t.Errorf("typed %q: got %v, want ErrConfirmationRequired", typed, err)
+ }
+ }
+ if n := len(h.world.requests()) - before; n != 0 {
+ t.Fatalf("a refused destroy made %d requests", n)
+ }
+ if _, err := h.reg.Apply(ctx, p, Confirmation{Typed: "web-1"}); err != nil {
+ t.Fatal(err)
+ }
+ left, _ := h.open().Inventory(ctx, Filter{})
+ if len(left) != 1 || left[0].ID != "s2" {
+ t.Fatalf("left after destroy: %+v", left)
+ }
+}
+
+// C6 and C7: a plan altered after it was built is refused, whichever part
+// was altered, before anything is sent.
+func TestApplyRefusesAlteredPlans(t *testing.T) {
+ h := newHarness(t, Options{}, server("s1", "web-1"), attachedVolume("v1", "s1"))
+ ctx := context.Background()
+ fresh := func() Plan {
+ prov, err := h.reg.Open(ctx, "fake", "production")
+ if err != nil {
+ t.Fatal(err)
+ }
+ p, err := prov.(*fakeProvider).PlanDestroy(ctx, "s1")
+ if err != nil {
+ t.Fatal(err)
+ }
+ return p
+ }
+ cases := map[string]func(*Plan){
+ "loses trimmed": func(p *Plan) { p.Loses = p.Loses[:1] },
+ "other target": func(p *Plan) {
+ b := *p.Changes[0].Before
+ b.ID, b.Name = "s9", "web-9"
+ p.Changes = []Change{{Action: Delete, Before: &b}}
+ p.Loses = []Resource{b}
+ },
+ "other account": func(p *Plan) { p.Account = "staging" },
+ "ID relabelled": func(p *Plan) { p.ID = "plan_000000000000000000000000" },
+ "digest zeroed": func(p *Plan) { p.Digest = [32]byte{} },
+ "expired": func(p *Plan) { p.Expires = time.Now().Add(-time.Second) },
+ "never expires": func(p *Plan) { p.Expires = time.Time{} },
+ "no changes": func(p *Plan) { p.Changes = nil },
+ "zero plan": func(p *Plan) { *p = Plan{} },
+ }
+ for name, mutate := range cases {
+ p := fresh()
+ mutate(&p)
+ before := h.world.writes()
+ _, err := h.reg.Apply(ctx, p, Confirmation{Typed: p.ConfirmText()})
+ if !errors.Is(err, ErrConflict) {
+ t.Errorf("%s: got %v, want ErrConflict", name, err)
+ }
+ if h.world.writes() != before {
+ t.Errorf("%s: the altered plan wrote", name)
+ }
+ }
+}
+
+func TestApplyNeedsAnApplier(t *testing.T) {
+ d := fakeDescriptor()
+ r, err := NewRegistry(validOptions(), Factory{Descriptor: d, New: func(context.Context, Account) (Provider, error) {
+ return readOnlyProvider{d}, nil
+ }})
+ if err != nil {
+ t.Fatal(err)
+ }
+ s := server("s1", "web-1")
+ p, err := NewPlan("fake", "production", []Change{{Action: Create, After: &s}}, nil, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, err = r.Apply(context.Background(), p, Confirmation{})
+ var ue *UnsupportedError
+ if !errors.Is(err, ErrUnsupported) || !errors.As(err, &ue) || ue.Interface != "Applier" {
+ t.Fatalf("got %v, want an UnsupportedError for Applier", err)
+ }
+ if _, err := r.Apply(context.Background(), Plan{Provider: "nope"}, Confirmation{}); err == nil {
+ t.Fatal("a plan for an unknown provider applied")
+ }
+}
+
+// Errors from the provider leave Apply redacted, and still match.
+type leakyProvider struct {
+ *fakeProvider
+ liveErr, execErr error
+}
+
+func (p leakyProvider) LiveState(ctx context.Context, pl Plan) ([]Resource, error) {
+ if p.liveErr != nil {
+ return nil, p.liveErr
+ }
+ return p.fakeProvider.LiveState(ctx, pl)
+}
+
+func (p leakyProvider) Execute(context.Context, Plan) (Result, error) { return Result{}, p.execErr }
+
+func TestApplyRedactsProviderErrors(t *testing.T) {
+ leak := rand.Text()
+ cause := errors.New("POST https://api.fake.test/v1/resources?token=" + leak + ": Authorization: Bearer " + leak)
+ for name, lp := range map[string]leakyProvider{
+ "live state": {liveErr: cause},
+ "execute": {execErr: cause},
+ } {
+ h := newHarness(t, Options{}, server("s1", "web-1"))
+ ctx := context.Background()
+ r, err := NewRegistry(Options{Transport: Transport{HTTP: h.world, Dial: noDial}, Credentials: h.creds, Logger: slog.New(slog.NewJSONHandler(h.logs, nil))},
+ Factory{Descriptor: fakeDescriptor(), New: func(_ context.Context, a Account) (Provider, error) {
+ lp.fakeProvider = &fakeProvider{acct: a}
+ return lp, nil
+ }})
+ if err != nil {
+ t.Fatal(err)
+ }
+ p, err := h.open().PlanCreate(ctx, Resource{Kind: "server", Name: "web-2"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, err = r.Apply(ctx, p, Confirmation{})
+ if err == nil || !errors.Is(err, cause) {
+ t.Fatalf("%s: got %v, want the cause wrapped", name, err)
+ }
+ if strings.Contains(err.Error(), leak) || strings.Contains(h.logs.String(), leak) {
+ t.Fatalf("%s: the token leaked: %v / %s", name, err, h.logs)
+ }
+ }
+}
+
+// A token that lost its write scope after planning is refused by the
+// provider, and the refusal still matches ErrDenied.
+func TestApplyAfterTheTokenLostWrite(t *testing.T) {
+ h := newHarness(t, Options{}, server("s1", "web-1"))
+ ctx := context.Background()
+ p, err := h.open().PlanCreate(ctx, Resource{Kind: "server", Name: "web-2"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ h.world.readOnly = true
+ if _, err := h.reg.Apply(ctx, p, Confirmation{}); !errors.Is(err, ErrDenied) {
+ t.Fatalf("got %v, want ErrDenied", err)
+ }
+}
+
+func TestErrorMessagesNameWhatWasRefused(t *testing.T) {
+ for err, want := range map[error]string{
+ &PlanError{PlanID: "plan_x", Reason: "moved"}: "plan plan_x: moved",
+ &UnsupportedError{Provider: "fake", Interface: "Applier"}: "fake does not implement Applier",
+ &RateLimitError{RetryAfter: 2 * time.Second}: "retry after 2s",
+ &RateLimitError{}: "cloudkit: rate limited",
+ } {
+ if !strings.Contains(err.Error(), want) {
+ t.Errorf("%q does not contain %q", err.Error(), want)
+ }
+ }
+}
diff --git a/src/retry.go b/src/retry.go
new file mode 100644
index 0000000..b4fbf2d
--- /dev/null
+++ b/src/retry.go
@@ -0,0 +1,215 @@
+package cloudkit
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "math/rand/v2"
+ "net/http"
+ "strconv"
+ "time"
+)
+
+// The RetryPolicy defaults, used for any field left zero.
+const (
+ DefaultMaxAttempts = 3
+ DefaultBaseDelay = 500 * time.Millisecond
+ DefaultMaxDelay = 30 * time.Second
+)
+
+// RetryPolicy says how the registry's clients retry. The zero value means 3
+// tries, jittered exponential backoff from 500 ms, and Retry-After honoured up
+// to 30 s. MaxAttempts 1 turns retries off.
+//
+// What is retried: a 429 for any request whose body can be replayed (the
+// provider did not act on it), and a 502, 503 or 504 or a transport error
+// only for idempotent methods (GET, HEAD, OPTIONS, PUT, DELETE), so a create
+// is never sent twice. A 429 that outlasts the budget, or asks to wait longer
+// than MaxDelay, ends in a [*RateLimitError].
+type RetryPolicy struct {
+ MaxAttempts int // total tries, the first included; 0 means 3
+ BaseDelay time.Duration // first backoff; 0 means 500 ms
+ MaxDelay time.Duration // longest single wait, Retry-After included; 0 means 30 s
+}
+
+// Validate refuses negative values and a BaseDelay above MaxDelay.
+func (p RetryPolicy) Validate() error {
+ if p.MaxAttempts < 0 || p.BaseDelay < 0 || p.MaxDelay < 0 {
+ return errors.New("cloudkit: retry policy: values must not be negative")
+ }
+ if p.baseDelay() > p.maxDelay() {
+ return errors.New("cloudkit: retry policy: BaseDelay is above MaxDelay")
+ }
+ return nil
+}
+
+func (p RetryPolicy) attempts() int {
+ if p.MaxAttempts == 0 {
+ return DefaultMaxAttempts
+ }
+ return p.MaxAttempts
+}
+
+func (p RetryPolicy) baseDelay() time.Duration {
+ if p.BaseDelay == 0 {
+ return DefaultBaseDelay
+ }
+ return p.BaseDelay
+}
+
+func (p RetryPolicy) maxDelay() time.Duration {
+ if p.MaxDelay == 0 {
+ return DefaultMaxDelay
+ }
+ return p.MaxDelay
+}
+
+// Backoff returns the wait before try n+1 after try n failed (n from 1): the
+// base doubled per try, capped at MaxDelay, then jittered into its upper half.
+func (p RetryPolicy) Backoff(n int) time.Duration {
+ d := p.baseDelay()
+ for i := 1; i < n && d < p.maxDelay(); i++ {
+ d *= 2
+ }
+ d = min(d, p.maxDelay())
+ half := d / 2
+ return half + time.Duration(rand.Int64N(int64(half)+1)) //nolint:gosec // jitter, not a secret
+}
+
+// RateLimitError is returned when the provider kept throttling past the retry
+// budget. It matches [ErrRateLimited].
+type RateLimitError struct {
+ RetryAfter time.Duration // what the provider last asked for; 0 if it did not say
+}
+
+func (e *RateLimitError) Error() string {
+ if e.RetryAfter > 0 {
+ return fmt.Sprintf("cloudkit: rate limited: retry after %s", e.RetryAfter)
+ }
+ return "cloudkit: rate limited"
+}
+
+// Unwrap lets errors.Is match [ErrRateLimited].
+func (e *RateLimitError) Unwrap() error { return ErrRateLimited }
+
+type retrier struct {
+ next http.RoundTripper
+ policy RetryPolicy
+ sleep func(ctx context.Context, d time.Duration) error
+}
+
+func (r *retrier) RoundTrip(req *http.Request) (*http.Response, error) {
+ replayable := req.Body == nil || req.Body == http.NoBody || req.GetBody != nil
+ idempotent := isIdempotent(req.Method)
+ attempts := r.policy.attempts()
+ for try := 1; ; try++ {
+ attempt := req
+ if try > 1 {
+ var err error
+ if attempt, err = rewind(req); err != nil {
+ return nil, err
+ }
+ }
+ resp, err := r.next.RoundTrip(attempt)
+ last := try >= attempts
+ var wait time.Duration
+ switch {
+ case err != nil:
+ if last || !idempotent || !replayable || req.Context().Err() != nil {
+ return nil, err
+ }
+ wait = r.policy.Backoff(try)
+ case resp.StatusCode == http.StatusTooManyRequests:
+ after, said := retryAfter(resp, time.Now())
+ discard(resp)
+ if last || !replayable || after > r.policy.maxDelay() {
+ return nil, &RateLimitError{RetryAfter: after}
+ }
+ wait = r.policy.Backoff(try)
+ if said {
+ wait = after
+ }
+ case transient(resp.StatusCode):
+ if last || !idempotent || !replayable {
+ return resp, nil
+ }
+ after, said := retryAfter(resp, time.Now())
+ if said && after > r.policy.maxDelay() {
+ return resp, nil
+ }
+ discard(resp)
+ wait = r.policy.Backoff(try)
+ if said {
+ wait = after
+ }
+ default:
+ return resp, nil
+ }
+ if err := r.sleep(req.Context(), wait); err != nil {
+ return nil, err
+ }
+ }
+}
+
+func transient(code int) bool {
+ return code == http.StatusBadGateway || code == http.StatusServiceUnavailable || code == http.StatusGatewayTimeout
+}
+
+func isIdempotent(method string) bool {
+ switch method {
+ case "", http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodPut, http.MethodDelete:
+ return true
+ }
+ return false
+}
+
+// rewind clones req with a fresh body for another try.
+func rewind(req *http.Request) (*http.Request, error) {
+ out := req.Clone(req.Context())
+ if req.GetBody != nil {
+ body, err := req.GetBody()
+ if err != nil {
+ return nil, err
+ }
+ out.Body = body
+ }
+ return out, nil
+}
+
+// retryAfter reads Retry-After as seconds or an HTTP date. said is false when
+// the header is absent or unreadable.
+func retryAfter(resp *http.Response, now time.Time) (d time.Duration, said bool) {
+ v := resp.Header.Get("Retry-After")
+ if v == "" {
+ return 0, false
+ }
+ if s, err := strconv.Atoi(v); err == nil {
+ return max(time.Duration(s)*time.Second, 0), true
+ }
+ if t, err := http.ParseTime(v); err == nil {
+ return max(t.Sub(now), 0), true
+ }
+ return 0, false
+}
+
+// discard drains a little of a body, so the connection can be reused, and
+// closes it.
+func discard(resp *http.Response) {
+ _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 64<<10))
+ _ = resp.Body.Close()
+}
+
+func sleepCtx(ctx context.Context, d time.Duration) error {
+ if d <= 0 {
+ return ctx.Err()
+ }
+ t := time.NewTimer(d)
+ defer t.Stop()
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-t.C:
+ return nil
+ }
+}
diff --git a/src/retry_test.go b/src/retry_test.go
new file mode 100644
index 0000000..99dda3c
--- /dev/null
+++ b/src/retry_test.go
@@ -0,0 +1,206 @@
+package cloudkit
+
+import (
+ "context"
+ "errors"
+ "io"
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+)
+
+// do sends one request through the harness's guarded client.
+func do(t *testing.T, h *harness, method, path, body string) (int, error) {
+ t.Helper()
+ var r io.Reader
+ if body != "" {
+ r = strings.NewReader(body)
+ }
+ req, err := http.NewRequestWithContext(context.Background(), method, "https://"+fakeHost+path, r)
+ if err != nil {
+ t.Fatal(err)
+ }
+ resp, err := h.open().acct.HTTP.Do(req)
+ if err != nil {
+ return 0, err
+ }
+ _ = resp.Body.Close()
+ return resp.StatusCode, nil
+}
+
+func TestRetryHonoursRetryAfterOn429(t *testing.T) {
+ h := newHarness(t, Options{})
+ h.world.fail = []int{http.StatusTooManyRequests}
+ code, err := do(t, h, http.MethodGet, "/v1/resources", "")
+ if err != nil || code != http.StatusOK {
+ t.Fatalf("got %d, %v", code, err)
+ }
+ if len(h.slept) != 1 || h.slept[0] != 2*time.Second {
+ t.Fatalf("slept %v, want [2s] from Retry-After", h.slept)
+ }
+ if h.creds.count() != 2 {
+ t.Fatalf("token fetched %d times, want once per try", h.creds.count())
+ }
+}
+
+func TestRetryGivesUpWithRateLimitError(t *testing.T) {
+ h := newHarness(t, Options{})
+ h.world.fail = []int{429, 429, 429, 429}
+ _, err := do(t, h, http.MethodGet, "/v1/resources", "")
+ var rl *RateLimitError
+ if !errors.Is(err, ErrRateLimited) || !errors.As(err, &rl) || rl.RetryAfter != 2*time.Second {
+ t.Fatalf("got %v, want a RateLimitError with RetryAfter 2s", err)
+ }
+ if n := len(h.world.requests()); n != DefaultMaxAttempts {
+ t.Fatalf("%d tries, want %d", n, DefaultMaxAttempts)
+ }
+}
+
+func TestRetryDoesNotWaitLongerThanMaxDelay(t *testing.T) {
+ h := newHarness(t, Options{Retry: RetryPolicy{MaxDelay: time.Second, BaseDelay: time.Millisecond}})
+ h.world.fail = []int{429} // asks for 2s
+ _, err := do(t, h, http.MethodGet, "/v1/resources", "")
+ if !errors.Is(err, ErrRateLimited) || len(h.slept) != 0 || len(h.world.requests()) != 1 {
+ t.Fatalf("got %v after %d tries and sleeps %v", err, len(h.world.requests()), h.slept)
+ }
+}
+
+// A create is never sent twice on an ambiguous failure, but a 429 (not acted
+// on) is retried with its body intact.
+func TestRetryNeverRepeatsAnAmbiguousWrite(t *testing.T) {
+ h := newHarness(t, Options{})
+ h.world.fail = []int{http.StatusServiceUnavailable}
+ code, err := do(t, h, http.MethodPost, "/v1/resources", `{"kind":"server","name":"web-1"}`)
+ if err != nil || code != http.StatusServiceUnavailable || len(h.world.requests()) != 1 {
+ t.Fatalf("POST after a 503: code %d, err %v, %d tries", code, err, len(h.world.requests()))
+ }
+
+ h = newHarness(t, Options{})
+ h.world.fail = []int{http.StatusTooManyRequests}
+ body := `{"kind":"server","name":"web-1"}`
+ code, err = do(t, h, http.MethodPost, "/v1/resources", body)
+ reqs := h.world.requests()
+ if err != nil || code != http.StatusCreated || len(reqs) != 2 || reqs[1].Body != body {
+ t.Fatalf("POST after a 429: code %d, err %v, tries %+v", code, err, reqs)
+ }
+}
+
+func TestRetryTransientErrorsOnIdempotentMethods(t *testing.T) {
+ for _, code := range []int{502, 503, 504} {
+ h := newHarness(t, Options{})
+ h.world.fail = []int{code, code}
+ got, err := do(t, h, http.MethodGet, "/v1/resources", "")
+ if err != nil || got != http.StatusOK || len(h.world.requests()) != 3 {
+ t.Errorf("%d: got %d, %v after %d tries", code, got, err, len(h.world.requests()))
+ }
+ }
+ h := newHarness(t, Options{})
+ h.world.fail = []int{500}
+ if got, _ := do(t, h, http.MethodGet, "/v1/resources", ""); got != 500 || len(h.world.requests()) != 1 {
+ t.Errorf("a 500 was retried")
+ }
+}
+
+func TestRetryTransportErrors(t *testing.T) {
+ flaky := func(fails int) (*int, roundTripFunc) {
+ tries := 0
+ return &tries, func(req *http.Request) (*http.Response, error) {
+ tries++
+ if req.Body != nil {
+ _ = req.Body.Close()
+ }
+ if tries <= fails {
+ return nil, errors.New("connection reset")
+ }
+ return reply(req, http.StatusOK, nil), nil
+ }
+ }
+ for _, c := range []struct {
+ method string
+ wantTries int
+ wantOK bool
+ }{
+ {http.MethodGet, 2, true},
+ {http.MethodDelete, 2, true},
+ {http.MethodPost, 1, false},
+ } {
+ tries, rt := flaky(1)
+ h := newHarness(t, Options{Transport: Transport{HTTP: rt, Dial: noDial}})
+ _, err := do(t, h, c.method, "/v1/resources", "")
+ if (err == nil) != c.wantOK || *tries != c.wantTries {
+ t.Errorf("%s: err %v after %d tries", c.method, err, *tries)
+ }
+ }
+}
+
+func TestRetryOffAndCancelled(t *testing.T) {
+ h := newHarness(t, Options{Retry: RetryPolicy{MaxAttempts: 1}})
+ h.world.fail = []int{503}
+ if got, _ := do(t, h, http.MethodGet, "/v1/resources", ""); got != 503 || len(h.world.requests()) != 1 {
+ t.Fatal("MaxAttempts 1 retried")
+ }
+
+ h = newHarness(t, Options{})
+ h.reg.sleep = sleepCtx
+ h.world.fail = []int{429}
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "https://"+fakeHost+"/v1/resources", nil)
+ resp, err := h.open().acct.HTTP.Do(req)
+ if err == nil {
+ _ = resp.Body.Close()
+ t.Fatal("a cancelled context kept retrying")
+ }
+ if !errors.Is(err, context.Canceled) {
+ t.Fatalf("got %v, want context.Canceled", err)
+ }
+}
+
+func TestRetryAfterForms(t *testing.T) {
+ now := time.Date(2026, 11, 3, 12, 0, 0, 0, time.UTC)
+ for v, want := range map[string]time.Duration{
+ "7": 7 * time.Second,
+ "-3": 0,
+ now.Add(time.Minute).Format(http.TimeFormat): time.Minute,
+ now.Add(-time.Minute).Format(http.TimeFormat): 0,
+ } {
+ resp := &http.Response{Header: http.Header{"Retry-After": {v}}}
+ got, said := retryAfter(resp, now)
+ if !said || got != want {
+ t.Errorf("Retry-After %q = %s, %v; want %s", v, got, said, want)
+ }
+ }
+ for _, v := range []string{"", "soon"} {
+ if _, said := retryAfter(&http.Response{Header: http.Header{"Retry-After": {v}}}, now); said {
+ t.Errorf("Retry-After %q was read", v)
+ }
+ }
+}
+
+func TestBackoffIsJitteredAndCapped(t *testing.T) {
+ p := RetryPolicy{BaseDelay: 100 * time.Millisecond, MaxDelay: time.Second}
+ for n, ceil := range map[int]time.Duration{1: 100 * time.Millisecond, 2: 200 * time.Millisecond, 3: 400 * time.Millisecond, 10: time.Second} {
+ for range 50 {
+ d := p.Backoff(n)
+ if d < ceil/2 || d > ceil {
+ t.Fatalf("Backoff(%d) = %s, want within [%s, %s]", n, d, ceil/2, ceil)
+ }
+ }
+ }
+ var zero RetryPolicy
+ if zero.attempts() != 3 || zero.baseDelay() != DefaultBaseDelay || zero.maxDelay() != DefaultMaxDelay || zero.Validate() != nil {
+ t.Fatal("the zero RetryPolicy is not 3 tries with the defaults")
+ }
+}
+
+func TestSleepCtx(t *testing.T) {
+ if err := sleepCtx(context.Background(), time.Millisecond); err != nil {
+ t.Fatal(err)
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ if err := sleepCtx(ctx, time.Hour); !errors.Is(err, context.Canceled) {
+ t.Fatalf("got %v", err)
+ }
+}