Files
egress-proxies-operator/docs/provider-contract.md
kacerrandClaude Fable 5.1 8d764de336 Write the generic provider contract
Fully abstract: MUST/SHOULD/MAY rules with the caller behind each one,
19 behaviour-profile axes with stable codes, credential hygiene, testing
obligations, the checklist and the contract's known limits.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-16 11:17:56 +00:00

43 KiB

The provider contract

This document is the specification a provider must meet. It is written for whoever builds the next one, human or agent, and it deliberately says nothing about how any existing provider is built. Those details are in providers-existing.md; the questionnaire a new provider's design must answer is provider-spec-template.md. Read the three in that order: this contract, then the template, then the appendix for worked examples.

Rule keywords: MUST means breaking the rule breaks a caller, and every MUST below names the caller and what breaks. SHOULD means every current provider does it and a new one needs a written reason not to. MAY means it is allowed and the choice must be recorded in the provider's spec.

Code references are a file path plus a function or symbol name, never a line number. Where a rule is enforced by a caller, that caller is one of:

  • the reconciler, internal/controller/proxy_controller.go;
  • orphan GC, internal/gc/gc.go;
  • the provider health monitor, internal/providerhealth/monitor.go;
  • the decorators, internal/provider/metrics.go and internal/provider/tracing.go;
  • the composition root, cmd/main.go.

1. What a provider is and who calls it

1.1 The types

A provider is a Go type implementing provider.Provider (internal/provider/provider.go): four methods over one VM-like instance, identified by an opaque providerID string.

  • Create(ctx, CreateRequest) (providerID, error)
  • Get(ctx, providerID) (*Instance, error)
  • Delete(ctx, providerID) error
  • ListByTag(ctx) ([]Instance, error)

