The plugin contract¶
CloudSlash is a read-only inventory and analysis engine. It never contains provider logic: it does not know what AWS, Google Cloud, GitHub or a bare-metal host is. Everything a provider knows lives in a plugin, and the only thing that joins the two is this contract.
That split is the reason a plugin can be written for any system — a cloud, a database, a Kubernetes cluster, a Git host, an internal CMDB — without touching core. It is also the reason the contract is strict. A loose contract would mean every plugin needed core changes, and a plugin that "mostly" worked would be indistinguishable from one that did not.
What the core owns¶
The core owns these, so a plugin never has to:
| Concern | Owner | Consequence for a plugin author |
|---|---|---|
| Envelope binding | SDK | You never fill in job_id, input_digest, nonce or trace id. |
| Batch sequence | SDK | The SDK numbers batches from 0; you never track position. |
| Observation time | SDK | observed_at is stamped as your batch leaves the process. |
| Contract validation | core | Every batch is checked against the declared schema before it is stored. |
| Analysis | core | You emit resources and relationships; findings are the core's job. |
| Transport | SDK + CLI | gRPC and gRPC-Web, TLS, tunnels and keepalives are handled for you. |
Anything in that table that a plugin had to do itself would be a place for every
plugin to get it wrong differently. v3.RegisterSource seals the envelope on the
single emit path shared by every source, so a batch that leaves your plugin is
always answerable to the contract.
What a plugin owns¶
A plugin declares four things and implements two methods.
- A capability — its name, version, role (
source), effect (read), the schemas it speaks, and its limits. - Connection methods — how a customer grants read access: the fields they fill in, and every permission the plugin will use.
- Preflight — prove the credential works and report what it can reach.
- Scan — emit resources and relationships.
func Capability() v3.Capability {
return v3.Capability{
Name: "read.inventory", Version: version,
Role: v3.Source, Effect: v3.Read,
Environments: []v3.Environment{v3.Hosted, v3.CustomerRunner},
InputSchema: "scan.request/v1",
OutputSchema: "scan.batch/v1",
Limits: v3.Limits{
MaxBatchBytes: 64 << 10, MaxOutputBytes: 64 << 20,
MaxResources: 50000, MaxDurationMS: 900000, MaxConcurrency: 8,
},
SupportsCancellation: true, SupportsPartial: true,
}
}
SupportsPartial is what lets you report a partial inventory instead of
discarding everything when one endpoint is denied. Set it.
Declare every permission you use, and no more¶
The permissions in your connection method are the promises a customer reviews. They are also what the setup flow needs in order to grant enough access. A plugin that declares seven permissions but calls fifteen endpoints will scan, fail on the eighth, and look broken to the customer.
There is no cap on how many permissions a method may declare. A large provider legitimately needs dozens or hundreds; the only bound is the request size.
Make the list the single source of truth. In a plugin that ships its own IAM
template, generate the template from this list rather than maintaining a second
copy. The AWS plugin does this with go run ./cmd/gen-setup, and a drift test
fails the build if the template and the manifest disagree.
Emit shape¶
A scan emits v3.Batch values. The SDK fills the envelope, so you write only
what you observed:
emit(v3.Batch{
Resources: []v3.Resource{{
CRN: "crn:devi:provider:region:service:kind:account:id",
Properties: json.RawMessage(`{"state":"running"}`),
}},
Relationships: []v3.Relationship{{
SourceCRN: parent, TargetCRN: child, Kind: "Contains",
}},
})
Then close the stream exactly once:
Rules the validator enforces¶
These are checked for every batch, whatever the provider:
- CRN syntax.
crn:devi:<provider>:<region>:<service>:<kind>:<account>:<id>. The<provider>segment is what the UI groups inventory by. - No duplicates. A resource CRN may appear once in the whole stream. The SDK deduplicates nodes for you if you use its collector; do not emit the same CRN from two goroutines.
- No dangling edges. Both ends of a relationship must exist in the stream, in the same batch or an earlier one.
- No non-public data. Properties and evidence are validated against a privacy filter. Do not put account identifiers, tokens or personal data in them.
- Final batch required. A stream that ends without
Final: trueis an error, because the core cannot tell a finished scan from a dropped connection. - Sequence starts at 0. The SDK owns this; only relevant if you send batches through a transport of your own.
Fail well¶
Two rules matter more than any other for how a plugin feels:
Report a partial result instead of failing the scan. If one service is denied
but everything else was read, emit what you have with State: v3.ResultPartial
and a diagnostic. A scan that collected 900 resources and throws them away
because the 901st was forbidden is a bug.
scanErr := scan.Run(ctx, cfg, stream)
if scanErr != nil && stream.emitted == 0 {
return scanErr // nothing was read: this is a real failure
}
final := v3.Batch{Final: true, State: v3.ResultComplete}
if scanErr != nil {
final.State = v3.ResultPartial
final.Diagnostics = []string{"partial inventory: " + scanErr.Error()}
}
Say what went wrong, in one line. The core stores your error, redacted and
bounded, and shows it to the customer. invalid plugin output tells nobody
anything; dynamodb:ListTables denied — grant dynamodb:ListTables does.
Concurrency¶
Collect in parallel where the provider allows it: regional scans and independent services are the usual wins. A serial scan of a large account takes longer than any reasonable timeout, and a scan that times out reads to the customer as a broken product.
- Bound concurrency (AWS uses 4 regions and 6 services at a time).
- Keep your collector mutex-protected, or give each worker its own collector and merge before emitting.
- Honour the context. A cancelled scan must stop making API calls.
Shipping it¶
cs plugin devbuilds, installs and runs it locally.cs plugin publish --version=v1.0.0builds every platform, creates the GitHub release and writesplugin.jsonwith real digests.cs plugin host <name> --runserves it read-only over a tunnel and registers it privately with a workspace.
The hosted server is one call at the end of main:
server := grpc.NewServer(
grpc.MaxRecvMsgSize(64<<10), grpc.MaxSendMsgSize(16<<20), grpc.MaxConcurrentStreams(4),
)
v3.RegisterSource(server, source{}, ed25519.PublicKey(key), runtimePluginName())
err := v3hosted.ServeTLS(ctx, listener, server, cert)
ServeTLS serves native gRPC and gRPC-Web from the same listener and keeps the
stream alive while a slow provider collects, which is what makes a public tunnel
usable.
Versions and connections¶
A connection is bound to your contract, not to one build of your plugin. The contract digest covers your capability set and connection methods but excludes the plugin version and the hosting endpoint, because both change on every release.
What that means in practice:
- Re-hosting, or publishing a patch release, keeps existing connections valid.
- Changing what your plugin can do — a new capability, a new connection method, a changed limit — requires a fresh review, because it is a different promise.
Checklist¶
- Capability declares
role: source,effect: read, and real limits -
SupportsPartialandSupportsCancellationare both true - Every endpoint the scan calls is in the declared permissions
- The setup template is generated from that one list
- Every emitted CRN parses and is unique across the whole stream
- Relationships reference resources that exist
- A denial produces a partial result, not a failed scan
- Errors name the failing call and the permission that would fix it
- Collection is bounded-parallel and honours the context
-
go test ./...covers the collector and the emit path