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) + } +}