CreateRequest carries Name (from NameFromUID in internal/provider/name.go, 22 characters, proxy- plus base32), UID (the owning Proxy's UID), Namespace, ProxyName, Placement (Region, Zone, MachineType, Image), CloudInit (already resolved from inline text or a Secret) and Port.

Instance carries ID, IP, State (one of StateProvisioning, StateRunning, StateStopped, StateTerminated), UID and CreatedAt.

The GC marks are the constants LabelManaged (value LabelManagedYes) and LabelUID, also in provider.go.

Providers are named instances of a type: two entries of one type with different config blocks are two providers. The configured name is what Proxy.spec.provider refers to, and it is immutable, so a Proxy never moves between providers.

1.2 The callers

Nothing above the provider watches backend resources. Everything the operator knows about an instance comes from polling Get.

  • The reconciler calls Create, Get and Delete from reconcileManaged, replaceInstance and reconcileDelete, and branches only on the error class (providerFailure, deletionFailure) and on Instance.State. It maps the CRD's placement to Placement one to one in placementFrom. Its poll intervals are set in applyDefaults: ProvisioningPoll and DeletionPoll 10s, DriftPoll 2m, RequeueNow 1s, QuotaRetry 5m. Up to 3 Proxies reconcile at once (MaxConcurrentReconciles in SetupWithManager).
  • Orphan GC calls ListByTag and Delete from Sweeper.sweep, every Interval (10m), and never deletes an instance younger than MinAge (10m). It loops over providers one after another in a single goroutine.
  • The provider health monitor calls the optional Checker from Monitor.checkOnce, every --provider-check-interval, all providers concurrently under one timeout.
  • The composition root builds every provider through registry.Build (internal/provider/registry/registry.go) before the manager starts, on every replica, then wraps each one in WithTracing(WithMetrics(...)).

Where the calls come from. The reconciler, GC, the health engine and the monitor run on the leader only. The constructors run on every replica at startup.

Concurrency. Up to 3 reconciles, a GC sweep and a health-check round can call the same provider at the same time, and GC's Delete can overlap the reconciler's Delete for the same instance. A provider MUST be safe for concurrent use, including two calls for the same instance name. Nothing above the provider serialises them.

1.3 What the instance is for

The instance is a forward proxy with its own egress path. Two parties connect to http://<Instance.IP>:<spec.port>:

  • the health engine, from the operator pod, which sends a real CONNECT through it (probe in internal/health/probe.go);
  • clients, which get ip and port from the discovery API (viewOf in internal/discovery/handlers.go).

So the IP a provider reports MUST be reachable on that port from the operator's pod network and from the clients. Whatever that takes on the backend (firewall rules, security groups, routing) is either done by the provider or documented as a prerequisite in the provider's guide.

The provider does not know which proxy software runs. The proxy is installed and configured by the Proxy's cloudInit; building install logic into the operator is a stated non-goal (docs/prompts/__initial-prompt.md, non-goals). A backend that cannot take user-data (see readiness in §4) documents that it ignores the field and how the proxy gets configured instead.

1.4 Where it runs

The manager container (config/manager/manager.yaml) runs as non-root with a read-only root filesystem, a 64Mi emptyDir at /tmp and a 128Mi memory limit, from a distroless static image (Dockerfile) that carries the public CA roots and nothing else. So a provider:

  • MUST stream large payloads rather than buffering them in memory;
  • MUST write scratch files only under /tmp, and delete them;
  • gets credentials either as mounted files (the providers ConfigMap at /etc/proxy-operator, optional Secret mounts) or from the environment or the pod's own identity. See §7.

1.5 Time and cancellation

No caller puts a deadline on the context. The reconcile and GC contexts are cancelled on shutdown and on loss of leadership, and otherwise never. The only bounded context is the health monitor's (Monitor.checkOnce, one timeout per round).

  • MUST honour ctx: return promptly once it is cancelled, on every method.
  • SHOULD bound every backend call itself: a dial timeout, a per-request timeout, a cap on any wait loop. What breaks otherwise: a hung Create, Get or Delete pins one of the 3 reconcile workers until the manager stops; a hung ListByTag stalls orphan GC for every provider, because Sweeper.sweep runs them sequentially; a Check that ignores its context wedges a monitor round. Choose budgets against the polls: a Get that routinely outlives ProvisioningPoll gains nothing.

2. The contract, method by method

2.1 Create(ctx, CreateRequest) (providerID, error)

  • MUST be idempotent by req.Name. A repeat call for a name that already exists returns that instance's providerID with a nil error, creates no duplicate and returns no error. Caller: the reconciler records the ID in status only after Create returns (reconcileManaged). A crash between the two, or a lost status write, runs Create again for the same Proxy; replaceInstance recovers from a crash between its two writes the same way.
  • MUST mark the instance as managed, with LabelManaged=LabelManagedYes and LabelUID=req.UID or the backend's equivalent, in the same call that creates it. When the backend's create call cannot carry the mark, the window between creation and marking MUST be far shorter than MinAge and documented in the provider's spec (gc-mark in §4). Caller: orphan GC finds instances only through the mark and never deletes one whose UID is empty (Sweeper.sweep). An instance that exists without the mark leaks if the operator crashes before marking it.
  • MUST return a providerID that is self-contained. Get and Delete receive only the ID, and the config or spec may have changed since Create (a zone edit during a replacement). Put whatever Get needs to find the instance into the ID, and validate config values that end up in the ID so they cannot contain the separator. See primary-key in §4 for backends that allocate their own identifier.
  • MUST NOT let Get answer NotFound for an ID that Create just returned. A backend with eventual consistency between create and read needs a read-after-write guarantee or a retry inside Create. Caller: on NotFound the reconciler clears the ID and creates again (reconcileManaged), so a lagging read makes a duplicate.
  • MUST honour ctx (§1.5).
  • MAY return once the request is submitted, or block until the instance is running (create-return in §4). Submit-and-return is preferred: readiness is discovered by polling Get, and a blocking Create holds one of the 3 workers for its whole duration. A provider that blocks MUST still honour cancellation and SHOULD cap the wait.
  • SHOULD reject a request it can never satisfy with ErrPermanent before touching the backend: a missing or unknown zone, machine type or image, or any placement value not in its catalogue. The CRD cannot validate placement, because it is provider-agnostic, so this error is the user's only feedback. The message SHOULD name the offending field and value.
  • Catalogue changes do not replace instances. The spec hash (specHashInput in internal/controller/spechash.go) covers placement, the resolved cloudInit and port, not the provider config. When a placement value names a config entry, changing what that entry means affects only new instances. The provider's guide MUST say so and tell users to add a new key rather than edit one in use.
  • MAY ignore Placement fields that do not apply. If a field is ignored, the provider's guide MUST say so, because the field is still part of the spec hash: changing it replaces the instance and has no other effect.
  • SHOULD deliver CloudInit as user-data, byte for byte, without parsing or re-rendering it. A provider that cannot documents that it ignores the field.
  • MUST NOT log CloudInit contents, put them in span attributes, or include them in error messages. It can hold secrets. Log its byte count. Test this with a sentinel string (§11).

2.2 Get(ctx, providerID) (*Instance, error)

  • MUST return ErrNotFound when, and only when, the instance is gone. Caller: ErrNotFound triggers three actions. In normal reconciling the reconciler clears the ID and creates again (reconcileManaged). During replacement it is the signal to create the new instance (replaceInstance). During deletion it releases the finalizer (reconcileDelete). A false NotFound during an outage creates a duplicate or loses the instance. "Unreachable", "wrong credentials" and "cannot tell" are Transient or Permanent, never NotFound.
  • MUST keep returning the instance, not NotFound, while a delete is in progress and the name is still taken. Caller: the replacement instance gets the same name. replaceInstance waits for NotFound before creating it; a NotFound too early makes Create adopt the dying instance.
  • MUST report StateRunning only with a non-empty IP. Running without an address yet is StateProvisioning. Caller: the IP from a Running answer goes straight into status and to the health engine (reconcileManaged).
  • MUST report StateStopped or StateTerminated only when the instance will not recover by itself. SHOULD map states it does not recognise to StateStopped. Caller: the reconciler reacts to both states identically: it calls Delete and requeues after DeletionPoll without clearing the ID, then polls Get and deletes again until NotFound, then creates (reconcileManaged). Instances are disposable, so this is the safe answer for anything unknown, but it makes the next rule critical.
  • MUST NOT report a freshly created instance that has not been started yet as StateStopped. A backend with no provisioning state of its own, where "defined but not yet started" and "stopped" look the same, MUST resolve this in one of two ways (provisioning-state in §4): block Create until the instance is started, or record enough at creation (written metadata, a task handle) for Get to answer StateProvisioning. Caller: the same branch of reconcileManaged would delete the instance right after it was created, forever.
  • During an asynchronous operation or a backend lock (a clone or a delete still running, an instance locked by the backend), Get MUST return either the last known state or ErrTransient, never NotFound and never Permanent (async-ops in §4).
  • MUST fill UID and CreatedAt by reading them back from the backend, never from memory. Caller: GC compares UID with the live Proxies and deletes anything whose CreatedAt is older than MinAge. A zero CreatedAt counts as very old (Sweeper.sweep), so a provider that forgets it makes instances still being created collectable. An unparsable timestamp giving the zero time is accepted: it never shields an orphan forever.
  • SHOULD return ErrPermanent for an ID it can never resolve: a malformed ID, a location no longer in the config. Caller: reconcileDelete then tries Delete anyway and releases the finalizer if that works. If the provider returned Transient, deletion would poll forever.

2.3 Delete(ctx, providerID) error

  • MUST be idempotent. Deleting something already gone returns nil. Caller: GC and the reconciler race, and a crash repeats the call.
  • MUST accept being called again while an earlier delete is still in progress. Treat "already deleting" as success, and SHOULD keep that cheap. Caller: while Get still returns the instance, reconcileDelete and the Stopped branch of reconcileManaged call Delete again every DeletionPoll until Get returns NotFound.
  • MUST remove everything Create made: disks, volumes, seeds, addresses, uploaded files. Either delete them explicitly or rely on the backend cascading (secondary-resources in §4). Caller: GC sees only what ListByTag returns, so secondary resources have no other cleanup path.
  • MAY return before the instance is gone (delete-return in §4), as long as Get keeps showing it until it is.
  • SHOULD succeed for an ID whose Get fails permanently when there is nothing left the provider could reach, and log that it did nothing. Caller: on the deletion path a permanent error is not latched; it retries forever (deletionFailure), and the Proxy is then only released through the AnnotationSkipCleanup annotation (api/v1alpha1/proxy_types.go).

2.4 ListByTag(ctx) ([]Instance, error)

  • MUST return only instances that carry the managed mark, each with an ID that Delete accepts, plus UID and CreatedAt. State and IP are not used by GC. Caller: GC deletes every listed instance whose UID is not live and whose age is past MinAge (Sweeper.sweep). Listing something without the mark is how an operator deletes an instance it does not own.
  • MUST return an error rather than a list it cannot vouch for. An error makes GC skip that provider for this sweep (Sweeper.sweep).
  • MAY leave out instances it could not read. Leaving something out is always safe, because GC never deletes what is not listed. Pick a policy (list-partial in §4) and document it.
  • SHOULD cover the whole scope the provider can create in: every namespace, zone, node or host. Anything outside that scope is never collected.
  • Scope caveat: GC protects instances by UID, not by installation. Two operator installs sharing one backend delete each other's instances (§13). Two provider entries of one install that share a scope are safe, because they share the live-UID set.

3. What a container-style backend and a VM-style backend have in common

The contract above is the same for both, but the profiles in §4 differ. A backend that runs a container has an instance that is ready seconds after Create, gets its address from the platform, is deleted in one call and has no secondary resources; a backend that boots a VM has a provisioning window of minutes, may discover the address only after the guest reports it, may need several deletes, and leaves disks and seeds behind unless told otherwise. Neither is preferred. What matters is that the provider's spec names its profile on every axis, because the appendix table and the test list are keyed by those names.

4. Behaviour profiles

Each axis below has a stable code. The provider's spec (the template) answers every axis; the appendix maps each existing provider to its profile on every axis. A profile is not a permission to skip a MUST in §2; it says which of the allowed ways of meeting it the provider chose.

4.1 readiness

When is an instance usable after Create?

  • immediate: the platform starts it at once; Get reports Running within a poll or two.
  • provisioning-window: minutes of booting; Get reports Provisioning for that time.

Callers tolerate both: the reconciler polls at ProvisioningPoll either way. An immediate provider still MUST report Provisioning until the IP is known.

4.2 create-return

Does Create return once the request is submitted or once the instance is running?

  • submit: preferred. Readiness comes from Get.
  • blocking: allowed. Costs one of 3 workers for the duration, and any first-use setup (an image download) runs inside it. MUST honour ctx, SHOULD cap the wait, and MUST keep every step idempotent so a cut-off attempt resumes.

4.3 delete-return

Does Delete return before the instance is gone?

  • returns-early: Get keeps returning the instance until it is gone (§2.2), and repeated Delete calls are cheap.
  • waits: Delete returns nil only when nothing is left. Bound the wait and honour ctx.

4.4 ip-source

Where does Instance.IP come from?

  • assigned-at-create: the backend reports it with the instance.
  • discovered: an agent in the guest, a DHCP lease, a neighbour table. Discovery failures mean "no address yet", never an error; Get returns Provisioning. The provider's guide MUST say what the guest needs (an agent package, outbound access to install it) and what happens without it.

4.5 gc-mark

Where do LabelManaged and LabelUID live?

  • native-labels: labels or tags the backend indexes; ListByTag can filter server-side.
  • written-metadata: a description, an annotation or a metadata element written by the provider; ListByTag lists everything and filters client-side.
  • mark-window: the create call cannot carry the mark and a second call writes it. Allowed only when the window is a few seconds, documented, and the unmarked instance is cleaned by the same Proxy's next reconcile (§2.1).

4.6 created-at

Where does CreatedAt come from?

  • backend-timestamp: the backend's own creation time.
  • written-at-create: the provider writes the time into the mark metadata in the create call. A backend with no timestamp MUST do this.

4.7 list-partial

What does ListByTag do when part of its scope is unreachable?

  • fail-whole: one unreachable part fails the list; GC skips the provider this sweep. Nothing is collected until every part answers.
  • omit-unreachable: unreachable parts are left out and the rest is returned. Orphans in the reachable parts are collected; the provider MUST log what it left out.

Both are safe (§2.4). A single unreadable instance MAY always be skipped.

4.8 quota

Can the provider tell "no capacity" from other failures?

  • detected: a recognisable quota, rate-limit or out-of-resources signal maps to ErrQuotaExceeded.
  • absent: capacity errors map to ErrTransient. The guide MUST say so, and the Checker SHOULD expose a capacity figure in Details instead.

4.9 not-found

How does the provider know an instance is gone?

  • status-404: a distinct not-found status or error code from the backend.
  • error-code: a code or message the backend uses for "does not exist", matched exactly and covered by the classify table test with real captured responses.
  • list-and-miss: absence from a listing. Allowed only when the listing can never be partial; otherwise a missing part reads as "gone" and §2.2 is broken.

4.10 primary-key

Is req.Name the backend's key?

  • name-is-key: the backend addresses instances by name. Create looks the name up, the ID is <location>/<name>.
  • allocated-id: the backend allocates an identifier and the name is a label. Create MUST first find an existing instance by name (idempotency), then allocate. Allocation MUST be safe against a concurrent Create for another Proxy: a deterministic identifier derived from req.UID, or an allocate-then-verify loop. The ID SHOULD embed the allocated key and the name; Get and Delete use the allocated key. If instances can move between locations (migration), the ID MUST NOT depend on the location.

4.11 provisioning-state

Does the backend have a state for "being created"?

  • native: the backend reports a provisioning or staging state, mapped to StateProvisioning.
  • create-blocks: no such state; Create blocks until the instance is started, so Get never sees "defined but not started".
  • marker: no such state and Create returns early; the provider records at creation (metadata or a task handle) what lets Get answer StateProvisioning for an instance that was never started, and StateStopped for one that stopped afterwards.

4.12 async-ops

Does the backend run create and delete as asynchronous tasks?

  • none: calls complete synchronously.
  • task-handle: the backend returns a task the provider can poll. The provider SHOULD keep the handle where Get can find it (metadata or the ID) so a failed create can be reported instead of read as NotFound.
  • backend-lock: the backend locks the instance during a task. Get returns the last known state or Transient while locked (§2.2); Delete on a locked instance returns Transient, not Permanent.

4.13 secondary-resources

What else does Create make, and who removes it?

  • none.
  • cascade: the backend deletes them with the instance (auto-delete flags). The provider MUST set those flags at create.
  • explicit: the provider deletes them by name in Delete, tolerating their absence. Resources that can exist without an instance (a crash between creating them and creating the instance) are a documented GC blind spot unless ListByTag covers them.

4.14 credentials

Where do credentials and trust material come from?

  • mounted-files: paths in the config block, pointing into an optional Secret mount (§7).
  • environment: standard SDK environment discovery.
  • workload-identity: the pod's own identity.

TLS trust for an HTTPS backend with a private CA is one of system-roots, ca-file (a path in the config block) or insecure (a boolean whose name starts with insecure, warned about at startup).

4.15 client-kind

What talks to the backend?

  • vendor-sdk: put it behind a small interface (the seam) so a fake can stand in.
  • thin-client: a net/http client written in the provider package. Preferred when no maintained SDK exists (CLAUDE.md: stdlib over third-party). It MUST set transport timeouts, MUST carry the backend's status and body in one error type that classify reads, and SHOULD wrap its transport for child spans (§9).
  • rpc-over-ssh or another non-HTTP transport: one pooled connection per target, a keepalive, and a documented answer to "what does a cancelled call do to the shared connection".

4.16 timeouts

How is each backend call bounded? (§1.5)

  • per-call: every request has its own deadline, independent of ctx.
  • ctx-only: the provider relies on ctx. Allowed only for backends whose client already enforces a timeout; the spec MUST cite it.

4.17 logging

Which log tiers does the provider produce? The logger MUST come from logf.FromContext(ctx) at call time (§9).

  • tiered: V(1) one line per operation outcome and per failed backend call; V(2) request and response detail; V(5) raw wire if there is one.
  • outcome-only: V(1) only.
  • none: allowed only for a backend whose client logs on its own.

4.18 child-spans

How do backend calls appear under the operation span? (§9)

  • sdk-instrumentation: the SDK emits spans itself.
  • transport-wrapper: a parent-gated otelhttp transport.
  • span-decorator: a decorator over the seam that starts one span per call.
  • none: a documented gap, with log correlation through traceID only.

4.19 backend-test

How is the provider proven against the real backend?

  • cluster-e2e: the kind end-to-end suite.
  • integration-tag: a test behind //go:build integration, skipped without its environment variables, with a make test-integration-<type> target and a section in docs/testing.md.
  • manual-runbook: a documented by-hand run. Allowed only when the backend cannot be reached from CI or a developer machine.

4.20 Summary

Axis Profiles Who tolerates the difference Template entry
readiness immediate, provisioning-window reconciler polls either way 7
create-return submit, blocking reconciler; worker budget 10
delete-return returns-early, waits reconciler re-calls Delete 11
ip-source assigned-at-create, discovered reconciler gates Running on IP 7
gc-mark native-labels, written-metadata, mark-window GC 8
created-at backend-timestamp, written-at-create GC MinAge 8
list-partial fail-whole, omit-unreachable GC 12
quota detected, absent reconciler QuotaRetry 13
not-found status-404, error-code, list-and-miss reconciler 13
primary-key name-is-key, allocated-id reconciler idempotency 2
provisioning-state native, create-blocks, marker reconciler Stopped branch 9
async-ops none, task-handle, backend-lock reconciler 10
secondary-resources none, cascade, explicit GC blind spots 11
credentials mounted-files, environment, workload-identity deployment 14
client-kind vendor-sdk, thin-client, rpc-over-ssh tests, tracing 17
timeouts per-call, ctx-only worker and GC budget 18
logging tiered, outcome-only, none operators 20
child-spans sdk-instrumentation, transport-wrapper, span-decorator, none tracing 17
backend-test cluster-e2e, integration-tag, manual-runbook CI 21

5. Error taxonomy

Every error a provider method returns MUST be built with provider.Wrap(class, op, providerName, id, err) (internal/provider/errors.go). op is one of create, get, delete or list, and providerName is the configured name (ProviderConfig.Name), not the type. Caller: the decorators label metrics and spans with the class (resultLabel), and the reconciler branches on provider.Class.

Wrap keeps both errors.Is(err, class) and errors.As to the backend's error type working (Error.Unwrap returns both).

Class Use it for During normal reconciling While deleting
ErrNotFound the instance does not exist Get: recreate or advance (§2.2); other ops: returned as the error, so exponential backoff Get: release the finalizer
ErrQuotaExceeded quota, rate limit, out of capacity Provisioned=False with reason QuotaExceeded, requeue after QuotaRetry, not counted as an error (providerFailure) requeue after QuotaRetry (deletionFailure)
ErrTransient network, 5xx, timeouts, cancellation, anything unknown error returned, so workqueue exponential backoff same
ErrPermanent bad config, bad argument, auth or permission denied, malformed ID latched: Provisioned=False with reason PermanentError, no more provider calls until metadata.generation changes (reconcileManaged) error returned and retried forever; a Permanent Get also triggers Delete (reconcileDelete)

Pitfalls:

  • Unclassified means Transient. provider.Class returns ErrTransient for any error not built with Wrap. That is the safe default, but a permanent failure that was never classified retries forever, and a quota error backs off far too fast.
  • Permanent is sticky. Use it only for errors that editing the Proxy spec or fixing the config can resolve. A permission-denied status that the backend also uses for quota is the hard case: read the backend's reason if it gives one, and if it does not, pick Permanent and document it.
  • Do not return NotFound from Create. The reconciler treats it as Transient there, but it misleads the metrics and anyone reading logs.
  • NotFound detection follows the not-found profile (§4.9). A backend that reports "does not exist" as a generic server error needs the exact message or code matched, with real captured responses in the test table.
  • Classify in one classify function with a table-driven test covering every class, including "not a backend error at all, so Transient".

6. Identity, names, marks, CreatedAt

  • Names: NameFromUID(uid) gives proxy- plus 16 lowercase base32 characters. It is a valid DNS label. A backend with stricter rules SHOULD map it deterministically rather than invent a new scheme, because idempotency depends on the name being the same every time. A backend that keys on an allocated identifier follows primary-key (§4.10).
  • providerID: opaque to everyone except the provider. Keep it <location>/<name>-shaped or <location>/<allocated-key>-shaped, parse it strictly, and return ErrPermanent for a malformed one (§2.2).
  • Marks: LabelManaged with value LabelManagedYes, and LabelUID. Use native labels or tags where the backend has them; otherwise write them into metadata in the create call (§4.5).
  • CreatedAt: the backend's creation timestamp, or written into the mark metadata at creation (§4.6).
  • Provider names: a configured name MUST be a lowercase DNS-1123 label of at most 63 characters (spec.provider in api/v1alpha1/proxy_types.go). The ProviderHealth object is named after the provider; Monitor.Start warns and Monitor.publish skips the object for a name that is not a valid object name. Config loading does not check this yet (§13).

7. Credentials, TLS and secret hygiene

  • The constructor MUST NOT dial the backend. registry.Build aborts the whole provider set on a constructor error, and the manager exits: one unreachable backend must not take the other providers down. Fail in the constructor only on config errors.
  • Credential files SHOULD be read lazily. A mount marked optional: true is empty when the Secret is missing; the constructor logs a warning and the first call fails Transient until the Secret lands. A file that exists but does not parse is Permanent.
  • Credentials MUST NOT appear in logs, span attributes, error strings or Error.Err. Backend errors that echo a request URL or header with a token in it MUST be sanitised before wrapping. The same holds for CloudInit (§2.1). Test both with a sentinel (§11).
  • TLS trust follows credentials (§4.14). An insecure toggle MUST be named so that it reads as insecure, default off, and MUST be logged as a warning at startup when on.
  • Rotation. State in the spec whether rotating the Secret takes effect without a restart. Reading files on every dial makes rotation free; caching them after first load needs a restart and must say so.
  • Scratch files only under /tmp (§1.4), removed when the call ends.

8. Optional interfaces: Checker

provider.Checker is Check(ctx) []TargetStatus (internal/provider/check.go). cmd/main.go type-asserts it on the unwrapped provider before adding the decorators, and the monitor treats a nil Checker as unsupported, not failed (Monitor.run).

  • MUST never fail as a whole. Every failure is a TargetStatus with Ready=false. Caller: Monitor.checkOnce has no error path per provider.
  • MUST return promptly once ctx ends. The monitor gives the whole round one timeout.
  • MUST return one entry per target, in a stable order. Targets are what can fail independently: an apiserver, a project, each host or node. Caller: the monitor logs transitions per target (logTransitions) and the gauges are labelled {provider,target}.
  • MUST NOT disrupt reconciles. A check that shares a connection with provisioning must not tear that connection down when it times out.
  • SHOULD use the shared reasons (ReasonReachable, ReasonTimeout, ReasonUnreachable, ReasonUnauthorized, ReasonForbidden, ReasonAPIError). Add provider-specific reasons only for failures an admin would fix differently, and document each one in the guide.
  • SHOULD check with the same credentials, verb and scope that ListByTag and Create use, and be cheap: one page, one object.
  • MAY fill Details with facts useful when triaging: versions, capacity, the account in use.
  • The status.type field of ProviderHealth (api/v1alpha1/providerhealth_types.go) lists the known types in its doc comment; add the new type there and run make manifests.

9. Observability

Tracing is OpenTelemetry, set up in internal/tracing from the standard OTEL_* environment variables. With no exporter configured, spans are no-ops and logs are unchanged (architecture.md §10).

Layer 1, operation spans and metrics: nothing to write. cmd/main.go wraps every provider in WithTracing(WithMetrics(...)):

  • WithTracing opens one client span per call, named provider.create, provider.get, provider.delete or provider.list, with provider.name, provider.id and provider.result (the error class) as attributes. ErrNotFound keeps the span status Ok, because polling to NotFound is expected.
  • WithMetrics counts each call as proxy_operator_provider_requests_total{provider,op,result} (internal/metrics/metrics.go). A Checker feeds proxy_operator_provider_target_up, proxy_operator_provider_check_duration_seconds and proxy_operator_provider_check_timestamp_seconds, labelled {provider,target}.

Rules:

  • MUST NOT open its own per-operation span or count its own requests. They would duplicate the decorators.
  • MUST pass the caller's ctx to every backend call and take the logger from logf.FromContext(ctx) at call time. tracing.StartSpan (internal/tracing/logger.go) puts a logger carrying traceID and spanID into ctx. A logger cached at construction, or a backend call made with context.Background(), loses the trace.
  • Health checks bypass the decorators. A Checker runs under the monitor's providerhealth.check span, and the same ctx rules apply.

Layer 2, child spans per backend call: SHOULD. One operation span says that Create took 40 seconds; child spans say which backend call took them. Follow child-spans (§4.18):

  • HTTP backends: wrap the transport with otelhttp, gated on an existing parent span as tracing.RestConfigWrapper does (internal/tracing/transport.go), so background traffic (token refreshes, keepalives) does not become root spans. Check first whether the SDK already does this.
  • Non-HTTP backends: start child spans yourself with tracing.Start(ctx, "<type>.<call>") around each call and around long steps such as a dial or a download, in a decorator over the seam, not at call sites.
  • MUST NOT put secrets or CloudInit in span attributes. Put identifiers, sizes and error codes there.

Layer 3, logs. Follow logging (§4.17). Log lines join a trace through the traceID key from layer 1.

10. Configuration and wiring

The providers file is parsed by LoadConfig (internal/provider/config.go) with UnmarshalStrict, so unknown keys already fail.

  • Add a <Type>Config struct and a ProviderConfig field for it.
  • Add the block to setBlocks, so a block that does not match Type is rejected.
  • Add the type to the allow-list in Config.validate. The same list exists a second time as the constructor map in cmd/main.go; both must change (§13).
  • Add required-field validation in its own config_<type>.go when it is more than a few lines, with rows in config_test.go.
  • Validate every config value that is pasted into an identifier: a location name that becomes part of the providerID must not contain the separator; a catalogue key that becomes a file or volume name must be a safe filename.
  • The constructor has the signature registry.Constructor: func(ctx, provider.ProviderConfig) (provider.Provider, error). Register it in the map in cmd/main.go. Flags a provider needs are defined there too and passed through a NewWith... variant, never read from the environment inside the package.
  • Decorators are applied by the composition root, in the order WithTracing(WithMetrics(p)), after the Checker has been collected from the unwrapped provider.

11. Testing obligations

CLAUDE.md applies: stdlib testing, table-driven, tests next to source, t.Parallel() where safe.

  • A fake behind the seam. Every unit test runs against an in-memory fake of the backend, never the network.
  • Contract tests, each against the fake:
    • repeat Create returns the same ID and creates nothing;
    • Get after Delete returns ErrNotFound;
    • Delete twice succeeds;
    • malformed ID is ErrPermanent from Get and Delete;
    • an unknown backend state maps to StateStopped;
    • StateRunning requires an IP, and a running instance without one is StateProvisioning;
    • UID and CreatedAt are read back from the fake, not remembered;
    • ListByTag excludes unmarked instances and instances with an empty UID are still listed with UID == "";
    • Get during an in-progress delete still returns the instance;
    • Get during an asynchronous operation or lock returns the last state or Transient (async-ops);
    • a created but not yet started instance is never StateStopped (provisioning-state);
    • a cancelled ctx makes every method return promptly.
  • Classify: one table test covering every class, every backend code the provider matches, and "not a backend error at all, so Transient". Use captured real responses for message matching.
  • Checker: a table test for the reasons, a test that Check returns promptly with an expired ctx, and a test that it returns one entry per target in config order.
  • Sentinel tests: a CloudInit containing a sentinel string and a credential containing another never appear in any log line, span attribute or error string.
  • Tracing: with an in-memory span recorder (sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(tracetest.NewSpanRecorder())), as in internal/provider/tracing_test.go), the child spans are children of the operation span.
  • Real-backend test per backend-test (§4.19). It runs the whole lifecycle: create, create again, poll until Running, list, delete, delete again, confirm it is gone and that no secondary resource remains. Keep it compiling with go vet -tags=integration ./....

