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>
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.goandinternal/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) errorListByTag(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,GetandDeletefromreconcileManaged,replaceInstanceandreconcileDelete, and branches only on the error class (providerFailure,deletionFailure) and onInstance.State. It maps the CRD's placement toPlacementone to one inplacementFrom. Its poll intervals are set inapplyDefaults:ProvisioningPollandDeletionPoll10s,DriftPoll2m,RequeueNow1s,QuotaRetry5m. Up to 3 Proxies reconcile at once (MaxConcurrentReconcilesinSetupWithManager). - Orphan GC calls
ListByTagandDeletefromSweeper.sweep, everyInterval(10m), and never deletes an instance younger thanMinAge(10m). It loops over providers one after another in a single goroutine. - The provider health monitor calls the optional
CheckerfromMonitor.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 inWithTracing(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
CONNECTthrough it (probeininternal/health/probe.go); - clients, which get
ipandportfrom the discovery API (viewOfininternal/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,GetorDeletepins one of the 3 reconcile workers until the manager stops; a hungListByTagstalls orphan GC for every provider, becauseSweeper.sweepruns them sequentially; aCheckthat ignores its context wedges a monitor round. Choose budgets against the polls: aGetthat routinely outlivesProvisioningPollgains 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 afterCreatereturns (reconcileManaged). A crash between the two, or a lost status write, runsCreateagain for the same Proxy;replaceInstancerecovers from a crash between its two writes the same way. - MUST mark the instance as managed, with
LabelManaged=LabelManagedYesandLabelUID=req.UIDor 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 thanMinAgeand documented in the provider's spec (gc-markin §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.
GetandDeletereceive only the ID, and the config or spec may have changed sinceCreate(a zone edit during a replacement). Put whateverGetneeds to find the instance into the ID, and validate config values that end up in the ID so they cannot contain the separator. Seeprimary-keyin §4 for backends that allocate their own identifier. - MUST NOT let
Getanswer NotFound for an ID thatCreatejust returned. A backend with eventual consistency between create and read needs a read-after-write guarantee or a retry insideCreate. 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-returnin §4). Submit-and-return is preferred: readiness is discovered by pollingGet, and a blockingCreateholds 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
ErrPermanentbefore touching the backend: a missing or unknown zone, machine type or image, or any placement value not in its catalogue. The CRD cannot validateplacement, 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
(
specHashInputininternal/controller/spechash.go) coversplacement, the resolvedcloudInitandport, 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
Placementfields 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
CloudInitas user-data, byte for byte, without parsing or re-rendering it. A provider that cannot documents that it ignores the field. - MUST NOT log
CloudInitcontents, 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
ErrNotFoundwhen, and only when, the instance is gone. Caller:ErrNotFoundtriggers 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.
replaceInstancewaits for NotFound before creating it; a NotFound too early makesCreateadopt the dying instance. - MUST report
StateRunningonly with a non-emptyIP. Running without an address yet isStateProvisioning. Caller: the IP from a Running answer goes straight into status and to the health engine (reconcileManaged). - MUST report
StateStoppedorStateTerminatedonly when the instance will not recover by itself. SHOULD map states it does not recognise toStateStopped. Caller: the reconciler reacts to both states identically: it callsDeleteand requeues afterDeletionPollwithout clearing the ID, then pollsGetand 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-statein §4): blockCreateuntil the instance is started, or record enough at creation (written metadata, a task handle) forGetto answerStateProvisioning. Caller: the same branch ofreconcileManagedwould 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),
GetMUST return either the last known state orErrTransient, never NotFound and never Permanent (async-opsin §4). - MUST fill
UIDandCreatedAtby reading them back from the backend, never from memory. Caller: GC comparesUIDwith the live Proxies and deletes anything whoseCreatedAtis older thanMinAge. A zeroCreatedAtcounts 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
ErrPermanentfor an ID it can never resolve: a malformed ID, a location no longer in the config. Caller:reconcileDeletethen triesDeleteanyway 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
Getstill returns the instance,reconcileDeleteand the Stopped branch ofreconcileManagedcallDeleteagain everyDeletionPolluntilGetreturns NotFound. - MUST remove everything
Createmade: disks, volumes, seeds, addresses, uploaded files. Either delete them explicitly or rely on the backend cascading (secondary-resourcesin §4). Caller: GC sees only whatListByTagreturns, so secondary resources have no other cleanup path. - MAY return before the instance is gone (
delete-returnin §4), as long asGetkeeps showing it until it is. - SHOULD succeed for an ID whose
Getfails 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 theAnnotationSkipCleanupannotation (api/v1alpha1/proxy_types.go).
2.4 ListByTag(ctx) ([]Instance, error)
- MUST return only instances that carry the managed mark, each with an
IDthatDeleteaccepts, plusUIDandCreatedAt.StateandIPare not used by GC. Caller: GC deletes every listed instance whose UID is not live and whose age is pastMinAge(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-partialin §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;
Getreports Running within a poll or two. - provisioning-window: minutes of booting;
Getreports 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:
Getkeeps returning the instance until it is gone (§2.2), and repeatedDeletecalls are cheap. - waits:
Deletereturns nil only when nothing is left. Bound the wait and honourctx.
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;
Getreturns 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;
ListByTagcan filter server-side. - written-metadata: a description, an annotation or a metadata element
written by the provider;
ListByTaglists 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 theCheckerSHOULD expose a capacity figure inDetailsinstead.
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.
Createlooks the name up, the ID is<location>/<name>. - allocated-id: the backend allocates an identifier and the name is a
label.
CreateMUST first find an existing instance by name (idempotency), then allocate. Allocation MUST be safe against a concurrentCreatefor another Proxy: a deterministic identifier derived fromreq.UID, or an allocate-then-verify loop. The ID SHOULD embed the allocated key and the name;GetandDeleteuse 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;
Createblocks until the instance is started, soGetnever sees "defined but not started". - marker: no such state and
Createreturns early; the provider records at creation (metadata or a task handle) what letsGetanswerStateProvisioningfor an instance that was never started, andStateStoppedfor 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
Getcan 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.
Getreturns the last known state or Transient while locked (§2.2);Deleteon 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 unlessListByTagcovers 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/httpclient 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 thatclassifyreads, 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
otelhttptransport. - span-decorator: a decorator over the seam that starts one span per call.
- none: a documented gap, with log correlation through
traceIDonly.
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 amake test-integration-<type>target and a section indocs/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.ClassreturnsErrTransientfor any error not built withWrap. 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-foundprofile (§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
classifyfunction 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)givesproxy-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 followsprimary-key(§4.10). - providerID: opaque to everyone except the provider. Keep it
<location>/<name>-shaped or<location>/<allocated-key>-shaped, parse it strictly, and returnErrPermanentfor a malformed one (§2.2). - Marks:
LabelManagedwith valueLabelManagedYes, andLabelUID. 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
nameMUST be a lowercase DNS-1123 label of at most 63 characters (spec.providerinapi/v1alpha1/proxy_types.go). TheProviderHealthobject is named after the provider;Monitor.Startwarns andMonitor.publishskips 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.Buildaborts 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: trueis 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 forCloudInit(§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
TargetStatuswithReady=false. Caller:Monitor.checkOncehas no error path per provider. - MUST return promptly once
ctxends. 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
ListByTagandCreateuse, and be cheap: one page, one object. - MAY fill
Detailswith facts useful when triaging: versions, capacity, the account in use. - The
status.typefield ofProviderHealth(api/v1alpha1/providerhealth_types.go) lists the known types in its doc comment; add the new type there and runmake 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(...)):
WithTracingopens one client span per call, namedprovider.create,provider.get,provider.deleteorprovider.list, withprovider.name,provider.idandprovider.result(the error class) as attributes.ErrNotFoundkeeps the span status Ok, because polling to NotFound is expected.WithMetricscounts each call asproxy_operator_provider_requests_total{provider,op,result}(internal/metrics/metrics.go). ACheckerfeedsproxy_operator_provider_target_up,proxy_operator_provider_check_duration_secondsandproxy_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
ctxto every backend call and take the logger fromlogf.FromContext(ctx)at call time.tracing.StartSpan(internal/tracing/logger.go) puts a logger carryingtraceIDandspanIDintoctx. A logger cached at construction, or a backend call made withcontext.Background(), loses the trace. - Health checks bypass the decorators. A
Checkerruns under the monitor'sproviderhealth.checkspan, and the samectxrules 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 astracing.RestConfigWrapperdoes (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>Configstruct and aProviderConfigfield for it. - Add the block to
setBlocks, so a block that does not matchTypeis rejected. - Add the type to the allow-list in
Config.validate. The same list exists a second time as the constructor map incmd/main.go; both must change (§13). - Add required-field validation in its own
config_<type>.gowhen it is more than a few lines, with rows inconfig_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 incmd/main.go. Flags a provider needs are defined there too and passed through aNewWith...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
Createreturns the same ID and creates nothing; GetafterDeletereturnsErrNotFound;Deletetwice succeeds;- malformed ID is
ErrPermanentfromGetandDelete; - an unknown backend state maps to
StateStopped; StateRunningrequires an IP, and a running instance without one isStateProvisioning;UIDandCreatedAtare read back from the fake, not remembered;ListByTagexcludes unmarked instances and instances with an empty UID are still listed withUID == "";Getduring an in-progress delete still returns the instance;Getduring 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
ctxmakes every method return promptly.
- repeat
- 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
Checkreturns promptly with an expiredctx, and a test that it returns one entry per target in config order. - Sentinel tests: a
CloudInitcontaining 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 ininternal/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 withgo vet -tags=integration ./....
12. Checklist for adding a provider
- Spec first. Copy
docs/provider-spec-template.mdtodocs/<type>-provider-spec.mdand answer every entry. Unanswered entries block implementation. - Package
internal/provider/<type>/with the provider type, a compile-time assertion forprovider.Providerand one forprovider.Checker, the seam interface, and a logger built fromlogf.FromContext(ctx).WithName("<type>").WithValues("provider", name). - Config per §10, including
config_test.gorows. - Constructor
New(ctx, provider.ProviderConfig)registered incmd/main.go; no dialing. - Contract per §2, tested per §11.
- Errors per §5,
classifyplus its table test,ProviderConfig.Namein everyWrap. - Checker per §8, with the
status.typecomment updated andmake manifestsrun. - Tracing and logging per §9 and §4.17, §4.18.
- Real-backend test per §4.19, with a
make test-integration-<type>target and a section indocs/testing.md. - RBAC: if the provider talks to the cluster's own API, add
+kubebuilder:rbacmarkers in their own comment block, never inside a doc comment, thenmake manifestsand checkgit diff config/rbac/role.yaml. - Deployment: an
optional: truecredentials mount inconfig/manager/manager.yaml; a commented entry inconfig/manager/providers_config.yamlandconfig/samples/providers-config.yaml; aconfig/samples/proxy_<type>.yamlwhose cloud-init installs a proxy on the sample image; a Makefile helper for the Secret if there is one;hack/providers-dev.yamlonly if the type needs no credentials. - 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 inproviders-existing.md; a row in thearchitecture.mdcomponents table and a block in its §5 and §11 (§10 too if the provider adds spans); a row in the README "Implemented providers" table; aCHANGELOG.mdentry once the user has confirmed it works. - 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.
Createadopts any existing instance with the right name without checking itsLabelUID. StateStoppedandStateTerminatedare 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 changemetadata.generation, andspec.provideris 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.validateand the constructor map incmd/main.go. Deleteis resubmitted on every deletion poll untilGetsays 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/providertestis a proposal in the appendix. - Stale comments in
internal/provider/provider.go,internal/provider/registry/registry.goandapi/v1alpha1/proxy_types.gostill describe amockprovider that no longer exists.
14. Related documents
- providers.md: the index.
- provider-spec-template.md: the questionnaire.
- providers-existing.md: how the existing providers meet this contract, and their gaps.
- architecture.md §5, §10 and §11.
- testing.md.