This document describes the architecture of OpenCtl and provides guidance for developing new plugins.
┌─────────────────────────────────────────────────────────────────────────┐
│ openctl CLI │
│ ┌─────────┐ ┌─────────┐ ┌──────────┐ ┌───────────┐ ┌───────────┐ │
│ │ Config │ │ Manifest│ │ Output │ │ Plugin │ │ State │ │
│ │ Loader │ │ Parser │ │Formatter │ │ Discovery │ │ Manager │ │
│ └─────────┘ └─────────┘ └──────────┘ └───────────┘ └───────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
│
stdin/stdout JSON
│
┌───────────────────────────────────┴─────────────────────────────────────┐
│ Plugin (openctl-*) │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌────────────────┐ │
│ │ Handler │ │ Provider │ │ Resource │ │ Dispatch │ │
│ │ Router │ │ Client │ │ Converters │ │ Generator │ │
│ └─────────────┘ └─────────────┘ └─────────────┘ └────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
openctl/
├── cmd/openctl/main.go # CLI entry point
├── internal/
│ ├── cli/ # Cobra commands
│ │ ├── root.go # Root command + globals
│ │ ├── provider.go # Dynamic provider subcommands
│ │ └── actions.go # get/create/delete/apply commands
│ ├── config/
│ │ ├── config.go # Config types + loading
│ │ └── paths.go # ~/.openctl paths
│ ├── manifest/
│ │ └── manifest.go # YAML parsing
│ ├── plugin/
│ │ ├── discovery.go # Find openctl-* binaries
│ │ ├── executor.go # Exec + stdin/stdout communication
│ │ └── dispatcher.go # Cross-plugin dispatch
│ ├── output/
│ │ └── formatter.go # Table/YAML/JSON output
│ ├── state/
│ │ └── manager.go # State persistence
│ └── errors/
│ └── errors.go # Error types
├── pkg/protocol/ # Shared types (for plugin authors)
│ ├── request.go # Request structure
│ ├── response.go # Response + Capabilities + State
│ ├── resource.go # Resource definition
│ └── dispatch.go # Dispatch protocol types
├── plugins/
│ ├── proxmox/ # Proxmox VE plugin
│ │ ├── cmd/openctl-proxmox/
│ │ └── internal/
│ │ ├── handler/ # Request handlers
│ │ ├── client/ # Proxmox API client
│ │ ├── resources/ # VM/Template converters
│ │ └── compute/ # Compute interface impl
│ └── k3s/ # K3s cluster plugin
│ ├── cmd/openctl-k3s/
│ └── internal/
│ ├── handler/ # Request handlers
│ ├── cluster/ # Create/delete logic
│ ├── resources/ # Cluster spec parsing
│ └── ssh/ # SSH client for K3s install
└── test/
└── e2e/ # End-to-end tests
├── harness.go # Test harness with mock plugins
└── cli_test.go # CLI integration tests
OpenCtl uses a JSON-over-stdio protocol to communicate with plugins. This design is inspired by Terraform's plugin system but simplified for our use case.
Plugins are discovered by searching for executables named openctl-<provider> in:
~/.openctl/plugins/(user plugins, highest priority)- Directories in
$PATH
When OpenCtl starts, it queries each plugin for its capabilities:
openctl-proxmox --capabilitiesResponse:
{
"providerName": "proxmox",
"protocolVersion": "1.0",
"resources": [
{
"kind": "VirtualMachine",
"plural": "vms",
"actions": ["get", "list", "create", "delete", "apply"]
},
{
"kind": "Template",
"plural": "templates",
"actions": ["get", "list"]
}
],
"computeProvider": {
"implements": "compute.openctl.io/v1",
"features": ["cloudImage", "cloudInit", "sshKeys"]
},
"supportsDispatch": false
}For operations, OpenCtl sends a JSON request via stdin and reads the response from stdout:
Request Format:
{
"version": "1.0",
"action": "create",
"resourceType": "VirtualMachine",
"resourceName": "web-01",
"manifest": {
"apiVersion": "proxmox.openctl.io/v1",
"kind": "VirtualMachine",
"metadata": {
"name": "web-01",
"labels": {"role": "webserver"}
},
"spec": {
"node": "pve1",
"cpu": {"cores": 4},
"memory": {"size": 8192}
}
},
"config": {
"endpoint": "https://pve.example.com:8006",
"node": "pve1",
"tokenId": "root@pam!openctl",
"tokenSecret": "secret-token",
"defaults": {"storage": "local-lvm"}
},
"continuationToken": "",
"dispatchResults": []
}Success Response:
{
"status": "success",
"resource": {
"apiVersion": "proxmox.openctl.io/v1",
"kind": "VirtualMachine",
"metadata": {"name": "web-01"},
"spec": {},
"status": {"state": "running", "vmid": 100}
},
"message": "VM web-01 created successfully"
}List Response:
{
"status": "success",
"resources": [
{"apiVersion": "...", "kind": "...", "metadata": {}},
{"apiVersion": "...", "kind": "...", "metadata": {}}
]
}Error Response:
{
"status": "error",
"error": {
"code": "NOT_FOUND",
"message": "VM not found",
"details": "VM 'web-01' does not exist on node pve1"
}
}| Action | Description | Request Fields | Response |
|---|---|---|---|
list |
List all resources | resourceType |
resources[] |
get |
Get single resource | resourceType, resourceName |
resource |
create |
Create resource | resourceType, manifest |
resource, message |
delete |
Delete resource | resourceType, resourceName |
message |
apply |
Create or update | resourceType, manifest |
resource, message |
| Code | Description |
|---|---|
NOT_FOUND |
Resource does not exist |
ALREADY_EXISTS |
Resource already exists (for create) |
INVALID_REQUEST |
Invalid request format or parameters |
UNAUTHORIZED |
Authentication failed |
INTERNAL |
Internal plugin error |
Plugins can delegate operations to other plugins using the dispatch protocol. This enables orchestration plugins (like K3s) that compose resources from multiple providers.
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ User │ │ CLI │ │ K3s │ │ Proxmox │
│ │ │ │ │ Plugin │ │ Plugin │
└────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘
│ │ │ │
│ create cluster │ │ │
│───────────────>│ │ │
│ │ request │ │
│ │───────────────>│ │
│ │ │ │
│ │ dispatchReqs │ │
│ │<───────────────│ │
│ │ │ │
│ │ create VM │
│ │────────────────────────────────>│
│ │ │ │
│ │ result │
│ │<────────────────────────────────│
│ │ │ │
│ │ request + │ │
│ │ results │ │
│ │───────────────>│ │
│ │ │ │
│ │ response │ │
│ │<───────────────│ │
│ result │ │ │
│<───────────────│ │ │
When a plugin needs to delegate work, it returns dispatch requests:
{
"status": "success",
"message": "Creating 3 VMs for cluster dev...",
"dispatchRequests": [
{
"id": "vm-dev-cp-0",
"provider": "proxmox",
"action": "create",
"resourceType": "VirtualMachine",
"manifest": {
"apiVersion": "proxmox.openctl.io/v1",
"kind": "VirtualMachine",
"metadata": {"name": "dev-cp-0"},
"spec": {}
},
"waitFor": {
"field": "status.state",
"value": "running",
"timeout": "5m"
}
}
],
"continuation": {
"token": "vms-created"
}
}The CLI executes dispatch requests and calls the plugin again with results:
{
"version": "1.0",
"action": "create",
"resourceType": "Cluster",
"continuationToken": "vms-created",
"dispatchResults": [
{
"id": "vm-dev-cp-0",
"status": "success",
"resource": {
"apiVersion": "proxmox.openctl.io/v1",
"kind": "VirtualMachine",
"metadata": {"name": "dev-cp-0"},
"status": {"state": "running", "vmid": 100, "ip": "192.168.1.50"}
}
}
]
}Dispatch requests can include wait conditions:
{
"waitFor": {
"field": "status.state",
"value": "running",
"timeout": "5m"
}
}The CLI will poll the resource until the condition is met or timeout occurs.
The Cross-Plugin Dispatch section above describes the legacy CLI protocol (continuation tokens,
dispatchRequests). The controller composes resources differently: a composite provider'sPlan()emits child manifests, and the controller's dispatcher applies them. Dependence, value-passing, and ordering all rest on one primitive — the$ref.
A resource declares that it depends on another by embedding a $ref marker
anywhere in its spec:
spec:
joinURLFrom:
$ref: { apiVersion: k3s.openctl.io/v1, kind: K3sNode, name: dev-cp-0, field: status.vmIP }Shape: {"$ref": {apiVersion, kind, name, field?}}. field is an optional
dot-path (status.vmIP, status.nodeToken); omit it to substitute the whole
resource ({apiVersion, kind, metadata, spec, status}). Refs may appear
anywhere in the spec tree — nested maps and arrays are traversed. A single
$ref both declares a dependency edge (this resource needs that one) and
names a value to pull from it.
This is distinct from owner references (openctl.io/owner-kind /
openctl.io/owner-name labels), which express parent→child composition for
lifecycle (block deleting a VM a Cluster owns) and carry no data.
Values flow through the resolver (internal/controller/refs). Every
manifest passes through Dispatcher.ApplyManifest, which — right before calling
the provider's Apply — replaces each $ref with a live value:
- Walk the spec; for each
$ref, callGet(apiVersion, kind, name)on the referenced resource. - Read the
fieldpath off the returned resource'sstatus/spec. - Hand the provider the resolved spec — it never sees a raw
$ref.
A resource "exposes an output" simply by putting it in its status, which
Get returns. A K3sNode exposes status.nodeToken / status.vmIP; a Proxmox
VM exposes status.ip from the guest agent.
Cross-provider is transparent. The resolver's data source is the provider
Registry, and Registry.Get routes by the apiVersion prefix to whichever
provider owns that kind. So a k3s K3sNode referencing a proxmox
VirtualMachine's status.ip is byte-for-byte identical to a same-provider
ref — the resolver never knows or cares they're different providers, and it
reads live provider state via Get (not a cached copy). There is no
separate "outputs" channel between providers.
Two value-flow styles coexist:
- Static bake at plan time. For deterministic values (e.g. a static IP from
AllocateIPs),Plan()writes the value straight into the child's spec. - Dynamic
$refat apply time. For values that only exist after a dependency is applied (a DHCP IP, a join token), the ref is resolved lazily when the dependent is dispatched.
Composite Apply orders its Plan children with a real dependency graph
(operations.RunGraph) — topological execution with cycle detection —
rather than hand-coded phase loops. Edges come from two sources:
$refedges (operations.RefChildEdges): if child B's spec references child A, B depends on A. A K3sNode thus depends on its VM (vmRef) and — for joiners — the first control plane (joinFrom), so the VM is created before the node and the first CP initializes before joiners resolve its token.- Explicit barrier edges for constraints that are not
$refs: e.g. the k3s CA bundle is an aggregation over all K3sNode states that every AgentInstall consumes, so every AgentInstall depends on the bundle task, which depends on all K3sNodes.
Ordering is therefore data-driven: it falls out of the graph and adapts to new kinds/edges automatically. A dependency cycle is a hard error naming the stuck tasks.
Execution is serial by default (one child at a time), which preserves
SSH-install semantics. Set OPENCTL_APPLY_CONCURRENCY=N to apply independent
nodes in parallel (VMs concurrently; joiners concurrently after the first CP).
Scope. This DAG governs ordering within a single composite Apply. The top-level dispatcher still processes separate operations FIFO — cross-op dependency scheduling is future work (see ROADMAP).
Plugins can request the CLI to persist state for tracking complex resources.
Plugins return state updates to save resource state:
{
"status": "success",
"stateUpdate": {
"operation": "save",
"provider": "k3s",
"name": "dev-cluster",
"state": {
"apiVersion": "k3s.openctl.io/v1",
"kind": "Cluster",
"spec": {},
"status": {
"phase": "Ready",
"message": "Cluster is ready",
"outputs": {
"kubeconfigPath": "/home/user/.openctl/k3s/dev-cluster/kubeconfig",
"serverIP": "192.168.1.50"
}
},
"children": [
{"provider": "proxmox", "kind": "VirtualMachine", "name": "dev-cp-0"},
{"provider": "proxmox", "kind": "VirtualMachine", "name": "dev-worker-0"}
]
}
}
}| Operation | Description |
|---|---|
save |
Create or update state |
delete |
Remove state |
State is stored in ~/.openctl/state/<provider>/<name>.yaml:
apiVersion: k3s.openctl.io/v1
kind: Cluster
spec:
compute:
provider: proxmox
status:
phase: Ready
message: Cluster is ready
outputs:
kubeconfigPath: /home/user/.openctl/k3s/dev-cluster/kubeconfig
children:
- provider: proxmox
kind: VirtualMachine
name: dev-cp-0State can track child resources for cascading operations (e.g., delete cluster → delete VMs):
{
"children": [
{"provider": "proxmox", "kind": "VirtualMachine", "name": "dev-cp-0"},
{"provider": "proxmox", "kind": "VirtualMachine", "name": "dev-worker-0"}
]
}mkdir -p plugins/myprovider/cmd/openctl-myprovider
mkdir -p plugins/myprovider/internal/{handler,client,resources}Create plugins/myprovider/go.mod:
module github.com/openctl/openctl-myprovider
go 1.21
require github.com/openctl/openctl v0.0.0
replace github.com/openctl/openctl => ../..Create plugins/myprovider/cmd/openctl-myprovider/main.go:
package main
import (
"encoding/json"
"os"
"github.com/openctl/openctl-myprovider/internal/handler"
"github.com/openctl/openctl/pkg/protocol"
)
func main() {
// Handle capabilities request
if len(os.Args) > 1 && os.Args[1] == "--capabilities" {
printCapabilities()
return
}
// Handle normal request
if err := handleRequest(); err != nil {
writeError(err)
os.Exit(1)
}
}
func printCapabilities() {
caps := protocol.Capabilities{
ProviderName: "myprovider",
ProtocolVersion: protocol.ProtocolVersion,
Resources: []protocol.ResourceDefinition{
{
Kind: "MyResource",
Plural: "myresources",
Actions: []string{"get", "list", "create", "delete", "apply"},
},
},
}
json.NewEncoder(os.Stdout).Encode(caps)
}
func handleRequest() error {
var req protocol.Request
if err := json.NewDecoder(os.Stdin).Decode(&req); err != nil {
return err
}
h := handler.New(&req.Config)
resp, err := h.Handle(&req)
if err != nil {
return err
}
return json.NewEncoder(os.Stdout).Encode(resp)
}
func writeError(err error) {
resp := protocol.Response{
Status: protocol.StatusError,
Error: &protocol.Error{
Code: protocol.ErrorCodeInternal,
Message: err.Error(),
},
}
json.NewEncoder(os.Stdout).Encode(resp)
}Create plugins/myprovider/internal/handler/handler.go:
package handler
import (
"fmt"
"github.com/openctl/openctl-myprovider/internal/client"
"github.com/openctl/openctl/pkg/protocol"
)
type Handler struct {
config *protocol.ProviderConfig
client *client.Client
}
func New(config *protocol.ProviderConfig) *Handler {
return &Handler{
config: config,
client: client.New(config.Endpoint, config.TokenID, config.TokenSecret),
}
}
func (h *Handler) Handle(req *protocol.Request) (*protocol.Response, error) {
switch req.ResourceType {
case "MyResource":
return h.handleMyResource(req)
default:
return &protocol.Response{
Status: protocol.StatusError,
Error: &protocol.Error{
Code: protocol.ErrorCodeInvalidRequest,
Message: fmt.Sprintf("unknown resource type: %s", req.ResourceType),
},
}, nil
}
}
func (h *Handler) handleMyResource(req *protocol.Request) (*protocol.Response, error) {
switch req.Action {
case protocol.ActionList:
return h.listResources()
case protocol.ActionGet:
return h.getResource(req.ResourceName)
case protocol.ActionCreate:
return h.createResource(req.Manifest)
case protocol.ActionDelete:
return h.deleteResource(req.ResourceName)
case protocol.ActionApply:
return h.applyResource(req.Manifest)
default:
return &protocol.Response{
Status: protocol.StatusError,
Error: &protocol.Error{
Code: protocol.ErrorCodeInvalidRequest,
Message: fmt.Sprintf("unknown action: %s", req.Action),
},
}, nil
}
}Add to Makefile:
build-plugin-myprovider:
cd plugins/myprovider && go build -o ../../bin/openctl-myprovider ./cmd/openctl-myprovider
install-plugin-myprovider: build-plugin-myprovider
mkdir -p ~/.openctl/plugins
cp bin/openctl-myprovider ~/.openctl/plugins/# Test capabilities
./bin/openctl-myprovider --capabilities
# Test with openctl
openctl plugin list
openctl myprovider get myresourcesResources follow a Kubernetes-style format:
apiVersion: <provider>.openctl.io/v1
kind: <ResourceKind>
metadata:
name: <resource-name>
namespace: <optional-namespace>
labels:
key: value
annotations:
key: value
spec:
# Resource-specific configuration
status:
# Resource status (read-only, populated by provider)The apiVersion should follow the format: <provider>.openctl.io/<version>
Examples:
proxmox.openctl.io/v1k3s.openctl.io/v1aws.openctl.io/v1beta1
This allows OpenCtl to auto-detect the provider when using openctl apply -f manifest.yaml.
The ProviderConfig passed to plugins contains:
type ProviderConfig struct {
Endpoint string // API endpoint URL
Node string // Optional: default node/region
TokenID string // Authentication token ID
TokenSecret string // Authentication token secret
Defaults map[string]string // Provider-specific defaults
}Users configure providers in ~/.openctl/config.yaml:
providers:
myprovider:
default-context: production
contexts:
production:
endpoint: https://api.example.com
credentials: prod-creds
staging:
endpoint: https://staging.example.com
credentials: staging-creds
credentials:
prod-creds:
tokenId: my-token
tokenSecretFile: ~/.openctl/secrets/prod.token
defaults:
region: us-east-1Test handlers without network calls using HTTP mocking:
func TestHandler_List(t *testing.T) {
// Create mock server
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(mockResponse)
}))
defer server.Close()
h := New(&protocol.ProviderConfig{
Endpoint: server.URL,
})
req := &protocol.Request{
Version: protocol.ProtocolVersion,
Action: protocol.ActionList,
ResourceType: "MyResource",
}
resp, err := h.Handle(req)
// Assert...
}Use the test harness to test full CLI flows:
func TestPlugin_ListResources(t *testing.T) {
h := NewHarness(t)
defer h.Cleanup()
h.InstallMockPlugin("mock", &MockPluginResponse{
Capabilities: &protocol.Capabilities{
ProviderName: "mock",
Resources: []protocol.ResourceDefinition{
{Kind: "MyResource", Plural: "myresources", Actions: []string{"list"}},
},
},
Responses: map[string]*protocol.Response{
"list:MyResource:": {
Status: protocol.StatusSuccess,
Resources: []*protocol.Resource{
{Metadata: protocol.ResourceMetadata{Name: "test"}},
},
},
},
})
result := h.Run("mock", "get", "myresources")
result.AssertSuccess(t)
result.AssertOutputContains(t, "test")
}- Error Handling: Return protocol errors for expected failures, Go errors for unexpected ones
- Timeouts: Respect the timeout passed in the request config
- Idempotency: Make
createandapplyoperations idempotent when possible - Status: Populate the
statusfield with runtime information (state, IDs, IPs, etc.) - Logging: Write debug logs to stderr (stdout is reserved for protocol)
- Validation: Validate manifests early and return clear error messages
- Testing: Write unit tests with HTTP mocking; avoid network calls in tests
The K3s plugin ships a per-node agent (openctl-k3s-agent) installed on every cluster node at create time. It exists so post-create operations can do host-level things the Kubernetes API can't see (systemd state, journald, k3s binary lifecycle) without paying SSH-handshake cost on every command and without depending on a healthy API server.
- The agent only does what kubectl can't. If a healthy kubectl from the user's laptop could do it, the plugin calls the Kubernetes API directly — the agent does not expose passthrough endpoints (no
GET /pods, no exec, no kubeconfig proxy). - Stateless. No caching of cluster state. Every request live-reads from systemd/proc/the local k3s API.
- Per-node, not in-cluster. A pod-based service is unreachable exactly when out-of-band tooling is most needed. Per-node systemd/OpenRC units survive a broken k3s.
- mTLS, per-cluster CA. A CA is generated at
createtime. Each node gets its own server certificate; the controller (the laptop runningopenctl) holds a single client certificate for the cluster. Cert material lives under~/.openctl/state/k3s/<cluster>/. - Bootstrap stays SSH. SSH installs k3s and drops the agent. Everything post-install goes through the agent.
┌──────────────────┐ ┌─────────────────────────────────────┐
│ openctl k3s │ │ k3s node │
│ (plugin) │ │ ┌───────────────────────────────┐ │
│ │ mTLS HTTPS │ │ openctl-k3s-agent (systemd or │ │
│ • client cert │ ───────────────>│ │ OpenRC service, :9443) │ │
│ • per-cluster │ │ │ │ │
│ CA │ │ │ /v1/info │ │
│ • node endpoint │ │ │ /v1/logs/k3s │ │
│ map (state) │ │ │ /v1/service/k3s/{start,...} │ │
│ │ │ └───────────────────────────────┘ │
│ │ │ │
│ │ k8s API (in- │ ┌───────────────────────────────┐ │
│ • kubeconfig │ ───────────────>│ │ k3s (server or agent) │ │
│ │ band; agent │ │ │ │
│ │ not involved) │ └───────────────────────────────┘ │
└──────────────────┘ └─────────────────────────────────────┘
Nodes may run different distros, init systems, and architectures. The agent absorbs these differences so the plugin doesn't have to:
| Concern | Strategy |
|---|---|
| CPU architecture | Static Go binary, multi-arch builds (linux/amd64, linux/arm64, linux/arm). SSH bootstrap picks the right artifact via uname -m. |
| Init system | Detected at SSH bootstrap: probe systemctl --version, fall back to rc-service --version (OpenRC). Fail clearly if neither. Two unit-file templates. |
| Logs | Agent's /v1/logs/k3s chooses journalctl -u k3s or tails /var/log/k3s.log based on its own startup detection. Plugin doesn't know or care. |
| Service control | Agent's /v1/service/k3s/* runs systemctl ... or rc-service ... based on detected init. |
| k3s file paths | /var/lib/rancher/k3s/ and /etc/rancher/k3s/ are k3s-managed and consistent across distros — assumed by the agent. |
The agent reports its detected environment in /v1/info (os, arch, init, capability flags). The plugin uses these for operator-facing diagnostics, not for routing — routing is the agent's responsibility.
create runs SSH-based steps in this order:
- Existing: install k3s on the first control plane, then additional CPs and workers.
- New: generate the per-cluster CA (if not already cached), generate per-node server cert + key, generate the controller's client cert + key (once per cluster).
- New: for each node, detect init system, upload
openctl-k3s-agent, server cert, server key, and CA cert to/etc/openctl-k3s-agent/. Drop the unit file. Enable and start. - New: poll each node's
/v1/infountil reachable (with a bounded timeout). Mark clusterReadyonly after every agent responds.
Cert material on the node lives at /etc/openctl-k3s-agent/{ca.pem,server.pem,server.key} (mode 0600, owned by root).
status:
agent:
caPath: ~/.openctl/state/k3s/dev/ca.pem
clientCertPath: ~/.openctl/state/k3s/dev/client.pem
clientKeyPath: ~/.openctl/state/k3s/dev/client.key
port: 9443
endpoints:
dev-cp-0: 192.168.1.50
dev-worker-0: 192.168.1.51| Endpoint | Purpose |
|---|---|
GET /v1/info |
Host facts, agent version, init system, k3s service status, supported capabilities |
GET /v1/logs/k3s?lines=N |
Recent k3s logs (abstracted over journald/files) |
POST /v1/service/k3s/{start,stop,restart} |
Init-system-agnostic service control |
Explicitly out of scope until proven needed: pod listing, exec, file uploads, binary upgrades, cert rotation. These can be added later as the agent only grows where the kubectl-equivalent path can't reach.
The agent reports its version in /v1/info. If it differs from what the plugin was built against, the plugin prints a warning to stderr and proceeds with the call. There is no hard refusal — operators upgrading a fleet often have nodes at mixed versions for short windows.
- A kubectl substitute. If a feature request can be satisfied by
kubectl <verb>from the user's laptop, the plugin should do that, not add an endpoint. - A stateful daemon. No background polling, no caching, no reconciliation loops. Each request is a fresh read.
- A general-purpose remote shell. Endpoints are narrow, named, and audit-friendly — not
POST /v1/exec.
- Progress streaming for long-running operations
- Watch/subscribe for resource changes
- Plugin versioning and compatibility checking
- Plugin marketplace/registry
- gRPC transport option for performance
- Automatic retry with backoff for transient failures (implemented in dispatcher)
- Additional compute providers (AWS, Azure, GCP)
- [~] K3s cluster upgrades — per-node binary-swap shipped (
openctl k3s upgrade --node --to); cluster-wide rolling upgrade (drain/cordon) remains - Certificate rotation for K3s clusters (agent + new endpoint)
- Plugin-defined CLI subcommands (generic protocol + CLI surface,
plus the k3s
logs/restart/upgradehandlers — see below)
Status: shipped. The generic CLI capability layer landed first
(protocol.Capabilities.Subcommands, protocol.Request.Args, and
internal/cli/provider.go registering plugin-defined Cobra commands alongside
get/create/delete/apply), and the k3s plugin now advertises and
implements the first two agent-backed subcommands:
openctl k3s logs <cluster> [--node <name>] [--lines N]— fetches the k3s journal from a node's agent. Single-node clusters pick the node automatically; multi-node clusters require--node.openctl k3s restart <cluster> --node <name>— restarts the k3s service on a node via its agent.openctl k3s upgrade <cluster> --node <name> --to <version>— swaps the k3s binary on a node to the target release (downloaded + sha256-verified by the agent) and restarts. Per-node by design; cluster-wide rolling upgrades (drain/cordon ordering) are the follow-up.
Handler dispatch lives in pkg/k3s/handler/handler.go (handleLogs,
handleRestart, handleUpgrade), which load the cluster state, reuse
extractAgentProbeConfig to locate the agent bundle, build a per-node
agentclient.Client via client.NewFromProbeOptions, and call the typed
Logs/RestartK3s/UpgradeK3s methods. Subcommand requests arrive with an
agent Action and no ResourceType, so Handle routes on the action name
before the resource-kind switch.
The upgrade endpoint (POST /v1/upgrade/k3s, pkg/k3s/agent/upgrade.go)
is a binary swap: the agent downloads the target k3s release for its arch,
verifies the published sha256sum-<arch>.txt, atomically renames the new
binary over the installed one, and restarts the service. The target version is
regex-validated (vX.Y.Z+k3sN) before it is ever placed in a download URL —
the sole injection guard. The download can outlast the server's 30s
WriteTimeout, so the handler extends its own write deadline via
http.NewResponseController. runUpgrade takes injected fetch/restart
seams so the swap orchestration is unit-tested without real downloads or root.
Original design (option 3 from the rollout discussion): extend protocol.Capabilities with a list of plugin-defined subcommands, and have internal/cli/provider.go register them as cobra commands alongside get/create/delete/apply.
Implemented approach (option 3 from the rollout discussion): extend protocol.Capabilities with a list of plugin-defined subcommands, and have internal/cli/provider.go register them as cobra commands alongside get/create/delete/apply.
// pkg/protocol/response.go
type Capabilities struct {
// ... existing fields ...
Subcommands []SubcommandDefinition `json:"subcommands,omitempty"`
}
type SubcommandDefinition struct {
Name string `json:"name"` // e.g. "logs"
Short string `json:"short"` // one-line help
Long string `json:"long,omitempty"`
Action string `json:"action"` // value sent in Request.Action
PositionalArgs []ArgSpec `json:"positionalArgs,omitempty"` // e.g. [{Name:"cluster", Required:true}]
Flags []FlagSpec `json:"flags,omitempty"`
}
type FlagSpec struct {
Name string `json:"name"` // long form, e.g. "node"
Short string `json:"short,omitempty"` // single-char, e.g. "n"
Type string `json:"type"` // "string" | "int" | "bool"
Default string `json:"default,omitempty"`
Required bool `json:"required,omitempty"`
Help string `json:"help,omitempty"`
}CLI side: in internal/cli/provider.go, after registering the standard commands, iterate caps.Subcommands and register a cobra command per entry. The command's RunE builds a protocol.Request with Action: subcmd.Action, packs positional args + flag values into Request.Args map[string]any, and dispatches via the existing executor. Structured responses go through the existing formatter; message-only responses print the message.
Plugin side (implemented): the k3s plugin's handler.Handle dispatches new
action names ("logs", "restart", "upgrade") that:
- Load the cluster's saved state file (
loadClusterStatus). - Pull the agent block from
status.outputs.agent(extractAgentProbeConfig). - Build an
agentclient.Clientfor the selected node (agentClientForNode→client.NewFromProbeOptions). - Call the typed method (
c.Logs(ctx, lines)/c.RestartK3s(ctx)) and return the result as aMessage.
Follow-ups still open:
- Cluster-wide rolling upgrade —
upgradeis per-node today; a rolling variant needs drain/cordon ordering (control plane first, one worker at a time), which the agent can't do alone (no kubectl access). - Streaming logs — the current path buffers the whole body; large journals could stream (chunked transfer + line-by-line print) later.
- k3s cluster upgrade rollback — the binary swap has no automatic rollback if the restarted k3s fails health; the previous binary is not retained.
Design considerations addressed:
- Authentication carryover — subcommands inherit
--contextvia the same dispatch path (provider config is resolved fromcontextName). - Sub-resource help in
openctl k3s --help— cobra registers cleanly from the advertisedShort/Long. - Error response format — agent text bodies (e.g.
/v1/logs/k3s) are wrapped into a structuredprotocol.Errorby the handler.