12. Checklist for adding a provider

  1. Spec first. Copy docs/provider-spec-template.md to docs/<type>-provider-spec.md and answer every entry. Unanswered entries block implementation.
  2. Package internal/provider/<type>/ with the provider type, a compile-time assertion for provider.Provider and one for provider.Checker, the seam interface, and a logger built from logf.FromContext(ctx).WithName("<type>").WithValues("provider", name).
  3. Config per §10, including config_test.go rows.
  4. Constructor New(ctx, provider.ProviderConfig) registered in cmd/main.go; no dialing.
  5. Contract per §2, tested per §11.
  6. Errors per §5, classify plus its table test, ProviderConfig.Name in every Wrap.
  7. Checker per §8, with the status.type comment updated and make manifests run.
  8. Tracing and logging per §9 and §4.17, §4.18.
  9. Real-backend test per §4.19, with a make test-integration-<type> target and a section in docs/testing.md.
  10. RBAC: if the provider talks to the cluster's own API, add +kubebuilder:rbac markers in their own comment block, never inside a doc comment, then make manifests and check git diff config/rbac/role.yaml.
  11. Deployment: an optional: true credentials mount in config/manager/manager.yaml; a commented entry in config/manager/providers_config.yaml and config/samples/providers-config.yaml; a config/samples/proxy_<type>.yaml whose cloud-init installs a proxy on the sample image; a Makefile helper for the Secret if there is one; hack/providers-dev.yaml only if the type needs no credentials.
  12. Docs: docs/<type>-provider.md (prerequisites, config reference, placement mapping, what lands on the backend, the health check, logging, known gaps); a row and a section in providers-existing.md; a row in the architecture.md components table and a block in its §5 and §11 (§10 too if the provider adds spans); a row in the README "Implemented providers" table; a CHANGELOG.md entry once the user has confirmed it works.
  13. Optional: a demo script under docs/demo/.

13. Known limits of the contract

These are facts a new provider has to live with today. None is designed or scheduled.

  • Adoption is not verified. Create adopts any existing instance with the right name without checking its LabelUID.
  • StateStopped and StateTerminated are indistinguishable to the reconciler; both mean delete and recreate.
  • Config is read once per process. Adding a provider or a location, or rotating credentials a provider caches, needs a manager restart.
  • GC has no installation identity. Two installs sharing one backend delete each other's instances, because GC only knows its own live UIDs.
  • An unconfigured provider latches the Proxy. A Proxy naming a provider missing from the config is latched PermanentError; adding the provider and restarting does not change metadata.generation, and spec.provider is immutable, so the Proxy stays latched until it is recreated.
  • Provider names are not validated as DNS-1123 at config load.
  • The type list exists twice: Config.validate and the constructor map in cmd/main.go.
  • Delete is resubmitted on every deletion poll until Get says NotFound.
  • Callers set no deadline (§1.5); each provider bounds itself.
  • There is no conformance suite. The §11 contract tests are written per provider; internal/provider/providertest is a proposal in the appendix.
  • Stale comments in internal/provider/provider.go, internal/provider/registry/registry.go and api/v1alpha1/proxy_types.go still describe a mock provider that no longer exists.