diff --git a/CHANGELOG.md b/CHANGELOG.md index bfcc0b6..6ab2548 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ All notable changes to the FlatRun CLI are documented in this file. +## [0.3.0] - 2026-08-11 + +### Added + +- Every agent endpoint is now a command: `flatrun FAMILY OPERATION [ARGS]`, covering 294 endpoints across 42 families. The table is generated from the agent's routes, so catching up is a regeneration rather than 294 hand-written wrappers. +- `flatrun` lists the resources, `flatrun RESOURCE` lists its commands, with singular and plural both accepted, and `--json` on either prints the same list with each command's method, path and arguments, for scripts and agents. One listing covers both the hand-shaped commands and the generated ones, and the singular families reach everything their plural counterparts do, so `deployment log-sources` works. +- Request bodies from repeatable `-f name=value`, or `--data JSON` / `--data @file.json`. A field value that reads as JSON is sent as JSON, so `-f enabled=true` sends a boolean. Query parameters with repeatable `-q name=value`. + +- Commands read the agent's own description of its API where the agent serves one, so a mistyped field or query parameter fails before the request with the name it was probably meant to be, `COMMAND --help` lists the fields an endpoint takes and the permission it needs, and answers print as tables laid out from the types the agent returns. An agent that does not describe itself behaves as before. + +### Fixed + +- `-url`, `-token` and other single-dash flags swallowed the following argument, because only the double-dash spelling was registered as taking a value. +- Path arguments were not escaped, so a value containing a slash reshaped the request path. + ## [0.2.0] - 2026-06-15 ### Added diff --git a/README.md b/README.md index 729f4e9..d8c419f 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,50 @@ flatrun container exec abc123 -- sh -c 'printenv | sort' `deployment action` runs a quick action defined on the deployment; `deployment actions` lists them. `deployment exec` runs an ad-hoc command instead: the command follows `--`, and the service is chosen positionally or with `--service` (a single-service deployment is resolved automatically, a multi-service one must be named). Both run in the service container, honor the deployment's protected-mode rules, and surface the command's output (including on a non-zero exit). -Call any backend endpoint while a polished command is still pending: +### Every other resource + +The commands above are shaped by hand because they print tables worth reading. Every other agent +endpoint is `flatrun FAMILY OPERATION [ARGS]`, from a table generated out of the agent's routes. + +```bash +flatrun # the resources +flatrun backups # what backups can do +flatrun backup list # singular and plural both work +flatrun certificates renew shop.example.com +flatrun deployment logs my-api -q service=web -q tail=200 +``` + +Bodies go in as fields or as JSON: + +```bash +flatrun domains create -f domain=shop.example.com -f deployment=shop +flatrun settings update --data '{"backups":{"enabled":true}}' +flatrun settings update --data @settings.json +``` + +A field value that reads as JSON is sent as JSON: `-f enabled=true` sends a boolean, `-f retention=7` +sends a number. + +### Output + +If the agent describes the answer, that decides the layout. Otherwise an array of objects prints as +a table, an array of scalars one per line, an empty one as `None`, and anything else as raw JSON. +`--json` overrides all of it. + +### Driving it from a script or an agent + +`--json` on any listing prints every command with its method, path and arguments: + +```bash +flatrun --json | jq '.[] | select(.family == "backups")' +flatrun backups --json +``` + +Add `--json` to any command for the raw response. + +### The raw bridge + +For anything the table does not cover, such as a streaming endpoint: ```bash flatrun api get /settings diff --git a/VERSION b/VERSION index 0ea3a94..0d91a54 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.0 +0.3.0 diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 6b97ee9..5466d98 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -130,9 +130,73 @@ flatrun container restart CONTAINER_ID flatrun container delete CONTAINER_ID ``` +## Every other resource + +The families above are shaped by hand. Every other agent endpoint is +`flatrun FAMILY OPERATION [ARGS]`, from a table generated out of the agent's routes. + +```bash +flatrun # the resources +flatrun backups # what backups can do +flatrun backup list # singular and plural both work +flatrun backups list +flatrun backups restore BACKUP_ID +flatrun certificates renew shop.example.com +``` + +Operation names follow the endpoint: a collection is `list`, one item is `get`, and a sub-resource +keeps its noun (`log-sources`, `actions`, `jobs`). Where a read and a write share a path, the read +keeps the plain name (`log-sources`, `log-sources-update`). Where a verb applies to one item or to +all of them, the targeted one is plain, so `certificates renew DOMAIN` renews one and +`certificates renew-all` renews everything. + +### Sending a body + +```bash +flatrun domains create -f domain=shop.example.com -f deployment=shop +flatrun settings update --data '{"backups":{"enabled":true}}' +flatrun settings update --data @settings.json +``` + +`-f name=value` is repeatable. A value that reads as JSON is sent as JSON, so `-f enabled=true` +sends a boolean and `-f ports=[8080]` sends an array. The two body forms cannot be combined. + +### Query parameters + +```bash +flatrun deployment logs my-api -q service=web -q tail=200 +``` + +## How output is laid out + +If the agent describes the answer, that description decides the layout. If it does not, the client +maps the JSON itself: + +| What comes back | What prints | +|---|---| +| Array of objects | A table, columns taken from the first row | +| Array of scalars | One per line | +| A single column | One per line, no heading | +| Empty array | `None` | +| No array, or more than one | The raw JSON | + +`--json` overrides all of it and prints the answer untouched. + +## Listing what exists + +```bash +flatrun # the resources +flatrun backups # one resource +flatrun --json # every command as JSON +flatrun backups --json # one family as JSON +``` + +The JSON gives each command's family, operation, method, path, arguments and exact invocation, +which is what a script or an agent needs to use the CLI without reading this page. + ## Raw API -Use the raw API bridge while a polished command is still pending: +Use the raw API bridge for anything the table does not cover, such as a streaming endpoint: ```bash flatrun api get /settings diff --git a/internal/command/endpoints.go b/internal/command/endpoints.go new file mode 100644 index 0000000..959c680 --- /dev/null +++ b/internal/command/endpoints.go @@ -0,0 +1,369 @@ +package command + +import ( + "context" + "encoding/json" + "flag" + "fmt" + "io" + "net/url" + "os" + "sort" + "strconv" + "strings" + "time" + + "github.com/flatrun/cli/internal/flatrun" + "github.com/flatrun/cli/internal/spec" +) + +// endpoint is one agent API endpoint, reachable as `flatrun FAMILY OP ARGS...`. The table is +// generated from the agent's routes rather than written by hand, so an endpoint the agent adds +// is one regeneration away from being a command instead of a hand-written wrapper that drifts. +type endpoint struct { + family string + op string + method string + path string + args []string + // Set on the commands written by hand: extra arguments they take beyond the path, and the + // marker that says the generated table is not the whole story for this one. + flags string + shaped bool +} + +func (e endpoint) command() string { return invocation(e) } + +// resolvePath substitutes the positional arguments into the path parameters. +func (e endpoint) resolvePath(args []string) (string, error) { + if len(args) != len(e.args) { + return "", fmt.Errorf("expected %d argument(s): %s", len(e.args), e.command()) + } + path := e.path + for i, name := range e.args { + if args[i] == "" { + return "", fmt.Errorf("%s cannot be empty: %s", strings.ToUpper(name), e.command()) + } + if strings.Contains(path, ":"+name) { + path = strings.Replace(path, ":"+name, url.PathEscape(args[i]), 1) + continue + } + // A wildcard stands for the rest of the path, so its separators are structure and only + // what sits between them is escaped. + path = strings.Replace(path, "*"+name, escapeSubPath(args[i]), 1) + } + return path, nil +} + +func escapeSubPath(value string) string { + segments := strings.Split(value, "/") + for i, segment := range segments { + segments[i] = url.PathEscape(segment) + } + return strings.Join(segments, "/") +} + +func findEndpoint(family, op string) (endpoint, bool) { + for _, e := range generatedEndpoints { + if e.family == family && e.op == op { + return e, true + } + } + return endpoint{}, false +} + +func knownFamily(family string) bool { + for _, e := range generatedEndpoints { + if e.family == family { + return true + } + } + return false +} + +// fieldValues collects repeated -f name=value pairs into a request body. A value that parses as +// JSON is kept as JSON, so -f enabled=true sends a boolean rather than the word. +type fieldValues map[string]any + +func (f fieldValues) String() string { return "" } + +func (f fieldValues) Set(raw string) error { + name, value, found := strings.Cut(raw, "=") + if !found || name == "" { + return fmt.Errorf("expected name=value, got %q", raw) + } + f[name] = parseFieldValue(value) + return nil +} + +func parseFieldValue(value string) any { + if value == "" { + return "" + } + switch value { + case "true": + return true + case "false": + return false + case "null": + return nil + } + if n, err := strconv.ParseFloat(value, 64); err == nil { + return n + } + if strings.HasPrefix(value, "{") || strings.HasPrefix(value, "[") { + var nested any + if err := json.Unmarshal([]byte(value), &nested); err == nil { + return nested + } + } + return value +} + +type queryValues url.Values + +func (q queryValues) String() string { return "" } + +func (q queryValues) Set(raw string) error { + name, value, found := strings.Cut(raw, "=") + if !found || name == "" { + return fmt.Errorf("expected name=value, got %q", raw) + } + url.Values(q).Add(name, value) + return nil +} + +// runEndpoint dispatches `flatrun FAMILY OP ...` against the generated table. +func runEndpoint(family string, args []string, stdout, stderr io.Writer) int { + if len(args) == 0 { + return listEndpoints(stdout, stderr, family, false) + } + switch args[0] { + case "help", "-h", "--help": + return listEndpoints(stdout, stderr, family, false) + case "--json": + return listEndpoints(stdout, stderr, family, true) + } + + if len(args) > 1 && (args[1] == "--help" || args[1] == "-h") { + return explainEndpoint(family, args[0], stdout, stderr) + } + + e, ok := findEndpoint(family, args[0]) + if !ok { + _, _ = fmt.Fprintf(stderr, "Unknown %s command: %s\n\n", family, args[0]) + listEndpoints(stderr, stderr, family, false) + return 2 + } + + fields := fieldValues{} + query := queryValues{} + dataArg := "" + var api *spec.Spec + var operation spec.Operation + described := false + + cmd := clientCommand{ + name: family + " " + e.op, + usage: "Usage: " + e.command() + " [-f name=value] [--data JSON] [-q name=value]", + positionals: len(e.args), + valueFlags: []string{"data", "f", "q"}, + flags: func(fs *flag.FlagSet) { + fs.StringVar(&dataArg, "data", "", "JSON request body, or @file to read one") + fs.Var(fields, "f", "Request body field as name=value, repeatable") + fs.Var(query, "q", "Query parameter as name=value, repeatable") + }, + run: func(ctx context.Context, client *flatrun.Client, positional []string) ([]byte, error) { + path, err := e.resolvePath(positional) + if err != nil { + return nil, err + } + + // The agent's own description of this endpoint, when it offers one. Checking here + // turns a 400 with no explanation into a message naming the field. + api = spec.Load(ctx, client, client.BaseURL()) + if api != nil { + if op, found := api.Operation(e.method, e.path); found { + operation = op + described = true + if err := checkQuery(api, op, query); err != nil { + return nil, err + } + } + } + + if len(query) > 0 { + path += "?" + url.Values(query).Encode() + } + payload, err := requestBody(dataArg, fields) + if err != nil { + return nil, err + } + if described { + if err := checkFields(api, operation, payload); err != nil { + return nil, err + } + } + return client.Do(ctx, e.method, path, payload) + }, + render: func(w io.Writer, data []byte) error { + if renderAnswer(w, api, operation, data) { + return nil + } + printResponse(w, true, data, "") + return nil + }, + } + return runClientCommand(cmd, args[1:], stdout, stderr) +} + +// runAliasedEndpoint reaches a plural family's endpoint from its singular name, so the two are +// not different surfaces. +func runAliasedEndpoint(plural, singular string, args []string, stdout, stderr io.Writer) int { + if _, ok := findEndpoint(plural, args[0]); ok { + return runEndpoint(plural, args, stdout, stderr) + } + _, _ = fmt.Fprintf(stderr, "Unknown %s command: %s\n\n", singular, args[0]) + listEndpoints(stderr, stderr, singular, false) + return 2 +} + +func explainEndpoint(family, op string, stdout, stderr io.Writer) int { + e, ok := findEndpoint(family, op) + if !ok { + _, _ = fmt.Fprintf(stderr, "Unknown %s command: %s\n", family, op) + return 2 + } + + client, err := clientFromOptions(globalOptions{Timeout: 30 * time.Second}) + if err != nil { + _, _ = fmt.Fprintln(stdout, invocation(e)) + _, _ = fmt.Fprintln(stdout, "\nConnect to an agent to see the fields this takes.") + return 0 + } + + api := spec.Load(context.Background(), client, client.BaseURL()) + if api == nil { + _, _ = fmt.Fprintln(stdout, invocation(e)) + _, _ = fmt.Fprintln(stdout, "\nThis agent does not describe its API, so the fields are not known here.") + return 0 + } + operation, found := api.Operation(e.method, e.path) + if !found { + _, _ = fmt.Fprintln(stdout, invocation(e)) + return 0 + } + describeEndpoint(stdout, api, e, operation) + return 0 +} + +func requestBody(dataArg string, fields fieldValues) (any, error) { + if dataArg != "" && len(fields) > 0 { + return nil, fmt.Errorf("use --data or -f, not both") + } + if dataArg != "" { + raw := []byte(dataArg) + if strings.HasPrefix(dataArg, "@") { + contents, err := os.ReadFile(strings.TrimPrefix(dataArg, "@")) + if err != nil { + return nil, err + } + raw = contents + } + var payload any + if err := json.Unmarshal(raw, &payload); err != nil { + return nil, fmt.Errorf("invalid JSON body: %w", err) + } + return payload, nil + } + if len(fields) > 0 { + return map[string]any(fields), nil + } + // A write with no body is normal: restarting a deployment carries nothing. + return nil, nil +} + +func argNames(e endpoint) string { + names := make([]string, 0, len(e.args)) + for _, arg := range e.args { + names = append(names, strings.ToUpper(arg)) + } + return strings.Join(names, " ") +} + +// listEndpoints prints what can be run, either for one family or for all of them. The JSON form +// exists because a program driving this CLI should not have to parse help text to find out what +// it can call. +func listEndpoints(stdout, stderr io.Writer, family string, asJSON bool) int { + list := make([]endpoint, 0, len(generatedEndpoints)) + for _, e := range catalogue() { + if family == "" || e.family == family { + list = append(list, e) + } + } + if len(list) == 0 { + _, _ = fmt.Fprintf(stderr, "Unknown family: %s\n", family) + return 2 + } + sort.Slice(list, func(i, j int) bool { + if list[i].family != list[j].family { + return list[i].family < list[j].family + } + return list[i].op < list[j].op + }) + + if asJSON { + type wire struct { + Family string `json:"family"` + Op string `json:"op"` + Method string `json:"method"` + Path string `json:"path"` + Args []string `json:"args"` + Command string `json:"command"` + Shaped bool `json:"shaped,omitempty"` + } + out := make([]wire, 0, len(list)) + for _, e := range list { + args := e.args + if args == nil { + args = []string{} + } + out = append(out, wire{e.family, e.op, e.method, e.path, args, e.command(), e.shaped}) + } + encoded, err := json.MarshalIndent(out, "", " ") + if err != nil { + _, _ = fmt.Fprintln(stderr, "Error:", err) + return 1 + } + _, _ = fmt.Fprintln(stdout, string(encoded)) + return 0 + } + + current := "" + for _, e := range list { + if e.family != current { + if current != "" { + _, _ = fmt.Fprintln(stdout) + } + current = e.family + _, _ = fmt.Fprintln(stdout, e.family) + } + _, _ = fmt.Fprintf(stdout, " %-38s %s %s\n", strings.TrimSpace(e.op+" "+argNames(e)+" "+e.flags), e.method, e.path) + } + _, _ = fmt.Fprintln(stdout) + _, _ = fmt.Fprintln(stdout, "Send a body with -f name=value (repeatable) or --data JSON.") + return 0 +} + +func families() []string { + seen := map[string]bool{} + names := []string{} + for _, e := range generatedEndpoints { + if !seen[e.family] { + seen[e.family] = true + names = append(names, e.family) + } + } + sort.Strings(names) + return names +} diff --git a/internal/command/endpoints_gen.go b/internal/command/endpoints_gen.go new file mode 100644 index 0000000..440a4a4 --- /dev/null +++ b/internal/command/endpoints_gen.go @@ -0,0 +1,313 @@ +// Code generated by tools/gen_endpoints.py from the agent's API description. DO NOT EDIT. + +package command + +var generatedEndpoints = []endpoint{ + {family: "agent", op: "update", method: "GET", path: "/agent/update", args: nil}, + {family: "agent", op: "update-create", method: "POST", path: "/agent/update", args: nil}, + {family: "ai", op: "agents", method: "GET", path: "/ai/agents", args: nil}, + {family: "ai", op: "agents-delete", method: "DELETE", path: "/ai/agents/:name", args: []string{"name"}}, + {family: "ai", op: "agents-get", method: "GET", path: "/ai/agents/:name", args: []string{"name"}}, + {family: "ai", op: "agents-update", method: "PUT", path: "/ai/agents/:name", args: []string{"name"}}, + {family: "ai", op: "agents-run", method: "POST", path: "/ai/agents/:name/run", args: []string{"name"}}, + {family: "ai", op: "analyze", method: "POST", path: "/ai/analyze", args: nil}, + {family: "ai", op: "sessions", method: "GET", path: "/ai/sessions", args: nil}, + {family: "ai", op: "sessions-create", method: "POST", path: "/ai/sessions", args: nil}, + {family: "ai", op: "sessions-delete", method: "DELETE", path: "/ai/sessions/:id", args: []string{"id"}}, + {family: "ai", op: "sessions-get", method: "GET", path: "/ai/sessions/:id", args: []string{"id"}}, + {family: "ai", op: "sessions-approve", method: "POST", path: "/ai/sessions/:id/approve", args: []string{"id"}}, + {family: "ai", op: "sessions-messages", method: "POST", path: "/ai/sessions/:id/messages", args: []string{"id"}}, + {family: "ai", op: "status", method: "GET", path: "/ai/status", args: nil}, + {family: "apikeys", op: "list", method: "GET", path: "/apikeys", args: nil}, + {family: "apikeys", op: "create", method: "POST", path: "/apikeys", args: nil}, + {family: "apikeys", op: "delete", method: "DELETE", path: "/apikeys/:id", args: []string{"id"}}, + {family: "apikeys", op: "get", method: "GET", path: "/apikeys/:id", args: []string{"id"}}, + {family: "apikeys", op: "update", method: "PUT", path: "/apikeys/:id", args: []string{"id"}}, + {family: "apikeys", op: "revoke", method: "POST", path: "/apikeys/:id/revoke", args: []string{"id"}}, + {family: "audit", op: "cleanup", method: "DELETE", path: "/audit/cleanup", args: nil}, + {family: "audit", op: "events", method: "GET", path: "/audit/events", args: nil}, + {family: "audit", op: "events-get", method: "GET", path: "/audit/events/:id", args: []string{"id"}}, + {family: "audit", op: "export", method: "POST", path: "/audit/export", args: nil}, + {family: "audit", op: "stats", method: "GET", path: "/audit/stats", args: nil}, + {family: "auth", op: "login", method: "POST", path: "/auth/login", args: nil}, + {family: "auth", op: "status", method: "GET", path: "/auth/status", args: nil}, + {family: "auth", op: "validate", method: "GET", path: "/auth/validate", args: nil}, + {family: "backup-destinations", op: "list", method: "GET", path: "/backup-destinations", args: nil}, + {family: "backup-destinations", op: "test", method: "POST", path: "/backup-destinations/test", args: nil}, + {family: "backups", op: "list", method: "GET", path: "/backups", args: nil}, + {family: "backups", op: "create", method: "POST", path: "/backups", args: nil}, + {family: "backups", op: "jobs", method: "GET", path: "/backups/jobs", args: nil}, + {family: "backups", op: "jobs-get", method: "GET", path: "/backups/jobs/:id", args: []string{"id"}}, + {family: "backups", op: "delete", method: "DELETE", path: "/backups/:id", args: []string{"id"}}, + {family: "backups", op: "get", method: "GET", path: "/backups/:id", args: []string{"id"}}, + {family: "backups", op: "download", method: "GET", path: "/backups/:id/download", args: []string{"id"}}, + {family: "backups", op: "restore", method: "POST", path: "/backups/:id/restore", args: []string{"id"}}, + {family: "certificates", op: "list", method: "GET", path: "/certificates", args: nil}, + {family: "certificates", op: "create", method: "POST", path: "/certificates", args: nil}, + {family: "certificates", op: "renew-all", method: "POST", path: "/certificates/renew", args: nil}, + {family: "certificates", op: "delete", method: "DELETE", path: "/certificates/:domain", args: []string{"domain"}}, + {family: "certificates", op: "get", method: "GET", path: "/certificates/:domain", args: []string{"domain"}}, + {family: "certificates", op: "auto-renew", method: "PATCH", path: "/certificates/:domain/auto-renew", args: []string{"domain"}}, + {family: "certificates", op: "renew", method: "POST", path: "/certificates/:domain/renew", args: []string{"domain"}}, + {family: "cluster", op: "accept", method: "POST", path: "/cluster/accept", args: nil}, + {family: "cluster", op: "deployments", method: "GET", path: "/cluster/deployments", args: nil}, + {family: "cluster", op: "exchange", method: "POST", path: "/cluster/exchange", args: nil}, + {family: "cluster", op: "invite", method: "POST", path: "/cluster/invite", args: nil}, + {family: "cluster", op: "peers", method: "GET", path: "/cluster/peers", args: nil}, + {family: "cluster", op: "peers-delete", method: "DELETE", path: "/cluster/peers/:name", args: []string{"name"}}, + {family: "cluster", op: "stats", method: "GET", path: "/cluster/stats", args: nil}, + {family: "cluster", op: "status", method: "GET", path: "/cluster/status", args: nil}, + {family: "compose", op: "update", method: "POST", path: "/compose/update", args: nil}, + {family: "config", op: "list", method: "GET", path: "/config", args: nil}, + {family: "config", op: "get", method: "GET", path: "/config/*key", args: []string{"key"}}, + {family: "config", op: "update", method: "PUT", path: "/config/*key", args: []string{"key"}}, + {family: "containers", op: "list", method: "GET", path: "/containers", args: nil}, + {family: "containers", op: "stats", method: "GET", path: "/containers/stats", args: nil}, + {family: "containers", op: "delete", method: "DELETE", path: "/containers/:id", args: []string{"id"}}, + {family: "containers", op: "exec", method: "GET", path: "/containers/:id/exec", args: []string{"id"}}, + {family: "containers", op: "exec-create", method: "POST", path: "/containers/:id/exec", args: []string{"id"}}, + {family: "containers", op: "logs", method: "GET", path: "/containers/:id/logs", args: []string{"id"}}, + {family: "containers", op: "resources", method: "GET", path: "/containers/:id/resources", args: []string{"id"}}, + {family: "containers", op: "resources-update", method: "PUT", path: "/containers/:id/resources", args: []string{"id"}}, + {family: "containers", op: "restart", method: "POST", path: "/containers/:id/restart", args: []string{"id"}}, + {family: "containers", op: "start", method: "POST", path: "/containers/:id/start", args: []string{"id"}}, + {family: "containers", op: "stats-get", method: "GET", path: "/containers/:id/stats", args: []string{"id"}}, + {family: "containers", op: "stop", method: "POST", path: "/containers/:id/stop", args: []string{"id"}}, + {family: "credentials", op: "list", method: "GET", path: "/credentials", args: nil}, + {family: "credentials", op: "create", method: "POST", path: "/credentials", args: nil}, + {family: "credentials", op: "delete", method: "DELETE", path: "/credentials/:id", args: []string{"id"}}, + {family: "credentials", op: "get", method: "GET", path: "/credentials/:id", args: []string{"id"}}, + {family: "credentials", op: "update", method: "PUT", path: "/credentials/:id", args: []string{"id"}}, + {family: "credentials", op: "test", method: "POST", path: "/credentials/:id/test", args: []string{"id"}}, + {family: "dashboards", op: "list", method: "GET", path: "/dashboards", args: nil}, + {family: "dashboards", op: "create", method: "POST", path: "/dashboards", args: nil}, + {family: "dashboards", op: "delete", method: "DELETE", path: "/dashboards/:id", args: []string{"id"}}, + {family: "dashboards", op: "get", method: "GET", path: "/dashboards/:id", args: []string{"id"}}, + {family: "databases", op: "create", method: "POST", path: "/databases/create", args: nil}, + {family: "databases", op: "delete", method: "POST", path: "/databases/delete", args: nil}, + {family: "databases", op: "list", method: "POST", path: "/databases/list", args: nil}, + {family: "databases", op: "privileges-grant", method: "POST", path: "/databases/privileges/grant", args: nil}, + {family: "databases", op: "query", method: "POST", path: "/databases/query", args: nil}, + {family: "databases", op: "tables", method: "POST", path: "/databases/tables", args: nil}, + {family: "databases", op: "tables-data", method: "POST", path: "/databases/tables/data", args: nil}, + {family: "databases", op: "tables-schema", method: "POST", path: "/databases/tables/schema", args: nil}, + {family: "databases", op: "test", method: "POST", path: "/databases/test", args: nil}, + {family: "databases", op: "users", method: "POST", path: "/databases/users", args: nil}, + {family: "databases", op: "users-by-database", method: "POST", path: "/databases/users/by-database", args: nil}, + {family: "databases", op: "users-create", method: "POST", path: "/databases/users/create", args: nil}, + {family: "databases", op: "users-delete", method: "POST", path: "/databases/users/delete", args: nil}, + {family: "deployments", op: "list", method: "GET", path: "/deployments", args: nil}, + {family: "deployments", op: "create", method: "POST", path: "/deployments", args: nil}, + {family: "deployments", op: "delete", method: "DELETE", path: "/deployments/:name", args: []string{"name"}}, + {family: "deployments", op: "get", method: "GET", path: "/deployments/:name", args: []string{"name"}}, + {family: "deployments", op: "update", method: "PUT", path: "/deployments/:name", args: []string{"name"}}, + {family: "deployments", op: "actions", method: "POST", path: "/deployments/:name/actions/:actionId", args: []string{"name", "actionId"}}, + {family: "deployments", op: "ai-analyze", method: "POST", path: "/deployments/:name/ai/analyze", args: []string{"name"}}, + {family: "deployments", op: "backup-config", method: "GET", path: "/deployments/:name/backup-config", args: []string{"name"}}, + {family: "deployments", op: "backup-config-update", method: "PUT", path: "/deployments/:name/backup-config", args: []string{"name"}}, + {family: "deployments", op: "backups", method: "GET", path: "/deployments/:name/backups", args: []string{"name"}}, + {family: "deployments", op: "backups-create", method: "POST", path: "/deployments/:name/backups", args: []string{"name"}}, + {family: "deployments", op: "certificates-renew", method: "POST", path: "/deployments/:name/certificates/renew", args: []string{"name"}}, + {family: "deployments", op: "compose", method: "GET", path: "/deployments/:name/compose", args: []string{"name"}}, + {family: "deployments", op: "compose-mount", method: "POST", path: "/deployments/:name/compose/mount", args: []string{"name"}}, + {family: "deployments", op: "compose-unmount", method: "POST", path: "/deployments/:name/compose/unmount", args: []string{"name"}}, + {family: "deployments", op: "container-files", method: "GET", path: "/deployments/:name/container-files/:service", args: []string{"name", "service"}}, + {family: "deployments", op: "container-files-materialize", method: "POST", path: "/deployments/:name/container-files/:service/materialize", args: []string{"name", "service"}}, + {family: "deployments", op: "deploy", method: "POST", path: "/deployments/:name/deploy", args: []string{"name"}}, + {family: "deployments", op: "domains", method: "GET", path: "/deployments/:name/domains", args: []string{"name"}}, + {family: "deployments", op: "domains-create", method: "POST", path: "/deployments/:name/domains", args: []string{"name"}}, + {family: "deployments", op: "domains-delete", method: "DELETE", path: "/deployments/:name/domains/:domainId", args: []string{"name", "domainId"}}, + {family: "deployments", op: "domains-update", method: "PUT", path: "/deployments/:name/domains/:domainId", args: []string{"name", "domainId"}}, + {family: "deployments", op: "env", method: "GET", path: "/deployments/:name/env", args: []string{"name"}}, + {family: "deployments", op: "env-update", method: "PUT", path: "/deployments/:name/env", args: []string{"name"}}, + {family: "deployments", op: "files", method: "GET", path: "/deployments/:name/files", args: []string{"name"}}, + {family: "deployments", op: "files-info", method: "GET", path: "/deployments/:name/files-info", args: []string{"name"}}, + {family: "deployments", op: "files-delete", method: "DELETE", path: "/deployments/:name/files/*path", args: []string{"name", "path"}}, + {family: "deployments", op: "files-get", method: "GET", path: "/deployments/:name/files/*path", args: []string{"name", "path"}}, + {family: "deployments", op: "files-create", method: "POST", path: "/deployments/:name/files/*path", args: []string{"name", "path"}}, + {family: "deployments", op: "images", method: "GET", path: "/deployments/:name/images", args: []string{"name"}}, + {family: "deployments", op: "images-cleanup", method: "POST", path: "/deployments/:name/images/cleanup", args: []string{"name"}}, + {family: "deployments", op: "jobs-active", method: "GET", path: "/deployments/:name/jobs/active", args: []string{"name"}}, + {family: "deployments", op: "jobs", method: "GET", path: "/deployments/:name/jobs/:jobId", args: []string{"name", "jobId"}}, + {family: "deployments", op: "log-sources", method: "GET", path: "/deployments/:name/log-sources", args: []string{"name"}}, + {family: "deployments", op: "log-sources-update", method: "PUT", path: "/deployments/:name/log-sources", args: []string{"name"}}, + {family: "deployments", op: "logs-delete", method: "DELETE", path: "/deployments/:name/logs", args: []string{"name"}}, + {family: "deployments", op: "logs", method: "GET", path: "/deployments/:name/logs", args: []string{"name"}}, + {family: "deployments", op: "metadata", method: "PUT", path: "/deployments/:name/metadata", args: []string{"name"}}, + {family: "deployments", op: "mkdir", method: "POST", path: "/deployments/:name/mkdir/*path", args: []string{"name", "path"}}, + {family: "deployments", op: "permissions", method: "PUT", path: "/deployments/:name/permissions/*path", args: []string{"name", "path"}}, + {family: "deployments", op: "protected-mode", method: "PUT", path: "/deployments/:name/protected-mode", args: []string{"name"}}, + {family: "deployments", op: "pull", method: "POST", path: "/deployments/:name/pull", args: []string{"name"}}, + {family: "deployments", op: "rebuild", method: "POST", path: "/deployments/:name/rebuild", args: []string{"name"}}, + {family: "deployments", op: "resources", method: "GET", path: "/deployments/:name/resources", args: []string{"name"}}, + {family: "deployments", op: "restart", method: "POST", path: "/deployments/:name/restart", args: []string{"name"}}, + {family: "deployments", op: "security", method: "GET", path: "/deployments/:name/security", args: []string{"name"}}, + {family: "deployments", op: "security-update", method: "PUT", path: "/deployments/:name/security", args: []string{"name"}}, + {family: "deployments", op: "security-events", method: "GET", path: "/deployments/:name/security/events", args: []string{"name"}}, + {family: "deployments", op: "services", method: "GET", path: "/deployments/:name/services", args: []string{"name"}}, + {family: "deployments", op: "services-job", method: "POST", path: "/deployments/:name/services/:service/job", args: []string{"name", "service"}}, + {family: "deployments", op: "services-pull", method: "POST", path: "/deployments/:name/services/:service/pull", args: []string{"name", "service"}}, + {family: "deployments", op: "services-rebuild", method: "POST", path: "/deployments/:name/services/:service/rebuild", args: []string{"name", "service"}}, + {family: "deployments", op: "services-restart", method: "POST", path: "/deployments/:name/services/:service/restart", args: []string{"name", "service"}}, + {family: "deployments", op: "services-start", method: "POST", path: "/deployments/:name/services/:service/start", args: []string{"name", "service"}}, + {family: "deployments", op: "services-stop", method: "POST", path: "/deployments/:name/services/:service/stop", args: []string{"name", "service"}}, + {family: "deployments", op: "serving", method: "GET", path: "/deployments/:name/serving", args: []string{"name"}}, + {family: "deployments", op: "ssl-disable", method: "POST", path: "/deployments/:name/ssl/disable", args: []string{"name"}}, + {family: "deployments", op: "start", method: "POST", path: "/deployments/:name/start", args: []string{"name"}}, + {family: "deployments", op: "stats", method: "GET", path: "/deployments/:name/stats", args: []string{"name"}}, + {family: "deployments", op: "stop", method: "POST", path: "/deployments/:name/stop", args: []string{"name"}}, + {family: "deployments", op: "touch", method: "POST", path: "/deployments/:name/touch/*path", args: []string{"name", "path"}}, + {family: "deployments", op: "traffic", method: "GET", path: "/deployments/:name/traffic", args: []string{"name"}}, + {family: "deployments", op: "users", method: "GET", path: "/deployments/:name/users", args: []string{"name"}}, + {family: "dns", op: "powerdns-disable", method: "POST", path: "/dns/powerdns/disable", args: nil}, + {family: "dns", op: "powerdns-enable", method: "POST", path: "/dns/powerdns/enable", args: nil}, + {family: "dns", op: "powerdns-restart", method: "POST", path: "/dns/powerdns/restart", args: nil}, + {family: "dns", op: "powerdns-status", method: "GET", path: "/dns/powerdns/status", args: nil}, + {family: "dns", op: "powerdns-zones", method: "GET", path: "/dns/powerdns/zones", args: nil}, + {family: "dns", op: "powerdns-zones-create", method: "POST", path: "/dns/powerdns/zones", args: nil}, + {family: "dns", op: "powerdns-zones-delete", method: "DELETE", path: "/dns/powerdns/zones/:zoneId", args: []string{"zoneId"}}, + {family: "dns", op: "powerdns-zones-get", method: "GET", path: "/dns/powerdns/zones/:zoneId", args: []string{"zoneId"}}, + {family: "dns", op: "powerdns-zones-update", method: "PATCH", path: "/dns/powerdns/zones/:zoneId", args: []string{"zoneId"}}, + {family: "dns", op: "providers", method: "GET", path: "/dns/providers", args: nil}, + {family: "health", op: "list", method: "GET", path: "/health", args: nil}, + {family: "images", op: "list", method: "GET", path: "/images", args: nil}, + {family: "images", op: "cleanup", method: "POST", path: "/images/cleanup", args: nil}, + {family: "images", op: "pull", method: "POST", path: "/images/pull", args: nil}, + {family: "images", op: "delete", method: "DELETE", path: "/images/:id", args: []string{"id"}}, + {family: "infrastructure", op: "list", method: "GET", path: "/infrastructure", args: nil}, + {family: "infrastructure", op: "migrate", method: "POST", path: "/infrastructure/migrate/:name", args: []string{"name"}}, + {family: "infrastructure", op: "stats", method: "GET", path: "/infrastructure/stats", args: nil}, + {family: "infrastructure", op: "get", method: "GET", path: "/infrastructure/:name", args: []string{"name"}}, + {family: "infrastructure", op: "logs", method: "GET", path: "/infrastructure/:name/logs", args: []string{"name"}}, + {family: "infrastructure", op: "restart", method: "POST", path: "/infrastructure/:name/restart", args: []string{"name"}}, + {family: "infrastructure", op: "start", method: "POST", path: "/infrastructure/:name/start", args: []string{"name"}}, + {family: "infrastructure", op: "stop", method: "POST", path: "/infrastructure/:name/stop", args: []string{"name"}}, + {family: "networks", op: "list", method: "GET", path: "/networks", args: nil}, + {family: "networks", op: "create", method: "POST", path: "/networks", args: nil}, + {family: "networks", op: "delete", method: "DELETE", path: "/networks/:name", args: []string{"name"}}, + {family: "networks", op: "connect", method: "POST", path: "/networks/:name/connect", args: []string{"name"}}, + {family: "networks", op: "disconnect", method: "POST", path: "/networks/:name/disconnect", args: []string{"name"}}, + {family: "notifications", op: "targets", method: "GET", path: "/notifications/targets", args: nil}, + {family: "notifications", op: "targets-update", method: "PUT", path: "/notifications/targets", args: nil}, + {family: "notifications", op: "test", method: "POST", path: "/notifications/test", args: nil}, + {family: "object-stores", op: "provision-managed", method: "POST", path: "/object-stores/provision-managed", args: nil}, + {family: "object-stores", op: "attach", method: "POST", path: "/object-stores/:name/attach", args: []string{"name"}}, + {family: "object-stores", op: "buckets", method: "GET", path: "/object-stores/:name/buckets", args: []string{"name"}}, + {family: "object-stores", op: "buckets-create", method: "POST", path: "/object-stores/:name/buckets", args: []string{"name"}}, + {family: "object-stores", op: "buckets-delete", method: "DELETE", path: "/object-stores/:name/buckets/:bucket", args: []string{"name", "bucket"}}, + {family: "object-stores", op: "objects-delete", method: "DELETE", path: "/object-stores/:name/objects", args: []string{"name"}}, + {family: "object-stores", op: "objects", method: "GET", path: "/object-stores/:name/objects", args: []string{"name"}}, + {family: "object-stores", op: "objects-create", method: "POST", path: "/object-stores/:name/objects", args: []string{"name"}}, + {family: "object-stores", op: "objects-download", method: "GET", path: "/object-stores/:name/objects/download", args: []string{"name"}}, + {family: "object-stores", op: "replicate", method: "POST", path: "/object-stores/:name/replicate", args: []string{"name"}}, + {family: "plans", op: "list", method: "GET", path: "/plans", args: nil}, + {family: "plans", op: "delete", method: "DELETE", path: "/plans/:id", args: []string{"id"}}, + {family: "plans", op: "get", method: "GET", path: "/plans/:id", args: []string{"id"}}, + {family: "plans", op: "apply", method: "POST", path: "/plans/:id/apply", args: []string{"id"}}, + {family: "plugins", op: "list", method: "GET", path: "/plugins", args: nil}, + {family: "plugins", op: "get", method: "GET", path: "/plugins/:name", args: []string{"name"}}, + {family: "plugins", op: "deployments", method: "POST", path: "/plugins/:name/deployments", args: []string{"name"}}, + {family: "ports", op: "list", method: "GET", path: "/ports", args: nil}, + {family: "ports", op: "kill", method: "POST", path: "/ports/:pid/kill", args: []string{"pid"}}, + {family: "proxy", op: "setup", method: "POST", path: "/proxy/setup/:name", args: []string{"name"}}, + {family: "proxy", op: "status", method: "GET", path: "/proxy/status/:name", args: []string{"name"}}, + {family: "proxy", op: "sync", method: "POST", path: "/proxy/sync", args: nil}, + {family: "proxy", op: "vhosts", method: "GET", path: "/proxy/vhosts", args: nil}, + {family: "proxy", op: "delete", method: "DELETE", path: "/proxy/:name", args: []string{"name"}}, + {family: "registries", op: "list", method: "GET", path: "/registries", args: nil}, + {family: "registries", op: "create", method: "POST", path: "/registries", args: nil}, + {family: "registries", op: "delete", method: "DELETE", path: "/registries/:slug", args: []string{"slug"}}, + {family: "registries", op: "get", method: "GET", path: "/registries/:slug", args: []string{"slug"}}, + {family: "registries", op: "update", method: "PUT", path: "/registries/:slug", args: []string{"slug"}}, + {family: "scheduler", op: "executions", method: "GET", path: "/scheduler/executions", args: nil}, + {family: "scheduler", op: "tasks", method: "GET", path: "/scheduler/tasks", args: nil}, + {family: "scheduler", op: "tasks-create", method: "POST", path: "/scheduler/tasks", args: nil}, + {family: "scheduler", op: "tasks-delete", method: "DELETE", path: "/scheduler/tasks/:id", args: []string{"id"}}, + {family: "scheduler", op: "tasks-get", method: "GET", path: "/scheduler/tasks/:id", args: []string{"id"}}, + {family: "scheduler", op: "tasks-update", method: "PUT", path: "/scheduler/tasks/:id", args: []string{"id"}}, + {family: "scheduler", op: "tasks-executions", method: "GET", path: "/scheduler/tasks/:id/executions", args: []string{"id"}}, + {family: "scheduler", op: "tasks-run", method: "POST", path: "/scheduler/tasks/:id/run", args: []string{"id"}}, + {family: "security", op: "blocked-ips", method: "GET", path: "/security/blocked-ips", args: nil}, + {family: "security", op: "blocked-ips-create", method: "POST", path: "/security/blocked-ips", args: nil}, + {family: "security", op: "blocked-ips-delete", method: "DELETE", path: "/security/blocked-ips/:ip", args: []string{"ip"}}, + {family: "security", op: "cleanup", method: "POST", path: "/security/cleanup", args: nil}, + {family: "security", op: "events", method: "GET", path: "/security/events", args: nil}, + {family: "security", op: "events-get", method: "GET", path: "/security/events/:id", args: []string{"id"}}, + {family: "security", op: "health", method: "GET", path: "/security/health", args: nil}, + {family: "security", op: "ips-events", method: "GET", path: "/security/ips/:ip/events", args: []string{"ip"}}, + {family: "security", op: "protected-routes", method: "GET", path: "/security/protected-routes", args: nil}, + {family: "security", op: "protected-routes-create", method: "POST", path: "/security/protected-routes", args: nil}, + {family: "security", op: "protected-routes-delete", method: "DELETE", path: "/security/protected-routes/:id", args: []string{"id"}}, + {family: "security", op: "protected-routes-update", method: "PUT", path: "/security/protected-routes/:id", args: []string{"id"}}, + {family: "security", op: "realtime-capture", method: "GET", path: "/security/realtime-capture", args: nil}, + {family: "security", op: "realtime-capture-update", method: "PUT", path: "/security/realtime-capture", args: nil}, + {family: "security", op: "refresh", method: "POST", path: "/security/refresh", args: nil}, + {family: "security", op: "stats", method: "GET", path: "/security/stats", args: nil}, + {family: "security", op: "whitelist", method: "GET", path: "/security/whitelist", args: nil}, + {family: "security", op: "whitelist-create", method: "POST", path: "/security/whitelist", args: nil}, + {family: "security", op: "whitelist-delete", method: "DELETE", path: "/security/whitelist/:id", args: []string{"id"}}, + {family: "server", op: "info", method: "GET", path: "/server/info", args: nil}, + {family: "server", op: "network-health", method: "GET", path: "/server/network-health", args: nil}, + {family: "settings", op: "list", method: "GET", path: "/settings", args: nil}, + {family: "settings", op: "update", method: "PUT", path: "/settings", args: nil}, + {family: "settings", op: "security", method: "PUT", path: "/settings/security", args: nil}, + {family: "setup", op: "authentication", method: "POST", path: "/setup/authentication", args: nil}, + {family: "setup", op: "complete", method: "POST", path: "/setup/complete", args: nil}, + {family: "setup", op: "info", method: "GET", path: "/setup/info", args: nil}, + {family: "setup", op: "settings", method: "POST", path: "/setup/settings", args: nil}, + {family: "setup", op: "status", method: "GET", path: "/setup/status", args: nil}, + {family: "setup", op: "validate", method: "POST", path: "/setup/validate", args: nil}, + {family: "setup", op: "verify-dns", method: "GET", path: "/setup/verify-dns", args: nil}, + {family: "source-credentials", op: "list", method: "GET", path: "/source-credentials", args: nil}, + {family: "source-credentials", op: "create", method: "POST", path: "/source-credentials", args: nil}, + {family: "source-credentials", op: "delete", method: "DELETE", path: "/source-credentials/:id", args: []string{"id"}}, + {family: "stats", op: "list", method: "GET", path: "/stats", args: nil}, + {family: "storage-credentials", op: "list", method: "GET", path: "/storage-credentials", args: nil}, + {family: "storage-credentials", op: "create", method: "POST", path: "/storage-credentials", args: nil}, + {family: "storage-credentials", op: "delete", method: "DELETE", path: "/storage-credentials/:id", args: []string{"id"}}, + {family: "storage-credentials", op: "update", method: "PUT", path: "/storage-credentials/:id", args: []string{"id"}}, + {family: "subdomain", op: "generate", method: "GET", path: "/subdomain/generate", args: nil}, + {family: "system", op: "files", method: "GET", path: "/system/files", args: nil}, + {family: "system", op: "files-info", method: "GET", path: "/system/files-info", args: nil}, + {family: "system", op: "files-delete", method: "DELETE", path: "/system/files/*path", args: []string{"path"}}, + {family: "system", op: "files-get", method: "GET", path: "/system/files/*path", args: []string{"path"}}, + {family: "system", op: "files-create", method: "POST", path: "/system/files/*path", args: []string{"path"}}, + {family: "system", op: "logs-delete", method: "DELETE", path: "/system/logs", args: nil}, + {family: "system", op: "logs", method: "GET", path: "/system/logs", args: nil}, + {family: "system", op: "logs-sources", method: "GET", path: "/system/logs/sources", args: nil}, + {family: "system", op: "mkdir", method: "POST", path: "/system/mkdir/*path", args: []string{"path"}}, + {family: "system", op: "permissions", method: "PUT", path: "/system/permissions/*path", args: []string{"path"}}, + {family: "system", op: "services", method: "GET", path: "/system/services", args: nil}, + {family: "system", op: "services-restart", method: "POST", path: "/system/services/:name/restart", args: []string{"name"}}, + {family: "system", op: "services-start", method: "POST", path: "/system/services/:name/start", args: []string{"name"}}, + {family: "system", op: "services-stop", method: "POST", path: "/system/services/:name/stop", args: []string{"name"}}, + {family: "system", op: "terminal", method: "GET", path: "/system/terminal", args: nil}, + {family: "system", op: "touch", method: "POST", path: "/system/touch/*path", args: []string{"path"}}, + {family: "templates", op: "list", method: "GET", path: "/templates", args: nil}, + {family: "templates", op: "categories", method: "GET", path: "/templates/categories", args: nil}, + {family: "templates", op: "infra-compose", method: "GET", path: "/templates/infra/:name/compose", args: []string{"name"}}, + {family: "templates", op: "infra-generate", method: "POST", path: "/templates/infra/:name/generate", args: []string{"name"}}, + {family: "templates", op: "refresh", method: "POST", path: "/templates/refresh", args: nil}, + {family: "templates", op: "compose", method: "GET", path: "/templates/:id/compose", args: []string{"id"}}, + {family: "templates", op: "generate", method: "POST", path: "/templates/:id/generate", args: []string{"id"}}, + {family: "traffic", op: "cleanup", method: "POST", path: "/traffic/cleanup", args: nil}, + {family: "traffic", op: "logs", method: "GET", path: "/traffic/logs", args: nil}, + {family: "traffic", op: "stats", method: "GET", path: "/traffic/stats", args: nil}, + {family: "traffic", op: "unknown-domains", method: "GET", path: "/traffic/unknown-domains", args: nil}, + {family: "users", op: "list", method: "GET", path: "/users", args: nil}, + {family: "users", op: "create", method: "POST", path: "/users", args: nil}, + {family: "users", op: "me", method: "GET", path: "/users/me", args: nil}, + {family: "users", op: "me-update", method: "PUT", path: "/users/me", args: nil}, + {family: "users", op: "me-password", method: "PUT", path: "/users/me/password", args: nil}, + {family: "users", op: "delete", method: "DELETE", path: "/users/:id", args: []string{"id"}}, + {family: "users", op: "get", method: "GET", path: "/users/:id", args: []string{"id"}}, + {family: "users", op: "update", method: "PUT", path: "/users/:id", args: []string{"id"}}, + {family: "users", op: "deployments", method: "GET", path: "/users/:id/deployments", args: []string{"id"}}, + {family: "users", op: "deployments-create", method: "POST", path: "/users/:id/deployments", args: []string{"id"}}, + {family: "users", op: "deployments-delete", method: "DELETE", path: "/users/:id/deployments/:name", args: []string{"id", "name"}}, + {family: "users", op: "deployments-update", method: "PUT", path: "/users/:id/deployments/:name", args: []string{"id", "name"}}, + {family: "volumes", op: "list", method: "GET", path: "/volumes", args: nil}, + {family: "volumes", op: "create", method: "POST", path: "/volumes", args: nil}, + {family: "volumes", op: "prune", method: "POST", path: "/volumes/prune", args: nil}, + {family: "volumes", op: "delete", method: "DELETE", path: "/volumes/:name", args: []string{"name"}}, +} diff --git a/internal/command/endpoints_test.go b/internal/command/endpoints_test.go new file mode 100644 index 0000000..1bc9f0b --- /dev/null +++ b/internal/command/endpoints_test.go @@ -0,0 +1,355 @@ +package command + +import ( + "bytes" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +type recordedRequest struct { + method string + path string + query string + rawURI string + body map[string]any +} + +func recordingServer(t *testing.T, reply string) (*httptest.Server, *recordedRequest) { + t.Helper() + got := &recordedRequest{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + got.method = r.Method + got.path = r.URL.Path + got.query = r.URL.RawQuery + got.rawURI = r.RequestURI + if raw, err := io.ReadAll(r.Body); err == nil && len(raw) > 0 { + _ = json.Unmarshal(raw, &got.body) + } + _, _ = w.Write([]byte(reply)) + })) + t.Cleanup(server.Close) + return server, got +} + +func runCLI(t *testing.T, server *httptest.Server, args ...string) (int, string, string) { + t.Helper() + t.Setenv("FLATRUN_URL", server.URL) + t.Setenv("FLATRUN_TOKEN", "secret") + var stdout, stderr bytes.Buffer + code := Run(args, &stdout, &stderr) + return code, stdout.String(), stderr.String() +} + +// The whole point of the generated table is that an agent endpoint is reachable without a +// hand-written wrapper, so this drives one that has none. +func TestGeneratedCommandCallsTheEndpoint(t *testing.T) { + server, got := recordingServer(t, `{"backups":[]}`) + + code, _, stderr := runCLI(t, server, "backups", "list", "--json") + if code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr) + } + if got.method != http.MethodGet || got.path != "/api/backups" { + t.Fatalf("called %s %s", got.method, got.path) + } +} + +func TestGeneratedCommandSubstitutesPathArguments(t *testing.T) { + server, got := recordingServer(t, `{"message":"ok"}`) + + code, _, stderr := runCLI(t, server, "certificates", "renew", "shop.example.com", "--json") + if code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr) + } + if got.path != "/api/certificates/shop.example.com/renew" { + t.Fatalf("path = %s", got.path) + } + if got.method != http.MethodPost { + t.Fatalf("method = %s", got.method) + } +} + +// A domain with a slash or a space in it must not be able to reshape the request path. +func TestGeneratedCommandEscapesPathArguments(t *testing.T) { + server, got := recordingServer(t, `{}`) + + code, _, stderr := runCLI(t, server, "certificates", "get", "one/../../admin", "--json") + if code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr) + } + // The server decodes before it hands over URL.Path, so what matters is what went on the + // wire: one escaped segment rather than a walk up the tree. + if !strings.Contains(got.rawURI, "%2F") { + t.Fatalf("the argument was not escaped on the wire: %s", got.rawURI) + } +} + +func TestGeneratedCommandBuildsABodyFromFields(t *testing.T) { + server, got := recordingServer(t, `{"message":"saved"}`) + + code, _, stderr := runCLI(t, server, "settings", "update", "-f", "name=backups", "-f", "enabled=true", "-f", "retention=7", "--json") + if code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr) + } + if got.body["name"] != "backups" { + t.Errorf("name = %#v", got.body["name"]) + } + if got.body["enabled"] != true { + t.Errorf("enabled should be sent as a boolean, got %#v", got.body["enabled"]) + } + if got.body["retention"] != float64(7) { + t.Errorf("retention should be sent as a number, got %#v", got.body["retention"]) + } +} + +func TestGeneratedCommandPassesQueryParameters(t *testing.T) { + server, got := recordingServer(t, `{"logs":""}`) + + code, _, stderr := runCLI(t, server, "deployments", "logs", "shop", "-q", "service=web", "-q", "tail=50", "--json") + if code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr) + } + if got.path != "/api/deployments/shop/logs" { + t.Fatalf("path = %s", got.path) + } + if got.query != "service=web&tail=50" { + t.Fatalf("query = %s", got.query) + } +} + +func TestGeneratedCommandRejectsBothBodyForms(t *testing.T) { + server, _ := recordingServer(t, `{}`) + + code, _, stderr := runCLI(t, server, "settings", "update", "--data", `{"a":1}`, "-f", "b=2") + if code == 0 { + t.Fatal("sending a body two ways at once should fail") + } + if !strings.Contains(stderr, "not both") { + t.Fatalf("stderr = %s", stderr) + } +} + +func TestMissingArgumentIsRefusedBeforeAnyRequest(t *testing.T) { + called := false + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true })) + defer server.Close() + + code, _, stderr := runCLI(t, server, "certificates", "renew") + if code == 0 { + t.Fatal("a missing argument should not be accepted") + } + if called { + t.Fatal("nothing should have been sent") + } + if !strings.Contains(stderr, "flatrun certificates renew DOMAIN") { + t.Fatalf("the usage should name the argument, got %s", stderr) + } +} + +// The singular family is hand-shaped and the plural one is generated; an operator should not +// have to know which is which. +func TestHandWrittenFamilyFallsBackToTheTable(t *testing.T) { + server, got := recordingServer(t, `{"sources":[]}`) + + code, _, stderr := runCLI(t, server, "deployment", "log-sources", "shop", "--json") + if code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr) + } + if got.path != "/api/deployments/shop/log-sources" { + t.Fatalf("path = %s", got.path) + } +} + +// A program driving the CLI reads this instead of the docs. +func TestJSONListingCoversEveryEndpoint(t *testing.T) { + var stdout, stderr bytes.Buffer + if code := Run([]string{"--json"}, &stdout, &stderr); code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr.String()) + } + + var listed []struct { + Family string `json:"family"` + Op string `json:"op"` + Method string `json:"method"` + Path string `json:"path"` + Args []string `json:"args"` + Command string `json:"command"` + } + if err := json.Unmarshal(stdout.Bytes(), &listed); err != nil { + t.Fatalf("the listing must be valid JSON: %v", err) + } + if len(listed) != len(catalogue()) { + t.Fatalf("listed %d of %d commands", len(listed), len(catalogue())) + } + for _, e := range listed { + if e.Family == "" || e.Op == "" || e.Method == "" || !strings.HasPrefix(e.Path, "/") { + t.Fatalf("incomplete entry: %+v", e) + } + } +} + +func TestJSONListingNarrowsToOneFamily(t *testing.T) { + var stdout, stderr bytes.Buffer + if code := Run([]string{"backups", "--json"}, &stdout, &stderr); code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr.String()) + } + + var listed []struct { + Family string `json:"family"` + } + if err := json.Unmarshal(stdout.Bytes(), &listed); err != nil { + t.Fatalf("the listing must be valid JSON: %v", err) + } + if len(listed) == 0 { + t.Fatal("no commands listed") + } + for _, e := range listed { + if e.Family != "backups" { + t.Fatalf("asked for one family, got %s", e.Family) + } + } +} + +// A caller reading the JSON must see the hand-shaped commands too, or it only learns half of +// what the CLI can do and reaches for the raw endpoint instead. +func TestJSONListingIncludesHandShapedCommands(t *testing.T) { + var stdout, stderr bytes.Buffer + if code := Run([]string{"--json"}, &stdout, &stderr); code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr.String()) + } + + var listed []struct { + Family string `json:"family"` + Op string `json:"op"` + Command string `json:"command"` + Shaped bool `json:"shaped"` + } + if err := json.Unmarshal(stdout.Bytes(), &listed); err != nil { + t.Fatal(err) + } + + found := false + for _, e := range listed { + if e.Family == "deployment" && e.Op == "exec" { + found = true + if !e.Shaped { + t.Error("a hand-shaped command should say so") + } + if !strings.Contains(e.Command, "-- COMMAND") { + t.Errorf("the invocation should show what it takes, got %q", e.Command) + } + } + } + if !found { + t.Error("deployment exec is missing from the listing") + } +} + +// Both names for one resource reach the same operations. +func TestSingularFamilyListsWhatThePluralDoes(t *testing.T) { + var singular, plural, stderr bytes.Buffer + if code := Run([]string{"deployment", "--json"}, &singular, &stderr); code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr.String()) + } + if code := Run([]string{"deployments", "--json"}, &plural, &stderr); code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr.String()) + } + + ops := func(raw []byte) map[string]bool { + var listed []struct { + Op string `json:"op"` + } + if err := json.Unmarshal(raw, &listed); err != nil { + t.Fatal(err) + } + out := map[string]bool{} + for _, e := range listed { + out[e.Op] = true + } + return out + } + + for op := range ops(plural.Bytes()) { + if !ops(singular.Bytes())[op] { + t.Errorf("deployments %s is not reachable as deployment %s", op, op) + } + } +} + +func TestEveryGeneratedCommandIsReachableAndUnique(t *testing.T) { + seen := map[string]string{} + for _, e := range generatedEndpoints { + key := e.family + " " + e.op + if previous, clash := seen[key]; clash { + t.Errorf("%q maps to both %s and %s", key, previous, e.path) + } + seen[key] = e.path + + found, ok := findEndpoint(e.family, e.op) + if !ok || found.path != e.path { + t.Errorf("%q does not dispatch back to %s", key, e.path) + } + // A parameter is written :name, or *name when it holds the rest of the path. + params := strings.Count(e.path, ":") + strings.Count(e.path, "*") + if params != len(e.args) { + t.Errorf("%s has %d path parameters but %d arguments", e.path, params, len(e.args)) + } + } +} + +// A wildcard holds the rest of the path, so its separators are structure and must survive. +func TestWildcardArgumentKeepsItsSeparators(t *testing.T) { + server, got := recordingServer(t, `{}`) + + code, _, stderr := runCLI(t, server, "deployments", "files-get", "shop", "src/app/main.go", "--json") + if code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr) + } + if got.path != "/api/deployments/shop/files/src/app/main.go" { + t.Fatalf("path = %s", got.path) + } +} + +func TestWildcardArgumentStillEscapesEachSegment(t *testing.T) { + server, got := recordingServer(t, `{}`) + + code, _, stderr := runCLI(t, server, "deployments", "files-get", "shop", "a b/c?d", "--json") + if code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr) + } + if !strings.Contains(got.rawURI, "a%20b/c%3Fd") { + t.Fatalf("the segments were not escaped: %s", got.rawURI) + } +} + +// Nobody should have to remember whether the API called it backup or backups. +func TestEitherSpellingReachesTheSameResource(t *testing.T) { + for _, spelling := range []string{"backups", "backup", "certificates", "certificate"} { + server, got := recordingServer(t, `{"items":[],"total":0}`) + code, _, stderr := runCLI(t, server, spelling, "list", "--json") + if code != 0 { + t.Fatalf("%s: code=%d stderr=%s", spelling, code, stderr) + } + if got.path == "" { + t.Errorf("%s reached nothing", spelling) + } + } +} + +func TestUnknownResourceIsStillUnknown(t *testing.T) { + server, got := recordingServer(t, `{}`) + code, _, stderr := runCLI(t, server, "bakcups", "list") + if code == 0 { + t.Fatal("a misspelt resource should not be accepted") + } + if got.path != "" { + t.Fatal("nothing should have been sent") + } + if !strings.Contains(stderr, "Unknown command") { + t.Fatalf("stderr = %s", stderr) + } +} diff --git a/internal/command/root.go b/internal/command/root.go index 50d50d0..ac75577 100644 --- a/internal/command/root.go +++ b/internal/command/root.go @@ -187,6 +187,10 @@ func Run(args []string, stdout, stderr io.Writer) int { case "help", "-h", "--help": usage(stdout) return 0 + case "--json": + // The whole surface, for anything driving the CLI that should not have to read help + // text to find out what it can call. + return listEndpoints(stdout, stderr, "", true) case "version", "--version": _, _ = fmt.Fprintf(stdout, "%s\nbuild_time=%s\ngit_commit=%s\n", Version, BuildTime, GitCommit) return 0 @@ -203,9 +207,23 @@ func Run(args []string, stdout, stderr io.Writer) int { case "api": return runAPI(args[1:], stdout, stderr) default: - _, _ = fmt.Fprintf(stderr, "Unknown command: %s\n\n", args[0]) - usage(stderr) - return 2 + // Families the CLI does not shape by hand still reach the agent, through the + // generated table, so a new endpoint there is reachable here without a wrapper. + family, known := resolveFamily(args[0]) + if !known { + _, _ = fmt.Fprintf(stderr, "Unknown command: %s\n\n", args[0]) + usage(stderr) + return 2 + } + if singular, aliased := shapedAlias[family]; aliased { + if len(args) > 1 && shapedCommand(singular, args[1]) { + return runShaped(singular, args[1:], stdout, stderr) + } + if len(args) == 1 { + return runShaped(singular, args[1:], stdout, stderr) + } + } + return runEndpoint(family, args[1:], stdout, stderr) } } @@ -213,17 +231,45 @@ func usage(w io.Writer) { _, _ = fmt.Fprintln(w, "FlatRun CLI") _, _ = fmt.Fprintln(w) _, _ = fmt.Fprintln(w, "Usage:") - _, _ = fmt.Fprintln(w, " flatrun [options]") + _, _ = fmt.Fprintln(w, " flatrun RESOURCE OPERATION [ARGS] [options]") + _, _ = fmt.Fprintln(w) + _, _ = fmt.Fprintln(w, "Resources:") + for _, line := range wrapNames(families(), 88) { + _, _ = fmt.Fprintln(w, " "+line) + } + _, _ = fmt.Fprintln(w) + _, _ = fmt.Fprintln(w, "Run `flatrun RESOURCE` for its operations. Singular and plural both work.") _, _ = fmt.Fprintln(w) - _, _ = fmt.Fprintln(w, "Commands:") - _, _ = fmt.Fprintln(w, " configure set Save a local profile") - _, _ = fmt.Fprintln(w, " configure list List local profiles") - _, _ = fmt.Fprintln(w, " health Check FlatRun API health") - _, _ = fmt.Fprintln(w, " deployment Manage deployments and their services/images/containers") - _, _ = fmt.Fprintln(w, " image Manage Docker images") - _, _ = fmt.Fprintln(w, " container Manage containers") - _, _ = fmt.Fprintln(w, " api Call any FlatRun API endpoint") + _, _ = fmt.Fprintln(w, "Other commands:") + _, _ = fmt.Fprintln(w, " configure Save and switch between local profiles") + _, _ = fmt.Fprintln(w, " health Check that the agent is reachable") + _, _ = fmt.Fprintln(w, " api Call any endpoint directly") _, _ = fmt.Fprintln(w, " version Print CLI version") + _, _ = fmt.Fprintln(w) + _, _ = fmt.Fprintln(w, "Add --json to any command for the raw answer, or to a listing for every command.") +} + +func wrapNames(names []string, width int) []string { + var lines []string + current := "" + for i, name := range names { + if i < len(names)-1 { + name += "," + } + if current != "" && len(current)+1+len(name) > width { + lines = append(lines, current) + current = "" + } + if current == "" { + current = name + continue + } + current += " " + name + } + if current != "" { + lines = append(lines, current) + } + return lines } func globalFlagSet(name string, opts *globalOptions, output, debugOut io.Writer) *flag.FlagSet { @@ -527,11 +573,14 @@ func runHealth(args []string, stdout, stderr io.Writer) int { func runDeployment(args []string, stdout, stderr io.Writer) int { if len(args) == 0 { - _, _ = fmt.Fprintln(stderr, "Usage: flatrun deployment ") - return 2 + return listEndpoints(stdout, stderr, "deployment", false) } switch args[0] { + case "help", "-h", "--help": + return listEndpoints(stdout, stderr, "deployment", false) + case "--json": + return listEndpoints(stdout, stderr, "deployment", true) case "list": return runDeploymentList(args[1:], stdout, stderr) case "info", "get": @@ -557,8 +606,7 @@ func runDeployment(args []string, stdout, stderr io.Writer) int { case "images", "containers", "services": return runDeploymentRead(args[0], args[1:], stdout, stderr) default: - _, _ = fmt.Fprintf(stderr, "Unknown deployment command: %s\n", args[0]) - return 2 + return runAliasedEndpoint("deployments", "deployment", args, stdout, stderr) } } @@ -1073,11 +1121,14 @@ func runDeploymentDeploy(args []string, stdout, stderr io.Writer) int { func runImage(args []string, stdout, stderr io.Writer) int { if len(args) == 0 { - _, _ = fmt.Fprintln(stderr, "Usage: flatrun image ") - return 2 + return listEndpoints(stdout, stderr, "image", false) } switch args[0] { + case "help", "-h", "--help": + return listEndpoints(stdout, stderr, "image", false) + case "--json": + return listEndpoints(stdout, stderr, "image", true) case "list": return runImageList(args[1:], stdout, stderr) case "pull": @@ -1085,8 +1136,7 @@ func runImage(args []string, stdout, stderr io.Writer) int { case "delete": return runImageDelete(args[1:], stdout, stderr) default: - _, _ = fmt.Fprintf(stderr, "Unknown image command: %s\n", args[0]) - return 2 + return runAliasedEndpoint("images", "image", args, stdout, stderr) } } @@ -1133,11 +1183,14 @@ func runImageDelete(args []string, stdout, stderr io.Writer) int { func runContainer(args []string, stdout, stderr io.Writer) int { if len(args) == 0 { - _, _ = fmt.Fprintln(stderr, "Usage: flatrun container ") - return 2 + return listEndpoints(stdout, stderr, "container", false) } switch args[0] { + case "help", "-h", "--help": + return listEndpoints(stdout, stderr, "container", false) + case "--json": + return listEndpoints(stdout, stderr, "container", true) case "list": return runContainerList(args[1:], stdout, stderr) case "start", "stop", "restart": @@ -1147,8 +1200,7 @@ func runContainer(args []string, stdout, stderr io.Writer) int { case "delete": return runContainerDelete(args[1:], stdout, stderr) default: - _, _ = fmt.Fprintf(stderr, "Unknown container command: %s\n", args[0]) - return 2 + return runAliasedEndpoint("containers", "container", args, stdout, stderr) } } @@ -1755,8 +1807,11 @@ func stringValue(value any) string { } func valueFlags(names ...string) map[string]bool { - result := make(map[string]bool, len(names)) + result := make(map[string]bool, len(names)*2) for _, name := range names { + // Go's flag package takes one dash or two, so both spellings have to be recognised + // here or the value gets read as the next flag. + result["-"+name] = true result["--"+name] = true } return result diff --git a/internal/command/schema.go b/internal/command/schema.go new file mode 100644 index 0000000..0363abb --- /dev/null +++ b/internal/command/schema.go @@ -0,0 +1,370 @@ +package command + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "sort" + "strconv" + "strings" + "text/tabwriter" + "time" + + "github.com/flatrun/cli/internal/spec" +) + +// checkFields refuses an unknown or missing field before anything is sent, since a 400 names +// neither the field the agent wanted nor the one it did not understand. It reads the body being +// sent rather than the flags it came from, so --data is held to the same requirements as -f. +func checkFields(api *spec.Spec, op spec.Operation, payload any) error { + fields := api.Fields(op) + if len(fields) == 0 { + return nil + } + sent, ok := payload.(map[string]any) + if payload != nil && !ok { + // A body that is not an object, such as a bare array, has no fields to check. + return nil + } + + known := make(map[string]bool, len(fields)) + names := make([]string, 0, len(fields)) + for _, field := range fields { + known[field.Name] = true + names = append(names, field.Name) + } + + var unknown []string + for name := range sent { + if !known[name] { + unknown = append(unknown, name) + } + } + sort.Strings(unknown) + if len(unknown) > 0 { + message := fmt.Sprintf("unknown field %s", strings.Join(unknown, ", ")) + if suggestion := closest(unknown[0], names); suggestion != "" { + message += fmt.Sprintf(". Did you mean %s?", suggestion) + } else { + message += fmt.Sprintf(". This endpoint takes: %s", strings.Join(names, ", ")) + } + return fmt.Errorf("%s", message) + } + + var missing []string + for _, field := range fields { + if field.Required { + if _, ok := sent[field.Name]; !ok { + missing = append(missing, field.Name) + } + } + } + if len(missing) > 0 { + return fmt.Errorf("missing required field %s", strings.Join(missing, ", ")) + } + return nil +} + +func checkQuery(api *spec.Spec, op spec.Operation, sent queryValues) error { + accepted := api.QueryParams(op) + if len(accepted) == 0 || len(sent) == 0 { + return nil + } + known := make(map[string]bool, len(accepted)) + for _, name := range accepted { + known[name] = true + } + for name := range sent { + if known[name] { + continue + } + message := fmt.Sprintf("unknown query parameter %s", name) + if suggestion := closest(name, accepted); suggestion != "" { + return fmt.Errorf("%s. Did you mean %s?", message, suggestion) + } + return fmt.Errorf("%s. This endpoint reads: %s", message, strings.Join(accepted, ", ")) + } + return nil +} + +// closest is the nearest accepted name to what was typed, when one is near enough to have been meant. +func closest(typed string, candidates []string) string { + best, bestDistance := "", len(typed)/2+1 + for _, candidate := range candidates { + if d := distance(typed, candidate); d <= bestDistance { + best, bestDistance = candidate, d + } + } + return best +} + +func distance(a, b string) int { + previous := make([]int, len(b)+1) + current := make([]int, len(b)+1) + for j := range previous { + previous[j] = j + } + for i := 1; i <= len(a); i++ { + current[0] = i + for j := 1; j <= len(b); j++ { + cost := 1 + if a[i-1] == b[j-1] { + cost = 0 + } + current[j] = min(previous[j]+1, min(current[j-1]+1, previous[j-1]+cost)) + } + copy(previous, current) + } + return previous[len(b)] +} + +func describeEndpoint(w io.Writer, api *spec.Spec, e endpoint, op spec.Operation) { + _, _ = fmt.Fprintln(w, invocation(e)) + if op.Permission != "" { + _, _ = fmt.Fprintf(w, "Needs %s\n", op.Permission) + } + + if fields := api.Fields(op); len(fields) > 0 { + _, _ = fmt.Fprintln(w, "\nFields, given as -f name=value:") + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + for _, field := range fields { + required := "" + if field.Required { + required = "required" + } + _, _ = fmt.Fprintf(tw, " %s\t%s\t%s\t%s\n", field.Name, field.Type, required, field.Help) + } + _ = tw.Flush() + } + + if query := api.QueryParams(op); len(query) > 0 { + _, _ = fmt.Fprintf(w, "\nQuery parameters, given as -q name=value:\n %s\n", strings.Join(query, ", ")) + } +} + +// renderAnswer lays out a response by the shape the agent answers in, so a list of certificates +// and a list of backups take the same path. +func renderAnswer(w io.Writer, api *spec.Spec, op spec.Operation, data []byte) bool { + var body map[string]json.RawMessage + if err := json.Unmarshal(data, &body); err != nil { + return false + } + + shape, ok := spec.Shape{}, false + if api != nil { + shape, ok = api.Shape(op) + } + if !ok { + // An endpoint the agent has not typed yet, or an agent too old to describe itself. The + // answer still says which of its fields holds the rows. + shape, ok = inferShape(body) + if !ok { + return false + } + } + + switch shape.Kind { + case "list": + return renderList(w, shape, body) + case "item": + return renderItem(w, shape, body) + case "message": + var message struct { + Message string `json:"message"` + } + if err := json.Unmarshal(data, &message); err != nil || message.Message == "" { + return false + } + _, _ = fmt.Fprintln(w, message.Message) + return true + } + return false +} + +// inferShape finds the rows in an answer that nothing described: the one field holding a list. +func inferShape(body map[string]json.RawMessage) (spec.Shape, bool) { + // The shared list shape carries its rows under items, and the name it used to answer under + // beside them, so there is no ambiguity to resolve. + if _, ok := body["items"]; ok { + return spec.Shape{Kind: "list", Key: "items"}, true + } + + found := "" + for name, raw := range body { + var rows []json.RawMessage + if err := json.Unmarshal(raw, &rows); err != nil { + continue + } + if found != "" { + return spec.Shape{}, false + } + found = name + } + if found == "" { + return spec.Shape{}, false + } + return spec.Shape{Kind: "list", Key: found}, true +} + +func renderList(w io.Writer, shape spec.Shape, body map[string]json.RawMessage) bool { + raw, ok := body[shape.Key] + if !ok { + return false + } + var values []any + if err := json.Unmarshal(raw, &values); err != nil { + return false + } + if len(values) == 0 { + _, _ = fmt.Fprintln(w, "None") + return true + } + + // A column heading over a single column of names is furniture. Names print as names. + rows := make([]map[string]any, 0, len(values)) + for _, value := range values { + row, ok := value.(map[string]any) + if !ok { + for _, value := range values { + _, _ = fmt.Fprintln(w, cell(value)) + } + return true + } + rows = append(rows, row) + } + + columns := shape.Columns + if len(columns) == 0 { + columns = inferColumns(raw, rows[0]) + } + if len(columns) == 1 { + for _, row := range rows { + _, _ = fmt.Fprintln(w, cell(row[columns[0]])) + } + return true + } + + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + headings := make([]string, 0, len(columns)) + for _, column := range columns { + headings = append(headings, strings.ToUpper(strings.ReplaceAll(column, "_", " "))) + } + _, _ = fmt.Fprintln(tw, strings.Join(headings, "\t")) + for _, row := range rows { + cells := make([]string, 0, len(columns)) + for _, column := range columns { + cells = append(cells, cell(row[column])) + } + _, _ = fmt.Fprintln(tw, strings.Join(cells, "\t")) + } + _ = tw.Flush() + return true +} + +// inferColumns takes the scalar fields of a row, in the order the answer wrote them, since a map +// has no order and alphabetical puts the identifier in the middle. +func inferColumns(raw json.RawMessage, first map[string]any) []string { + scalar := map[string]bool{} + for name, value := range first { + switch value.(type) { + case map[string]any, []any: + default: + scalar[name] = true + } + } + + var ordered []string + decoder := json.NewDecoder(bytes.NewReader(raw)) + depth := 0 + for { + token, err := decoder.Token() + if err != nil { + break + } + switch typed := token.(type) { + case json.Delim: + if typed == '{' || typed == '[' { + depth++ + } else { + depth-- + } + if depth < 2 && len(ordered) > 0 { + // Past the first row, so the order is settled. + return capColumns(ordered) + } + case string: + if depth == 2 && scalar[typed] { + ordered = append(ordered, typed) + scalar[typed] = false + } + } + } + return capColumns(ordered) +} + +func capColumns(columns []string) []string { + if len(columns) > maxInferredColumns { + return columns[:maxInferredColumns] + } + return columns +} + +const maxInferredColumns = 6 + +func renderItem(w io.Writer, shape spec.Shape, body map[string]json.RawMessage) bool { + raw, ok := body[shape.Key] + if !ok { + return false + } + var fields map[string]any + if err := json.Unmarshal(raw, &fields); err != nil { + return false + } + + names := make([]string, 0, len(fields)) + for name := range fields { + names = append(names, name) + } + sort.Strings(names) + + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + for _, name := range names { + _, _ = fmt.Fprintf(tw, "%s\t%s\n", strings.ReplaceAll(name, "_", " "), cell(fields[name])) + } + _ = tw.Flush() + return true +} + +func cell(value any) string { + switch typed := value.(type) { + case nil: + return "-" + case string: + if at, err := time.Parse(time.RFC3339, typed); err == nil { + return at.Local().Format("2006-01-02 15:04") + } + return typed + case bool: + if typed { + return "yes" + } + return "no" + case float64: + if typed == float64(int64(typed)) { + return strconv.FormatInt(int64(typed), 10) + } + return strconv.FormatFloat(typed, 'f', 2, 64) + case []any: + parts := make([]string, 0, len(typed)) + for _, item := range typed { + parts = append(parts, cell(item)) + } + return strings.Join(parts, ",") + } + encoded, err := json.Marshal(value) + if err != nil { + return "-" + } + return string(encoded) +} diff --git a/internal/command/schema_test.go b/internal/command/schema_test.go new file mode 100644 index 0000000..efd2012 --- /dev/null +++ b/internal/command/schema_test.go @@ -0,0 +1,319 @@ +package command + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +// A slice of a real agent's description: one endpoint with a required field, and one that +// answers with rows and says which of their fields make columns. +const testSpec = `{ + "openapi": "3.1.0", + "info": {"version": "0.4.0"}, + "paths": { + "/api/backups": { + "post": { + "operationId": "post-backups", + "x-permission": "backups:write", + "requestBody": {"required": true, "content": {"application/json": { + "schema": {"$ref": "#/components/schemas/backup.CreateBackupRequest"}}}}, + "responses": {"200": {"description": "Success"}} + }, + "get": { + "operationId": "get-backups", + "parameters": [{"name": "deployment", "in": "query", "schema": {"type": "string"}}], + "responses": {"200": {"description": "Success", "content": {"application/json": { + "schema": {"$ref": "#/components/schemas/api.ListOfBackup"}}}}} + } + } + }, + "components": {"schemas": { + "backup.CreateBackupRequest": { + "type": "object", + "required": ["deployment_name"], + "x-property-order": ["deployment_name", "description"], + "properties": { + "deployment_name": {"type": "string"}, + "description": {"type": "string"} + } + }, + "api.ListOfBackup": { + "type": "object", + "x-render": "list", + "x-property-order": ["items", "total"], + "properties": { + "items": {"type": "array", "items": {"$ref": "#/components/schemas/backup.Backup"}}, + "total": {"type": "integer"} + } + }, + "backup.Backup": { + "type": "object", + "x-columns": ["id", "deployment_name", "status"], + "x-property-order": ["id", "deployment_name", "status", "path"], + "properties": { + "id": {"type": "string"}, + "deployment_name": {"type": "string"}, + "status": {"type": "string"}, + "path": {"type": "string"} + } + } + }} +}` + +// describingServer answers the description on /openapi.json and the given reply everywhere else. +func describingServer(t *testing.T, reply string) (*httptest.Server, *recordedRequest) { + t.Helper() + got := &recordedRequest{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/openapi.json") { + _, _ = w.Write([]byte(testSpec)) + return + } + got.method = r.Method + got.path = r.URL.Path + got.query = r.URL.RawQuery + _, _ = w.Write([]byte(reply)) + })) + t.Cleanup(server.Close) + return server, got +} + +// Each test gets its own cache, or one test's description would answer another's question. +func isolateCache(t *testing.T) { + t.Helper() + dir := t.TempDir() + t.Setenv("XDG_CACHE_HOME", dir) + t.Setenv("HOME", filepath.Join(dir, "home")) + if err := os.MkdirAll(filepath.Join(dir, "home"), 0755); err != nil { + t.Fatal(err) + } +} + +func TestUnknownFieldIsRefusedBeforeSending(t *testing.T) { + isolateCache(t) + server, got := describingServer(t, `{"message":"created"}`) + + code, _, stderr := runCLI(t, server, "backups", "create", "-f", "deployment_nmae=shop") + if code == 0 { + t.Fatal("a field the endpoint does not take should fail") + } + if got.path != "" { + t.Fatalf("nothing should have been sent, but %s %s was", got.method, got.path) + } + if !strings.Contains(stderr, "deployment_name") { + t.Fatalf("the error should name the field that was meant, got %s", stderr) + } +} + +func TestKnownFieldsAreSent(t *testing.T) { + isolateCache(t) + server, got := describingServer(t, `{"message":"created"}`) + + code, _, stderr := runCLI(t, server, "backups", "create", "-f", "deployment_name=shop", "--json") + if code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr) + } + if got.path != "/api/backups" { + t.Fatalf("path = %s", got.path) + } +} + +func TestUnknownQueryParameterIsRefused(t *testing.T) { + isolateCache(t) + server, got := describingServer(t, `{"backups":[]}`) + + code, _, stderr := runCLI(t, server, "backups", "list", "-q", "deploymnet=shop") + if code == 0 { + t.Fatal("a query parameter the endpoint does not read should fail") + } + if got.path != "" { + t.Fatal("nothing should have been sent") + } + if !strings.Contains(stderr, "deployment") { + t.Fatalf("the error should name the parameter that was meant, got %s", stderr) + } +} + +// The layout comes from the description, so an endpoint nobody wrote a renderer for still prints +// as a table. +func TestAnswerIsRenderedFromTheDescription(t *testing.T) { + isolateCache(t) + reply := `{"items":[ + {"id":"b-1","deployment_name":"shop","status":"complete","path":"/srv/b-1"}, + {"id":"b-2","deployment_name":"blog","status":"failed","path":"/srv/b-2"}],"total":2,"backups":[ + {"id":"b-1","deployment_name":"shop","status":"complete","path":"/srv/b-1"}, + {"id":"b-2","deployment_name":"blog","status":"failed","path":"/srv/b-2"}]}` + server, _ := describingServer(t, reply) + + code, stdout, stderr := runCLI(t, server, "backups", "list") + if code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr) + } + if !strings.Contains(stdout, "ID") || !strings.Contains(stdout, "DEPLOYMENT NAME") { + t.Fatalf("expected column headings, got:\n%s", stdout) + } + if !strings.Contains(stdout, "b-1") || !strings.Contains(stdout, "complete") { + t.Fatalf("expected the rows, got:\n%s", stdout) + } + if strings.Contains(stdout, "/srv/b-1") { + t.Fatalf("a field that is not a column should stay out of the table:\n%s", stdout) + } +} + +func TestJSONStillWinsOverTheTable(t *testing.T) { + isolateCache(t) + server, _ := describingServer(t, `{"items":[{"id":"b-1","deployment_name":"shop","status":"complete"}],"total":1}`) + + code, stdout, stderr := runCLI(t, server, "backups", "list", "--json") + if code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr) + } + var decoded map[string]any + if err := json.Unmarshal([]byte(stdout), &decoded); err != nil { + t.Fatalf("--json should print the raw answer, got:\n%s", stdout) + } +} + +func TestHelpShowsWhatAnEndpointTakes(t *testing.T) { + isolateCache(t) + server, _ := describingServer(t, `{}`) + + code, stdout, stderr := runCLI(t, server, "backups", "create", "--help") + if code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr) + } + for _, want := range []string{"deployment_name", "required", "description", "backups:write"} { + if !strings.Contains(stdout, want) { + t.Errorf("help should mention %q, got:\n%s", want, stdout) + } + } +} + +// An older agent that cannot describe itself still has to work. +func TestCommandsWorkWithoutADescription(t *testing.T) { + isolateCache(t) + got := &recordedRequest{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/openapi.json") { + w.WriteHeader(http.StatusNotFound) + return + } + got.path = r.URL.Path + _, _ = w.Write([]byte(`{"backups":[]}`)) + })) + defer server.Close() + + var stdout, stderr bytes.Buffer + t.Setenv("FLATRUN_URL", server.URL) + t.Setenv("FLATRUN_TOKEN", "secret") + if code := Run([]string{"backups", "list", "-q", "anything=goes"}, &stdout, &stderr); code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr.String()) + } + if got.path != "/api/backups" { + t.Fatalf("path = %s", got.path) + } +} + +// A column heading over one column of names is furniture, so names print as names. +func TestNamesPrintAsLinesNotATable(t *testing.T) { + isolateCache(t) + spec := strings.Replace(testSpec, + `"x-columns": ["id", "deployment_name", "status"]`, + `"x-columns": ["id"]`, 1) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/openapi.json") { + _, _ = w.Write([]byte(spec)) + return + } + _, _ = w.Write([]byte(`{"items":[{"id":"b-1"},{"id":"b-2"}],"total":2}`)) + })) + defer server.Close() + + code, stdout, stderr := runCLI(t, server, "backups", "list") + if code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr) + } + if strings.Contains(stdout, "ID") { + t.Fatalf("one column needs no heading, got:\n%s", stdout) + } + if stdout != "b-1\nb-2\n" { + t.Fatalf("expected one name per line, got:\n%q", stdout) + } +} + +// A required field is required however the body was given, so --data is held to it too. +func TestRequiredFieldIsCheckedWhateverCarriesTheBody(t *testing.T) { + isolateCache(t) + server, got := describingServer(t, `{"message":"created"}`) + + code, _, stderr := runCLI(t, server, "backups", "create", "--data", `{"description":"nightly"}`) + if code == 0 { + t.Fatal("a body missing a required field should fail") + } + if got.path != "" { + t.Fatal("nothing should have been sent") + } + if !strings.Contains(stderr, "deployment_name") { + t.Fatalf("the error should name the missing field, got %s", stderr) + } +} + +func TestNoBodyAtAllStillReportsWhatIsRequired(t *testing.T) { + isolateCache(t) + server, _ := describingServer(t, `{"message":"created"}`) + + code, _, stderr := runCLI(t, server, "backups", "create") + if code == 0 { + t.Fatal("an endpoint with a required field should not accept an empty body") + } + if !strings.Contains(stderr, "deployment_name") { + t.Fatalf("the error should name the missing field, got %s", stderr) + } +} + +// An agent that has not typed an endpoint, or is too old to describe itself at all, should still +// get a table rather than a wall of JSON. +func TestUndescribedListStillPrintsAsATable(t *testing.T) { + isolateCache(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/openapi.json") { + w.WriteHeader(http.StatusNotFound) + return + } + _, _ = w.Write([]byte(`{"backups":[{"id":"b-1","deployment_name":"shop","status":"complete"}]}`)) + })) + defer server.Close() + + code, stdout, stderr := runCLI(t, server, "backups", "list") + if code != 0 { + t.Fatalf("code=%d stderr=%s", code, stderr) + } + if !strings.Contains(stdout, "ID") || !strings.Contains(stdout, "b-1") { + t.Fatalf("expected a table, got:\n%s", stdout) + } +} + +// Columns follow the order the answer wrote them in, since alphabetical buries the identifier. +func TestInferredColumnsKeepTheAnswersOrder(t *testing.T) { + isolateCache(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/openapi.json") { + w.WriteHeader(http.StatusNotFound) + return + } + _, _ = w.Write([]byte(`{"backups":[{"id":"b-1","zone":"eu","alpha":"a"}]}`)) + })) + defer server.Close() + + _, stdout, _ := runCLI(t, server, "backups", "list") + heading := strings.SplitN(stdout, "\n", 2)[0] + if strings.Index(heading, "ID") > strings.Index(heading, "ALPHA") { + t.Fatalf("the identifier should come first, got %q", heading) + } +} diff --git a/internal/command/shaped.go b/internal/command/shaped.go new file mode 100644 index 0000000..c60bd3d --- /dev/null +++ b/internal/command/shaped.go @@ -0,0 +1,126 @@ +package command + +import ( + "io" + "strings" +) + +// shapedCommands are the commands written by hand rather than taken from the route table, +// because they render a table, take flags shaped for the task, or read a command after `--`. +// They are listed here so that one catalogue answers what the CLI can do: a caller reading the +// JSON listing sees them alongside the generated ones instead of only half the surface. +var shapedCommands = []endpoint{ + {family: "deployment", op: "list", method: "GET", path: "/deployments"}, + {family: "deployment", op: "info", method: "GET", path: "/deployments/:name", args: []string{"name"}}, + {family: "deployment", op: "get", method: "GET", path: "/deployments/:name", args: []string{"name"}}, + {family: "deployment", op: "create", method: "POST", path: "/deployments", flags: "--image --port --host-port"}, + {family: "deployment", op: "delete", method: "DELETE", path: "/deployments/:name", args: []string{"name"}}, + {family: "deployment", op: "start", method: "POST", path: "/deployments/:name/manage", args: []string{"name"}}, + {family: "deployment", op: "stop", method: "POST", path: "/deployments/:name/manage", args: []string{"name"}}, + {family: "deployment", op: "restart", method: "POST", path: "/deployments/:name/manage", args: []string{"name"}}, + {family: "deployment", op: "rebuild", method: "POST", path: "/deployments/:name/manage", args: []string{"name"}}, + {family: "deployment", op: "deploy", method: "POST", path: "/deployments/:name/deploy", args: []string{"name"}, flags: "--operation --pull"}, + {family: "deployment", op: "pull", method: "POST", path: "/deployments/:name/pull", args: []string{"name"}, flags: "--only-latest"}, + {family: "deployment", op: "images", method: "GET", path: "/deployments/:name/images", args: []string{"name"}}, + {family: "deployment", op: "containers", method: "GET", path: "/deployments/:name/containers", args: []string{"name"}}, + {family: "deployment", op: "services", method: "GET", path: "/deployments/:name/services", args: []string{"name"}}, + {family: "deployment", op: "actions", method: "GET", path: "/deployments/:name/actions", args: []string{"name"}}, + {family: "deployment", op: "action", method: "POST", path: "/deployments/:name/actions/:actionId", args: []string{"name", "actionId"}}, + {family: "deployment", op: "exec", method: "POST", path: "/deployments/:name/exec", args: []string{"name"}, flags: "[SERVICE] -- COMMAND"}, + {family: "deployment", op: "image set", method: "PUT", path: "/deployments/:name/compose", args: []string{"name", "service", "image"}, flags: "--deploy --operation"}, + + {family: "image", op: "list", method: "GET", path: "/images"}, + {family: "image", op: "pull", method: "POST", path: "/images/pull", args: []string{"image"}, flags: "--credential-id"}, + {family: "image", op: "delete", method: "DELETE", path: "/images/:id", args: []string{"id"}}, + + {family: "container", op: "list", method: "GET", path: "/containers"}, + {family: "container", op: "start", method: "POST", path: "/containers/:id/start", args: []string{"id"}}, + {family: "container", op: "stop", method: "POST", path: "/containers/:id/stop", args: []string{"id"}}, + {family: "container", op: "restart", method: "POST", path: "/containers/:id/restart", args: []string{"id"}}, + {family: "container", op: "exec", method: "POST", path: "/containers/:id/exec", args: []string{"id"}, flags: "-- COMMAND"}, + {family: "container", op: "delete", method: "DELETE", path: "/containers/:id", args: []string{"id"}}, +} + +// catalogue is every command the CLI can run: the hand-shaped ones and the generated ones. The +// singular family a hand-shaped command lives under also reaches its plural counterpart, so both +// names appear rather than only the half a reader happened to look under. +func catalogue() []endpoint { + all := make([]endpoint, 0, len(shapedCommands)+len(generatedEndpoints)) + seen := map[string]bool{} + for _, e := range shapedCommands { + e.shaped = true + all = append(all, e) + seen[e.family+" "+e.op] = true + } + for _, e := range generatedEndpoints { + if seen[e.family+" "+e.op] { + continue + } + all = append(all, e) + // A hand-shaped singular family reaches every operation of its plural one. + if singular, ok := shapedAlias[e.family]; ok && !seen[singular+" "+e.op] { + alias := e + alias.family = singular + all = append(all, alias) + } + } + return all +} + +// shapedAlias maps a generated family onto the singular name the hand-shaped commands use. +var shapedAlias = map[string]string{ + "deployments": "deployment", + "images": "image", + "containers": "container", +} + +func invocation(e endpoint) string { + parts := []string{"flatrun", e.family, e.op} + for _, arg := range e.args { + parts = append(parts, strings.ToUpper(arg)) + } + if e.flags != "" { + parts = append(parts, e.flags) + } + return strings.Join(parts, " ") +} + +func shapedCommand(family, op string) bool { + for _, e := range shapedCommands { + if e.family == family && e.op == op { + return true + } + } + return false +} + +// runShaped dispatches to the hand-written command, so `containers list` and `container list` +// print the same thing rather than one table and one wall of JSON. +func runShaped(family string, args []string, stdout, stderr io.Writer) int { + switch family { + case "deployment": + return runDeployment(args, stdout, stderr) + case "image": + return runImage(args, stdout, stderr) + case "container": + return runContainer(args, stdout, stderr) + } + return 2 +} + +// resolveFamily takes whichever way a resource was typed and answers with the one name the CLI +// holds it under. Nobody should have to remember whether the API said backup or backups. +func resolveFamily(typed string) (string, bool) { + if knownFamily(typed) { + return typed, true + } + if singular, ok := shapedAlias[typed]; ok { + return singular, true + } + for _, candidate := range []string{typed + "s", typed + "es", strings.TrimSuffix(typed, "s")} { + if candidate != typed && knownFamily(candidate) { + return candidate, true + } + } + return "", false +} diff --git a/internal/flatrun/client.go b/internal/flatrun/client.go index 1a94ceb..43609b3 100644 --- a/internal/flatrun/client.go +++ b/internal/flatrun/client.go @@ -69,6 +69,10 @@ func New(baseURL, token string, timeout time.Duration, insecure bool) *Client { } } +// BaseURL is the agent this client talks to, which is what a cache of that agent's API +// description is keyed on. +func (c *Client) BaseURL() string { return c.baseURL } + func (c *Client) Health(ctx context.Context) ([]byte, error) { return c.Do(ctx, http.MethodGet, "/health", nil) } diff --git a/internal/spec/fetch.go b/internal/spec/fetch.go new file mode 100644 index 0000000..7cdafd5 --- /dev/null +++ b/internal/spec/fetch.go @@ -0,0 +1,73 @@ +package spec + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "time" +) + +type Fetcher interface { + Do(ctx context.Context, method, path string, payload any) ([]byte, error) +} + +// An agent's API changes only when it is upgraded, so this need only be short enough that an +// upgrade is noticed the same day. +const cacheTTL = 12 * time.Hour + +// Load returns nil for an agent too old to describe itself: the CLI works without a description, +// it just cannot check anything. +func Load(ctx context.Context, client Fetcher, baseURL string) *Spec { + path := cachePath(baseURL) + if raw, err := readFresh(path); err == nil { + if parsed, err := Parse(raw); err == nil { + return parsed + } + } + + raw, err := client.Do(ctx, "GET", "/openapi.json", nil) + if err != nil { + return nil + } + parsed, err := Parse(raw) + if err != nil { + return nil + } + write(path, raw) + return parsed +} + +func readFresh(path string) ([]byte, error) { + info, err := os.Stat(path) + if err != nil { + return nil, err + } + if time.Since(info.ModTime()) > cacheTTL { + return nil, os.ErrDeadlineExceeded + } + return os.ReadFile(path) +} + +func write(path string, raw []byte) { + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return + } + _ = os.WriteFile(path, raw, 0644) +} + +// cachePath keys the cache by agent. +func cachePath(baseURL string) string { + sum := sha256.Sum256([]byte(baseURL)) + name := hex.EncodeToString(sum[:8]) + ".json" + + if dir, err := os.UserCacheDir(); err == nil { + return filepath.Join(dir, "flatrun", "api", name) + } + home, err := os.UserHomeDir() + if err != nil { + return filepath.Join(os.TempDir(), "flatrun-api-"+name) + } + return filepath.Join(home, ".flatrun", "cache", "api", name) +} diff --git a/internal/spec/spec.go b/internal/spec/spec.go new file mode 100644 index 0000000..a999f5d --- /dev/null +++ b/internal/spec/spec.go @@ -0,0 +1,251 @@ +// Package spec reads the description an agent serves of its own API. Without it the CLI can only +// pass fields through and hope. +package spec + +import ( + "encoding/json" + "fmt" + "sort" + "strings" +) + +type Spec struct { + OpenAPI string `json:"openapi"` + Info Info `json:"info"` + Paths map[string]map[string]Operation `json:"paths"` + Components Components `json:"components"` +} + +type Info struct { + Version string `json:"version"` +} + +type Components struct { + Schemas map[string]*Schema `json:"schemas"` +} + +type Operation struct { + OperationID string `json:"operationId"` + Parameters []Parameter `json:"parameters"` + RequestBody *RequestBody `json:"requestBody"` + Responses map[string]struct { + Content map[string]struct { + Schema *Schema `json:"schema"` + } `json:"content"` + } `json:"responses"` + Permission string `json:"x-permission"` +} + +type Parameter struct { + Name string `json:"name"` + In string `json:"in"` + Required bool `json:"required"` + Schema *Schema `json:"schema"` +} + +type RequestBody struct { + Required bool `json:"required"` + Content map[string]struct { + Schema *Schema `json:"schema"` + } `json:"content"` +} + +type Schema struct { + Ref string `json:"$ref"` + Type string `json:"type"` + Format string `json:"format"` + Items *Schema `json:"items"` + Properties map[string]*Schema `json:"properties"` + PropertyOrder []string `json:"x-property-order"` + Columns []string `json:"x-columns"` + Render string `json:"x-render"` + Required []string `json:"required"` + Description string `json:"description"` + AdditionalProperties *Schema `json:"additionalProperties"` +} + +func Parse(raw []byte) (*Spec, error) { + var s Spec + if err := json.Unmarshal(raw, &s); err != nil { + return nil, fmt.Errorf("the agent's API description could not be read: %w", err) + } + if len(s.Paths) == 0 { + return nil, fmt.Errorf("the agent's API description contains no endpoints") + } + return &s, nil +} + +// Resolve follows a $ref to the schema it names. +func (s *Spec) Resolve(schema *Schema) *Schema { + seen := 0 + for schema != nil && schema.Ref != "" && seen < 10 { + name := strings.TrimPrefix(schema.Ref, "#/components/schemas/") + schema = s.Components.Schemas[name] + seen++ + } + return schema +} + +// Operation takes the path as the CLI holds it, `/deployments/:name`, not as the spec writes it. +func (s *Spec) Operation(method, path string) (Operation, bool) { + op, ok := s.Paths[specPath(path)][strings.ToLower(method)] + return op, ok +} + +func specPath(path string) string { + segments := strings.Split(path, "/") + for i, segment := range segments { + if strings.HasPrefix(segment, ":") { + segments[i] = "{" + strings.TrimPrefix(segment, ":") + "}" + } + } + return "/api" + strings.Join(segments, "/") +} + +// Field is one thing an endpoint accepts in its body. +type Field struct { + Name string + Type string + Required bool + Help string +} + +// Fields are an endpoint's body fields in declaration order, so help reads the way the type does. +func (s *Spec) Fields(op Operation) []Field { + if op.RequestBody == nil { + return nil + } + content, ok := op.RequestBody.Content["application/json"] + if !ok { + return nil + } + schema := s.Resolve(content.Schema) + if schema == nil || len(schema.Properties) == 0 { + return nil + } + + required := map[string]bool{} + for _, name := range schema.Required { + required[name] = true + } + + order := schema.PropertyOrder + if len(order) == 0 { + for name := range schema.Properties { + order = append(order, name) + } + sort.Strings(order) + } + + fields := make([]Field, 0, len(order)) + for _, name := range order { + property := s.Resolve(schema.Properties[name]) + if property == nil { + continue + } + fields = append(fields, Field{ + Name: name, + Type: typeName(property), + Required: required[name], + Help: property.Description, + }) + } + return fields +} + +func typeName(schema *Schema) string { + switch schema.Type { + case "array": + if schema.Items != nil && schema.Items.Type != "" { + return schema.Items.Type + " list" + } + return "list" + case "": + return "any" + case "string": + if schema.Format == "date-time" { + return "timestamp" + } + return "string" + } + return schema.Type +} + +// QueryParams are the query keys an endpoint reads. +func (s *Spec) QueryParams(op Operation) []string { + var names []string + for _, p := range op.Parameters { + if p.In == "query" { + names = append(names, p.Name) + } + } + sort.Strings(names) + return names +} + +// Shape is how an endpoint's answer is presented: "list", "item", "message", or empty when the +// agent does not say. Key holds the rows or the thing within the answer. +type Shape struct { + Kind string + Key string + Columns []string +} + +// maxColumns keeps a wide type readable. Everything is still in --json. +const maxColumns = 6 + +func (s *Spec) Shape(op Operation) (Shape, bool) { + ok200, ok := op.Responses["200"] + if !ok { + return Shape{}, false + } + content, ok := ok200.Content["application/json"] + if !ok { + return Shape{}, false + } + schema := s.Resolve(content.Schema) + if schema == nil || schema.Render == "" { + return Shape{}, false + } + + shape := Shape{Kind: schema.Render} + for _, name := range propertyOrder(schema) { + property := schema.Properties[name] + if property == nil { + continue + } + switch schema.Render { + case "list": + if property.Type != "array" || property.Items == nil { + continue + } + shape.Key = name + if row := s.Resolve(property.Items); row != nil { + shape.Columns = row.Columns + if len(shape.Columns) > maxColumns { + shape.Columns = shape.Columns[:maxColumns] + } + } + return shape, true + case "item": + shape.Key = name + return shape, true + } + } + if schema.Render == "message" { + return shape, true + } + return Shape{}, false +} + +func propertyOrder(schema *Schema) []string { + if len(schema.PropertyOrder) > 0 { + return schema.PropertyOrder + } + names := make([]string, 0, len(schema.Properties)) + for name := range schema.Properties { + names = append(names, name) + } + sort.Strings(names) + return names +} diff --git a/tools/gen_endpoints.py b/tools/gen_endpoints.py new file mode 100644 index 0000000..23935cb --- /dev/null +++ b/tools/gen_endpoints.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +"""Regenerate internal/command/endpoints_gen.go from the agent's API description. + + python3 tools/gen_endpoints.py ../agent/internal/api/openapi.json > internal/command/endpoints_gen.go + +The agent describes itself, so the commands are named from that description rather than from a +second reading of the agent's source. Whatever the agent serves is what the CLI can reach. +""" + +import collections +import json +import sys + +WRITE_VERB = {"POST": "create", "PUT": "update", "PATCH": "update", "DELETE": "delete"} + +# Reached by the agent's own components, not by an operator. +SKIP_PREFIXES = ("/api/internal", "/api/_internal", "/api/security/events/ingest", "/api/traffic/ingest") + +# The description of the API is not a resource of it. +SKIP_PATHS = ("/api/openapi.json",) + + +def endpoints(spec): + for path, methods in spec["paths"].items(): + if path.startswith(SKIP_PREFIXES) or path in SKIP_PATHS: + continue + for method, operation in methods.items(): + params = [p for p in operation.get("parameters", []) if p["in"] == "path"] + yield { + "method": method.upper(), + # The CLI holds paths as the router writes them, without the /api the client adds. + "path": path[len("/api"):], + "args": [p["name"] for p in params], + "rest": {p["name"] for p in params if p.get("x-rest-of-path")}, + } + + +def op_name(method, segments): + literals = [s for s in segments if not s.startswith("{")] + params = [s for s in segments if s.startswith("{")] + if not literals: + if method == "GET": + return "get" if params else "list" + return WRITE_VERB[method] + return "-".join(literals) + + +def cli_path(endpoint): + """The path as the router writes it, so a segment holding the rest of the path stays marked.""" + out = [] + for segment in endpoint["path"].strip("/").split("/"): + if not segment.startswith("{"): + out.append(segment) + continue + name = segment.strip("{}") + out.append(("*" if name in endpoint["rest"] else ":") + name) + return "/" + "/".join(out) + + +def build(spec): + families = collections.defaultdict(list) + for endpoint in endpoints(spec): + segments = endpoint["path"].strip("/").split("/") + families[segments[0]].append((endpoint, segments[1:])) + + table = [] + for family in sorted(families): + entries = [[op_name(e["method"], rest), e, rest] + for e, rest in sorted(families[family], key=lambda r: (r[0]["path"], r[0]["method"]))] + + # Several endpoints under one noun share a name: the collection and the single item, and + # the read and the write. The plainest keeps the bare name and the rest say what they do. + used = collections.Counter(name for name, _, _ in entries) + methods = collections.defaultdict(set) + for name, endpoint, _ in entries: + methods[name].add(endpoint["method"]) + plainest = {} + for name, endpoint, _ in entries: + count = len(endpoint["args"]) + if endpoint["method"] == "GET" and count < plainest.get(name, (99,))[0]: + plainest[name] = (count, endpoint["path"]) + + # Taken before any renaming below, which would otherwise change what is being counted. + fewest_args = {} + for name, endpoint, _ in entries: + count = len(endpoint["args"]) + fewest_args[name] = min(fewest_args.get(name, count), count) + + for entry in entries: + name, endpoint, _ = entry + if used[name] == 1: + continue + count = len(endpoint["args"]) + if len(methods[name]) == 1: + # The same verb on the collection and on one item. Whichever is safer to type by + # mistake keeps the bare name: reading the collection, but writing to one item, so + # "renew DOMAIN" renews one and "renew-all" says what it does. + fewest = fewest_args[name] + if endpoint["method"] == "GET": + if count > fewest: + entry[0] = name + "-get" + elif count == fewest: + entry[0] = name + "-all" + continue + if name in plainest and plainest[name][1] == endpoint["path"] and endpoint["method"] == "GET": + continue + entry[0] = name + "-" + ("get" if endpoint["method"] == "GET" else WRITE_VERB[endpoint["method"]]) + + for name, endpoint, _ in entries: + table.append({ + "family": family, + "op": name, + "method": endpoint["method"], + "path": cli_path(endpoint), + "args": endpoint["args"], + }) + return table + + +def main(): + args = [a for a in sys.argv[1:] if not a.startswith("--")] + if len(args) != 1: + sys.exit("usage: gen_endpoints.py PATH_TO_OPENAPI_JSON [--json]") + table = build(json.load(open(args[0]))) + + if "--json" in sys.argv: + print(json.dumps(table, indent=1)) + return + + out = [ + "// Code generated by tools/gen_endpoints.py from the agent's API description. DO NOT EDIT.", + "", + "package command", + "", + "var generatedEndpoints = []endpoint{", + ] + for entry in table: + args = "nil" if not entry["args"] else "[]string{" + ", ".join('"%s"' % a for a in entry["args"]) + "}" + out.append('\t{family: "%s", op: "%s", method: "%s", path: "%s", args: %s},' + % (entry["family"], entry["op"], entry["method"], entry["path"], args)) + out.append("}") + print("\n".join(out)) + + +if __name__ == "__main__": + main()