Skip to content

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.

  1. A capability — its name, version, role (source), effect (read), the schemas it speaks, and its limits.
  2. Connection methods — how a customer grants read access: the fields they fill in, and every permission the plugin will use.
  3. Preflight — prove the credential works and report what it can reach.
  4. 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.

permissions:
  provider:
    read:
      - service:ListResources
      - service:DescribeResource

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:

return emit(v3.Batch{Final: true, State: v3.ResultPartial})

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: true is 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

  1. cs plugin dev builds, installs and runs it locally.
  2. cs plugin publish --version=v1.0.0 builds every platform, creates the GitHub release and writes plugin.json with real digests.
  3. cs plugin host <name> --run serves 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
  • SupportsPartial and SupportsCancellation are 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