diff --git a/CHANGELOG.md b/CHANGELOG.md index bfcc0b6..74b6d3d 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 families, `flatrun FAMILY` lists its commands, 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..8e45c88 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,44 @@ 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 families +flatrun backups # what backups can do +flatrun backups list +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. + +### 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..2f7cb71 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -130,9 +130,57 @@ 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 families +flatrun backups # what backups can do +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 +``` + +## Listing what exists + +```bash +flatrun # the families +flatrun backups # one family +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..19aadce --- /dev/null +++ b/internal/command/endpoints.go @@ -0,0 +1,359 @@ +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) } + +func (e endpoint) writes() bool { return e.method != "GET" } + +// 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()) + } + path = strings.Replace(path, ":"+name, url.PathEscape(args[i]), 1) + } + return path, nil +} + +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 := checkFields(api, op, fields); err != nil { + return nil, err + } + 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, e) + if err != nil { + return nil, err + } + return client.Do(ctx, e.method, path, payload) + }, + render: func(w io.Writer, data []byte) error { + if described && 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, e endpoint) (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 + } + if e.writes() { + // A write with no body is normal here: restarting a deployment or renewing a + // certificate carries nothing. + return nil, nil + } + 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..c837c62 --- /dev/null +++ b/internal/command/endpoints_gen.go @@ -0,0 +1,300 @@ +// Code generated by tools/gen_endpoints.py from the agent's route table. 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: "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: "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: "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: "certificates", op: "list", method: "GET", path: "/certificates", args: nil}, + {family: "certificates", op: "create", method: "POST", path: "/certificates", 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: "certificates", op: "renew-all", method: "POST", path: "/certificates/renew", args: nil}, + {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: "*key", method: "GET", path: "/config/*key", args: nil}, + {family: "config", op: "*key-update", method: "PUT", path: "/config/*key", args: nil}, + {family: "containers", op: "list", method: "GET", path: "/containers", 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: "containers", op: "stats", method: "GET", path: "/containers/stats", args: nil}, + {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-*path-delete", method: "DELETE", path: "/deployments/:name/files/*path", args: []string{"name"}}, + {family: "deployments", op: "files-*path", method: "GET", path: "/deployments/:name/files/*path", args: []string{"name"}}, + {family: "deployments", op: "files-*path-create", method: "POST", path: "/deployments/:name/files/*path", args: []string{"name"}}, + {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", method: "GET", path: "/deployments/:name/jobs/:jobId", args: []string{"name", "jobId"}}, + {family: "deployments", op: "jobs-active", method: "GET", path: "/deployments/:name/jobs/active", args: []string{"name"}}, + {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-*path", method: "POST", path: "/deployments/:name/mkdir/*path", args: []string{"name"}}, + {family: "deployments", op: "permissions-*path", method: "PUT", path: "/deployments/:name/permissions/*path", args: []string{"name"}}, + {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-*path", method: "POST", path: "/deployments/:name/touch/*path", args: []string{"name"}}, + {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: "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: "delete", method: "DELETE", path: "/images/:id", args: []string{"id"}}, + {family: "images", op: "cleanup", method: "POST", path: "/images/cleanup", args: nil}, + {family: "images", op: "pull", method: "POST", path: "/images/pull", args: nil}, + {family: "infrastructure", op: "list", method: "GET", path: "/infrastructure", 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: "infrastructure", op: "migrate", method: "POST", path: "/infrastructure/migrate/:name", args: []string{"name"}}, + {family: "infrastructure", op: "stats", method: "GET", path: "/infrastructure/stats", args: nil}, + {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: "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: "object-stores", op: "provision-managed", method: "POST", path: "/object-stores/provision-managed", args: nil}, + {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: "delete", method: "DELETE", path: "/proxy/:name", args: []string{"name"}}, + {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: "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-*path-delete", method: "DELETE", path: "/system/files/*path", args: nil}, + {family: "system", op: "files-*path", method: "GET", path: "/system/files/*path", args: nil}, + {family: "system", op: "files-*path-create", method: "POST", path: "/system/files/*path", args: nil}, + {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-*path", method: "POST", path: "/system/mkdir/*path", args: nil}, + {family: "system", op: "permissions-*path", method: "PUT", path: "/system/permissions/*path", args: nil}, + {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-*path", method: "POST", path: "/system/touch/*path", args: nil}, + {family: "templates", op: "list", method: "GET", path: "/templates", 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: "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: "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: "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: "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: "volumes", op: "list", method: "GET", path: "/volumes", args: nil}, + {family: "volumes", op: "create", method: "POST", path: "/volumes", args: nil}, + {family: "volumes", op: "delete", method: "DELETE", path: "/volumes/:name", args: []string{"name"}}, + {family: "volumes", op: "prune", method: "POST", path: "/volumes/prune", args: nil}, +} diff --git a/internal/command/endpoints_test.go b/internal/command/endpoints_test.go new file mode 100644 index 0000000..8c26120 --- /dev/null +++ b/internal/command/endpoints_test.go @@ -0,0 +1,300 @@ +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) + } + if strings.Count(e.path, ":") != len(e.args) { + t.Errorf("%s has %d path parameters but %d arguments", e.path, strings.Count(e.path, ":"), len(e.args)) + } + } +} diff --git a/internal/command/root.go b/internal/command/root.go index 50d50d0..700fb05 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,6 +207,11 @@ func Run(args []string, stdout, stderr io.Writer) int { case "api": return runAPI(args[1:], stdout, stderr) default: + // 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. + if knownFamily(args[0]) { + return runEndpoint(args[0], args[1:], stdout, stderr) + } _, _ = fmt.Fprintf(stderr, "Unknown command: %s\n\n", args[0]) usage(stderr) return 2 @@ -224,6 +233,11 @@ func usage(w io.Writer) { _, _ = fmt.Fprintln(w, " container Manage containers") _, _ = fmt.Fprintln(w, " api Call any FlatRun API endpoint") _, _ = fmt.Fprintln(w, " version Print CLI version") + _, _ = fmt.Fprintln(w) + _, _ = fmt.Fprintln(w, "Resource families:") + _, _ = fmt.Fprintln(w, " "+strings.Join(families(), ", ")) + _, _ = fmt.Fprintln(w) + _, _ = fmt.Fprintln(w, "Run `flatrun ` for its commands, or add --json for all of them.") } func globalFlagSet(name string, opts *globalOptions, output, debugOut io.Writer) *flag.FlagSet { @@ -527,11 +541,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 +574,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 +1089,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 +1104,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 +1151,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 +1168,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 +1775,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..6ffe40b --- /dev/null +++ b/internal/command/schema.go @@ -0,0 +1,285 @@ +package command + +import ( + "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. +func checkFields(api *spec.Spec, op spec.Operation, sent fieldValues) error { + fields := api.Fields(op) + if len(fields) == 0 { + 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 && len(sent) > 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 { + shape, ok := api.Shape(op) + if !ok { + return false + } + + var body map[string]json.RawMessage + if err := json.Unmarshal(data, &body); err != nil { + 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 +} + +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 { + for name, value := range rows[0] { + if _, nested := value.(map[string]any); !nested { + columns = append(columns, name) + } + } + sort.Strings(columns) + } + 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 +} + +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..1ab6238 --- /dev/null +++ b/internal/command/schema_test.go @@ -0,0 +1,248 @@ +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) + } +} diff --git a/internal/command/shaped.go b/internal/command/shaped.go new file mode 100644 index 0000000..a61feaf --- /dev/null +++ b/internal/command/shaped.go @@ -0,0 +1,83 @@ +package command + +import "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, " ") +} 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..0570274 --- /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 route table. + + python3 tools/gen_endpoints.py ../agent > internal/command/endpoints_gen.go + +The agent registers its routes in internal/api/server.go and publishes no machine-readable +spec, so the routes are read from that file. Adding an endpoint there and rerunning this is all +the CLI needs to reach it. +""" + +import collections +import json +import re +import sys + +GROUP_PREFIX = { + "api": "", + "protected": "", + "setupGroup": "/setup", + "guarded": "/setup", + "usersGroup": "/users", + "apiKeysGroup": "/apikeys", + "dnsGroup": "/dns", + "clusterGroup": "/cluster", +} + +# Reached by the agent's own plugins and by nginx, never by an operator. +SKIP_PREFIXES = ("/internal", "/_internal", "/security/events/ingest", "/traffic/ingest") + +# Streaming endpoints: a websocket or a long-lived follow, which the table's request/response +# shape cannot carry. +SKIP_SUFFIXES = ("/stream", "/ws", "/terminal/interactive", "/exec/interactive") + +WRITE_VERB = {"POST": "create", "PUT": "update", "PATCH": "update", "DELETE": "delete"} + + +def routes(agent_path): + src = open(agent_path + "/internal/api/server.go").read() + pattern = re.compile(r'\b(\w+)\.(GET|POST|PUT|DELETE|PATCH)\(\s*"([^"]+)"(.*?)\)\s*$', re.M) + for match in pattern.finditer(src): + group, method, path, rest = match.groups() + if group not in GROUP_PREFIX: + continue + full = GROUP_PREFIX[group] + path + if full.startswith(SKIP_PREFIXES) or full.endswith(SKIP_SUFFIXES): + continue + perm = re.search(r"auth\.(Perm\w+)", rest) + yield {"method": method, "path": full, "perm": perm.group(1) if perm else ""} + + +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] + name = "-".join(literals) + return name + + +def build(agent_path): + families = collections.defaultdict(list) + for route in routes(agent_path): + segments = route["path"].strip("/").split("/") + family, rest = segments[0], segments[1:] + families[family].append((route, rest)) + + table = [] + for family in sorted(families): + used = collections.Counter() + entries = [] + for route, rest in sorted(families[family], key=lambda r: (r[0]["path"], r[0]["method"])): + name = op_name(route["method"], rest) + entries.append([name, route, rest]) + # Several endpoints under one noun share a name: the collection and the single item, + # and the read and the write. The plainest one keeps the bare name and the rest say what + # they do, so "domains" lists them and "domains-delete" removes one. + for name, _, _ in entries: + used[name] += 1 + plainest = {} + for name, route, rest in entries: + arg_count = sum(1 for s in rest if s.startswith(":")) + if route["method"] == "GET" and arg_count < plainest.get(name, (99,))[0]: + plainest[name] = (arg_count, route["path"]) + methods = collections.defaultdict(set) + for name, route, _ in entries: + methods[name].add(route["method"]) + for entry in entries: + name, route, rest = entry + if used[name] == 1: + continue + arg_count = sum(1 for s in rest if s.startswith(":")) + 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 = min(sum(1 for s in r.strip("/").split("/") if s.startswith(":")) + for n, rt, r in [(n, rt, rt["path"]) for n, rt, _ in entries if n == name]) + if route["method"] == "GET": + if arg_count > fewest: + entry[0] = name + "-get" + elif arg_count == fewest: + entry[0] = name + "-all" + continue + if name in plainest and plainest[name][1] == route["path"] and route["method"] == "GET": + continue + entry[0] = name + "-" + ("get" if route["method"] == "GET" else WRITE_VERB[route["method"]]) + for name, route, rest in entries: + args = [s.lstrip(":") for s in route["path"].strip("/").split("/") if s.startswith(":")] + table.append({ + "family": family, + "op": name, + "method": route["method"], + "path": route["path"], + "args": args, + "perm": route["perm"], + }) + 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_AGENT_CHECKOUT [--json]") + table = build(args[0]) + if "--json" in sys.argv: + print(json.dumps(table, indent=1)) + return + + out = [] + out.append("// Code generated by tools/gen_endpoints.py from the agent's route table. DO NOT EDIT.") + out.append("") + out.append("package command") + out.append("") + out.append("var generatedEndpoints = []endpoint{") + for e in table: + args = "nil" if not e["args"] else "[]string{" + ", ".join('"%s"' % a for a in e["args"]) + "}" + out.append('\t{family: "%s", op: "%s", method: "%s", path: "%s", args: %s},' + % (e["family"], e["op"], e["method"], e["path"], args)) + out.append("}") + print("\n".join(out)) + + +if __name__ == "__main__": + main()