Build a plugin¶
CloudSlash itself knows no providers. If you can read an API, a database, a cluster, a repository, or a file, you can make it visible in CloudSlash — and the CLI, TUI, and Horizon all see it through the same core graph.
Plugins collect resources and emit typed relationships. They do not mutate
infrastructure: capability effects are limited to read and compute, and
permissions.*.mutate is rejected at manifest validation. That is what makes it
safe to install a plugin someone else wrote.
Pick a path¶
| You want | Use | Runtime |
|---|---|---|
| The fastest local plugin | Script plugin (scan.py) |
Local |
| A local Go provider binary | cs plugin create |
Local, go-plugin |
| Hosted or customer-runner collection | v3 source plugin (Plugin Studio) | gRPC over TLS |
All three feed the same core: canonical resources, arbitrary properties, and typed edges. Core computes findings, verdicts, cost, carbon, and plans.
1. Script plugin (5 minutes)¶
scan.py already inventories real local Docker containers and mounted disks.
Replace discover_resources() with your own read-only inventory:
#!/usr/bin/env python3
import json, os, urllib.request
region = os.environ.get("CLOUDSLASH_REGION", "us-east-1")
token = os.environ["MY_API_TOKEN"]
req = urllib.request.Request(
"https://api.example.com/v1/instances",
headers={"Authorization": f"Bearer {token}"},
)
with urllib.request.urlopen(req, timeout=10) as response:
instances = json.load(response)["items"]
print(json.dumps({
"provider": "example",
"account": "production",
"resources": [
{
"id": item["id"],
"type": "instance",
"service": "compute",
"region": item.get("region", region),
"cost_monthly": item.get("monthly_cost", 0),
"tags": item.get("tags", {}),
}
for item in instances
],
}))
The runtime exports every scan config value as CLOUDSLASH_<KEY>. The script
must print exactly one JSON document and exit 0. id and type are the only
required fields per resource.
# The script runtime is bundled with the CloudSlash release; if building
# from the workspace, build it once from plugins/cloudslash-plugin-script.
make install
cs scan
Core builds the CRN, deduplicates resources, and runs every heuristic, policy, cost, and carbon model over the result.
2. Hosted v3 source plugin¶
This is the contract Horizon and customer runners use. Open Horizon → DEVI DEVI → Plugin Studio, name the plugin, and describe what it should collect. DEVI generates a v3 project and implements the collector; download the ZIP and continue locally.
The scaffold makes the contract explicit:
const PluginName = "cloudslash-plugin-example"
const CapabilityName = "read.inventory"
func Capability() v3.Capability {
return v3.Capability{
Name: CapabilityName, Version: "1.0.0",
Role: v3.Source, Effect: v3.Read,
Environments: []v3.Environment{v3.Hosted, v3.CustomerRunner},
InputSchema: "scan.request/v1", OutputSchema: "scan.batch/v1",
ConnectionMethodIDs: []string{"api-token"},
ReadDataClasses: []string{"resource.observations"},
OutputDataClasses: []string{"resource.observations"},
Limits: v3.Limits{
MaxBatchBytes: 64 << 10, MaxOutputBytes: 4 << 20,
MaxResources: 10000, MaxDurationMS: 30000, MaxConcurrency: 4,
},
SupportsCancellation: true, SupportsPartial: true,
}
}
Implement two methods:
func (s *Source) Preflight(ctx context.Context, req v3.PreflightRequest) (v3.PreflightResult, error) {
// Verify the real read permissions. Never return success just because a
// credential was supplied.
}
func (s *Source) Scan(ctx context.Context, req v3.ScanRequest, emit func(v3.Batch) error) error {
observations := collectFromYourAPI(ctx, req.ConnectionConfig)
return emit(v3.Batch{
Envelope: req.ExpectedEnvelope,
Resources: []v3.Resource{
{CRN: "crn:example:us-east-1:prod:compute:instance:i-123",
Properties: json.RawMessage(`{"state":"running","cpu":3.2}`)},
},
Relationships: []v3.Relationship{
{SourceCRN: "crn:example:us-east-1:prod:compute:instance:i-123",
TargetCRN: "crn:example:us-east-1:prod:network:vpc:vpc-1",
Kind: "AttachedTo"},
},
Final: true, State: v3.ResultComplete,
})
}
The validator enforces:
- Preserve
req.ExpectedEnvelopein every batch; incrementSequence. - Paginate every scope; honour
ctxcancellation. - Never invent resources, evidence, costs, or scope.
- Emit an explicit final state. Use
ResultPartialwhen some scopes were unavailable, and name the coverage actually collected. - If nothing reliable was collected, return an error — an empty complete inventory must never stand in for a permission failure.
- Keep credentials out of properties, diagnostics, errors, and logs.
Test and export:
The --manifest export drives registry metadata. A hosted endpoint must be
publicly trusted gRPC over TLS on port 443, pinned to the release digest, and
approved by the operator. See
Hosted plugins.
Hosted entrypoint¶
A plugin that customers can self-host must ship a cmd/hosted entrypoint. The
CloudSlash CLI builds it from the installed source; the publisher does not hand
customers a compiled server or a URL to paste.
Generate the current template with:
This writes main.go, cmd/hosted/main.go, internal/hosted/hosted.go and
source/source.go, with commented ConnectionMethods() and setup-mode
examples. Add, remove or replace anything for your system; the generated files
are a starter, not a fixed schema.
// cmd/hosted/main.go
var version = "0.0.0" // injected by `cs plugin host` from the manifest version
func main() {
if len(os.Args) == 2 && os.Args[1] == "--manifest" {
// Print plugin_name, version, capabilities, v3_capabilities and
// connection_methods as one JSON document. Horizon registers this.
return
}
// Read HORIZON_PUBLIC_KEY, PLUGIN_NAME, TLS_CERT_FILE, TLS_KEY_FILE and
// LISTEN_ADDR; register the v3 source; then:
// v3hosted.ServeTLS(ctx, listener, grpcServer, cert)
}
Transport: native gRPC and gRPC-Web¶
A hosted source must serve both protocols on the same TLS listener:
import v3hosted "github.com/DrSkyle/cloudslash-plugin-sdk/shared/v3/hosted"
server := grpc.NewServer()
v3.RegisterSource(server, source{}, publicKey, pluginName)
if err := v3hosted.ServeTLS(ctx, listener, server, cert); err != nil {
return err
}
v3hosted.ServeTLS serves gRPC-Web (HTTP/1.1) for HTTP-only edges such as the
Cloudflare quick tunnel and native gRPC (HTTP/2) for a direct customer-runner
ingress. Native gRPC over a Cloudflare public hostname loses its trailers, so
the SaaS client speaks gRPC-Web; the plugin does not need a Cloudflare account,
named tunnel or DNS record.
cs plugin host <source> --run starts the quick tunnel with an isolated HOME
and --protocol http2. The isolated home prevents an existing named-tunnel
certificate from attaching to the trycloudflare hostname, which otherwise makes
every request return 404. See
Hosted plugin transport for the
protocol, framing, deadline and debugging details.
cs plugin host <source> --run injects main.version from the installed
manifest. A publisher bumps version in the manifest once; every local build,
self-hosted release, registry release and connection reports that same version.
Never hardcode a hosted release version such as 1.0.0.
The hosted server receives no ambient cloud credentials from Horizon. It uses
only the encrypted connection configuration stored for the workspace. The
PLUGIN_NAME environment variable is the workspace-namespaced audience Horizon
uses in its signed token; the SDK verifies that token against
HORIZON_PUBLIC_KEY.
The --manifest output must include:
plugin_nameand a semverversion;capabilities(names) andv3_capabilities(the full v3 manifest);connection_methods(the plugin-owned setup contract).
Horizon computes two digests: the manifest digest from name, version, endpoint and capabilities, and the connection-schema digest from the methods. Both are pinned at registration and rechecked at preflight. Changing the version, endpoint, capability schema or connection methods creates a new release.
Connection methods and setup pages¶
The plugin author, not CloudSlash, defines the connection form. A method is data, never executable UI:
{
"id": "api-token",
"title": "Read-only API token",
"instructions": "Create a read-only token in the provider console.",
"permissions": ["inventory:read", "billing:read"],
"read_only": true,
"fields": [
{ "key": "token", "label": "Token", "type": "secret",
"help": "Read-only token", "required": true, "max_length": 512 }
],
"setup_url": "https://plugins.example.com/setup",
"setup_mode": "return"
}
The method can ask for IAM, an SSH key, a database URL, a kubeconfig, an API
token, or anything else. read_only must be true; mutation methods are
rejected. Keep every permission narrow and every field bounded.
A method may declare a setup page:
setup_mode: "return"— the plugin page performs the provider-specific work and returns a finished connection. Horizon creates a one-time 15-minute session and openssetup_urlwithcloudslash_stateandcloudslash_return. The page returns tocloudslash_returnwith?cloudslash_setup=<base64url(JSON)>containingstate,display_name,configurationandgranted_permissions. Horizon validates the session, release digest, method schema and permissions and creates the connection automatically. The customer only approves inside the plugin's page. This is the one-click path; the plugin must implement the return. A plugin that runs setup inside a provider account can instead POST the same payload to/v1/connections/setup-plugin-callbackwith the one-timestate; no browser token is required. The AWS reference implementation uses a CloudFormation stack whose Lambda custom resource calls that endpoint after creating the read-only role. Its setup URL is the CloudFormation review page directly; the template is on S3 because CloudFormation requires an S3TemplateURL.
A setup_url may contain CLOUDSLASH_STATE and CLOUDSLASH_RETURN tokens.
Horizon replaces them with the one-time session and return URL before opening
the page. If neither token is present, Horizon appends cloudslash_state and
cloudslash_return as query parameters. This is how a plugin can send the
customer straight to AWS, GCP, Azure, or any provider page instead of an
intermediate CloudSlash page.
- setup_mode: "console" (or omitted) — the page only opens a provider console.
The customer completes the provider steps and enters the details in the
reviewed field form. Use this until the plugin has a return-capable page.
The capability must list every method in connection_method_ids and include
the matching connection_schema_digest. The customer may record a subset of
the requested permissions; the credential or role still determines actual
access, and preflight must report missing scopes honestly.
Provider setup templates¶
The generated project includes a setup/ directory. It belongs to the plugin,
not CloudSlash. Put the provider artifact there:
- AWS: CloudFormation YAML hosted on a public S3 bucket (CloudFormation only
accepts an S3
TemplateURL); - GCP: Deployment Manager/Terraform in the customer's project;
- Azure: ARM/Bicep in the customer's subscription;
- Kubernetes: YAML/Helm applied in the customer's cluster;
- databases/SaaS: the SQL, role, OAuth app or token page the provider requires.
The customer reviews and creates the resources in their own account, project, cluster or database. CloudSlash opens the setup URL and receives the finished connection through the one-time callback; it does not create resources in the customer's provider. The template is a public artifact; never put credentials or a publisher account role in it.
Version the template path with the plugin version
(aws/2.0.1/cloudslash-readonly-role.yaml) and publish it with the release.
The scaffold's setup/README.md is provider-neutral. The only shipped example
is the optional setup/examples/aws-cloudformation.yaml; delete it for GCP,
Azure, Kubernetes, databases, SaaS, or anything else. CloudSlash core never
reads the setup/ directory; it only opens the plugin's setup_url and
receives the one-time callback.
Versions and updates¶
The manifest version is the single source of truth. The publisher bumps it
(2.0.1 → 2.0.2) and releases. Self-hosted builds inject the new version;
the private listing reports it; Horizon compares it with the public catalog. If
the public catalog is newer, Settings → Hosted plugins shows an update hint
and the exact command:
Re-running replaces the private endpoint and release in place. Installations and audit history remain; connections become invalid until re-verified. Hosted plugins count as installed plugins for the workspace limit, even though they never enter the public catalog.
3. Local Go provider¶
cs plugin create my-provider
cd cloudslash-plugin-my-provider
# implement Scan() in main.go
cs plugin dev # builds and installs into ~/.cloudslash/plugins/
cs scan
Local Go plugins use the go-plugin subprocess contract. They run with your environment and are trusted by the local install; they are not hosted.
The data model¶
Every resource is identified by one CRN:
crn and devi are fixed. source identifies the plugin's system, scope is
the region/zone (may be empty), and a literal colon inside a segment is encoded
as %3A.
- Two plugins that emit the same CRN merge into one node.
AttachedTo,SecuredBy,Contains,Runs,FlowsTo,Grants,Usesexpress relationships.- Properties are free-form. Core reads common signals such as
state,tags,cost_monthly,cpu_utilization_pct, andattached, but never requires a provider-specific schema. - Findings, verdicts, cost, and carbon are computed by core, not the plugin.
Manifest essentials¶
| Field | Meaning |
|---|---|
name, version, type |
Identity; version is semver |
description, author, license |
Listing metadata |
repository |
Public GitHub repository for CLI installs |
capabilities |
What the plugin can do (discovery, network, pricing, ...) |
capability_contracts |
v3: role/effect/environments/connection methods per capability |
connections |
Setup flows rendered before installation |
permissions.<provider>.read |
Exact read actions; rendered by cs permissions |
support_url |
Optional tip link; plugins launch free |
A read capability with effect: read is the only mutation-free contract.
effect: mutate and permissions.*.mutate are rejected by core.
Test before publishing¶
cs plugin verify # manifest, binary, required fields
cs plugin dev # install and run locally
cs scan # real resources?
cs permissions --plugin <name> # least-privilege policy
For hosted collectors, add fixtures for pagination, permission denial, missing evidence, partial failures, cancellation, and cost/telemetry.
Publish¶
Local Go plugin
This cross-compiles binaries, writes real SHA-256 digests, creates the GitHub release, and lists the plugin in the registry. Keep the source public; private repositories require an enterprise arrangement.
Script or hosted v3 plugin
Publish the repository, then submit the release on the registry Publish page
(import the --manifest export for v3). The registry records the version,
capabilities, permissions, and optional TLS endpoint. Publication never approves
a hosted endpoint — the operator approval catalog does that.
What core rejects¶
- Mutation effects or mutation permissions.
- Missing or ambiguous CRNs, or relationships pointing at unknown resources.
- Unbounded batches, a missing final state, or fabricated evidence.
- Capability claims the runtime does not report; the
CapabilityNegotiatorcross-checks the manifest againstGetMetadata. - Signed manifests whose signature fails verification.
That is the deal: you own the read path, core owns the graph, and every surface gets your system for free.