diff --git a/docs/developer/plugins.md b/docs/developer/plugins.md new file mode 100644 index 0000000..7dba003 --- /dev/null +++ b/docs/developer/plugins.md @@ -0,0 +1,277 @@ +--- +title: "Plugin System Design" +sidebar: + order: 6 +--- + +# datumctl Plugin System + +This document covers the architectural design for the datumctl plugin system. +It is intended as a reference for contributors building the plugin infrastructure +and for teams authoring first-party plugins. + +--- + +## Goals + +- Let domain teams (compute, networking, billing, audit) ship CLI extensions + independently without modifying core `datumctl`. +- Establish a stable contract that survives datumctl version upgrades. +- Keep the security blast radius of a third-party plugin small. +- Give plugin authors a clear, low-friction path to ship. + +--- + +## How plugins work + +A plugin is any executable named `datumctl-` that is either: + +1. **Managed** — installed via `datumctl plugin install` into + `~/.datumctl/plugins/` and recorded in `plugins.json`. +2. **Unmanaged** — placed on the user's `PATH` by any other means. + +When a user runs `datumctl `, datumctl resolves the command using this +precedence order: + +1. **Built-in commands** — always win. A plugin named `datumctl-get` or + `datumctl-login` will never shadow a built-in. `datumctl plugin install` + rejects any plugin whose name collides with a built-in at install time. +2. **Managed plugins** — binaries in `~/.datumctl/plugins/`. +3. **Unmanaged plugins** — binaries named `datumctl-` found on `PATH`. + +If a matching plugin is found (steps 2–3), datumctl execs it, passing through +all remaining arguments. + +Unmanaged plugins trigger a one-time warning: + +``` +warning: 'datumctl-compute' is not a managed plugin and has not been verified. +``` + +--- + +## Directory layout + +``` +~/.datumctl/ + config # CLI configuration + credentials.json # stored credentials + plugins/ # managed plugin binaries + plugins/plugins.json # install record (see below) + plugins/plugin-index.json # cached plugin index +``` + +datumctl searches `plugins/` before `PATH` so managed and unmanaged plugins +are always distinguishable. + +--- + +## Installation + +### From the curated index + +The curated plugin index lives at +[datum-cloud/datumctl-plugins](https://github.com/datum-cloud/datumctl-plugins). +Plugins listed there can be installed by name: + +```sh +datumctl plugin install compute +datumctl plugin search +datumctl plugin list +datumctl plugin upgrade compute +datumctl plugin remove compute +``` + +The index is cached locally at `~/.datumctl/plugins/plugin-index.json` +and refreshed automatically when stale (default TTL: 1 hour). Override the +index URL with `DATUMCTL_PLUGIN_INDEX_URL` for testing. + +### From a GitHub Release + +Any plugin can be installed directly from a GitHub Release without being listed +in the curated index: + +```sh +datumctl plugin install datum-cloud/datumctl-compute # latest release +datumctl plugin install datum-cloud/datumctl-compute@v1.2.0 # specific version +``` + +This path requires a `checksums.txt` file alongside the release archives in +goreleaser's default two-column format. + +### Restoring all plugins + +Running `datumctl plugin install` with no arguments restores all plugins +recorded in `plugins.json` — useful for reproducing a plugin set on a new +machine. + +--- + +## plugins.json schema + +`plugins.json` records every managed install. It is the source of truth for +`plugin list`, `plugin upgrade`, and `plugin remove`. + +```json +{ + "plugins": { + "compute": { + "source": "compute", + "version": "v0.8.0", + "sha256": "abc123...", + "installed_at": "2026-05-26T00:00:00Z", + "manifest": { + "name": "compute", + "version": "v0.8.0", + "description": "Deploy and manage containerized workloads on Datum Cloud", + "api_version": 1 + } + } + } +} +``` + +`source` is either the short index name (e.g. `compute`) or a +`github.com/owner/repo` path for direct GitHub installs. + +--- + +## Plugin manifest + +Every plugin binary must respond to `--plugin-manifest` with a JSON document +on stdout: + +```json +{ + "name": "compute", + "version": "v0.8.0", + "description": "Deploy and manage containerized workloads on Datum Cloud", + "min_datumctl_version": "v0.10.0", + "api_version": 1, + "min_api_version": 1 +} +``` + +datumctl reads this manifest at install time to validate compatibility. If a +plugin does not respond to `--plugin-manifest`, datumctl treats it as +unversioned and skips compatibility checks. + +--- + +## Context passthrough + +datumctl sets the following environment variables before execing a plugin: + +| Variable | Value | +|----------------------------|----------------------------------------------------| +| `DATUM_ORG` | Current organization slug | +| `DATUM_PROJECT` | Current project slug (may be empty) | +| `DATUM_API_HOST` | API base URL (e.g. `api.datum.net`) | +| `DATUM_PLUGIN_API_VERSION` | Integer API version (currently `1`) | +| `DATUM_CREDENTIALS_HELPER` | Absolute path to the datumctl binary | +| `DATUM_SESSION` | Active session name (may be empty) | + +**Tokens are not passed as environment variables.** Plugins fetch a token on +demand via the credentials helper: + +```sh +$DATUM_CREDENTIALS_HELPER auth get-token --session $DATUM_SESSION +``` + +Omit `--session` when `DATUM_SESSION` is empty. The Go SDK's `plugin.Token()` +handles this automatically. + +### Why not `DATUM_TOKEN`? + +Passing a raw token in an environment variable freezes the auth mechanism — +every plugin that reads `DATUM_TOKEN` directly must be updated if tokens become +shorter-lived, audience-scoped, or replaced by a different credential type. +The credentials helper insulates plugins from these changes entirely. + +--- + +## `DATUM_PLUGIN_API_VERSION` + +This integer increments only when the plugin contract (env var names, manifest +schema, credentials helper interface) changes in a breaking way. Plugin authors +check this value if they need to handle multiple datumctl generations. It is +independent of datumctl's own semver version. + +Current version: **1** + +--- + +## Go SDK + +First-party and community plugins written in Go should use: + +``` +go.datum.net/datumctl/plugin +``` + +The SDK provides: + +- `plugin.Context()` — reads all `DATUM_*` env vars into a typed struct. +- `plugin.Token()` — calls the credentials helper and returns a token string. +- `plugin.NewRootCmd(name, short)` — returns a pre-configured `*cobra.Command` + with `--org`, `--project`, and `--output` flags wired to the injected context. +- `plugin.ServeManifest(m)` — handles `--plugin-manifest` and exits before + Cobra runs. + +See `examples/plugin-dns/` for a working reference implementation. + +Plugins written in other languages can implement the same contract manually — +the protocol is just environment variables and a subprocess call. + +--- + +## Security model + +| Plugin type | Token access | Verification | +|-------------|--------------|--------------| +| Managed (index) | On demand via helper | SHA256 verified against index manifest | +| Managed (GitHub) | On demand via helper | SHA256 verified against `checksums.txt` | +| Unmanaged | On demand via helper | None — user warning shown | + +Because tokens are fetched on demand rather than injected at startup, a plugin +process that exfiltrates its environment variables does not automatically +capture a usable credential. A determined attacker can still call the helper, +but this raises the bar meaningfully over raw env var injection. + +Future: audience-scoped tokens (e.g., `datumctl auth get-token +--audience=dns.datum.net`) will let datumctl issue tokens that are only valid +for a specific plugin's API surface. + +--- + +## Compatibility and versioning + +Plugin authors declare a minimum datumctl version in their manifest. datumctl +validates this at install time and warns (but does not block) at invocation if +the running version is below the declared minimum. + +datumctl guarantees that `DATUM_PLUGIN_API_VERSION=1` env vars and the +credentials helper interface are stable for the lifetime of API version 1. +Breaking changes increment the version and are announced in release notes with +a migration guide. + +--- + +## V1 scope + +| Component | Status | +|-----------|--------| +| PATH shim (`datumctl `) | V1 | +| Managed install dir + `plugins.json` | V1 | +| `datumctl plugin install/list/upgrade/remove` | V1 | +| Curated plugin index (`datum-cloud/datumctl-plugins`) | V1 | +| Direct GitHub Release install (`owner/repo[@version]`) | V1 | +| ENV context passthrough | V1 | +| Credentials helper (`datumctl auth get-token`) | V1 | +| Plugin manifest (`--plugin-manifest`) | V1 | +| Go SDK (`go.datum.net/datumctl/plugin`) | V1 | +| Reference first-party plugin (`compute`) | V1 | +| TUI panel extension points | V2 | +| MCP tool registration | V2 | +| `datumctl plugin new` scaffolding | V2 | +| Audience-scoped tokens | V2 | diff --git a/examples/plugin-dns/README.md b/examples/plugin-dns/README.md new file mode 100644 index 0000000..82f5b62 --- /dev/null +++ b/examples/plugin-dns/README.md @@ -0,0 +1,190 @@ +# datumctl-dns — Reference Plugin Example + +This directory contains a reference implementation of a datumctl plugin using +the Go SDK (`go.datum.net/datumctl/plugin`). It demonstrates the complete +end-to-end integration: manifest protocol, context injection, and token +acquisition. + +## Quick Start for Plugin Authors + +### 1. Add the SDK dependency + +```sh +go get go.datum.net/datumctl/plugin +``` + +The SDK lives inside the main `datumctl` module. You get context reading, +credential helper invocation, and pre-wired Cobra flags in a single dependency +with no `internal/` exposure. + +### 2. Declare your manifest and call ServeManifest + +Call `plugin.ServeManifest(m)` at the very top of `main()`, before Cobra runs: + +```go +var m = plugin.Manifest{ + Name: "dns", + Version: "v0.1.0", + Description: "Manage Datum Cloud DNS zones", + APIVersion: 1, + MinAPIVersion: 1, +} + +func main() { + plugin.ServeManifest(m) // handles --plugin-manifest and exits + // ... +} +``` + +`ServeManifest` scans `os.Args` for `--plugin-manifest`. When found, it prints +the manifest as JSON to stdout and exits 0. This must run before `cobra.Execute()` +so it works even when other flags would fail parsing. + +### 3. Use NewRootCmd for pre-wired flags + +```go +root := plugin.NewRootCmd("dns", "Manage Datum Cloud DNS resources") +``` + +This gives you `--org`, `--project`, and `--output` flags pre-populated from +the `DATUM_ORG`, `DATUM_PROJECT`, and default values injected by datumctl. + +### 4. Read context and fetch tokens + +```go +ctx := plugin.Context() // reads all DATUM_* env vars +token, err := plugin.Token() // calls $DATUM_CREDENTIALS_HELPER auth get-token +``` + +`Token()` automatically passes `--session $DATUM_SESSION` when a session is +active, and omits it when `DATUM_SESSION` is empty. Call `Token()` immediately +before each API request — tokens are short-lived. + +### 5. Build and test locally + +```sh +# Build the plugin binary +go build -o datumctl-dns . + +# Place it on your PATH or in your managed plugins dir +cp datumctl-dns ~/.datumctl/plugins/ + +# Run it via datumctl (context and credentials are injected automatically) +datumctl dns zones list + +# Test the manifest protocol +./datumctl-dns --plugin-manifest +``` + +## Distributing Your Plugin + +There are two distribution paths: the **curated index** (recommended for +first-party plugins) and **direct GitHub install** (for third-party plugins). + +### Curated index (datumctl plugin install dns) + +The curated index lives at +[datum-cloud/datumctl-plugins](https://github.com/datum-cloud/datumctl-plugins). +When your plugin is listed there, users install it with just a name: + +```sh +datumctl plugin install dns +``` + +To submit a plugin: + +1. Add the `datumctl-plugin` topic to your GitHub repository. +2. Open a PR adding `plugins/.yaml` to the index repo, + following the [schema](https://github.com/datum-cloud/datumctl-plugins/blob/main/schema/plugin-v1alpha1.json): + +```yaml +apiVersion: datumctl.datum.net/v1alpha1 +kind: Plugin +metadata: + name: dns +spec: + shortDescription: Manage Datum Cloud DNS zones + homepage: https://github.com/datum-cloud/datumctl-dns + version: v1.0.0 + platforms: + - selector: + matchLabels: + os: linux + arch: amd64 + uri: https://github.com/datum-cloud/datumctl-dns/releases/download/v1.0.0/datumctl-dns_Linux_x86_64.tar.gz + sha256: + - selector: + matchLabels: + os: darwin + arch: arm64 + uri: https://github.com/datum-cloud/datumctl-dns/releases/download/v1.0.0/datumctl-dns_Darwin_arm64.tar.gz + sha256: +``` + +The binary inside each archive must be named `datumctl-` (or +`datumctl-.exe` on Windows). + +### Direct GitHub install (datumctl plugin install owner/repo) + +Third-party plugins can be installed directly from any GitHub Release without +being listed in the curated index: + +```sh +datumctl plugin install datum-cloud/datumctl-dns # latest release +datumctl plugin install datum-cloud/datumctl-dns@v1.0.0 # specific version +``` + +This path requires a `checksums.txt` file alongside the release archives, in +goreleaser's default two-column format: + +``` +abc123... datumctl-dns_Linux_x86_64.tar.gz +def456... datumctl-dns_Darwin_arm64.tar.gz +``` + +Use goreleaser with the following asset naming convention: + +| GOOS | GOARCH | Filename | +|---------|--------|---------------------------------------| +| linux | amd64 | `datumctl-dns_Linux_x86_64.tar.gz` | +| linux | arm64 | `datumctl-dns_Linux_arm64.tar.gz` | +| darwin | amd64 | `datumctl-dns_Darwin_x86_64.tar.gz` | +| darwin | arm64 | `datumctl-dns_Darwin_arm64.tar.gz` | +| windows | amd64 | `datumctl-dns_Windows_x86_64.zip` | + +## Environment Variables Injected by datumctl + +datumctl sets these variables before exec-replacing with the plugin binary: + +| Variable | Description | +|----------------------------|----------------------------------------------------| +| `DATUM_ORG` | Current organization slug | +| `DATUM_PROJECT` | Current project slug (empty if not set) | +| `DATUM_API_HOST` | API base URL (e.g. `api.datum.net`) | +| `DATUM_PLUGIN_API_VERSION` | Integer API version declared by this host | +| `DATUM_CREDENTIALS_HELPER` | Absolute path to the datumctl binary | +| `DATUM_SESSION` | Active session name (may be empty) | + +The Go SDK's `plugin.Context()` reads all of these automatically. + +## Credentials Helper Protocol + +Plugins fetch tokens by running: + +```sh +$DATUM_CREDENTIALS_HELPER auth get-token --session $DATUM_SESSION +``` + +When `DATUM_SESSION` is empty, omit the `--session` flag. The Go SDK's +`plugin.Token()` handles this automatically. + +## Shell Completion + +datumctl forwards `__complete` invocations to the plugin binary, so your +Cobra command's built-in completion works transparently when users run: + +```sh +datumctl dns +``` + +No special configuration is needed — this is automatic. diff --git a/examples/plugin-dns/go.mod b/examples/plugin-dns/go.mod new file mode 100644 index 0000000..90b65c3 --- /dev/null +++ b/examples/plugin-dns/go.mod @@ -0,0 +1,15 @@ +module github.com/datum-cloud/datumctl-examples/plugin-dns + +go 1.25.8 + +require ( + github.com/spf13/cobra v1.10.2 + go.datum.net/datumctl v0.0.0 +) + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect +) + +replace go.datum.net/datumctl => ../.. diff --git a/examples/plugin-dns/go.sum b/examples/plugin-dns/go.sum new file mode 100644 index 0000000..ef5d78d --- /dev/null +++ b/examples/plugin-dns/go.sum @@ -0,0 +1,11 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/examples/plugin-dns/main.go b/examples/plugin-dns/main.go new file mode 100644 index 0000000..f2ef240 --- /dev/null +++ b/examples/plugin-dns/main.go @@ -0,0 +1,81 @@ +package main + +import ( + "fmt" + "net/http" + "os" + + "github.com/spf13/cobra" + "go.datum.net/datumctl/plugin" +) + +var m = plugin.Manifest{ + Name: "dns", + Version: "v0.1.0", + Description: "Manage Datum Cloud DNS zones (reference plugin example)", + APIVersion: 1, + MinAPIVersion: 1, +} + +func main() { + // ServeManifest handles --plugin-manifest and exits before cobra runs. + plugin.ServeManifest(m) + + root := plugin.NewRootCmd("dns", "Manage Datum Cloud DNS resources") + + zonesCmd := &cobra.Command{ + Use: "zones", + Short: "Manage DNS zones", + } + + zonesListCmd := &cobra.Command{ + Use: "list", + Short: "List DNS zones", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := plugin.Context() + + if ctx.CredentialsHelper == "" { + return fmt.Errorf("DATUM_CREDENTIALS_HELPER is not set; run this plugin via 'datumctl dns zones list'") + } + + // Demonstrate token acquisition — the core of the end-to-end demo. + token, err := plugin.Token() + if err != nil { + return fmt.Errorf("failed to get credentials: %w", err) + } + + // Make a real API call using the injected context and fresh token. + // In a production plugin this would call the Datum Cloud DNS API. + apiURL := fmt.Sprintf("https://%s/v1/organizations/%s/projects/%s/dnszones", + ctx.APIHost, ctx.Org, ctx.Project) + + req, err := http.NewRequestWithContext(cmd.Context(), http.MethodGet, apiURL, nil) + if err != nil { + return err + } + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return fmt.Errorf("API request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("API returned %d", resp.StatusCode) + } + + fmt.Fprintf(cmd.OutOrStdout(), + "DNS zones for org=%s project=%s (status %d)\n", + ctx.Org, ctx.Project, resp.StatusCode) + return nil + }, + } + + zonesCmd.AddCommand(zonesListCmd) + root.AddCommand(zonesCmd) + + if err := root.Execute(); err != nil { + os.Exit(1) + } +} diff --git a/examples/plugin-dns/plugin-dns b/examples/plugin-dns/plugin-dns new file mode 100755 index 0000000..72ebd32 Binary files /dev/null and b/examples/plugin-dns/plugin-dns differ diff --git a/internal/cmd/auth/get_token.go b/internal/cmd/auth/get_token.go index 5f24855..8278761 100644 --- a/internal/cmd/auth/get_token.go +++ b/internal/cmd/auth/get_token.go @@ -23,7 +23,7 @@ const ( // getTokenCmd retrieves tokens based on the --output flag. var getTokenCmd = &cobra.Command{ Use: "get-token", - Short: "Print the active user's access token (advanced / kubectl integration)", + Short: "Print an access token (kubectl and plugin credential helper)", Long: `Print the current access token for the active Datum Cloud user. Most datumctl users do not need this command — datumctl handles @@ -38,6 +38,11 @@ This command exists for two advanced use cases: 2. Scripting or direct API calls: use --output=token to get a raw bearer token to pass to curl or other HTTP clients. + 3. Plugin credential helper: datumctl plugins use this command to obtain + a fresh access token. Plugins call it as: + $DATUM_CREDENTIALS_HELPER auth get-token --session $DATUM_SESSION + When DATUM_SESSION is empty, omit the --session flag. + If the stored token is expired, datumctl automatically uses the stored refresh token to obtain a new one before printing. diff --git a/internal/cmd/plugin/helpers.go b/internal/cmd/plugin/helpers.go new file mode 100644 index 0000000..114a356 --- /dev/null +++ b/internal/cmd/plugin/helpers.go @@ -0,0 +1,456 @@ +package plugin + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" + + "golang.org/x/mod/semver" + + "go.datum.net/datumctl/internal/pluginstore" +) + +const ( + pluginDownloadTimeout = 60 * time.Second + manifestReadTimeout = 5 * time.Second +) + +// pluginAssetName returns the archive filename for the current OS/arch. +// Follows the same naming convention as updatecheck.archiveName() (capitalised OS, goarch-style arch). +// Example: "datumctl-dns_Linux_x86_64.tar.gz" +func pluginAssetName(pluginName, version string) (string, error) { + var osName string + switch runtime.GOOS { + case "linux": + osName = "Linux" + case "darwin": + osName = "Darwin" + case "windows": + osName = "Windows" + default: + return "", fmt.Errorf("unsupported OS %q", runtime.GOOS) + } + + var arch string + switch runtime.GOARCH { + case "amd64": + arch = "x86_64" + case "386": + arch = "i386" + case "arm64": + arch = "arm64" + default: + return "", fmt.Errorf("unsupported architecture %q", runtime.GOARCH) + } + + ext := "tar.gz" + if runtime.GOOS == "windows" { + ext = "zip" + } + return fmt.Sprintf("%s_%s_%s.%s", pluginName, osName, arch, ext), nil +} + +// fetchLatestTag resolves the latest GitHub Release tag via redirect. +// It issues a HEAD request to releases/latest and parses the tag from the final URL. +// FetchLatestTag resolves the latest release tag for a GitHub owner/repo. +// Exported so callers outside this package can pass it to pluginstore.RefreshIndex. +func FetchLatestTag(ctx context.Context, owner, repo string) (string, error) { + return fetchLatestTag(ctx, owner, repo) +} + +func fetchLatestTag(ctx context.Context, owner, repo string) (string, error) { + url := fmt.Sprintf("https://github.com/%s/%s/releases/latest", owner, repo) + + client := &http.Client{ + Timeout: 15 * time.Second, + CheckRedirect: func(req *http.Request, via []*http.Request) error { + // Follow redirects automatically; net/http handles this. + return nil + }, + } + + req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil) + if err != nil { + return "", err + } + req.Header.Set("User-Agent", "datumctl-plugin-install") + + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("fetch latest release tag: %w", err) + } + defer resp.Body.Close() + + // After following redirect, the final URL has the tag at the end. + finalURL := resp.Request.URL.String() + tag := filepath.Base(finalURL) + if tag == "" || tag == "." || tag == "/" { + return "", fmt.Errorf("could not parse release tag from URL %q", finalURL) + } + if !strings.HasPrefix(tag, "v") { + return "", fmt.Errorf("unexpected release tag format %q (expected vX.Y.Z)", tag) + } + return tag, nil +} + +// fetchChecksums downloads and parses checksums.txt from a GitHub Release. +// Returns a map of filename → sha256hex. +func fetchChecksums(ctx context.Context, owner, repo, tag string) (map[string]string, error) { + url := fmt.Sprintf("https://github.com/%s/%s/releases/download/%s/checksums.txt", owner, repo, tag) + + httpClient := &http.Client{Timeout: 15 * time.Second} + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", "datumctl-plugin-install") + + resp, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("download checksums.txt: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("couldn't verify %s/%s@%s — the release doesn't include a checksums file.\nThe plugin author needs to add checksums.txt to the GitHub Release before it can be installed safely", owner, repo, tag) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read checksums.txt: %w", err) + } + + checksums := make(map[string]string) + for _, line := range strings.Split(string(body), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + // goreleaser format: " " (two spaces) or " " (one space) + fields := strings.Fields(line) + if len(fields) != 2 { + continue + } + checksums[fields[1]] = fields[0] + } + return checksums, nil +} + +// downloadAndVerify downloads the release archive, verifies its SHA256 against +// the checksums map, and extracts the plugin binary. +// Returns the binary bytes and the sha256hex of the archive. +func downloadAndVerify(ctx context.Context, owner, repo, tag, assetName string, checksums map[string]string) (binaryBytes []byte, archiveSHA256 string, err error) { + expectedSHA, ok := checksums[assetName] + if !ok { + return nil, "", fmt.Errorf("no checksum found for %q in checksums.txt", assetName) + } + + url := fmt.Sprintf("https://github.com/%s/%s/releases/download/%s/%s", owner, repo, tag, assetName) + httpClient := &http.Client{Timeout: pluginDownloadTimeout} + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, "", err + } + req.Header.Set("User-Agent", "datumctl-plugin-install") + + resp, err := httpClient.Do(req) + if err != nil { + return nil, "", fmt.Errorf("download %s: %w", assetName, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, "", fmt.Errorf("download %s: HTTP %d", assetName, resp.StatusCode) + } + + archiveBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, "", fmt.Errorf("read archive: %w", err) + } + + // Verify archive SHA256. + sum := sha256.Sum256(archiveBytes) + gotSHA := hex.EncodeToString(sum[:]) + if !strings.EqualFold(gotSHA, expectedSHA) { + return nil, "", fmt.Errorf("SHA256 mismatch for %s: expected %s, got %s", assetName, expectedSHA, gotSHA) + } + + // Extract the binary from the archive. + // The binary name inside the archive is the plugin name without the archive extension. + // e.g. for "datumctl-dns_Linux_x86_64.tar.gz", the binary is "datumctl-dns" + pluginBinName := binaryNameFromAsset(assetName) + var extracted []byte + if strings.HasSuffix(assetName, ".tar.gz") { + extracted, err = extractBinaryFromTarGz(bytes.NewReader(archiveBytes), pluginBinName) + } else if strings.HasSuffix(assetName, ".zip") { + extracted, err = extractBinaryFromZip(archiveBytes, pluginBinName) + } else { + return nil, "", fmt.Errorf("unsupported archive format: %s", assetName) + } + if err != nil { + return nil, "", fmt.Errorf("extract binary from archive: %w", err) + } + + return extracted, gotSHA, nil +} + + +func extractBinaryFromTarGz(r io.Reader, binName string) ([]byte, error) { + gz, err := gzip.NewReader(r) + if err != nil { + return nil, fmt.Errorf("gunzip archive: %w", err) + } + defer gz.Close() + + tr := tar.NewReader(gz) + for { + hdr, err := tr.Next() + if err == io.EOF { + return nil, fmt.Errorf("binary %q not found in archive", binName) + } + if err != nil { + return nil, fmt.Errorf("read tar archive: %w", err) + } + if hdr.Typeflag != tar.TypeReg { + continue + } + if filepath.Base(hdr.Name) != binName { + continue + } + data, err := io.ReadAll(tr) + if err != nil { + return nil, fmt.Errorf("extract %s: %w", binName, err) + } + return data, nil + } +} + +func extractBinaryFromZip(archiveBytes []byte, binName string) ([]byte, error) { + zr, err := zip.NewReader(bytes.NewReader(archiveBytes), int64(len(archiveBytes))) + if err != nil { + return nil, fmt.Errorf("open zip archive: %w", err) + } + for _, f := range zr.File { + if filepath.Base(f.Name) != binName { + continue + } + rc, err := f.Open() + if err != nil { + return nil, fmt.Errorf("open %s in zip: %w", binName, err) + } + data, copyErr := io.ReadAll(rc) + rc.Close() + if copyErr != nil { + return nil, fmt.Errorf("extract %s: %w", binName, copyErr) + } + return data, nil + } + return nil, fmt.Errorf("binary %q not found in archive", binName) +} + +// binaryNameFromAsset extracts the expected binary name from an asset filename. +// "datumctl-dns_Linux_x86_64.tar.gz" → "datumctl-dns" +// "datumctl-dns_Windows_x86_64.zip" → "datumctl-dns.exe" +func binaryNameFromAsset(assetName string) string { + name := strings.TrimSuffix(assetName, ".tar.gz") + name = strings.TrimSuffix(name, ".zip") + // The plugin name is everything before the first underscore. + if i := strings.Index(name, "_"); i >= 0 { + name = name[:i] + } + if strings.Contains(assetName, "_Windows_") { + name += ".exe" + } + return name +} + +// downloadAndVerifyURI downloads the archive at uri, verifies its SHA256 against +// sha256hex, and returns the raw archive bytes. This is the index-based download +// path; it is distinct from downloadAndVerify (which takes GitHub asset names). +func downloadAndVerifyURI(ctx context.Context, uri, sha256hex string) ([]byte, error) { + httpClient := &http.Client{Timeout: pluginDownloadTimeout} + req, err := http.NewRequestWithContext(ctx, http.MethodGet, uri, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", "datumctl-plugin-install") + + resp, err := httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("download archive: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("download archive: HTTP %d", resp.StatusCode) + } + + archiveBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read archive: %w", err) + } + + sum := sha256.Sum256(archiveBytes) + gotSHA := hex.EncodeToString(sum[:]) + if !strings.EqualFold(gotSHA, sha256hex) { + return nil, fmt.Errorf("SHA256 mismatch: expected %s, got %s", sha256hex, gotSHA) + } + + return archiveBytes, nil +} + +// readPluginManifest writes binaryPath to a temp file (if needed), runs it with +// --plugin-manifest, and parses the JSON output. Returns nil, nil if the binary +// exits non-zero or produces no valid JSON. +func readPluginManifest(binaryPath string) (*pluginstore.PluginManifest, error) { + ctx, cancel := context.WithTimeout(context.Background(), manifestReadTimeout) + defer cancel() + + cmd := exec.CommandContext(ctx, binaryPath, "--plugin-manifest") + out, err := cmd.Output() + if err != nil { + // Non-zero exit — treat as "no manifest". + return nil, nil + } + if len(out) == 0 { + return nil, nil + } + + var m pluginstore.PluginManifest + if err := json.Unmarshal(out, &m); err != nil { + return nil, fmt.Errorf("parse plugin manifest: %w", err) + } + return &m, nil +} + +// readPluginManifestFromBytes writes binary bytes to a temp file, runs it with +// --plugin-manifest, and returns the parsed manifest. +func readPluginManifestFromBytes(binaryData []byte) (*pluginstore.PluginManifest, error) { + tmp, err := os.CreateTemp("", "datumctl-plugin-verify-*") + if err != nil { + return nil, fmt.Errorf("create temp file for manifest check: %w", err) + } + defer os.Remove(tmp.Name()) + + if _, err := tmp.Write(binaryData); err != nil { + tmp.Close() + return nil, fmt.Errorf("write temp binary: %w", err) + } + if err := tmp.Chmod(0o755); err != nil { + tmp.Close() + return nil, fmt.Errorf("chmod temp binary: %w", err) + } + if err := tmp.Close(); err != nil { + return nil, fmt.Errorf("close temp binary: %w", err) + } + + return readPluginManifest(tmp.Name()) +} + +// checkCompatibility validates a manifest against the running datumctl version and API version. +// Returns (warn, nil) for soft warnings, ("", err) for hard errors. +// At install time, hard errors prevent installation. +// At invocation time, callers use warn for non-blocking messages and err for blocking. +func checkCompatibility(m *pluginstore.PluginManifest, currentVersion string, currentAPIVersion int) (warn string, err error) { + if m == nil { + return "", nil + } + + // Check min_datumctl_version. + if m.MinDatumctlVersion != "" && semver.IsValid(m.MinDatumctlVersion) { + if semver.IsValid(currentVersion) && semver.Compare(currentVersion, m.MinDatumctlVersion) < 0 { + return "", fmt.Errorf("plugin requires datumctl %s or newer (current: %s); upgrade datumctl first", + m.MinDatumctlVersion, currentVersion) + } + } + + // Check min_api_version. + if m.MinAPIVersion > 0 && currentAPIVersion < m.MinAPIVersion { + return "", fmt.Errorf("plugin requires API version %d or higher (current host API version: %d); upgrade datumctl to use this plugin", + m.MinAPIVersion, currentAPIVersion) + } + + return "", nil +} + +// writeBinary writes binaryData to pluginsDir/binaryName with executable permissions. +func writeBinary(pluginsDir, binaryName string, binaryData []byte) (string, error) { + if err := os.MkdirAll(pluginsDir, 0o755); err != nil { + return "", fmt.Errorf("create plugins directory: %w", err) + } + + destPath := filepath.Join(pluginsDir, binaryName) + tmp := destPath + ".tmp" + if err := os.WriteFile(tmp, binaryData, 0o755); err != nil { + return "", fmt.Errorf("write plugin binary: %w", err) + } + if err := os.Rename(tmp, destPath); err != nil { + _ = os.Remove(tmp) + return "", fmt.Errorf("install plugin binary: %w", err) + } + return destPath, nil +} + +// parseSource parses "owner/repo[@version]" into owner, repo, version. +// version is empty if not specified. +func parseSource(source string) (owner, repo, version string, err error) { + // Strip "github.com/" prefix if present. + source = strings.TrimPrefix(source, "github.com/") + + // Split off @version suffix. + sourcePart, versionPart, _ := strings.Cut(source, "@") + version = versionPart + + parts := strings.SplitN(sourcePart, "/", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return "", "", "", fmt.Errorf("invalid plugin source %q: expected owner/repo[@version]", source) + } + return parts[0], parts[1], version, nil +} + +// pluginNameFromRepo derives the plugin name from the repo name. +// "datumctl-dns" → "dns", "datum-dns" → "datum-dns" (no stripping) +func pluginNameFromRepo(repoName string) string { + name := strings.TrimPrefix(repoName, "datumctl-") + return name +} + +// isUpdateAvailable returns true when latest is a valid semver tag newer than installed. +func isUpdateAvailable(installed, latest string) bool { + if latest == "" || !semver.IsValid(installed) || !semver.IsValid(latest) { + return false + } + return semver.Compare(installed, latest) < 0 +} + +// InstallPlugin performs the full install flow for a single named plugin from +// the curated index. pluginName must match a Plugin.Name entry in idx. +func InstallPlugin(ctx context.Context, pluginsDir, pluginName, version, currentVersion string, idx *pluginstore.CachedIndex) (*pluginstore.InstalledPlugin, string, error) { + return installPlugin(ctx, pluginsDir, pluginName, version, currentVersion, idx) +} + +// sha256HexOf returns the lowercase hex-encoded SHA256 digest of b. +func sha256HexOf(b []byte) string { + sum := sha256.Sum256(b) + return hex.EncodeToString(sum[:]) +} + +// newBytesReader wraps b in a *bytes.Reader for callers that need an io.Reader. +func newBytesReader(b []byte) *bytes.Reader { + return bytes.NewReader(b) +} + diff --git a/internal/cmd/plugin/install.go b/internal/cmd/plugin/install.go new file mode 100644 index 0000000..e893207 --- /dev/null +++ b/internal/cmd/plugin/install.go @@ -0,0 +1,359 @@ +package plugin + +import ( + "context" + "fmt" + "runtime" + "strings" + "time" + + "github.com/spf13/cobra" + componentversion "k8s.io/component-base/version" + + "go.datum.net/datumctl/internal/client" + customerrors "go.datum.net/datumctl/internal/errors" + "go.datum.net/datumctl/internal/plugindispatch" + "go.datum.net/datumctl/internal/pluginstore" +) + +func installCmd(factory *client.DatumCloudFactory) *cobra.Command { + cmd := &cobra.Command{ + Use: "install [name | owner/repo[@version]]", + Short: "Install a datumctl plugin", + Long: `Install a datumctl plugin from the curated plugin index or a GitHub Release. + +With no arguments, restores all plugins recorded in plugins.json to their +recorded versions. Use this to reproduce a plugin set on a new machine. + +With a plugin name argument, installs from the curated index: + - name installs the latest indexed version + +With an owner/repo argument, installs directly from a GitHub Release: + - owner/repo installs the latest release + - owner/repo@v1.2.0 installs a specific version + +The plugin binary is written to the managed plugins directory +(~/.local/share/datumctl/plugins/ by default).`, + Example: ` # Install the dns plugin from the curated index + datumctl plugin install dns + + # Install directly from a GitHub Release + datumctl plugin install datum-cloud/datumctl-dns + + # Install a specific version from GitHub + datumctl plugin install datum-cloud/datumctl-dns@v1.2.0 + + # Restore all plugins from plugins.json + datumctl plugin install`, + RunE: func(cmd *cobra.Command, args []string) error { + pluginsDir, err := resolvePluginsDir(cmd) + if err != nil { + return err + } + + currentVersion := componentversion.Get().GitVersion + + if len(args) == 0 { + return installAllFromManifest(cmd, pluginsDir, currentVersion) + } + + arg := args[0] + + // Third-party GitHub release path. + if strings.Contains(arg, "/") { + owner, repo, version, parseErr := parseSource(arg) + if parseErr != nil { + return customerrors.NewUserError(parseErr.Error()) + } + entry, pluginName, installErr := installPluginFromGitHub(cmd.Context(), pluginsDir, owner, repo, version, currentVersion) + if installErr != nil { + return customerrors.NewUserError(fmt.Sprintf("install plugin %s/%s: %v", owner, repo, installErr)) + } + return saveAndReport(cmd, pluginsDir, pluginName, entry) + } + + // Curated index path. + idx, idxErr := loadOrRefreshIndex(cmd) + if idxErr != nil { + return customerrors.NewUserError("could not fetch plugin index: " + idxErr.Error()) + } + entry, pluginName, installErr := installPlugin(cmd.Context(), pluginsDir, arg, "", currentVersion, idx) + if installErr != nil { + return customerrors.NewUserError(fmt.Sprintf("install plugin %s: %v", arg, installErr)) + } + return saveAndReport(cmd, pluginsDir, pluginName, entry) + }, + } + cmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + idx, _ := pluginstore.LoadIndex() + var names []string + if idx != nil { + for _, p := range idx.Plugins { + names = append(names, p.Name+"\t"+p.Spec.ShortDescription) + } + } + return names, cobra.ShellCompDirectiveNoFileComp + } + return cmd +} + +// saveAndReport upserts plugins.json and prints the install confirmation. +func saveAndReport(cmd *cobra.Command, pluginsDir, pluginName string, entry *pluginstore.InstalledPlugin) error { + manifest, err := pluginstore.Load(pluginsDir) + if err != nil { + return fmt.Errorf("load plugins manifest: %w", err) + } + if manifest.Plugins == nil { + manifest.Plugins = make(map[string]*pluginstore.InstalledPlugin) + } + manifest.Plugins[pluginName] = entry + if err := pluginstore.Save(pluginsDir, manifest); err != nil { + return fmt.Errorf("save plugins manifest: %w", err) + } + fmt.Fprintf(cmd.OutOrStdout(), "Installed %s %s\n", pluginName, entry.Version) + return nil +} + +// loadOrRefreshIndex loads the cached index; if stale, refreshes it. +// Returns (nil, err) only if both load and refresh fail without a cache. +func loadOrRefreshIndex(cmd *cobra.Command) (*pluginstore.CachedIndex, error) { + idx, _ := pluginstore.LoadIndex() + if pluginstore.IsStale(idx) { + fresh, refreshErr := pluginstore.RefreshIndex(cmd.Context()) + if refreshErr != nil { + if fresh == nil { + return nil, refreshErr + } + fmt.Fprintf(cmd.ErrOrStderr(), "warning: index refresh failed (%v), using cached results\n", refreshErr) + return fresh, nil + } + return fresh, nil + } + return idx, nil +} + +// installAllFromManifest restores all plugins recorded in plugins.json. +func installAllFromManifest(cmd *cobra.Command, pluginsDir, currentVersion string) error { + manifest, err := pluginstore.Load(pluginsDir) + if err != nil { + return fmt.Errorf("load plugins manifest: %w", err) + } + + if len(manifest.Plugins) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "No plugins recorded in plugins.json.") + return nil + } + + // Load/refresh the index up front for index-based entries. + idx, _ := pluginstore.LoadIndex() + if pluginstore.IsStale(idx) { + fresh, refreshErr := pluginstore.RefreshIndex(cmd.Context()) + if refreshErr != nil { + if fresh == nil { + fmt.Fprintf(cmd.ErrOrStderr(), "warning: index refresh failed (%v); index-based plugins may fail\n", refreshErr) + } else { + fmt.Fprintf(cmd.ErrOrStderr(), "warning: index refresh failed (%v), using cached results\n", refreshErr) + idx = fresh + } + } else { + idx = fresh + } + } + + var errs []string + for name, entry := range manifest.Plugins { + // Third-party source (owner/repo format). + if strings.Contains(entry.Source, "/") { + owner, repo, _, parseErr := parseSource(entry.Source) + if parseErr != nil { + errs = append(errs, fmt.Sprintf("%s: invalid source %q: %v", name, entry.Source, parseErr)) + continue + } + newEntry, _, installErr := installPluginFromGitHub(cmd.Context(), pluginsDir, owner, repo, entry.Version, currentVersion) + if installErr != nil { + errs = append(errs, fmt.Sprintf("%s: %v", name, installErr)) + continue + } + manifest.Plugins[name] = newEntry + fmt.Fprintf(cmd.OutOrStdout(), "Installed %s %s\n", name, newEntry.Version) + continue + } + + // Curated index source (short name). + newEntry, _, installErr := installPlugin(cmd.Context(), pluginsDir, name, entry.Version, currentVersion, idx) + if installErr != nil { + errs = append(errs, fmt.Sprintf("%s: %v", name, installErr)) + continue + } + manifest.Plugins[name] = newEntry + fmt.Fprintf(cmd.OutOrStdout(), "Installed %s %s\n", name, newEntry.Version) + } + + if saveErr := pluginstore.Save(pluginsDir, manifest); saveErr != nil { + errs = append(errs, fmt.Sprintf("save plugins manifest: %v", saveErr)) + } + + if len(errs) > 0 { + return customerrors.NewUserError("some plugins failed to install:\n " + strings.Join(errs, "\n ")) + } + return nil +} + +// installPlugin installs a named plugin from the curated index. +func installPlugin(ctx context.Context, pluginsDir, pluginName, version, currentVersion string, idx *pluginstore.CachedIndex) (*pluginstore.InstalledPlugin, string, error) { + if idx == nil { + return nil, pluginName, fmt.Errorf("plugin index is not available; run 'datumctl plugin search' to fetch it") + } + + plugin := pluginstore.FindInIndex(idx, pluginName) + if plugin == nil { + return nil, pluginName, fmt.Errorf("plugin %q not found in index; run 'datumctl plugin search' to list available plugins", pluginName) + } + + // Determine target version. + if version == "" { + version = plugin.Spec.Version + } + + // Select the matching platform. + platform, err := pluginstore.GetMatchingPlatform(plugin, runtime.GOOS, runtime.GOARCH) + if err != nil { + return nil, pluginName, fmt.Errorf("select platform: %w", err) + } + + // Download and verify the archive. + archiveBytes, err := downloadAndVerifyURI(ctx, platform.URI, platform.SHA256) + if err != nil { + return nil, pluginName, fmt.Errorf("download plugin: %w", err) + } + + // Extract the binary. + binaryName := "datumctl-" + pluginName + if runtime.GOOS == "windows" { + binaryName += ".exe" + } + + binaryBytes, err := extractFromArchive(archiveBytes, platform, pluginName, platform.URI) + if err != nil { + return nil, pluginName, fmt.Errorf("extract binary: %w", err) + } + + // Read and check the manifest before writing. + pluginManifest, err := readPluginManifestFromBytes(binaryBytes) + if err != nil { + return nil, pluginName, fmt.Errorf("read plugin manifest: %w", err) + } + if pluginManifest != nil { + if _, err := checkCompatibility(pluginManifest, currentVersion, plugindispatch.PluginAPIVersion); err != nil { + return nil, pluginName, err + } + } + + // Write the binary to disk. + if _, err := writeBinary(pluginsDir, binaryName, binaryBytes); err != nil { + return nil, pluginName, err + } + + // Compute SHA256 of archive for the lock record. + archiveSHA256 := sha256HexOf(archiveBytes) + + entry := &pluginstore.InstalledPlugin{ + Source: pluginName, + Version: version, + SHA256: archiveSHA256, + InstalledAt: time.Now().UTC(), + Manifest: pluginManifest, + } + return entry, pluginName, nil +} + +// extractFromArchive extracts the plugin binary from the archive bytes. +// If platform.Files is non-empty, the first matching FileOperation is used. +// Otherwise the binary is auto-detected by matching datumctl-[.exe]. +func extractFromArchive(archiveBytes []byte, platform *pluginstore.Platform, pluginName, archiveURI string) ([]byte, error) { + isTarGz := strings.HasSuffix(archiveURI, ".tar.gz") || strings.HasSuffix(archiveURI, ".tgz") + isZip := strings.HasSuffix(archiveURI, ".zip") + + if len(platform.Files) > 0 { + // Use the first FileOperation that specifies the binary. + op := platform.Files[0] + if isTarGz { + return extractBinaryFromTarGz(newBytesReader(archiveBytes), op.From) + } else if isZip { + return extractBinaryFromZip(archiveBytes, op.From) + } + return nil, fmt.Errorf("unsupported archive format for URI: %s", archiveURI) + } + + // Auto-detect: find the file named datumctl-[.exe]. + binName := "datumctl-" + pluginName + if runtime.GOOS == "windows" { + binName += ".exe" + } + + if isTarGz { + return extractBinaryFromTarGz(newBytesReader(archiveBytes), binName) + } else if isZip { + return extractBinaryFromZip(archiveBytes, binName) + } + return nil, fmt.Errorf("unsupported archive format for URI: %s", archiveURI) +} + +// installPluginFromGitHub is the third-party GitHub Release install path. +// It mirrors the original installPlugin logic for owner/repo[@version] sources. +func installPluginFromGitHub(ctx context.Context, pluginsDir, owner, repo, version, currentVersion string) (*pluginstore.InstalledPlugin, string, error) { + pluginName := pluginNameFromRepo(repo) + + if version == "" { + var err error + version, err = fetchLatestTag(ctx, owner, repo) + if err != nil { + return nil, pluginName, fmt.Errorf("resolve latest version for %s/%s: %w", owner, repo, err) + } + } + + assetName, err := pluginAssetName("datumctl-"+pluginName, version) + if err != nil { + return nil, pluginName, err + } + + checksums, err := fetchChecksums(ctx, owner, repo, version) + if err != nil { + return nil, pluginName, err + } + + binaryBytes, archiveSHA256, err := downloadAndVerify(ctx, owner, repo, version, assetName, checksums) + if err != nil { + return nil, pluginName, err + } + + pluginManifest, err := readPluginManifestFromBytes(binaryBytes) + if err != nil { + return nil, pluginName, fmt.Errorf("read plugin manifest: %w", err) + } + if pluginManifest != nil { + if _, err := checkCompatibility(pluginManifest, currentVersion, plugindispatch.PluginAPIVersion); err != nil { + return nil, pluginName, err + } + } + + binaryName := "datumctl-" + pluginName + if runtime.GOOS == "windows" { + binaryName += ".exe" + } + if _, err := writeBinary(pluginsDir, binaryName, binaryBytes); err != nil { + return nil, pluginName, err + } + + entry := &pluginstore.InstalledPlugin{ + Source: "github.com/" + owner + "/" + repo, + Version: version, + SHA256: archiveSHA256, + InstalledAt: time.Now().UTC(), + Manifest: pluginManifest, + } + return entry, pluginName, nil +} diff --git a/internal/cmd/plugin/install_test.go b/internal/cmd/plugin/install_test.go new file mode 100644 index 0000000..711a3b8 --- /dev/null +++ b/internal/cmd/plugin/install_test.go @@ -0,0 +1,437 @@ +package plugin + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "fmt" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "go.datum.net/datumctl/internal/plugindispatch" + "go.datum.net/datumctl/internal/pluginstore" +) + +// TestPluginAssetName_allPlatforms verifies archive name generation for the +// current platform matches the expected pattern. +func TestPluginAssetName_allPlatforms(t *testing.T) { + t.Parallel() + + got, err := pluginAssetName("datumctl-dns", "v1.0.0") + if err != nil { + supported := (runtime.GOOS == "linux" || runtime.GOOS == "darwin" || runtime.GOOS == "windows") && + (runtime.GOARCH == "amd64" || runtime.GOARCH == "arm64" || runtime.GOARCH == "386") + if supported { + t.Fatalf("pluginAssetName for current platform: %v", err) + } + t.Skipf("pluginAssetName: unsupported test platform %s/%s", runtime.GOOS, runtime.GOARCH) + } + + if !strings.HasPrefix(got, "datumctl-dns_") { + t.Errorf("pluginAssetName: got %q, want prefix %q", got, "datumctl-dns_") + } + if runtime.GOOS == "windows" { + if !strings.HasSuffix(got, ".zip") { + t.Errorf("pluginAssetName on windows: got %q, want .zip suffix", got) + } + } else { + if !strings.HasSuffix(got, ".tar.gz") { + t.Errorf("pluginAssetName on non-windows: got %q, want .tar.gz suffix", got) + } + } +} + +// TestBinaryNameFromAsset verifies that binaryNameFromAsset correctly derives +// the expected binary name from various asset filenames. +func TestBinaryNameFromAsset(t *testing.T) { + t.Parallel() + + tests := []struct { + assetName string + want string + }{ + {"datumctl-dns_Linux_x86_64.tar.gz", "datumctl-dns"}, + {"datumctl-dns_Darwin_arm64.tar.gz", "datumctl-dns"}, + {"datumctl-dns_Windows_x86_64.zip", "datumctl-dns.exe"}, + } + + for _, tt := range tests { + got := binaryNameFromAsset(tt.assetName) + if got != tt.want { + t.Errorf("binaryNameFromAsset(%q) = %q, want %q", tt.assetName, got, tt.want) + } + } +} + +// TestFetchChecksums_parsesOneSpaceAndTwoSpace verifies that fetchChecksums +// handles both "sha256 file" (two spaces, goreleaser default) and "sha256 file" +// (one space). +func TestFetchChecksums_parsesOneSpaceAndTwoSpace(t *testing.T) { + t.Parallel() + + checksumBody := "" + + "abc123 datumctl-dns_Linux_x86_64.tar.gz\n" + + "def456 datumctl-dns_Darwin_arm64.tar.gz\n" + + "\n" // trailing blank line — must be ignored + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, checksumBody) + })) + defer srv.Close() + + expected := map[string]string{ + "datumctl-dns_Linux_x86_64.tar.gz": "abc123", + "datumctl-dns_Darwin_arm64.tar.gz": "def456", + } + + ctx := context.Background() + got, err := fetchChecksumsFromURL(ctx, srv.URL) + if err != nil { + t.Fatalf("fetchChecksumsFromURL: %v", err) + } + + for file, sha := range expected { + if got[file] != sha { + t.Errorf("checksums[%q] = %q, want %q", file, got[file], sha) + } + } +} + +// fetchChecksumsFromURL is a test helper that fetches and parses a raw checksums +// URL (as opposed to fetchChecksums which constructs the GitHub Release URL). +func fetchChecksumsFromURL(ctx context.Context, rawURL string) (map[string]string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return nil, err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + var body strings.Builder + buf := make([]byte, 4096) + for { + n, readErr := resp.Body.Read(buf) + body.Write(buf[:n]) + if readErr != nil { + break + } + } + + checksums := make(map[string]string) + for _, line := range strings.Split(body.String(), "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + fields := strings.Fields(line) + if len(fields) != 2 { + continue + } + checksums[fields[1]] = fields[0] + } + return checksums, nil +} + +// TestCheckCompatibility_rejectsOldVersion verifies that checkCompatibility +// returns an error when the current datumctl version is below min_datumctl_version. +func TestCheckCompatibility_rejectsOldVersion(t *testing.T) { + t.Parallel() + + m := &pluginstore.PluginManifest{ + Name: "dns", + APIVersion: plugindispatch.PluginAPIVersion, + MinDatumctlVersion: "v2.0.0", + } + + _, err := checkCompatibility(m, "v1.0.0", plugindispatch.PluginAPIVersion) + if err == nil { + t.Error("checkCompatibility: want error when current version < min_datumctl_version, got nil") + } +} + +// TestCheckCompatibility_rejectsLowAPIVersion verifies that checkCompatibility +// returns an error when the host API version is below min_api_version. +func TestCheckCompatibility_rejectsLowAPIVersion(t *testing.T) { + t.Parallel() + + m := &pluginstore.PluginManifest{ + Name: "dns", + APIVersion: 1, + MinAPIVersion: 5, + } + + _, err := checkCompatibility(m, "v9.0.0", 2) + if err == nil { + t.Error("checkCompatibility: want error when host API version < min_api_version, got nil") + } +} + +// TestCheckCompatibility_warnsOnVersionMismatch verifies that soft mismatches +// (such as invocation-time API version drift) return a non-empty warn string +// via CheckCompatibilityAtInvocation. +func TestCheckCompatibility_warnsOnVersionMismatch(t *testing.T) { + t.Parallel() + + m := &pluginstore.PluginManifest{ + Name: "dns", + APIVersion: 1, + } + + warn, err := plugindispatch.CheckCompatibilityAtInvocation(m, "v9.0.0", 2) + if err != nil { + t.Fatalf("CheckCompatibilityAtInvocation: unexpected hard error: %v", err) + } + if warn == "" { + t.Error("CheckCompatibilityAtInvocation: want non-empty warn for API version mismatch, got empty string") + } +} + +// TestReadPluginManifest_noManifest verifies that readPluginManifest returns +// nil, nil when the binary exits non-zero on --plugin-manifest. +func TestReadPluginManifest_noManifest(t *testing.T) { + t.Parallel() + + binPath := buildHelperBinary(t, ` +package main + +import "os" + +func main() { + os.Exit(1) +} +`) + + m, err := readPluginManifest(binPath) + if err != nil { + t.Errorf("readPluginManifest non-zero exit: want nil error, got %v", err) + } + if m != nil { + t.Errorf("readPluginManifest non-zero exit: want nil manifest, got %+v", m) + } +} + +// TestReadPluginManifest_validJSON verifies that readPluginManifest parses the +// manifest when the binary prints valid JSON to stdout and exits 0. +func TestReadPluginManifest_validJSON(t *testing.T) { + t.Parallel() + + binPath := buildHelperBinary(t, ` +package main + +import "fmt" + +func main() { + fmt.Println(`+"`"+`{"name":"datumctl-dns","version":"v1.0.0","description":"DNS plugin","api_version":1}`+"`"+`) +} +`) + + m, err := readPluginManifest(binPath) + if err != nil { + t.Fatalf("readPluginManifest valid JSON: %v", err) + } + if m == nil { + t.Fatal("readPluginManifest valid JSON: want non-nil manifest, got nil") + } + if m.Name != "datumctl-dns" { + t.Errorf("manifest.Name = %q, want %q", m.Name, "datumctl-dns") + } + if m.APIVersion != 1 { + t.Errorf("manifest.APIVersion = %d, want 1", m.APIVersion) + } +} + +// TestReadPluginManifest_malformedJSON verifies that readPluginManifest returns +// an error when the binary prints invalid JSON. +func TestReadPluginManifest_malformedJSON(t *testing.T) { + t.Parallel() + + binPath := buildHelperBinary(t, ` +package main + +import "fmt" + +func main() { + fmt.Println("{not valid json") +} +`) + + _, err := readPluginManifest(binPath) + if err == nil { + t.Error("readPluginManifest malformed JSON: want error, got nil") + } +} + +// TestInstallPlugin_notInIndex verifies that installPlugin returns an error when +// the plugin name is not in the index. +func TestInstallPlugin_notInIndex(t *testing.T) { + t.Parallel() + + idx := &pluginstore.CachedIndex{ + RefreshedAt: time.Now(), + Plugins: []pluginstore.Plugin{}, + } + + _, _, err := installPlugin(context.Background(), t.TempDir(), "nonexistent", "", "v0.0.0", idx) + if err == nil { + t.Error("installPlugin: want error for plugin not in index, got nil") + } +} + +// TestInstallPlugin_nilIndex verifies that installPlugin returns an error when +// idx is nil. +func TestInstallPlugin_nilIndex(t *testing.T) { + t.Parallel() + + _, _, err := installPlugin(context.Background(), t.TempDir(), "dns", "", "v0.0.0", nil) + if err == nil { + t.Error("installPlugin: want error for nil index, got nil") + } +} + +// TestInstallPlugin_selectorBased verifies that installPlugin correctly selects +// the matching platform and downloads the archive from a stubbed HTTP server. +func TestInstallPlugin_selectorBased(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("test builds a unix binary helper; skip on windows") + } + t.Parallel() + + // Build a valid plugin binary that responds to --plugin-manifest. + pluginBin := buildHelperBinary(t, ` +package main + +import ( + "fmt" + "os" +) + +func main() { + for _, a := range os.Args[1:] { + if a == "--plugin-manifest" { + fmt.Println(`+"`"+`{"name":"datumctl-testplugin","version":"v0.1.0","description":"test","api_version":1}`+"`"+`) + return + } + } +} +`) + + binData, err := os.ReadFile(pluginBin) + if err != nil { + t.Fatalf("read plugin binary: %v", err) + } + + archiveBytes := makeTarGz(t, "datumctl-testplugin", binData) + archiveSHA := sha256HexOf(archiveBytes) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(archiveBytes) //nolint:errcheck + })) + defer srv.Close() + + idx := &pluginstore.CachedIndex{ + RefreshedAt: time.Now(), + Plugins: []pluginstore.Plugin{ + { + ObjectMeta: metav1.ObjectMeta{Name: "testplugin"}, + Spec: pluginstore.PluginSpec{ + ShortDescription: "test plugin", + Version: "v0.1.0", + Platforms: []pluginstore.Platform{ + { + // nil Selector matches all platforms. + URI: srv.URL + "/datumctl-testplugin.tar.gz", + SHA256: archiveSHA, + }, + }, + }, + }, + }, + } + + dir := t.TempDir() + entry, name, err := installPlugin(context.Background(), dir, "testplugin", "", "v9.0.0", idx) + if err != nil { + t.Fatalf("installPlugin: %v", err) + } + if name != "testplugin" { + t.Errorf("pluginName = %q, want %q", name, "testplugin") + } + if entry.Version != "v0.1.0" { + t.Errorf("entry.Version = %q, want %q", entry.Version, "v0.1.0") + } + if entry.Source != "testplugin" { + t.Errorf("entry.Source = %q, want %q", entry.Source, "testplugin") + } + + // Verify binary exists on disk. + binPath := filepath.Join(dir, "datumctl-testplugin") + if _, err := os.Stat(binPath); err != nil { + t.Errorf("expected binary at %s: %v", binPath, err) + } +} + +// makeTarGz creates a .tar.gz archive in memory containing a single file +// named binName with the given content. +func makeTarGz(t *testing.T, binName string, content []byte) []byte { + t.Helper() + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + + hdr := &tar.Header{ + Name: binName, + Mode: 0o755, + Size: int64(len(content)), + Typeflag: tar.TypeReg, + } + if err := tw.WriteHeader(hdr); err != nil { + t.Fatalf("tar write header: %v", err) + } + if _, err := tw.Write(content); err != nil { + t.Fatalf("tar write content: %v", err) + } + if err := tw.Close(); err != nil { + t.Fatalf("tar close: %v", err) + } + if err := gz.Close(); err != nil { + t.Fatalf("gz close: %v", err) + } + return buf.Bytes() +} + +// buildHelperBinary compiles a Go program from src into a temp dir and returns +// the path to the resulting binary. The binary is cleaned up via t.Cleanup. +func buildHelperBinary(t *testing.T, src string) string { + t.Helper() + + dir := t.TempDir() + srcFile := filepath.Join(dir, "main.go") + if err := os.WriteFile(srcFile, []byte(src), 0o644); err != nil { + t.Fatalf("write helper source: %v", err) + } + + binName := "helper" + if runtime.GOOS == "windows" { + binName += ".exe" + } + binPath := filepath.Join(dir, binName) + + cmd := exec.Command("go", "build", "-o", binPath, srcFile) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("build helper binary: %v\n%s", err, out) + } + return binPath +} diff --git a/internal/cmd/plugin/list.go b/internal/cmd/plugin/list.go new file mode 100644 index 0000000..a451627 --- /dev/null +++ b/internal/cmd/plugin/list.go @@ -0,0 +1,86 @@ +package plugin + +import ( + "fmt" + "os" + "text/tabwriter" + + "github.com/spf13/cobra" + "golang.org/x/term" + + "go.datum.net/datumctl/internal/plugindispatch" + "go.datum.net/datumctl/internal/pluginstore" +) + +func listCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List installed datumctl plugins", + Long: `List all managed plugins recorded in plugins.json. + +Reads plugins.json only — never execs plugin binaries. + +Status column indicators: + ok Plugin is installed and API version matches. + update A newer version is available in the plugin index. + ! Stored api_version does not match the host's API version. + ? No manifest recorded (plugin did not respond to --plugin-manifest). + +Run 'datumctl plugin search' to refresh the plugin index.`, + Example: ` # List all installed plugins + datumctl plugin list`, + RunE: func(cmd *cobra.Command, args []string) error { + pluginsDir, err := resolvePluginsDir(cmd) + if err != nil { + return err + } + + manifest, err := pluginstore.Load(pluginsDir) + if err != nil { + return fmt.Errorf("load plugins manifest: %w", err) + } + + if len(manifest.Plugins) == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "No managed plugins installed.") + return nil + } + + // Load the cached index read-only — no network calls. + idx, _ := pluginstore.LoadIndex() + + w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 3, ' ', 0) + fmt.Fprintln(w, "NAME\tVERSION\tDESCRIPTION\tSTATUS") + var anyUpdates bool + for name, entry := range manifest.Plugins { + description := "" + status := "?" + if entry.Manifest != nil { + description = entry.Manifest.Description + if entry.Manifest.APIVersion == plugindispatch.PluginAPIVersion { + status = "ok" + } else { + status = "!" + } + } + if status == "ok" { + if indexEntry := pluginstore.FindInIndex(idx, name); indexEntry != nil { + if isUpdateAvailable(entry.Version, indexEntry.Spec.Version) { + status = "update" + anyUpdates = true + } + } + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", name, entry.Version, description, status) + } + if err := w.Flush(); err != nil { + return err + } + if anyUpdates && term.IsTerminal(int(os.Stdout.Fd())) { + fmt.Fprintln(cmd.ErrOrStderr(), + "\nRun 'datumctl plugin upgrade' to update plugins with available upgrades.") + } + return nil + }, + } + return cmd +} diff --git a/internal/cmd/plugin/list_test.go b/internal/cmd/plugin/list_test.go new file mode 100644 index 0000000..f94dd0c --- /dev/null +++ b/internal/cmd/plugin/list_test.go @@ -0,0 +1,110 @@ +package plugin + +import ( + "bytes" + "strings" + "testing" + "time" + + "go.datum.net/datumctl/internal/plugindispatch" + "go.datum.net/datumctl/internal/pluginstore" +) + +// executeListCmd runs the list subcommand with the given plugins.json content +// and returns the captured stdout. +func executeListCmd(t *testing.T, manifest *pluginstore.Manifest) string { + t.Helper() + + dir := t.TempDir() + if err := pluginstore.Save(dir, manifest); err != nil { + t.Fatalf("Save manifest for test: %v", err) + } + + cmd := Command(nil) // factory is not used by listCmd + cmd.PersistentFlags().Set("plugins-dir", dir) //nolint:errcheck + + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"list"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("Execute list: %v", err) + } + return out.String() +} + +// TestListCmd_noPlugins verifies that an empty plugins.json prints the +// "No managed plugins installed" message. +func TestListCmd_noPlugins(t *testing.T) { + t.Parallel() + + output := executeListCmd(t, &pluginstore.Manifest{}) + + if !strings.Contains(output, "No managed plugins installed") { + t.Errorf("list output %q does not contain 'No managed plugins installed'", output) + } +} + +// TestListCmd_showsDescription verifies that the stored manifest description +// appears in the list output without executing the plugin binary. +func TestListCmd_showsDescription(t *testing.T) { + t.Parallel() + + manifest := &pluginstore.Manifest{ + Plugins: map[string]*pluginstore.InstalledPlugin{ + "dns": { + Source: "github.com/datum-cloud/datumctl-dns", + Version: "v1.2.3", + SHA256: "abc", + InstalledAt: time.Now().UTC(), + Manifest: &pluginstore.PluginManifest{ + Name: "datumctl-dns", + Version: "v1.2.3", + Description: "Manage DNS zones on Datum Cloud", + APIVersion: plugindispatch.PluginAPIVersion, + }, + }, + }, + } + + output := executeListCmd(t, manifest) + + if !strings.Contains(output, "Manage DNS zones on Datum Cloud") { + t.Errorf("list output %q does not contain description", output) + } + if !strings.Contains(output, "v1.2.3") { + t.Errorf("list output %q does not contain version", output) + } + if !strings.Contains(output, "dns") { + t.Errorf("list output %q does not contain plugin name", output) + } +} + +// TestListCmd_showsAPIVersionMismatch verifies that when the stored api_version +// does not match the host API version, "!" appears in the output. +func TestListCmd_showsAPIVersionMismatch(t *testing.T) { + t.Parallel() + + mismatchedAPIVersion := plugindispatch.PluginAPIVersion + 999 + + manifest := &pluginstore.Manifest{ + Plugins: map[string]*pluginstore.InstalledPlugin{ + "dns": { + Source: "github.com/datum-cloud/datumctl-dns", + Version: "v0.1.0", + InstalledAt: time.Now().UTC(), + Manifest: &pluginstore.PluginManifest{ + Name: "datumctl-dns", + APIVersion: mismatchedAPIVersion, + }, + }, + }, + } + + output := executeListCmd(t, manifest) + + if !strings.Contains(output, "!") { + t.Errorf("list output %q does not contain '!' for API version mismatch", output) + } +} diff --git a/internal/cmd/plugin/plugin.go b/internal/cmd/plugin/plugin.go new file mode 100644 index 0000000..802f4ce --- /dev/null +++ b/internal/cmd/plugin/plugin.go @@ -0,0 +1,95 @@ +// Package plugin implements the 'datumctl plugin' subcommand group for +// installing, listing, upgrading, removing, and trusting datumctl plugins. +package plugin + +import ( + "fmt" + + "github.com/spf13/cobra" + + "go.datum.net/datumctl/internal/client" + "go.datum.net/datumctl/internal/pluginstore" +) + +// Command returns the root 'plugin' command with all subcommands registered. +func Command(factory *client.DatumCloudFactory) *cobra.Command { + cmd := &cobra.Command{ + Use: "plugin", + Short: "Manage datumctl plugins", + Long: `Manage datumctl plugins — install, list, upgrade, remove, and trust plugins. + +Plugins are independent binaries named 'datumctl-' that extend the CLI +with additional commands. Run them as 'datumctl '. + +Managed plugins are installed via 'datumctl plugin install' and recorded in +plugins.json (~/.local/share/datumctl/plugins/plugins.json by default). +Datumctl automatically verifies their SHA256 at install time. + +Unmanaged plugins (binaries found on PATH but not installed via this command) +show a verification warning on each invocation. Use 'datumctl plugin trust' +to suppress the warning.`, + Example: ` # Install the DNS plugin + datumctl plugin install datum-cloud/datumctl-dns + + # List all installed plugins + datumctl plugin list + + # Upgrade the dns plugin + datumctl plugin upgrade dns + + # Remove the dns plugin + datumctl plugin remove dns + + # Trust an unmanaged plugin on PATH + datumctl plugin trust dns`, + } + + cmd.PersistentFlags().String("plugins-dir", "", + "Override the managed plugins directory (default: ~/.local/share/datumctl/plugins/)") + + cmd.AddCommand( + installCmd(factory), + listCmd(), + searchCmd(), + upgradeCmd(factory), + removeCmd(), + trustCmd(), + untrustCmd(), + ) + return cmd +} + +// installedPluginNames returns completion candidates from plugins.json. +func installedPluginNames(cmd *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + pluginsDir, err := resolvePluginsDir(cmd) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + manifest, err := pluginstore.Load(pluginsDir) + if err != nil { + return nil, cobra.ShellCompDirectiveNoFileComp + } + names := make([]string, 0, len(manifest.Plugins)) + for name := range manifest.Plugins { + names = append(names, name) + } + return names, cobra.ShellCompDirectiveNoFileComp +} + +// resolvePluginsDir reads --plugins-dir from the nearest ancestor command that +// has a "plugins-dir" persistent flag, then falls back to pluginstore.PluginsDir. +func resolvePluginsDir(cmd *cobra.Command) (string, error) { + override := "" + // Walk up the command tree to find the persistent flag. + for c := cmd; c != nil; c = c.Parent() { + if f := c.PersistentFlags().Lookup("plugins-dir"); f != nil { + override = f.Value.String() + break + } + } + dir, err := pluginstore.PluginsDir(override) + if err != nil { + return "", fmt.Errorf("resolve plugins directory: %w", err) + } + return dir, nil +} diff --git a/internal/cmd/plugin/remove.go b/internal/cmd/plugin/remove.go new file mode 100644 index 0000000..89ead43 --- /dev/null +++ b/internal/cmd/plugin/remove.go @@ -0,0 +1,64 @@ +package plugin + +import ( + "fmt" + "os" + "path/filepath" + "runtime" + + "github.com/spf13/cobra" + + customerrors "go.datum.net/datumctl/internal/errors" + "go.datum.net/datumctl/internal/pluginstore" +) + +func removeCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "remove ", + Short: "Remove an installed plugin", + Long: `Remove a managed plugin binary and its plugins.json entry. + +The binary is deleted from the managed plugins directory. The plugin entry +is removed from plugins.json.`, + Example: ` # Remove the dns plugin + datumctl plugin remove dns`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + name := args[0] + pluginsDir, err := resolvePluginsDir(cmd) + if err != nil { + return err + } + + manifest, err := pluginstore.Load(pluginsDir) + if err != nil { + return fmt.Errorf("load plugins manifest: %w", err) + } + + if _, ok := manifest.Plugins[name]; !ok { + return customerrors.NewUserError(fmt.Sprintf("plugin %q is not installed", name)) + } + + // Remove the binary. + binaryName := "datumctl-" + name + if runtime.GOOS == "windows" { + binaryName += ".exe" + } + binaryPath := filepath.Join(pluginsDir, binaryName) + if err := os.Remove(binaryPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove plugin binary: %w", err) + } + + // Remove from manifest. + delete(manifest.Plugins, name) + if err := pluginstore.Save(pluginsDir, manifest); err != nil { + return fmt.Errorf("save plugins manifest: %w", err) + } + + fmt.Fprintf(cmd.OutOrStdout(), "Removed %s\n", name) + return nil + }, + } + cmd.ValidArgsFunction = installedPluginNames + return cmd +} diff --git a/internal/cmd/plugin/search.go b/internal/cmd/plugin/search.go new file mode 100644 index 0000000..c8a6943 --- /dev/null +++ b/internal/cmd/plugin/search.go @@ -0,0 +1,59 @@ +package plugin + +import ( + "fmt" + "strings" + "text/tabwriter" + + "github.com/spf13/cobra" + + customerrors "go.datum.net/datumctl/internal/errors" + "go.datum.net/datumctl/internal/pluginstore" +) + +func searchCmd() *cobra.Command { + return &cobra.Command{ + Use: "search [query]", + Short: "Search for available datumctl plugins", + Long: `Search the curated plugin index for available datumctl plugins. + +The index is fetched from the datum-cloud/datumctl-plugins repository and +cached locally. An optional query filters results by name or description. + +Run 'datumctl plugin install ' to install a plugin listed here.`, + Example: ` # List all available plugins + datumctl plugin search + + # Search for DNS-related plugins + datumctl plugin search dns`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + idx, err := pluginstore.LoadIndex() + if err != nil || pluginstore.IsStale(idx) { + idx, err = pluginstore.RefreshIndex(cmd.Context()) + if err != nil { + if idx == nil { + return customerrors.NewUserError("could not fetch plugin index: " + err.Error()) + } + fmt.Fprintf(cmd.ErrOrStderr(), "warning: index refresh failed (%v), showing cached results\n", err) + } + } + + query := "" + if len(args) == 1 { + query = strings.ToLower(args[0]) + } + + w := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 3, ' ', 0) + fmt.Fprintln(w, "NAME\tVERSION\tDESCRIPTION") + for _, p := range idx.Plugins { + if query != "" && !strings.Contains(p.Name, query) && + !strings.Contains(strings.ToLower(p.Spec.ShortDescription), query) { + continue + } + fmt.Fprintf(w, "%s\t%s\t%s\n", p.Name, p.Spec.Version, p.Spec.ShortDescription) + } + return w.Flush() + }, + } +} diff --git a/internal/cmd/plugin/trust.go b/internal/cmd/plugin/trust.go new file mode 100644 index 0000000..5c3b4d9 --- /dev/null +++ b/internal/cmd/plugin/trust.go @@ -0,0 +1,123 @@ +package plugin + +import ( + "fmt" + "os/exec" + "path/filepath" + "time" + + "github.com/spf13/cobra" + + customerrors "go.datum.net/datumctl/internal/errors" + "go.datum.net/datumctl/internal/pluginstore" +) + +func trustCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "trust ", + Short: "Trust an unmanaged plugin to suppress the verification warning", + Long: `Record that an unmanaged (PATH-installed) plugin is trusted. + +When datumctl finds a plugin binary on the PATH that is not recorded in +plugins.json, it shows a warning on every invocation. Use 'trust' to +suppress this warning for a specific plugin. + +The resolved absolute path is stored in plugins.json so the warning only +remains suppressed as long as the binary stays at the same location. + +To revoke trust, use 'datumctl plugin untrust '.`, + Example: ` # Trust the dns plugin found on PATH + datumctl plugin trust dns`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + name := args[0] + pluginsDir, err := resolvePluginsDir(cmd) + if err != nil { + return err + } + + // Resolve the binary path — managed dir first, then PATH. + binaryName := "datumctl-" + name + var resolvedPath string + + // Check managed dir first. + managedPath := filepath.Join(pluginsDir, binaryName) + found, lookErr := exec.LookPath(managedPath) + if lookErr == nil { + resolvedPath = found + } else { + // Try PATH. + found, lookErr = exec.LookPath(binaryName) + if lookErr != nil { + return customerrors.NewUserError(fmt.Sprintf("cannot find 'datumctl-%s' in the managed directory or PATH", name)) + } + resolvedPath = found + } + + // Resolve symlinks. + if abs, absErr := filepath.EvalSymlinks(resolvedPath); absErr == nil { + resolvedPath = abs + } + + manifest, err := pluginstore.Load(pluginsDir) + if err != nil { + return fmt.Errorf("load plugins manifest: %w", err) + } + if manifest.Trusted == nil { + manifest.Trusted = make(map[string]*pluginstore.TrustedEntry) + } + manifest.Trusted[name] = &pluginstore.TrustedEntry{ + Path: resolvedPath, + TrustedAt: time.Now().UTC(), + } + if err := pluginstore.Save(pluginsDir, manifest); err != nil { + return fmt.Errorf("save plugins manifest: %w", err) + } + + fmt.Fprintf(cmd.OutOrStdout(), "Trusted %s (%s)\n", name, resolvedPath) + fmt.Fprintf(cmd.OutOrStdout(), "To revoke: datumctl plugin untrust %s\n", name) + return nil + }, + } + cmd.ValidArgsFunction = installedPluginNames + return cmd +} + +func untrustCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "untrust ", + Short: "Remove a plugin from the trusted list", + Long: `Remove a previously trusted unmanaged plugin from plugins.json. + +After running this command, invoking the plugin will show the unmanaged +plugin warning again.`, + Example: ` # Revoke trust for the dns plugin + datumctl plugin untrust dns`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + name := args[0] + pluginsDir, err := resolvePluginsDir(cmd) + if err != nil { + return err + } + + manifest, err := pluginstore.Load(pluginsDir) + if err != nil { + return fmt.Errorf("load plugins manifest: %w", err) + } + + if manifest.Trusted == nil || manifest.Trusted[name] == nil { + return customerrors.NewUserError(fmt.Sprintf("plugin %q is not in the trusted list", name)) + } + + delete(manifest.Trusted, name) + if err := pluginstore.Save(pluginsDir, manifest); err != nil { + return fmt.Errorf("save plugins manifest: %w", err) + } + + fmt.Fprintf(cmd.OutOrStdout(), "Revoked trust for %s\n", name) + return nil + }, + } + return cmd +} diff --git a/internal/cmd/plugin/trust_test.go b/internal/cmd/plugin/trust_test.go new file mode 100644 index 0000000..21429af --- /dev/null +++ b/internal/cmd/plugin/trust_test.go @@ -0,0 +1,144 @@ +package plugin + +import ( + "bytes" + "os" + "path/filepath" + "strings" + "testing" + + "go.datum.net/datumctl/internal/pluginstore" +) + +// executeTrustCmd runs the trust subcommand against a temporary plugins dir +// with the given PATH and returns the error (if any) and stdout. +func executeTrustCmd(t *testing.T, dir, name, extraPATH string) (string, error) { + t.Helper() + + cmd := Command(nil) + cmd.PersistentFlags().Set("plugins-dir", dir) //nolint:errcheck + + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"trust", name}) + + if extraPATH != "" { + origPath := os.Getenv("PATH") + t.Setenv("PATH", extraPATH+string(os.PathListSeparator)+origPath) + } + + err := cmd.Execute() + return out.String(), err +} + +// executeUntrustCmd runs the untrust subcommand against a temporary plugins dir. +func executeUntrustCmd(t *testing.T, dir, name string) (string, error) { + t.Helper() + + cmd := Command(nil) + cmd.PersistentFlags().Set("plugins-dir", dir) //nolint:errcheck + + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"untrust", name}) + + err := cmd.Execute() + return out.String(), err +} + +// TestTrustCmd_writesRecord verifies that trusting a real executable writes an +// absolute path + timestamp to plugins.json. +func TestTrustCmd_writesRecord(t *testing.T) { + // Not parallel — uses t.Setenv. + dir := t.TempDir() + pathDir := t.TempDir() + + // Write a real executable on PATH. + binPath := filepath.Join(pathDir, "datumctl-mytools") + if err := os.WriteFile(binPath, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatalf("write binary: %v", err) + } + + _, err := executeTrustCmd(t, dir, "mytools", pathDir) + if err != nil { + t.Fatalf("trust cmd: %v", err) + } + + manifest, err := pluginstore.Load(dir) + if err != nil { + t.Fatalf("Load manifest: %v", err) + } + + entry, ok := manifest.Trusted["mytools"] + if !ok { + t.Fatal("trusted entry 'mytools' not found in plugins.json") + } + if !filepath.IsAbs(entry.Path) { + t.Errorf("trusted path %q is not absolute", entry.Path) + } + if !strings.Contains(entry.Path, "datumctl-mytools") { + t.Errorf("trusted path %q does not reference the binary", entry.Path) + } + if entry.TrustedAt.IsZero() { + t.Error("TrustedAt is zero — timestamp not written") + } +} + +// TestUntrustCmd_removesRecord verifies that untrusting a plugin removes the +// trusted entry from plugins.json. +func TestUntrustCmd_removesRecord(t *testing.T) { + // Not parallel — uses t.Setenv via executeTrustCmd. + dir := t.TempDir() + pathDir := t.TempDir() + + binPath := filepath.Join(pathDir, "datumctl-mytools") + if err := os.WriteFile(binPath, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatalf("write binary: %v", err) + } + + // First trust it. + _, err := executeTrustCmd(t, dir, "mytools", pathDir) + if err != nil { + t.Fatalf("trust cmd: %v", err) + } + + // Now untrust it. + _, err = executeUntrustCmd(t, dir, "mytools") + if err != nil { + t.Fatalf("untrust cmd: %v", err) + } + + manifest, err := pluginstore.Load(dir) + if err != nil { + t.Fatalf("Load manifest: %v", err) + } + + if manifest.Trusted != nil && manifest.Trusted["mytools"] != nil { + t.Error("trusted entry 'mytools' still present after untrust") + } +} + +// TestTrustCmd_errorWhenBinaryNotFound verifies that trust returns an error when +// the binary is not on PATH and not in the managed dir. +func TestTrustCmd_errorWhenBinaryNotFound(t *testing.T) { + // Not parallel — uses t.Setenv. + dir := t.TempDir() + + // Set PATH to an empty dir so the binary cannot be found. + t.Setenv("PATH", t.TempDir()) + + cmd := Command(nil) + cmd.PersistentFlags().Set("plugins-dir", dir) //nolint:errcheck + + var out bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&out) + cmd.SetArgs([]string{"trust", "nonexistent"}) + + err := cmd.Execute() + if err == nil { + t.Error("trust nonexistent binary: want error, got nil") + } +} diff --git a/internal/cmd/plugin/upgrade.go b/internal/cmd/plugin/upgrade.go new file mode 100644 index 0000000..f1299ba --- /dev/null +++ b/internal/cmd/plugin/upgrade.go @@ -0,0 +1,91 @@ +package plugin + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + componentversion "k8s.io/component-base/version" + + "go.datum.net/datumctl/internal/client" + customerrors "go.datum.net/datumctl/internal/errors" + "go.datum.net/datumctl/internal/pluginstore" +) + +func upgradeCmd(factory *client.DatumCloudFactory) *cobra.Command { + cmd := &cobra.Command{ + Use: "upgrade ", + Short: "Upgrade an installed plugin to the latest version", + Long: `Download and install the latest release of a managed plugin. + +The plugin must already be recorded in plugins.json. The same install flow +runs as 'datumctl plugin install': SHA256 verification, manifest check, and +compatibility validation.`, + Example: ` # Upgrade the dns plugin to the latest version + datumctl plugin upgrade dns`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + name := args[0] + pluginsDir, err := resolvePluginsDir(cmd) + if err != nil { + return err + } + + manifest, err := pluginstore.Load(pluginsDir) + if err != nil { + return fmt.Errorf("load plugins manifest: %w", err) + } + + entry, ok := manifest.Plugins[name] + if !ok { + return customerrors.NewUserError(fmt.Sprintf("plugin %q is not installed; run 'datumctl plugin install' first", name)) + } + + currentVersion := componentversion.Get().GitVersion + + // Load/refresh the index — three-case handling per design. + idx, refreshErr := pluginstore.RefreshIndex(cmd.Context()) + switch { + case refreshErr == nil: + // Success — proceed normally. + case idx != nil: + // Degraded: stale cache available. + fmt.Fprintf(cmd.ErrOrStderr(), "warning: index refresh failed (%v), using cached index\n", refreshErr) + default: + // No cache at all. + return customerrors.NewUserError(fmt.Sprintf("could not fetch plugin index: %v", refreshErr)) + } + + var newEntry *pluginstore.InstalledPlugin + + // Third-party source (owner/repo format) — use GitHub release flow. + if strings.Contains(entry.Source, "/") { + owner, repo, _, parseErr := parseSource(entry.Source) + if parseErr != nil { + return customerrors.NewUserError(fmt.Sprintf("invalid source for plugin %q: %v", name, parseErr)) + } + // Install latest (no pinned version → fetchLatestTag). + newEntry, _, err = installPluginFromGitHub(cmd.Context(), pluginsDir, owner, repo, "", currentVersion) + if err != nil { + return customerrors.NewUserError(fmt.Sprintf("upgrade plugin %s: %v", name, err)) + } + } else { + // Curated index source. + newEntry, _, err = installPlugin(cmd.Context(), pluginsDir, name, "", currentVersion, idx) + if err != nil { + return customerrors.NewUserError(fmt.Sprintf("upgrade plugin %s: %v", name, err)) + } + } + + manifest.Plugins[name] = newEntry + if err := pluginstore.Save(pluginsDir, manifest); err != nil { + return fmt.Errorf("save plugins manifest: %w", err) + } + + fmt.Fprintf(cmd.OutOrStdout(), "Upgraded %s to %s\n", name, newEntry.Version) + return nil + }, + } + cmd.ValidArgsFunction = installedPluginNames + return cmd +} diff --git a/internal/cmd/plugin_suggest.go b/internal/cmd/plugin_suggest.go new file mode 100644 index 0000000..a0d006b --- /dev/null +++ b/internal/cmd/plugin_suggest.go @@ -0,0 +1,87 @@ +package cmd + +import ( + "bufio" + "fmt" + "os" + "strings" + + "github.com/spf13/cobra" + "golang.org/x/term" + componentversion "k8s.io/component-base/version" + + plugincmd "go.datum.net/datumctl/internal/cmd/plugin" + "go.datum.net/datumctl/internal/client" + "go.datum.net/datumctl/internal/plugindispatch" + "go.datum.net/datumctl/internal/pluginstore" +) + +// suggestAndInstallPlugin checks the local plugin index for an exact match on +// name. If found and stdin is a TTY, it prompts the user to install and +// re-exec. Returns nil (allowing the caller to fall through to the normal +// "unknown command" error) when no match is found or the user declines. +func suggestAndInstallPlugin(cmd *cobra.Command, name, pluginsDir string, originalArgs []string, factory *client.DatumCloudFactory) error { + if !term.IsTerminal(int(os.Stdin.Fd())) { + return nil + } + + idx, err := pluginstore.LoadIndex() + if err != nil || pluginstore.IsStale(idx) { + fresh, refreshErr := pluginstore.RefreshIndex(cmd.Context()) + if refreshErr == nil { + idx = fresh + } + } + + entry := pluginstore.FindInIndex(idx, name) + if entry == nil { + return nil + } + + version := entry.Spec.Version + if version == "" { + version = "latest" + } + + fmt.Fprintf(cmd.ErrOrStderr(), "\nTo use %q you need to install the %s plugin.\n", name, name) + if entry.Spec.ShortDescription != "" { + fmt.Fprintf(cmd.ErrOrStderr(), "%s\n", entry.Spec.ShortDescription) + } + fmt.Fprintf(cmd.ErrOrStderr(), "\nWould you like to install it now? [y/N] ") + + scanner := bufio.NewScanner(os.Stdin) + if !scanner.Scan() { + return nil + } + if !strings.EqualFold(strings.TrimSpace(scanner.Text()), "y") { + return nil + } + + fmt.Fprintf(cmd.ErrOrStderr(), "\nInstalling %s...\n", entry.Name) + currentVersion := componentversion.Get().GitVersion + installed, _, installErr := plugincmd.InstallPlugin(cmd.Context(), pluginsDir, entry.Name, "", currentVersion, idx) + if installErr != nil { + return fmt.Errorf("install plugin: %w", installErr) + } + + // Register the installed plugin in plugins.json so plugin list/remove work. + manifest, loadErr := pluginstore.Load(pluginsDir) + if loadErr == nil { + if manifest.Plugins == nil { + manifest.Plugins = make(map[string]*pluginstore.InstalledPlugin) + } + manifest.Plugins[entry.Name] = installed + _ = pluginstore.Save(pluginsDir, manifest) + } + + binaryPath, _, findErr := plugindispatch.FindPlugin(name, pluginsDir) + if findErr != nil { + return fmt.Errorf("plugin installed but binary not found: %w", findErr) + } + + fmt.Fprintf(cmd.ErrOrStderr(), "Installed %s %s. Running: datumctl %s\n\n", + entry.Name, installed.Version, strings.Join(append([]string{name}, originalArgs[1:]...), " ")) + + // Re-exec the original command via the now-installed plugin. + return plugindispatch.Exec(binaryPath, originalArgs[1:], factory) +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 8c9da18..18c5d0f 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -4,6 +4,8 @@ import ( "context" "fmt" "os" + "path/filepath" + "strings" "time" "github.com/spf13/cobra" @@ -33,9 +35,12 @@ import ( "go.datum.net/datumctl/internal/cmd/docs" "go.datum.net/datumctl/internal/cmd/login" "go.datum.net/datumctl/internal/cmd/logout" + plugincmd "go.datum.net/datumctl/internal/cmd/plugin" "go.datum.net/datumctl/internal/cmd/whoami" "go.datum.net/datumctl/internal/datumconfig" customerrors "go.datum.net/datumctl/internal/errors" + "go.datum.net/datumctl/internal/plugindispatch" + "go.datum.net/datumctl/internal/pluginstore" "go.datum.net/datumctl/internal/updatecheck" ) @@ -57,6 +62,31 @@ func hidePersistentFlags(cmd *cobra.Command, flags ...string) { } func RootCmd() *cobra.Command { + // Resolve plugins directory early so ForwardCompletion can use it. + // Failures are non-fatal here — if we can't find the dir, completion + // forwarding simply won't work but built-in commands are unaffected. + earlyPluginsDir, _ := pluginstore.PluginsDir("") + + // Create factory before ForwardCompletion so DATUM_* environment variables + // can be injected into plugin processes during shell completion. This allows + // completion handlers that call the Datum API (e.g. listing workload names) + // to authenticate successfully. + ctx := context.Background() + factory, err := client.NewDatumFactory(ctx) + if err != nil { + panic(err) + } + + // ForwardCompletion and ForwardHelp must run before cobra.Execute() because + // Cobra intercepts both __complete and --help before RunE fires, and plugin + // names are not registered as Cobra subcommands. + if err := plugindispatch.ForwardCompletion(earlyPluginsDir, factory); err != nil { + fmt.Fprintf(os.Stderr, "warning: completion forwarding error: %v\n", err) + } + if err := plugindispatch.ForwardHelp(earlyPluginsDir); err != nil { + fmt.Fprintf(os.Stderr, "warning: help forwarding error: %v\n", err) + } + rootCmd := &cobra.Command{ Use: "datumctl", Short: "The official CLI for Datum Cloud", @@ -71,7 +101,9 @@ Get started: datumctl login datumctl get organizations datumctl get dnszones`, - Run: runLanding, + // ArbitraryArgs allows unknown subcommand names to reach RunE so the + // plugin dispatch logic can handle them before Cobra rejects them. + Args: cobra.ArbitraryArgs, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { format, _ := cmd.Flags().GetString("error-format") switch format { @@ -86,7 +118,84 @@ Get started: handleUpdateCheck(cmd) return nil }, + RunE: func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + runLanding(cmd, args) + return nil + } + name := args[0] + + // Built-in commands should have been dispatched by Cobra already. + // If we reach here with a built-in name it means something is wrong. + if plugindispatch.IsBuiltIn(cmd, name) { + return customerrors.NewUserError(fmt.Sprintf("unknown command %q; run 'datumctl --help' for available commands", name)) + } + + // Resolve plugins directory (respects DATUMCTL_PLUGINS_DIR env var or default). + pluginsDir, _ := pluginstore.PluginsDir("") + + binaryPath, managed, findErr := plugindispatch.FindPlugin(name, pluginsDir) + if findErr != nil { + if err := suggestAndInstallPlugin(cmd, name, pluginsDir, args, factory); err != nil { + return err + } + return customerrors.NewUserError(fmt.Sprintf("unknown command %q; run 'datumctl --help' for available commands", name)) + } + + if !managed { + // Check DATUMCTL_TRUSTED_PLUGINS env var. + trusted := isTrustedByEnv(name) + if !trusted { + // Check plugins.json trusted entries. + manifest, loadErr := pluginstore.Load(pluginsDir) + if loadErr == nil { + trusted = isTrustedByManifest(manifest, name, binaryPath) + } + } + if !trusted { + fmt.Fprintf(cmd.ErrOrStderr(), "warning: 'datumctl-%s' is not a managed plugin and has not been verified.\n To suppress this warning: datumctl plugin trust %s\n To install as a managed plugin: datumctl plugin install datum-cloud/datumctl-%s\n", + name, name, name) + } + } + + // Invocation-time compatibility check from plugins.json manifest if managed. + if managed { + manifest, loadErr := pluginstore.Load(pluginsDir) + if loadErr == nil && manifest.Plugins != nil { + if entry, ok := manifest.Plugins[name]; ok && entry.Manifest != nil { + currentVersion := componentversion.Get().GitVersion + if warn, compatErr := plugindispatch.CheckCompatibilityAtInvocation(entry.Manifest, currentVersion, plugindispatch.PluginAPIVersion); compatErr != nil { + return customerrors.NewUserError(compatErr.Error()) + } else if warn != "" { + fmt.Fprintf(cmd.ErrOrStderr(), "warning: %s\n", warn) + } + } + } + } + + return plugindispatch.Exec(binaryPath, args[1:], factory) + }, + } + // Surface installed and PATH plugins as tab-completion candidates for the + // root command so "datumctl com" resolves to plugin names alongside + // built-in subcommands. + rootCmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) != 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + pluginsDir, _ := pluginstore.PluginsDir("") + plugindispatch.LoadMissingManifests(pluginsDir) + descriptions := map[string]string{} + if m, err := pluginstore.Load(pluginsDir); err == nil { + for name, entry := range m.Plugins { + if entry.Manifest != nil && entry.Manifest.Description != "" { + descriptions[name] = entry.Manifest.Description + } + } + } + return plugindispatch.ListPluginNames(pluginsDir, descriptions), cobra.ShellCompDirectiveNoFileComp } + // kubectl version expects this flag to exist; add it here to avoid nil deref. rootCmd.PersistentFlags().Bool("warnings-as-errors", false, "Treat warnings as errors") rootCmd.PersistentFlags().String("error-format", customerrors.FormatHuman, @@ -97,11 +206,6 @@ Get started: ErrOut: rootCmd.ErrOrStderr(), } - ctx := context.Background() - factory, err := client.NewDatumFactory(ctx) - if err != nil { - panic(err) - } factory.AddFlags(rootCmd.PersistentFlags()) factory.AddFlagMutualExclusions(rootCmd) @@ -592,9 +696,53 @@ Specify the resource type and name to view its history.` consoleCmd.GroupID = "other" rootCmd.AddCommand(consoleCmd) + pluginCommand := plugincmd.Command(factory) + pluginCommand.GroupID = "other" + rootCmd.AddCommand(pluginCommand) + + // ForwardPlugin must run after the full command tree is built so IsBuiltIn + // can distinguish plugin names from registered subcommands. For managed + // plugins, this replaces the process before cobra can parse (and discard) + // flags that belong to the plugin's own subcommands. + if err := plugindispatch.ForwardPlugin(earlyPluginsDir, rootCmd, factory); err != nil { + fmt.Fprintf(os.Stderr, "warning: plugin forwarding error: %v\n", err) + } + return rootCmd } +// isTrustedByEnv checks the DATUMCTL_TRUSTED_PLUGINS comma-separated env var. +func isTrustedByEnv(name string) bool { + env := os.Getenv("DATUMCTL_TRUSTED_PLUGINS") + if env == "" { + return false + } + for _, trusted := range strings.Split(env, ",") { + if strings.TrimSpace(trusted) == name { + return true + } + } + return false +} + +// isTrustedByManifest checks plugins.json trusted entries for the given plugin. +// The resolved binary path must match the stored trusted path. +func isTrustedByManifest(manifest *pluginstore.Manifest, name, binaryPath string) bool { + if manifest == nil || manifest.Trusted == nil { + return false + } + entry, ok := manifest.Trusted[name] + if !ok { + return false + } + // Compare resolved paths. + resolvedBinary := binaryPath + if abs, err := filepath.EvalSymlinks(binaryPath); err == nil { + resolvedBinary = abs + } + return entry.Path == resolvedBinary +} + // activeUpdateChecker holds the in-flight update check between when it is // launched and when its result is consumed within PersistentPreRunE. Keyed by // the root command pointer so concurrent test invocations don't collide. diff --git a/internal/plugindispatch/completion.go b/internal/plugindispatch/completion.go new file mode 100644 index 0000000..750814c --- /dev/null +++ b/internal/plugindispatch/completion.go @@ -0,0 +1,121 @@ +package plugindispatch + +import ( + "os" + "os/exec" + "strings" + + "go.datum.net/datumctl/internal/client" +) + +// ForwardCompletion checks whether os.Args represents a __complete call for a plugin. +// If the second argument is "__complete" and the third argument resolves to a known +// plugin name (managed dir or PATH), it runs the plugin with the completion args and +// exits with the child's exit code. +// +// factory is optional; when non-nil the DATUM_* environment variables are injected +// into the plugin process so that completion handlers can authenticate API calls. +// If factory is nil or BuildEnv fails, the plugin is still executed without env +// injection (completion may return empty candidates rather than failing outright). +// +// Returns nil if not applicable (not a completion call, or name is not a plugin). +// Must be called before cobra.Execute(). +func ForwardCompletion(pluginsDir string, factory *client.DatumCloudFactory) error { + // Need at least: datumctl __complete [args...] + if len(os.Args) < 3 { + return nil + } + if os.Args[1] != "__complete" { + return nil + } + + name := os.Args[2] + + // Find the plugin binary. + binaryPath, _, err := FindPlugin(name, pluginsDir) + if err != nil { + // Not a plugin — let Cobra handle it. + return nil + } + + // Strip the plugin name from the forwarded args so the plugin sees + // ["__complete", ] rather than ["__complete", "compute", ]. + // The plugin's own cobra tree has no knowledge of its name as a subcommand. + pluginArgs := append([]string{"__complete"}, os.Args[3:]...) + + cmd := exec.Command(binaryPath, pluginArgs...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + // Inject DATUM_* variables so the plugin can authenticate API calls during + // completion. Non-fatal: if env construction fails we proceed without injection + // and the plugin will return empty candidates instead of erroring. + if factory != nil { + if env, buildErr := BuildEnv(factory); buildErr == nil { + cmd.Env = overlayEnv(os.Environ(), env) + } + } + + runErr := cmd.Run() + if runErr == nil { + os.Exit(0) + } + + if exitErr, ok := runErr.(*exec.ExitError); ok { + os.Exit(exitErr.ExitCode()) + } + // Unexpected error running the plugin — exit non-zero. + os.Exit(1) + return nil // unreachable +} + +// ForwardHelp checks whether os.Args looks like "datumctl ... --help/-h". +// If so, it execs the plugin with the original args (minus the binary name) so the +// plugin's own help is shown instead of datumctl's root help. +// +// Cobra intercepts --help before RunE fires, so this must be called before +// cobra.Execute(), mirroring the ForwardCompletion pattern. +func ForwardHelp(pluginsDir string) error { + args := os.Args[1:] // strip "datumctl" + if len(args) == 0 { + return nil + } + name := args[0] + if strings.HasPrefix(name, "-") { + return nil + } + + hasHelp := false + for _, a := range args[1:] { + if a == "--help" || a == "-h" { + hasHelp = true + break + } + } + if !hasHelp { + return nil + } + + binaryPath, _, err := FindPlugin(name, pluginsDir) + if err != nil { + // Not a known plugin — let Cobra handle it normally. + return nil + } + + // Forward the args to the plugin (strip the plugin name, keep the rest). + cmd := exec.Command(binaryPath, args[1:]...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + runErr := cmd.Run() + if runErr == nil { + os.Exit(0) + } + if exitErr, ok := runErr.(*exec.ExitError); ok { + os.Exit(exitErr.ExitCode()) + } + os.Exit(1) + return nil // unreachable +} diff --git a/internal/plugindispatch/completion_test.go b/internal/plugindispatch/completion_test.go new file mode 100644 index 0000000..d98cfd0 --- /dev/null +++ b/internal/plugindispatch/completion_test.go @@ -0,0 +1,47 @@ +package plugindispatch + +import ( + "os" + "testing" +) + +// TestForwardCompletion_noop_whenNotComplete verifies that ForwardCompletion +// returns nil without doing anything when os.Args[1] is not "__complete". +func TestForwardCompletion_noop_whenNotComplete(t *testing.T) { + t.Parallel() + + // ForwardCompletion reads os.Args but only acts when os.Args[1] == "__complete". + // We save and restore os.Args to isolate the test. + orig := os.Args + os.Args = []string{"datumctl", "get", "resourcegroups"} + t.Cleanup(func() { os.Args = orig }) + + managedDir := t.TempDir() + + err := ForwardCompletion(managedDir, nil) + if err != nil { + t.Errorf("ForwardCompletion with non-completion args: want nil, got %v", err) + } +} + +// TestForwardCompletion_noop_whenBuiltin verifies that ForwardCompletion returns +// nil (does not exec or exit) when the third argument is a built-in command name +// — we test by passing a managed dir that contains no plugins, so FindPlugin +// will fail and Cobra is left to handle it. +func TestForwardCompletion_noop_whenBuiltin(t *testing.T) { + t.Parallel() + + orig := os.Args + // "__complete" + builtin name "get" — FindPlugin("get", emptyDir) will fail, + // so ForwardCompletion returns nil without calling os.Exit. + os.Args = []string{"datumctl", "__complete", "get", ""} + t.Cleanup(func() { os.Args = orig }) + + // Use an empty managed dir so no plugin binary named "datumctl-get" exists. + managedDir := t.TempDir() + + err := ForwardCompletion(managedDir, nil) + if err != nil { + t.Errorf("ForwardCompletion for builtin name: want nil, got %v", err) + } +} diff --git a/internal/plugindispatch/dispatch.go b/internal/plugindispatch/dispatch.go new file mode 100644 index 0000000..7bbb3c2 --- /dev/null +++ b/internal/plugindispatch/dispatch.go @@ -0,0 +1,249 @@ +// Package plugindispatch handles plugin discovery, environment passthrough, +// process replacement, and shell completion forwarding for datumctl plugins. +package plugindispatch + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + "golang.org/x/mod/semver" + + "go.datum.net/datumctl/internal/authutil" + "go.datum.net/datumctl/internal/client" + "go.datum.net/datumctl/internal/datumconfig" + "go.datum.net/datumctl/internal/pluginstore" +) + +// Exec replaces the current process with the plugin binary. Sets DATUM_* +// environment variables before exec. On success this function does not return. +// Returns an error only if the exec setup fails. +func Exec(binaryPath string, args []string, factory *client.DatumCloudFactory) error { + env, err := BuildEnv(factory) + if err != nil { + env = nil + } + merged := overlayEnv(os.Environ(), env) + return execPlatform(binaryPath, args, merged) +} + +// PluginAPIVersion is the current plugin API version declared by this host. +const PluginAPIVersion = 1 + +// FindPlugin resolves a plugin name to an absolute binary path. +// Managed dir is searched first, then PATH. +// Returns (path, isManaged, error). +func FindPlugin(name, pluginsDir string) (path string, managed bool, err error) { + binaryName := "datumctl-" + name + + // Search managed dir first. + if pluginsDir != "" { + managedPath := filepath.Join(pluginsDir, binaryName) + if info, statErr := os.Stat(managedPath); statErr == nil && !info.IsDir() { + abs, absErr := filepath.Abs(managedPath) + if absErr != nil { + return "", false, fmt.Errorf("resolve managed plugin path: %w", absErr) + } + return abs, true, nil + } + } + + // Fall back to PATH. + found, lookErr := exec.LookPath(binaryName) + if lookErr != nil { + return "", false, fmt.Errorf("plugin %q not found in managed directory or PATH", name) + } + return found, false, nil +} + +// BuildEnv constructs the plugin ENV overlay as a []string suitable for +// appending to os.Environ(). It sets the six DATUM_* variables. +// Exported for testing. +func BuildEnv(factory *client.DatumCloudFactory) ([]string, error) { + // Resolve DATUM_CREDENTIALS_HELPER — must be absolute. + exePath, err := os.Executable() + if err != nil { + return nil, fmt.Errorf("resolve executable path: %w", err) + } + resolved, err := filepath.EvalSymlinks(exePath) + if err == nil { + exePath = resolved + } + + // Resolve scope. + project, org, _, scopeErr := factory.ConfigFlags.ResolvedScope() + if scopeErr != nil { + // Non-fatal: pass empty strings. Plugin can decide whether org/project are required. + project, org = "", "" + } + + // Resolve API host. + apiHost, hostErr := authutil.GetAPIHostname() + if hostErr != nil { + apiHost = "" + } + + // Resolve active session. + sessionName := "" + cfg, cfgErr := datumconfig.LoadAuto() + if cfgErr == nil && cfg != nil { + sessionName = cfg.ActiveSession + } + + return []string{ + "DATUM_ORG=" + org, + "DATUM_PROJECT=" + project, + "DATUM_API_HOST=" + apiHost, + fmt.Sprintf("DATUM_PLUGIN_API_VERSION=%d", PluginAPIVersion), + "DATUM_CREDENTIALS_HELPER=" + exePath, + "DATUM_SESSION=" + sessionName, + }, nil +} + +// ListPluginNames returns completion candidates for installed plugins. +// Each entry is "name\tdescription" (cobra tab-completion format). +// Managed plugins are listed first; PATH plugins follow with duplicates dropped. +// descriptions is an optional map of plugin name → description (from plugins.json). +func ListPluginNames(pluginsDir string, descriptions map[string]string) []string { + seen := map[string]bool{} + var names []string + + candidate := func(name, desc string) { + if name == "" || seen[name] { + return + } + seen[name] = true + if desc != "" { + names = append(names, name+"\t"+desc) + } else { + names = append(names, name) + } + } + + // Managed dir first. + if pluginsDir != "" { + entries, _ := os.ReadDir(pluginsDir) + for _, e := range entries { + if e.IsDir() { + continue + } + if n, ok := strings.CutPrefix(e.Name(), "datumctl-"); ok { + candidate(n, descriptions[n]) + } + } + } + + // PATH: walk every directory and collect datumctl-* executables. + for _, dir := range filepath.SplitList(os.Getenv("PATH")) { + entries, _ := os.ReadDir(dir) + for _, e := range entries { + if e.IsDir() { + continue + } + if n, ok := strings.CutPrefix(e.Name(), "datumctl-"); ok { + candidate(n, descriptions[n]) + } + } + } + + return names +} + +// IsBuiltIn reports whether name matches a registered Cobra subcommand or a +// reserved internal name. Built-in commands always take precedence over plugins. +func IsBuiltIn(root *cobra.Command, name string) bool { + // Reserved names that are always built-in. + reserved := map[string]bool{ + "help": true, + "completion": true, + "__complete": true, + "__completeNoDesc": true, + } + if reserved[name] { + return true + } + + // Walk the live command tree. + for _, sub := range root.Commands() { + if sub.Name() == name { + return true + } + // Check aliases. + for _, alias := range sub.Aliases { + if alias == name { + return true + } + } + } + return false +} + +// CheckCompatibilityAtInvocation performs the invocation-time compatibility checks. +// Some checks are warnings (soft) rather than hard errors. +// Returns (warn, nil) for soft warnings, ("", err) for hard blocking errors. +func CheckCompatibilityAtInvocation(m *pluginstore.PluginManifest, currentVersion string, currentAPIVersion int) (warn string, err error) { + if m == nil { + return "", nil + } + + // If host API version is lower than what the plugin was built against, refuse. + if m.APIVersion > currentAPIVersion { + return "", fmt.Errorf("plugin was built for API version %d but host only supports API version %d; upgrade datumctl to run this plugin", + m.APIVersion, currentAPIVersion) + } + + // If min_api_version is set and host is below it, hard block. + if m.MinAPIVersion > 0 && currentAPIVersion < m.MinAPIVersion { + return "", fmt.Errorf("plugin requires API version %d or higher (current: %d)", + m.MinAPIVersion, currentAPIVersion) + } + + var warns []string + + // Warn if plugin was built for an older API version (forward compatibility). + if m.APIVersion > 0 && m.APIVersion < currentAPIVersion { + warns = append(warns, fmt.Sprintf("plugin was built for API version %d (host is %d); it may not support all current features", + m.APIVersion, currentAPIVersion)) + } + + // Warn if datumctl version is below min_datumctl_version at invocation time. + if m.MinDatumctlVersion != "" && semver.IsValid(m.MinDatumctlVersion) { + if semver.IsValid(currentVersion) && semver.Compare(currentVersion, m.MinDatumctlVersion) < 0 { + warns = append(warns, fmt.Sprintf("plugin requires datumctl %s or newer (current: %s); some features may not work", + m.MinDatumctlVersion, currentVersion)) + } + } + + if len(warns) > 0 { + return strings.Join(warns, "; "), nil + } + return "", nil +} + +// overlayEnv merges overlay variables onto base, with overlay values winning. +// Overlay entries must be in "KEY=VALUE" format. +func overlayEnv(base []string, overlay []string) []string { + // Build a set of keys that appear in the overlay. + overrideKeys := make(map[string]struct{}, len(overlay)) + for _, kv := range overlay { + if idx := strings.IndexByte(kv, '='); idx >= 0 { + overrideKeys[kv[:idx]] = struct{}{} + } + } + + // Keep base entries whose keys are not in the overlay. + result := make([]string, 0, len(base)+len(overlay)) + for _, kv := range base { + if idx := strings.IndexByte(kv, '='); idx >= 0 { + if _, ok := overrideKeys[kv[:idx]]; ok { + continue + } + } + result = append(result, kv) + } + result = append(result, overlay...) + return result +} diff --git a/internal/plugindispatch/dispatch_test.go b/internal/plugindispatch/dispatch_test.go new file mode 100644 index 0000000..450b4a4 --- /dev/null +++ b/internal/plugindispatch/dispatch_test.go @@ -0,0 +1,241 @@ +package plugindispatch + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" + + "go.datum.net/datumctl/internal/client" +) + +// buildMinimalFactory creates a DatumCloudFactory suitable for unit tests. +// It uses NewDatumFactory but in an isolated home/config environment to avoid +// picking up the developer's real credentials. +func buildMinimalFactory(t *testing.T) *client.DatumCloudFactory { + t.Helper() + + // Point HOME to a temp dir so LoadAuto returns an empty config rather + // than reading real credentials. + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + t.Setenv("USERPROFILE", tmpHome) // Windows compat + + f, err := client.NewDatumFactory(context.Background()) + if err != nil { + t.Fatalf("NewDatumFactory: %v", err) + } + return f +} + +// writeFakeBinary writes an empty executable file at dir/name and returns its path. +func writeFakeBinary(t *testing.T, dir, name string) string { + t.Helper() + path := filepath.Join(dir, name) + if err := os.WriteFile(path, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatalf("write fake binary %s: %v", path, err) + } + return path +} + +// TestFindPlugin_managedWins verifies that a binary in the managed dir is +// returned (as managed=true) even when a same-named binary exists on PATH. +func TestFindPlugin_managedWins(t *testing.T) { + // Not parallel — uses t.Setenv. + managedDir := t.TempDir() + pathDir := t.TempDir() + + writeFakeBinary(t, managedDir, "datumctl-dns") + writeFakeBinary(t, pathDir, "datumctl-dns") + + origPath := os.Getenv("PATH") + t.Setenv("PATH", pathDir+string(os.PathListSeparator)+origPath) + + gotPath, managed, err := FindPlugin("dns", managedDir) + if err != nil { + t.Fatalf("FindPlugin: %v", err) + } + if !managed { + t.Errorf("FindPlugin: managed=false, want true — managed dir binary should win") + } + wantPath := filepath.Join(managedDir, "datumctl-dns") + wantAbs, _ := filepath.Abs(wantPath) + gotAbs, _ := filepath.Abs(gotPath) + if gotAbs != wantAbs { + t.Errorf("FindPlugin path: got %q, want %q", gotAbs, wantAbs) + } +} + +// TestFindPlugin_fallbackToPath verifies that when the binary is not in the +// managed dir, FindPlugin falls back to PATH and returns managed=false. +func TestFindPlugin_fallbackToPath(t *testing.T) { + // Not parallel — uses t.Setenv. + managedDir := t.TempDir() + pathDir := t.TempDir() + + writeFakeBinary(t, pathDir, "datumctl-dns") + + origPath := os.Getenv("PATH") + t.Setenv("PATH", pathDir+string(os.PathListSeparator)+origPath) + + gotPath, managed, err := FindPlugin("dns", managedDir) + if err != nil { + t.Fatalf("FindPlugin: %v", err) + } + if managed { + t.Errorf("FindPlugin: managed=true, want false — binary is on PATH only") + } + if !strings.Contains(gotPath, "datumctl-dns") { + t.Errorf("FindPlugin path %q does not contain 'datumctl-dns'", gotPath) + } +} + +// TestFindPlugin_noneFound verifies that FindPlugin returns an error when the +// binary is in neither the managed dir nor PATH. +func TestFindPlugin_noneFound(t *testing.T) { + // Not parallel — uses t.Setenv. + managedDir := t.TempDir() + + // Use an empty temp dir as the entire PATH so the binary cannot be found. + t.Setenv("PATH", t.TempDir()) + + _, _, err := FindPlugin("no-such-plugin", managedDir) + if err == nil { + t.Fatal("FindPlugin: want error for missing plugin, got nil") + } +} + +// TestIsBuiltIn_builtinReturnsTrue verifies that real cobra subcommand names +// return true. +func TestIsBuiltIn_builtinReturnsTrue(t *testing.T) { + t.Parallel() + + root := buildMinimalCobraTree() + + builtins := []string{"get", "login", "plugin", "help", "completion", "__complete"} + for _, name := range builtins { + if !IsBuiltIn(root, name) { + t.Errorf("IsBuiltIn(%q): got false, want true", name) + } + } +} + +// TestIsBuiltIn_unknownReturnsFalse verifies that unknown command names return false. +func TestIsBuiltIn_unknownReturnsFalse(t *testing.T) { + t.Parallel() + + root := buildMinimalCobraTree() + + unknown := []string{"dns", "myplugin", "foobar", "xyz"} + for _, name := range unknown { + if IsBuiltIn(root, name) { + t.Errorf("IsBuiltIn(%q): got true, want false", name) + } + } +} + +// buildMinimalCobraTree returns a minimal cobra root command that mirrors the +// real datumctl command tree just enough to test IsBuiltIn. +func buildMinimalCobraTree() *cobra.Command { + root := &cobra.Command{Use: "datumctl"} + root.AddCommand( + &cobra.Command{Use: "get"}, + &cobra.Command{Use: "login"}, + &cobra.Command{Use: "plugin"}, + ) + return root +} + +// TestBuildEnv_absoluteCredentialsHelper verifies that DATUM_CREDENTIALS_HELPER +// is an absolute path (not a bare "datumctl" basename). +func TestBuildEnv_absoluteCredentialsHelper(t *testing.T) { + // Not parallel — uses t.Setenv via buildMinimalFactory. + factory := buildMinimalFactory(t) + + env, err := BuildEnv(factory) + if err != nil { + t.Fatalf("BuildEnv: %v", err) + } + + helper := envValue(env, "DATUM_CREDENTIALS_HELPER") + if helper == "" { + t.Fatal("DATUM_CREDENTIALS_HELPER not found in env") + } + if !filepath.IsAbs(helper) { + t.Errorf("DATUM_CREDENTIALS_HELPER=%q is not an absolute path", helper) + } +} + +// TestBuildEnv_sessionPropagated verifies that DATUM_SESSION reflects the +// active session name from the datumctl config. +func TestBuildEnv_sessionPropagated(t *testing.T) { + // Not parallel — writes a config file to a temp HOME. + tmpHome := t.TempDir() + t.Setenv("HOME", tmpHome) + t.Setenv("USERPROFILE", tmpHome) + + cfgDir := filepath.Join(tmpHome, ".datumctl") + if err := os.MkdirAll(cfgDir, 0o755); err != nil { + t.Fatalf("mkdir .datumctl: %v", err) + } + cfgContent := "kind: DatumctlConfig\nactive-session: my-session\n" + if err := os.WriteFile(filepath.Join(cfgDir, "config"), []byte(cfgContent), 0o644); err != nil { + t.Fatalf("write config: %v", err) + } + + f, err := client.NewDatumFactory(context.Background()) + if err != nil { + t.Fatalf("NewDatumFactory: %v", err) + } + + env, err := BuildEnv(f) + if err != nil { + t.Fatalf("BuildEnv: %v", err) + } + + session := envValue(env, "DATUM_SESSION") + if session != "my-session" { + t.Errorf("DATUM_SESSION=%q, want %q", session, "my-session") + } +} + +// TestBuildEnv_sessionEmptyWhenNone verifies that DATUM_SESSION is present in +// the env slice but set to "" when no active session is configured. +func TestBuildEnv_sessionEmptyWhenNone(t *testing.T) { + // Not parallel — uses t.Setenv via buildMinimalFactory. + factory := buildMinimalFactory(t) + + env, err := BuildEnv(factory) + if err != nil { + t.Fatalf("BuildEnv: %v", err) + } + + found := false + for _, kv := range env { + if strings.HasPrefix(kv, "DATUM_SESSION=") { + found = true + val := strings.TrimPrefix(kv, "DATUM_SESSION=") + if val != "" { + t.Errorf("DATUM_SESSION=%q, want empty string when no session configured", val) + } + } + } + if !found { + t.Error("DATUM_SESSION key not found in BuildEnv output") + } +} + +// envValue extracts the value of key from a []string "KEY=VALUE" slice. +// Returns "" if not found. +func envValue(env []string, key string) string { + prefix := key + "=" + for _, kv := range env { + if strings.HasPrefix(kv, prefix) { + return strings.TrimPrefix(kv, prefix) + } + } + return "" +} diff --git a/internal/plugindispatch/exec_unix.go b/internal/plugindispatch/exec_unix.go new file mode 100644 index 0000000..450af26 --- /dev/null +++ b/internal/plugindispatch/exec_unix.go @@ -0,0 +1,14 @@ +//go:build !windows + +package plugindispatch + +import ( + kubecmd "k8s.io/kubectl/pkg/cmd" +) + +// execPlatform replaces the current process with binaryPath using syscall.Exec +// via kubectl's DefaultPluginHandler (which handles both Unix and Windows). +func execPlatform(binaryPath string, args []string, env []string) error { + handler := kubecmd.NewDefaultPluginHandler([]string{"datumctl"}) + return handler.Execute(binaryPath, args, env) +} diff --git a/internal/plugindispatch/exec_windows.go b/internal/plugindispatch/exec_windows.go new file mode 100644 index 0000000..b10732a --- /dev/null +++ b/internal/plugindispatch/exec_windows.go @@ -0,0 +1,29 @@ +//go:build windows + +package plugindispatch + +import ( + "errors" + "os" + "os/exec" +) + +// execPlatform runs binaryPath on Windows using exec.Command and os.Exit. +// Windows does not support syscall.Exec. +func execPlatform(binaryPath string, args []string, env []string) error { + cmd := exec.Command(binaryPath, args...) + cmd.Stdin = os.Stdin + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Env = env + + runErr := cmd.Run() + if runErr == nil { + os.Exit(0) + } + var exitErr *exec.ExitError + if errors.As(runErr, &exitErr) { + os.Exit(exitErr.ExitCode()) + } + return runErr +} diff --git a/internal/plugindispatch/forward.go b/internal/plugindispatch/forward.go new file mode 100644 index 0000000..96c3b24 --- /dev/null +++ b/internal/plugindispatch/forward.go @@ -0,0 +1,39 @@ +package plugindispatch + +import ( + "os" + "strings" + + "github.com/spf13/cobra" + + "go.datum.net/datumctl/internal/client" +) + +// ForwardPlugin checks whether os.Args represents a managed-plugin invocation and, +// if so, replaces the current process with the plugin binary before cobra can +// parse (and discard) flags that belong to the plugin's own subcommands. +// +// It only execs managed plugins (found in pluginsDir). PATH-based plugins reach +// the same destination via the root RunE, where trust-checking also runs. +// +// Must be called after the cobra command tree is fully built so that IsBuiltIn +// can correctly distinguish plugin names from registered subcommands. +// On success this function does not return (process is replaced). +func ForwardPlugin(pluginsDir string, root *cobra.Command, factory *client.DatumCloudFactory) error { + args := os.Args[1:] + if len(args) == 0 || strings.HasPrefix(args[0], "-") { + return nil + } + name := args[0] + + if IsBuiltIn(root, name) { + return nil + } + + binaryPath, managed, err := FindPlugin(name, pluginsDir) + if err != nil || !managed { + return nil + } + + return Exec(binaryPath, args[1:], factory) +} diff --git a/internal/plugindispatch/manifests.go b/internal/plugindispatch/manifests.go new file mode 100644 index 0000000..3ed61fd --- /dev/null +++ b/internal/plugindispatch/manifests.go @@ -0,0 +1,92 @@ +package plugindispatch + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "go.datum.net/datumctl/internal/pluginstore" +) + +const manifestProbeTimeout = 3 * time.Second + +// LoadMissingManifests scans pluginsDir for managed plugin binaries that have +// no manifest entry in plugins.json, probes each one with --plugin-manifest, +// and writes the results back to plugins.json. +// +// This is called lazily (e.g. during tab completion) so plugins placed manually +// into the managed directory get their descriptions populated on first use without +// requiring a full `datumctl plugin install` run. +func LoadMissingManifests(pluginsDir string) { + if pluginsDir == "" { + return + } + + manifest, err := pluginstore.Load(pluginsDir) + if err != nil { + manifest = &pluginstore.Manifest{Plugins: map[string]*pluginstore.InstalledPlugin{}} + } + if manifest.Plugins == nil { + manifest.Plugins = map[string]*pluginstore.InstalledPlugin{} + } + + entries, err := os.ReadDir(pluginsDir) + if err != nil { + return + } + + updated := false + for _, e := range entries { + if e.IsDir() { + continue + } + name, ok := strings.CutPrefix(e.Name(), "datumctl-") + if !ok || name == "" { + continue + } + + entry := manifest.Plugins[name] + if entry != nil && entry.Manifest != nil { + continue // already have manifest + } + + binaryPath := filepath.Join(pluginsDir, e.Name()) + m := probeManifest(binaryPath) + if m == nil { + continue + } + + if entry == nil { + entry = &pluginstore.InstalledPlugin{} + } + entry.Manifest = m + if entry.Version == "" && m.Version != "" { + entry.Version = m.Version + } + manifest.Plugins[name] = entry + updated = true + } + + if updated { + _ = pluginstore.Save(pluginsDir, manifest) + } +} + +func probeManifest(binaryPath string) *pluginstore.PluginManifest { + ctx, cancel := context.WithTimeout(context.Background(), manifestProbeTimeout) + defer cancel() + + out, err := exec.CommandContext(ctx, binaryPath, "--plugin-manifest").Output() + if err != nil || len(out) == 0 { + return nil + } + var m pluginstore.PluginManifest + if err := json.Unmarshal(out, &m); err != nil { + return nil + } + return &m +} diff --git a/internal/pluginstore/index.go b/internal/pluginstore/index.go new file mode 100644 index 0000000..9704686 --- /dev/null +++ b/internal/pluginstore/index.go @@ -0,0 +1,165 @@ +package pluginstore + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "time" + + "sigs.k8s.io/yaml" +) + +const indexStaleTTL = time.Hour + +// IndexURL is the location of the remote plugin index. Override with +// DATUMCTL_PLUGIN_INDEX_URL for testing or custom deployments. +var IndexURL = "https://raw.githubusercontent.com/datum-cloud/datumctl-plugins/main/index.yaml" + +func init() { + if u := os.Getenv("DATUMCTL_PLUGIN_INDEX_URL"); u != "" { + IndexURL = u + } +} + +// IndexPath returns the path to the local plugin index file. +func IndexPath() (string, error) { + dir, err := PluginsDir("") + if err != nil { + return "", err + } + return filepath.Join(dir, "plugin-index.json"), nil +} + +// LoadIndex reads the cached index from disk. If the file does not exist or +// cannot be parsed (e.g. old format), it returns a zero-value CachedIndex +// (which IsStale returns true for) and no error. +func LoadIndex() (*CachedIndex, error) { + path, err := IndexPath() + if err != nil { + return nil, err + } + data, err := os.ReadFile(path) + if os.IsNotExist(err) { + return &CachedIndex{}, nil + } + if err != nil { + return nil, err + } + var idx CachedIndex + if err := json.Unmarshal(data, &idx); err != nil { + // Old format or corrupt — return zero-value so caller knows to refresh. + return &CachedIndex{}, nil + } + return &idx, nil +} + +// SaveIndex writes the index to disk atomically. +func SaveIndex(idx *CachedIndex) error { + path, err := IndexPath() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + data, err := json.MarshalIndent(idx, "", " ") + if err != nil { + return err + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0o600); err != nil { + return err + } + return os.Rename(tmp, path) +} + +// IsStale reports whether the index is missing or older than the TTL. +func IsStale(idx *CachedIndex) bool { + return idx == nil || idx.RefreshedAt.IsZero() || time.Since(idx.RefreshedAt) > indexStaleTTL +} + +// FindInIndex returns the Plugin whose Name (from ObjectMeta) exactly matches +// pluginName, or nil if not found. +func FindInIndex(idx *CachedIndex, pluginName string) *Plugin { + if idx == nil { + return nil + } + for i := range idx.Plugins { + if idx.Plugins[i].Name == pluginName { + return &idx.Plugins[i] + } + } + return nil +} + +// RefreshIndex fetches IndexURL, parses the PluginList, saves, and returns the +// result. +// +// Three-case return contract: +// - (non-nil, nil) — success +// - (non-nil, non-nil) — fetch failed but stale cache exists on disk +// - (nil, non-nil) — fetch failed and no cache available +func RefreshIndex(ctx context.Context) (*CachedIndex, error) { + httpCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(httpCtx, http.MethodGet, IndexURL, nil) + if err != nil { + return degradedFallback(fmt.Errorf("build index request: %w", err)) + } + + // Attach GitHub token if available. + if token := githubToken(); token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + req.Header.Set("User-Agent", "datumctl-plugin-index") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return degradedFallback(fmt.Errorf("fetch plugin index: %w", err)) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return degradedFallback(fmt.Errorf("fetch plugin index: HTTP %s", resp.Status)) + } + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return degradedFallback(fmt.Errorf("read index response: %w", err)) + } + + var list PluginList + if err := yaml.Unmarshal(raw, &list); err != nil { + return degradedFallback(fmt.Errorf("parse plugin index: %w", err)) + } + + idx := &CachedIndex{ + RefreshedAt: time.Now(), + Plugins: list.Items, + } + _ = SaveIndex(idx) + return idx, nil +} + +// degradedFallback tries to load a stale cache and returns it alongside the +// original error. If no cache exists, returns (nil, err). +func degradedFallback(origErr error) (*CachedIndex, error) { + cached, loadErr := LoadIndex() + if loadErr != nil || cached == nil || cached.RefreshedAt.IsZero() { + return nil, origErr + } + return cached, origErr +} + +// githubToken returns a GitHub personal access token from the environment. +func githubToken() string { + if t := os.Getenv("DATUMCTL_GITHUB_TOKEN"); t != "" { + return t + } + return os.Getenv("GITHUB_TOKEN") +} diff --git a/internal/pluginstore/manifest.go b/internal/pluginstore/manifest.go new file mode 100644 index 0000000..af8a8cd --- /dev/null +++ b/internal/pluginstore/manifest.go @@ -0,0 +1,52 @@ +package pluginstore + +import ( + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// Plugin is the index record for a single datumctl plugin. +// apiVersion/kind fields follow Kubernetes object conventions but are +// used only for YAML parsing — no API server is involved. +type Plugin struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + Spec PluginSpec `json:"spec"` +} + +// PluginSpec holds the plugin's release metadata and per-platform download info. +type PluginSpec struct { + ShortDescription string `json:"shortDescription"` + Description string `json:"description,omitempty"` + Homepage string `json:"homepage,omitempty"` + Version string `json:"version"` + Platforms []Platform `json:"platforms"` +} + +// Platform describes a downloadable archive for one OS/arch combination. +// Selector is matched against {"os": GOOS, "arch": GOARCH}. +type Platform struct { + Selector *metav1.LabelSelector `json:"selector,omitempty"` + URI string `json:"uri"` + SHA256 string `json:"sha256"` + Files []FileOperation `json:"files,omitempty"` +} + +// FileOperation specifies how to copy a file out of the downloaded archive. +type FileOperation struct { + From string `json:"from"` + To string `json:"to,omitempty"` +} + +// CachedIndex is the on-disk cache of the remote plugin index. +type CachedIndex struct { + RefreshedAt time.Time `json:"refreshed_at"` + Plugins []Plugin `json:"plugins"` +} + +// PluginList is the wrapper document type for the remote index.yaml. +type PluginList struct { + metav1.TypeMeta `json:",inline"` + Items []Plugin `json:"items"` +} diff --git a/internal/pluginstore/selector.go b/internal/pluginstore/selector.go new file mode 100644 index 0000000..81a4f8a --- /dev/null +++ b/internal/pluginstore/selector.go @@ -0,0 +1,27 @@ +package pluginstore + +import ( + "fmt" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" +) + +// GetMatchingPlatform returns the first Platform whose Selector matches +// {os: goos, arch: goarch}. A nil or empty Selector matches everything. +func GetMatchingPlatform(plugin *Plugin, goos, goarch string) (*Platform, error) { + for i := range plugin.Spec.Platforms { + p := &plugin.Spec.Platforms[i] + if p.Selector == nil || (len(p.Selector.MatchLabels) == 0 && len(p.Selector.MatchExpressions) == 0) { + return p, nil + } + sel, err := metav1.LabelSelectorAsSelector(p.Selector) + if err != nil { + return nil, fmt.Errorf("invalid selector for platform: %w", err) + } + if sel.Matches(labels.Set{"os": goos, "arch": goarch}) { + return p, nil + } + } + return nil, fmt.Errorf("no platform found matching os=%s arch=%s", goos, goarch) +} diff --git a/internal/pluginstore/store.go b/internal/pluginstore/store.go new file mode 100644 index 0000000..3ea5f96 --- /dev/null +++ b/internal/pluginstore/store.go @@ -0,0 +1,79 @@ +package pluginstore + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" +) + +const ( + pluginsDirEnvVar = "DATUMCTL_PLUGINS_DIR" + manifestFileName = "plugins.json" +) + +// PluginsDir returns the resolved managed plugins directory. +// Respects (in order): explicit override arg, DATUMCTL_PLUGINS_DIR env var, default. +// Default is ~/.datumctl/plugins/, consistent with ~/.datumctl/config and credentials. +func PluginsDir(override string) (string, error) { + if override != "" { + return override, nil + } + if env := os.Getenv(pluginsDirEnvVar); env != "" { + return env, nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home directory: %w", err) + } + return filepath.Join(home, ".datumctl", "plugins"), nil +} + +// ManifestPath returns /plugins.json. +func ManifestPath(pluginsDir string) string { + return filepath.Join(pluginsDir, manifestFileName) +} + +// Load reads plugins.json from pluginsDir. Returns an empty Manifest if not found. +func Load(pluginsDir string) (*Manifest, error) { + path := ManifestPath(pluginsDir) + data, err := os.ReadFile(path) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return &Manifest{}, nil + } + return nil, fmt.Errorf("read plugins manifest: %w", err) + } + + var m Manifest + if err := json.Unmarshal(data, &m); err != nil { + return nil, fmt.Errorf("parse plugins manifest: %w", err) + } + return &m, nil +} + +// Save writes plugins.json atomically (write to tmp, rename). +// It creates the pluginsDir if it does not exist. +func Save(pluginsDir string, m *Manifest) error { + if err := os.MkdirAll(pluginsDir, 0o755); err != nil { + return fmt.Errorf("create plugins directory: %w", err) + } + + data, err := json.MarshalIndent(m, "", " ") + if err != nil { + return fmt.Errorf("marshal plugins manifest: %w", err) + } + data = append(data, '\n') + + path := ManifestPath(pluginsDir) + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0o644); err != nil { + return fmt.Errorf("write plugins manifest tmp: %w", err) + } + if err := os.Rename(tmp, path); err != nil { + _ = os.Remove(tmp) + return fmt.Errorf("replace plugins manifest: %w", err) + } + return nil +} diff --git a/internal/pluginstore/store_test.go b/internal/pluginstore/store_test.go new file mode 100644 index 0000000..1233250 --- /dev/null +++ b/internal/pluginstore/store_test.go @@ -0,0 +1,193 @@ +package pluginstore + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestLoad_missing(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + m, err := Load(dir) + if err != nil { + t.Fatalf("Load from nonexistent path: want nil error, got %v", err) + } + if m == nil { + t.Fatal("Load from nonexistent path: want empty Manifest{}, got nil") + } + if len(m.Plugins) != 0 || len(m.Trusted) != 0 { + t.Errorf("Load from nonexistent path: want empty Manifest, got %+v", m) + } +} + +func TestLoad_malformed(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := ManifestPath(dir) + if err := os.WriteFile(path, []byte("{not valid json"), 0o644); err != nil { + t.Fatalf("write malformed file: %v", err) + } + + _, err := Load(dir) + if err == nil { + t.Fatal("Load from malformed JSON: want error, got nil") + } +} + +func TestSave_roundtrip(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + + original := &Manifest{ + Plugins: map[string]*InstalledPlugin{ + "dns": { + Source: "github.com/datum-cloud/datumctl-dns", + Version: "v1.2.3", + SHA256: "abc123", + InstalledAt: time.Date(2024, 1, 15, 12, 0, 0, 0, time.UTC), + Manifest: &PluginManifest{ + Name: "datumctl-dns", + Version: "v1.2.3", + Description: "DNS plugin for datumctl", + APIVersion: 1, + }, + }, + }, + Trusted: map[string]*TrustedEntry{ + "mytools": { + Path: "/usr/local/bin/datumctl-mytools", + TrustedAt: time.Date(2024, 2, 1, 8, 0, 0, 0, time.UTC), + }, + }, + } + + if err := Save(dir, original); err != nil { + t.Fatalf("Save: %v", err) + } + + loaded, err := Load(dir) + if err != nil { + t.Fatalf("Load after Save: %v", err) + } + + if loaded.Plugins == nil { + t.Fatal("roundtrip: Plugins map is nil") + } + dns, ok := loaded.Plugins["dns"] + if !ok { + t.Fatal("roundtrip: 'dns' plugin entry missing") + } + if dns.Source != original.Plugins["dns"].Source { + t.Errorf("roundtrip Source: got %q, want %q", dns.Source, original.Plugins["dns"].Source) + } + if dns.Version != original.Plugins["dns"].Version { + t.Errorf("roundtrip Version: got %q, want %q", dns.Version, original.Plugins["dns"].Version) + } + if dns.SHA256 != original.Plugins["dns"].SHA256 { + t.Errorf("roundtrip SHA256: got %q, want %q", dns.SHA256, original.Plugins["dns"].SHA256) + } + if dns.Manifest == nil { + t.Fatal("roundtrip: Manifest is nil") + } + if dns.Manifest.Description != original.Plugins["dns"].Manifest.Description { + t.Errorf("roundtrip Manifest.Description: got %q, want %q", + dns.Manifest.Description, original.Plugins["dns"].Manifest.Description) + } + + if loaded.Trusted == nil { + t.Fatal("roundtrip: Trusted map is nil") + } + trusted, ok := loaded.Trusted["mytools"] + if !ok { + t.Fatal("roundtrip: 'mytools' trusted entry missing") + } + if trusted.Path != original.Trusted["mytools"].Path { + t.Errorf("roundtrip Trusted.Path: got %q, want %q", trusted.Path, original.Trusted["mytools"].Path) + } +} + +func TestSave_atomic(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + manifestPath := ManifestPath(dir) + tmpPath := manifestPath + ".tmp" + + m := &Manifest{ + Plugins: map[string]*InstalledPlugin{ + "dns": { + Source: "github.com/datum-cloud/datumctl-dns", + Version: "v1.0.0", + SHA256: "deadbeef", + }, + }, + } + + if err := Save(dir, m); err != nil { + t.Fatalf("Save: %v", err) + } + + // After a successful save, the .tmp file must not exist (it was renamed away). + if _, err := os.Stat(tmpPath); !os.IsNotExist(err) { + t.Errorf("atomic save: .tmp file still exists after successful Save — want it removed after rename") + } + + // The final file must exist and be valid JSON. + _, err := Load(dir) + if err != nil { + t.Fatalf("Load after atomic save: %v", err) + } +} + +func TestPluginsDir_default(t *testing.T) { + // Do not run in parallel — manipulates env. + t.Setenv(pluginsDirEnvVar, "") + + dir, err := PluginsDir("") + if err != nil { + t.Fatalf("PluginsDir default: %v", err) + } + + want := filepath.Join(".datumctl", "plugins") + if !strings.HasSuffix(dir, want) { + t.Errorf("PluginsDir default: got %q, want suffix %q", dir, want) + } +} + +func TestPluginsDir_envVar(t *testing.T) { + // Do not run in parallel — manipulates env. + customDir := t.TempDir() + t.Setenv(pluginsDirEnvVar, customDir) + + dir, err := PluginsDir("") + if err != nil { + t.Fatalf("PluginsDir env var: %v", err) + } + + if dir != customDir { + t.Errorf("PluginsDir env var: got %q, want %q", dir, customDir) + } +} + +func TestPluginsDir_flagOverride(t *testing.T) { + // Do not run in parallel — manipulates env. + envDir := t.TempDir() + t.Setenv(pluginsDirEnvVar, envDir) + + flagDir := t.TempDir() + + dir, err := PluginsDir(flagDir) + if err != nil { + t.Fatalf("PluginsDir flag override: %v", err) + } + + if dir != flagDir { + t.Errorf("PluginsDir flag override: got %q, want %q (env was %q)", dir, flagDir, envDir) + } +} diff --git a/internal/pluginstore/types.go b/internal/pluginstore/types.go new file mode 100644 index 0000000..5fcb2df --- /dev/null +++ b/internal/pluginstore/types.go @@ -0,0 +1,34 @@ +package pluginstore + +import "time" + +// Manifest is the in-memory representation of plugins.json. +type Manifest struct { + Plugins map[string]*InstalledPlugin `json:"plugins,omitempty"` + Trusted map[string]*TrustedEntry `json:"trusted,omitempty"` +} + +// InstalledPlugin is one entry in the managed install record. +type InstalledPlugin struct { + Source string `json:"source"` + Version string `json:"version"` + SHA256 string `json:"sha256"` + InstalledAt time.Time `json:"installed_at"` + Manifest *PluginManifest `json:"manifest"` +} + +// PluginManifest is the JSON produced by a plugin binary's --plugin-manifest flag. +type PluginManifest struct { + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description"` + MinDatumctlVersion string `json:"min_datumctl_version,omitempty"` + APIVersion int `json:"api_version"` + MinAPIVersion int `json:"min_api_version,omitempty"` +} + +// TrustedEntry records a trusted PATH-plugin binary path. +type TrustedEntry struct { + Path string `json:"path"` + TrustedAt time.Time `json:"trusted_at"` +} diff --git a/plugin/completion.go b/plugin/completion.go new file mode 100644 index 0000000..d0967fc --- /dev/null +++ b/plugin/completion.go @@ -0,0 +1,36 @@ +package plugin + +import ( + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// WithFlagCompletion wraps a ValidArgsFunction so that the command's flags are +// included as completion candidates on plain , not only when the user has +// already typed "--". This is useful for commands whose primary input is flags +// (e.g. deploy) so users discover available options without needing to type a +// prefix first. +// +// If inner is nil, only flags are returned. +func WithFlagCompletion(inner cobra.CompletionFunc) cobra.CompletionFunc { + return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + var results []string + directive := cobra.ShellCompDirectiveNoFileComp + + if inner != nil { + results, directive = inner(cmd, args, toComplete) + } + + cmd.Flags().VisitAll(func(f *pflag.Flag) { + if !f.Hidden { + entry := "--" + f.Name + if f.Usage != "" { + entry += "\t" + f.Usage + } + results = append(results, entry) + } + }) + + return results, directive + } +} diff --git a/plugin/context.go b/plugin/context.go new file mode 100644 index 0000000..d549026 --- /dev/null +++ b/plugin/context.go @@ -0,0 +1,44 @@ +// Package plugin is the Go SDK for datumctl plugins. Plugin authors can import +// this package to get automatic context injection, credential helper access, +// and pre-wired Cobra flags. +// +// This package must never import from go.datum.net/datumctl/internal — it reads +// only environment variables and execs subprocesses so that any Go binary can +// depend on it without pulling in internal dependencies. +package plugin + +import ( + "os" + "strconv" +) + +// PluginContext holds the context injected by datumctl before exec-replacing a plugin. +type PluginContext struct { + // Org is the current Datum Cloud organization slug (DATUM_ORG). + Org string + // Project is the current Datum Cloud project slug (DATUM_PROJECT). Empty if not set. + Project string + // APIHost is the Datum Cloud API base URL (DATUM_API_HOST), e.g. "api.datum.net". + APIHost string + // PluginAPIVersion is the integer API version the host declares (DATUM_PLUGIN_API_VERSION). + PluginAPIVersion int + // CredentialsHelper is the absolute path to the datumctl binary (DATUM_CREDENTIALS_HELPER). + CredentialsHelper string + // Session is the active datumctl session name (DATUM_SESSION). May be empty. + Session string +} + +// Context reads all DATUM_* environment variables and returns a PluginContext. +// It does not validate that required variables are set; callers should check +// PluginContext.Org / PluginContext.Project before making API calls. +func Context() PluginContext { + apiVer, _ := strconv.Atoi(os.Getenv("DATUM_PLUGIN_API_VERSION")) + return PluginContext{ + Org: os.Getenv("DATUM_ORG"), + Project: os.Getenv("DATUM_PROJECT"), + APIHost: os.Getenv("DATUM_API_HOST"), + PluginAPIVersion: apiVer, + CredentialsHelper: os.Getenv("DATUM_CREDENTIALS_HELPER"), + Session: os.Getenv("DATUM_SESSION"), + } +} diff --git a/plugin/context_test.go b/plugin/context_test.go new file mode 100644 index 0000000..098a123 --- /dev/null +++ b/plugin/context_test.go @@ -0,0 +1,84 @@ +package plugin + +import ( + "testing" +) + +// TestContext_readsAllEnvVars verifies that Context() reads all DATUM_* variables +// and reflects them in the returned PluginContext. +func TestContext_readsAllEnvVars(t *testing.T) { + // Not parallel — uses t.Setenv. + t.Setenv("DATUM_ORG", "test-org") + t.Setenv("DATUM_PROJECT", "test-project") + t.Setenv("DATUM_API_HOST", "api.test.datum.net") + t.Setenv("DATUM_PLUGIN_API_VERSION", "3") + t.Setenv("DATUM_CREDENTIALS_HELPER", "/usr/local/bin/datumctl") + t.Setenv("DATUM_SESSION", "prod") + + ctx := Context() + + if ctx.Org != "test-org" { + t.Errorf("Org = %q, want %q", ctx.Org, "test-org") + } + if ctx.Project != "test-project" { + t.Errorf("Project = %q, want %q", ctx.Project, "test-project") + } + if ctx.APIHost != "api.test.datum.net" { + t.Errorf("APIHost = %q, want %q", ctx.APIHost, "api.test.datum.net") + } + if ctx.PluginAPIVersion != 3 { + t.Errorf("PluginAPIVersion = %d, want 3", ctx.PluginAPIVersion) + } + if ctx.CredentialsHelper != "/usr/local/bin/datumctl" { + t.Errorf("CredentialsHelper = %q, want %q", ctx.CredentialsHelper, "/usr/local/bin/datumctl") + } + if ctx.Session != "prod" { + t.Errorf("Session = %q, want %q", ctx.Session, "prod") + } +} + +// TestContext_missingVarsReturnEmpty verifies that Context() returns zero values +// when no DATUM_* variables are set, and does not panic. +func TestContext_missingVarsReturnEmpty(t *testing.T) { + // Not parallel — clears env vars. + t.Setenv("DATUM_ORG", "") + t.Setenv("DATUM_PROJECT", "") + t.Setenv("DATUM_API_HOST", "") + t.Setenv("DATUM_PLUGIN_API_VERSION", "") + t.Setenv("DATUM_CREDENTIALS_HELPER", "") + t.Setenv("DATUM_SESSION", "") + + ctx := Context() + + if ctx.Org != "" { + t.Errorf("Org = %q, want empty", ctx.Org) + } + if ctx.Project != "" { + t.Errorf("Project = %q, want empty", ctx.Project) + } + if ctx.APIHost != "" { + t.Errorf("APIHost = %q, want empty", ctx.APIHost) + } + if ctx.PluginAPIVersion != 0 { + t.Errorf("PluginAPIVersion = %d, want 0", ctx.PluginAPIVersion) + } + if ctx.CredentialsHelper != "" { + t.Errorf("CredentialsHelper = %q, want empty", ctx.CredentialsHelper) + } + if ctx.Session != "" { + t.Errorf("Session = %q, want empty", ctx.Session) + } +} + +// TestContext_apiVersionParseError verifies that a non-numeric +// DATUM_PLUGIN_API_VERSION produces PluginAPIVersion == 0 (not a panic). +func TestContext_apiVersionParseError(t *testing.T) { + // Not parallel — uses t.Setenv. + t.Setenv("DATUM_PLUGIN_API_VERSION", "not-a-number") + + ctx := Context() + + if ctx.PluginAPIVersion != 0 { + t.Errorf("PluginAPIVersion = %d, want 0 for non-numeric input", ctx.PluginAPIVersion) + } +} diff --git a/plugin/manifest.go b/plugin/manifest.go new file mode 100644 index 0000000..118c9d3 --- /dev/null +++ b/plugin/manifest.go @@ -0,0 +1,35 @@ +package plugin + +import ( + "encoding/json" + "fmt" + "os" +) + +// Manifest describes a plugin binary. Plugin binaries should call ServeManifest(m) +// at the top of main() to handle the --plugin-manifest protocol automatically. +type Manifest struct { + Name string `json:"name"` + Version string `json:"version"` + Description string `json:"description"` + MinDatumctlVersion string `json:"min_datumctl_version,omitempty"` + APIVersion int `json:"api_version"` + MinAPIVersion int `json:"min_api_version,omitempty"` +} + +// ServeManifest checks os.Args for --plugin-manifest. If found, it prints m as JSON +// to stdout and exits 0. This must be called before cobra.Execute() so the manifest +// is served even if cobra flag parsing would otherwise fail. +func ServeManifest(m Manifest) { + for _, arg := range os.Args[1:] { + if arg == "--plugin-manifest" { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + if err := enc.Encode(m); err != nil { + fmt.Fprintf(os.Stderr, "plugin manifest encode error: %v\n", err) + os.Exit(1) + } + os.Exit(0) + } + } +} diff --git a/plugin/manifest_test.go b/plugin/manifest_test.go new file mode 100644 index 0000000..2e81208 --- /dev/null +++ b/plugin/manifest_test.go @@ -0,0 +1,125 @@ +package plugin + +import ( + "encoding/json" + "os" + "os/exec" + "strings" + "testing" +) + +// TestServeManifest_noFlag verifies that ServeManifest does nothing and returns +// normally when --plugin-manifest is not present in os.Args. +// +// We test this via a subprocess that sets SERVE_MANIFEST_SUBPROCESS=no_flag so +// the helper does NOT inject --plugin-manifest into os.Args. If ServeManifest +// returns normally the subprocess exits 0. +func TestServeManifest_noFlag(t *testing.T) { + t.Parallel() + + cmd := exec.Command( + os.Args[0], + "-test.run=TestServeManifestSubprocess", + "-test.v", + ) + cmd.Env = append(os.Environ(), "SERVE_MANIFEST_SUBPROCESS=no_flag") + + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("subprocess exited non-zero: %v\n%s", err, out) + } +} + +// TestServeManifest_withFlag verifies that ServeManifest prints valid JSON and +// exits 0 when --plugin-manifest is in os.Args. +// +// We test this via a subprocess that sets SERVE_MANIFEST_SUBPROCESS=with_flag +// so the helper injects --plugin-manifest into os.Args before calling +// ServeManifest. ServeManifest should call os.Exit(0), ending the subprocess. +func TestServeManifest_withFlag(t *testing.T) { + t.Parallel() + + cmd := exec.Command( + os.Args[0], + "-test.run=TestServeManifestSubprocess", + "-test.v", + ) + cmd.Env = append(os.Environ(), "SERVE_MANIFEST_SUBPROCESS=with_flag") + + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("subprocess exited non-zero: %v\n%s", err, out) + } + + // The subprocess output should contain valid JSON with the manifest fields. + raw := string(out) + jsonStart := strings.Index(raw, "{") + if jsonStart < 0 { + t.Fatalf("no JSON object found in subprocess output:\n%s", raw) + } + // Find the matching closing brace for the JSON object. + jsonPart := raw[jsonStart:] + depth := 0 + jsonEnd := -1 + for i, ch := range jsonPart { + switch ch { + case '{': + depth++ + case '}': + depth-- + if depth == 0 { + jsonEnd = i + 1 + } + } + if jsonEnd > 0 { + break + } + } + if jsonEnd < 0 { + t.Fatalf("cannot find JSON end in subprocess output:\n%s", raw) + } + + var m Manifest + if err := json.Unmarshal([]byte(jsonPart[:jsonEnd]), &m); err != nil { + t.Fatalf("parse subprocess manifest JSON: %v\noutput:\n%s", err, raw) + } + if m.Name != "subprocess-test-plugin" { + t.Errorf("manifest.Name = %q, want %q", m.Name, "subprocess-test-plugin") + } + if m.APIVersion != 1 { + t.Errorf("manifest.APIVersion = %d, want 1", m.APIVersion) + } +} + +// TestServeManifestSubprocess is the subprocess helper used by the +// TestServeManifest_* tests. It only executes when SERVE_MANIFEST_SUBPROCESS +// is set to "no_flag" or "with_flag". +func TestServeManifestSubprocess(t *testing.T) { + mode := os.Getenv("SERVE_MANIFEST_SUBPROCESS") + if mode == "" { + t.Skip("not running as subprocess") + } + + m := Manifest{ + Name: "subprocess-test-plugin", + Version: "v0.0.1", + Description: "test only", + APIVersion: 1, + } + + switch mode { + case "no_flag": + // os.Args does NOT contain --plugin-manifest; ServeManifest must return. + ServeManifest(m) + // If we reach here, ServeManifest returned normally — test passes. + + case "with_flag": + // Inject --plugin-manifest into os.Args so ServeManifest triggers. + orig := os.Args + os.Args = append([]string{orig[0]}, "--plugin-manifest") + // ServeManifest will call os.Exit(0), ending the subprocess. + ServeManifest(m) + // Unreachable if ServeManifest works correctly. + t.Error("ServeManifest should have called os.Exit(0) but returned") + } +} diff --git a/plugin/root.go b/plugin/root.go new file mode 100644 index 0000000..fc77032 --- /dev/null +++ b/plugin/root.go @@ -0,0 +1,28 @@ +package plugin + +import ( + "github.com/spf13/cobra" +) + +// NewRootCmd returns a pre-configured *cobra.Command suitable for use as a plugin's root command. +// It wires --org, --project, and --output flags to the injected DATUM_* context values as defaults, +// so plugin authors do not need to declare these flags manually. +// +// name is the plugin name (e.g. "dns"); short is the one-line description shown in help. +func NewRootCmd(name, short string) *cobra.Command { + ctx := Context() + + cmd := &cobra.Command{ + Use: name, + Short: short, + } + + cmd.PersistentFlags().String("org", ctx.Org, + "Datum Cloud organization (defaults to DATUM_ORG injected by datumctl)") + cmd.PersistentFlags().String("project", ctx.Project, + "Datum Cloud project (defaults to DATUM_PROJECT injected by datumctl)") + cmd.PersistentFlags().StringP("output", "o", "table", + "Output format. One of: table|json|yaml") + + return cmd +} diff --git a/plugin/root_test.go b/plugin/root_test.go new file mode 100644 index 0000000..c5d186a --- /dev/null +++ b/plugin/root_test.go @@ -0,0 +1,52 @@ +package plugin + +import ( + "testing" +) + +// TestNewRootCmd_defaultsFromEnv verifies that when DATUM_ORG and DATUM_PROJECT +// are set, the --org and --project persistent flags on the root command have +// defaults matching those values. +func TestNewRootCmd_defaultsFromEnv(t *testing.T) { + // Not parallel — uses t.Setenv. + t.Setenv("DATUM_ORG", "my-org") + t.Setenv("DATUM_PROJECT", "my-project") + // Clear other DATUM_* vars to keep test focused. + t.Setenv("DATUM_PLUGIN_API_VERSION", "") + t.Setenv("DATUM_CREDENTIALS_HELPER", "") + t.Setenv("DATUM_SESSION", "") + + cmd := NewRootCmd("test-plugin", "A test plugin") + + orgFlag := cmd.PersistentFlags().Lookup("org") + if orgFlag == nil { + t.Fatal("--org flag not found on root command") + } + if orgFlag.DefValue != "my-org" { + t.Errorf("--org default = %q, want %q", orgFlag.DefValue, "my-org") + } + + projectFlag := cmd.PersistentFlags().Lookup("project") + if projectFlag == nil { + t.Fatal("--project flag not found on root command") + } + if projectFlag.DefValue != "my-project" { + t.Errorf("--project default = %q, want %q", projectFlag.DefValue, "my-project") + } +} + +// TestNewRootCmd_outputFlagPresent verifies that the --output / -o flag is +// wired up with a default of "table". +func TestNewRootCmd_outputFlagPresent(t *testing.T) { + t.Parallel() + + cmd := NewRootCmd("test-plugin", "A test plugin") + + outputFlag := cmd.PersistentFlags().Lookup("output") + if outputFlag == nil { + t.Fatal("--output flag not found on root command") + } + if outputFlag.DefValue != "table" { + t.Errorf("--output default = %q, want %q", outputFlag.DefValue, "table") + } +} diff --git a/plugin/token.go b/plugin/token.go new file mode 100644 index 0000000..580c00c --- /dev/null +++ b/plugin/token.go @@ -0,0 +1,33 @@ +package plugin + +import ( + "bytes" + "fmt" + "os/exec" + "strings" +) + +// Token calls the datumctl credentials helper and returns a fresh access token. +// It resolves the helper path from DATUM_CREDENTIALS_HELPER. +// Plugins should call Token() immediately before each API call — tokens are short-lived. +func Token() (string, error) { + ctx := Context() + if ctx.CredentialsHelper == "" { + return "", fmt.Errorf("DATUM_CREDENTIALS_HELPER is not set; is this plugin running via datumctl?") + } + + args := []string{"auth", "get-token"} + if ctx.Session != "" { + args = append(args, "--session", ctx.Session) + } + + var stdout, stderr bytes.Buffer + cmd := exec.Command(ctx.CredentialsHelper, args...) + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return "", fmt.Errorf("credentials helper failed: %w\nstderr: %s", err, stderr.String()) + } + return strings.TrimSpace(stdout.String()), nil +} diff --git a/plugin/token_test.go b/plugin/token_test.go new file mode 100644 index 0000000..f8442e0 --- /dev/null +++ b/plugin/token_test.go @@ -0,0 +1,153 @@ +package plugin + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// buildTokenHelper compiles a small credential-helper binary for tests. +// The binary writes a fixed token to stdout when called with "auth get-token", +// and optionally checks that --session is/isn't present in os.Args. +func buildTokenHelper(t *testing.T, src string) string { + t.Helper() + + dir := t.TempDir() + srcPath := filepath.Join(dir, "main.go") + if err := os.WriteFile(srcPath, []byte(src), 0o644); err != nil { + t.Fatalf("write token helper source: %v", err) + } + + binName := "credhelper" + if runtime.GOOS == "windows" { + binName += ".exe" + } + binPath := filepath.Join(dir, binName) + cmd := exec.Command("go", "build", "-o", binPath, srcPath) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("build token helper: %v\n%s", err, out) + } + return binPath +} + +// TestToken_callsHelperWithSession verifies that when DATUM_SESSION is set, +// Token() invokes the helper with --session . +func TestToken_callsHelperWithSession(t *testing.T) { + // Not parallel — uses t.Setenv. + const wantSession = "staging" + + // Helper that exits 1 if --session is not found in os.Args. + helperSrc := `package main + +import ( + "fmt" + "os" +) + +func main() { + args := os.Args[1:] + for i, a := range args { + if a == "--session" && i+1 < len(args) && args[i+1] == "` + wantSession + `" { + fmt.Println("mytoken") + os.Exit(0) + } + } + fmt.Fprintln(os.Stderr, "--session ` + wantSession + ` not found in args") + os.Exit(1) +} +` + helperPath := buildTokenHelper(t, helperSrc) + + t.Setenv("DATUM_CREDENTIALS_HELPER", helperPath) + t.Setenv("DATUM_SESSION", wantSession) + + token, err := Token() + if err != nil { + t.Fatalf("Token with session: %v", err) + } + if strings.TrimSpace(token) != "mytoken" { + t.Errorf("Token = %q, want %q", token, "mytoken") + } +} + +// TestToken_callsHelperWithoutSession verifies that when DATUM_SESSION is empty, +// Token() does not pass --session to the helper. +func TestToken_callsHelperWithoutSession(t *testing.T) { + // Not parallel — uses t.Setenv. + + // Helper that exits 1 if --session appears in os.Args at all. + helperSrc := `package main + +import ( + "fmt" + "os" +) + +func main() { + for _, a := range os.Args[1:] { + if a == "--session" { + fmt.Fprintln(os.Stderr, "--session should not appear when DATUM_SESSION is empty") + os.Exit(1) + } + } + fmt.Println("mytoken") +} +` + helperPath := buildTokenHelper(t, helperSrc) + + t.Setenv("DATUM_CREDENTIALS_HELPER", helperPath) + t.Setenv("DATUM_SESSION", "") + + token, err := Token() + if err != nil { + t.Fatalf("Token without session: %v", err) + } + if strings.TrimSpace(token) != "mytoken" { + t.Errorf("Token = %q, want %q", token, "mytoken") + } +} + +// TestToken_helperNotSet verifies that Token() returns a descriptive error +// when DATUM_CREDENTIALS_HELPER is not set. +func TestToken_helperNotSet(t *testing.T) { + // Not parallel — uses t.Setenv. + t.Setenv("DATUM_CREDENTIALS_HELPER", "") + + _, err := Token() + if err == nil { + t.Fatal("Token with no helper: want error, got nil") + } + if !strings.Contains(err.Error(), "DATUM_CREDENTIALS_HELPER") { + t.Errorf("error %q does not mention DATUM_CREDENTIALS_HELPER", err.Error()) + } +} + +// TestToken_helperExitsNonZero verifies that Token() returns a wrapped error +// when the credentials helper exits non-zero. +func TestToken_helperExitsNonZero(t *testing.T) { + // Not parallel — uses t.Setenv. + helperSrc := `package main + +import ( + "fmt" + "os" +) + +func main() { + fmt.Fprintln(os.Stderr, "auth error: not logged in") + os.Exit(1) +} +` + helperPath := buildTokenHelper(t, helperSrc) + + t.Setenv("DATUM_CREDENTIALS_HELPER", helperPath) + t.Setenv("DATUM_SESSION", "") + + _, err := Token() + if err == nil { + t.Fatal("Token with failing helper: want error, got nil") + } +}