From c34747852defc7b2ccd65f373b5aecd521286077 Mon Sep 17 00:00:00 2001 From: nash Date: Wed, 27 May 2026 18:53:06 +0500 Subject: [PATCH] feat(tencent): add --format json to `clanker tencent list ` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing JSON-emitting methods on *tencent.Client (JSONCVMs, JSONVPCs, JSONSecurityGroups, ...) are what the HTTP API in `clanker server` surfaces at /api/v1/tencent/resources/{type}. They were the canonical data source for programmatic consumers but had no CLI affordance — `clanker tencent list cvm` only printed a tabwriter table, so any pipeline that wanted JSON had to either parse the table or stand up the full HTTP server. Add a --format flag (table | json, default table) on the list subcommand. JSON mode dispatches to the corresponding JSONX method via a new listAsJSON / emitTypedJSON pair in internal/tencent/list_json.go. Multi-region behaviour: --format json --all-regions emits an explicit envelope { "regions": [{"region": "", "data": }, ...], "errors": [{"region": "", "error": "..."}, ...] }. Errors are per-region so a single regional outage doesn't poison the whole sweep. Subnets type returns a guidance message ("use `tencent list vpc --format json` and read the embedded Subnets field") rather than a generic "unknown type" — subnets don't have a dedicated JSON method because they're embedded in the VPC summary. Why now: clanker-cloud Phase b (Tencent backend integration) shells out to `clanker tencent list --json` for inventory. Without this flag the backend would have to either parse table output (brittle) or spawn `clanker server` as a sidecar (lifecycle complexity). Tests cover the dispatch arms (unknown type, subnets guidance message). go vet + gofmt -s clean. Backward-compatible: existing callers with no --format flag still get the table output. --- internal/tencent/list_json.go | 154 ++++++++++++++++++++++++++++ internal/tencent/list_json_test.go | 40 ++++++++ internal/tencent/static_commands.go | 21 ++++ 3 files changed, 215 insertions(+) create mode 100644 internal/tencent/list_json.go create mode 100644 internal/tencent/list_json_test.go diff --git a/internal/tencent/list_json.go b/internal/tencent/list_json.go new file mode 100644 index 0000000..6124666 --- /dev/null +++ b/internal/tencent/list_json.go @@ -0,0 +1,154 @@ +package tencent + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" +) + +// listAsJSON renders the `clanker tencent list --format json` +// output. It reuses the existing JSON* methods on *Client (the same +// ones the HTTP API surfaces in clanker-cloud) so the wire format is +// shared between CLI and HTTP consumers. +// +// Output shape: +// +// Single region (default): +// +// +// With --all-regions: +// {"regions": [{"region": "", "data": }, ...]} +// +// The single-region shape preserves backward compatibility with +// anything that already consumes `JSONCVMs` etc. directly (HTTP API). +// The multi-region envelope is new — explicit so downstream tools can +// trivially filter per region. +func listAsJSON(ctx context.Context, client *Client, resourceType string, regions []string, allRegions bool) error { + // Single-region fast path: most resource types only make sense in + // one region anyway, and the HTTP API shape matches this exactly. + if !allRegions || len(regions) <= 1 { + region := client.Region() + if len(regions) == 1 { + region = regions[0] + } + scoped := client.WithRegion(region) + body, err := emitTypedJSON(ctx, scoped, resourceType) + if err != nil { + return err + } + fmt.Println(body) + return nil + } + + // Multi-region fan-out: walk every region serially. Errors are + // captured per-region so a single regional outage doesn't poison + // the whole sweep — callers see {"errors":[{"region":..., "error":...}]}. + type regionData struct { + Region string `json:"region"` + Data json.RawMessage `json:"data"` + } + type regionErr struct { + Region string `json:"region"` + Error string `json:"error"` + } + var entries []regionData + var errors []regionErr + for _, r := range regions { + scoped := client.WithRegion(r) + body, err := emitTypedJSON(ctx, scoped, resourceType) + if err != nil { + errors = append(errors, regionErr{Region: r, Error: err.Error()}) + continue + } + // Empty body → empty array so the consumer doesn't need a + // special case for "no resources here." + if strings.TrimSpace(body) == "" { + body = "[]" + } + entries = append(entries, regionData{Region: r, Data: json.RawMessage(body)}) + } + + envelope := map[string]interface{}{ + "regions": entries, + } + if len(errors) > 0 { + envelope["errors"] = errors + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(envelope) +} + +// emitTypedJSON dispatches the resource-type string to the matching +// JSON method on Client. Returns the raw JSON string (which may be an +// empty string when the SDK returned no resources for the type). +// +// Service-global types (cos, ssl, cam, cdn, edgeone, waf, antiddos, +// ccn, cloudaudit) ignore the client's region; they're listed here for +// completeness so the dispatch is exhaustive against the table-mode +// switch above. +func emitTypedJSON(ctx context.Context, client *Client, resourceType string) (string, error) { + switch resourceType { + case "cvm", "instance", "instances", "vm", "vms": + return client.JSONCVMs(ctx) + case "vpc", "vpcs": + return client.JSONVPCs(ctx) + case "sg", "sgs", "security-group", "security-groups": + return client.JSONSecurityGroups(ctx) + case "mysql", "cdb": + return client.JSONMySQL(ctx) + case "postgres", "postgresql", "pg": + return client.JSONPostgres(ctx) + case "cos", "bucket", "buckets": + return client.JSONCOS(ctx) + case "tke", "k8s", "cluster", "clusters", "kubernetes": + return client.JSONTKE(ctx) + case "clb", "lb", "lbs", "load-balancer", "load-balancers": + return client.JSONCLB(ctx) + case "eip", "eips", "address", "addresses": + return client.JSONEIP(ctx) + case "cbs", "disk", "disks", "volume", "volumes": + return client.JSONCBS(ctx) + case "ssl", "cert", "certs", "certificate", "certificates": + return client.JSONSSL(ctx) + case "cam", "iam", "user", "users": + return client.JSONCAM(ctx) + case "redis", "valkey": + return client.JSONRedis(ctx) + case "mongo", "mongodb": + return client.JSONMongoDB(ctx) + case "cynosdb", "tdsql-c", "tdsqlc": + return client.JSONCynosDB(ctx) + case "cdn", "cdn-domains": + return client.JSONCDN(ctx) + case "edgeone", "teo", "zones": + return client.JSONEdgeOne(ctx) + case "waf", "waf-hosts": + return client.JSONWAF(ctx) + case "antiddos", "ddos": + return client.JSONAntiDDoS(ctx) + case "nat", "nat-gateway", "natgateway": + return client.JSONNATGateways(ctx) + case "vpn", "vpn-gateway", "vpngateway": + return client.JSONVPNGateways(ctx) + case "ccn", "cloud-connect": + return client.JSONCCNs(ctx) + case "dc", "direct-connect", "directconnect": + return client.JSONDirectConnects(ctx) + case "monitor", "alarm", "alarms", "alarm-policy": + return client.JSONAlarmPolicies(ctx) + case "cls", "log", "logs", "log-topics": + return client.JSONCLSTopics(ctx) + case "cloudaudit", "audit", "tracks": + return client.JSONCloudAudit(ctx) + case "subnet", "subnets": + // Subnets don't have a dedicated JSON method; they're embedded + // in JSONVPCs. Returning an explicit message lets the user + // know rather than silently emitting the VPCs payload. + return "", fmt.Errorf("--format json is not yet supported for subnets (use `tencent list vpc --format json` and read the embedded Subnets field)") + default: + return "", fmt.Errorf("unknown resource type: %s", resourceType) + } +} diff --git a/internal/tencent/list_json_test.go b/internal/tencent/list_json_test.go new file mode 100644 index 0000000..5e1173f --- /dev/null +++ b/internal/tencent/list_json_test.go @@ -0,0 +1,40 @@ +package tencent + +import ( + "context" + "strings" + "testing" +) + +// TestEmitTypedJSON_DispatchRejectsUnknownTypes verifies the dispatch +// table errors out clearly on a resource-type the caller mistyped. +// We can't easily exercise the JSON* methods themselves without a real +// Tencent client + SDK roundtrip, but we can pin the dispatch arms. +func TestEmitTypedJSON_DispatchRejectsUnknownTypes(t *testing.T) { + // nil client is safe here because we expect to short-circuit on + // the unknown-type case before any method dispatch. + _, err := emitTypedJSON(context.Background(), nil, "definitely-not-a-real-type") + if err == nil { + t.Fatal("expected error for unknown resource type") + } + if !strings.Contains(err.Error(), "unknown resource type") { + t.Errorf("error should mention 'unknown resource type', got %q", err.Error()) + } + if !strings.Contains(err.Error(), "definitely-not-a-real-type") { + t.Errorf("error should echo the bad value for debuggability, got %q", err.Error()) + } +} + +// TestEmitTypedJSON_SubnetReturnsHelpfulMessage verifies that the +// subnets case — which doesn't have a dedicated JSON method (it's +// embedded in JSONVPCs) — returns a guidance message rather than a +// generic "unknown type" so the user knows where to look. +func TestEmitTypedJSON_SubnetReturnsHelpfulMessage(t *testing.T) { + _, err := emitTypedJSON(context.Background(), nil, "subnets") + if err == nil { + t.Fatal("expected error for subnets type") + } + if !strings.Contains(err.Error(), "tencent list vpc --format json") { + t.Errorf("error should redirect the user to `vpc --format json`, got %q", err.Error()) + } +} diff --git a/internal/tencent/static_commands.go b/internal/tencent/static_commands.go index 767df5b..ceb4edd 100644 --- a/internal/tencent/static_commands.go +++ b/internal/tencent/static_commands.go @@ -21,11 +21,18 @@ func CreateTencentCommands() *cobra.Command { tencentCmd.PersistentFlags().StringVar(®ion, "region", "", "Tencent Cloud region (default from config / TENCENTCLOUD_REGION / TENCENT_REGION / ap-singapore)") var allRegions bool + var listFormat string listCmd := &cobra.Command{ Use: "list [resource]", Short: "List Tencent Cloud resources", Long: `List Tencent Cloud resources of a specific type. +Output formats (--format): + table Human-readable tabwriter output (default). + json JSON-encoded summary suitable for piping into jq, scripts, or + the clanker-cloud HTTP API. Single-region by default; with + --all-regions the output is {"regions":[{"region":"...","data":[...]}, ...]}. + Supported resources: cvm, instances - Cloud Virtual Machine instances vpc, vpcs - Virtual Private Clouds @@ -86,6 +93,19 @@ to cos, which uses a service-global endpoint).`, } } + // JSON output path. Uses the existing JSON* methods on Client + // (the same ones the HTTP API surfaces), so the wire format is + // shared between `clanker tencent list ... --format json` and + // `GET /api/v1/tencent/resources/...`. Multi-region fan-out + // emits an explicit envelope so consumers can correlate. + format := strings.ToLower(strings.TrimSpace(listFormat)) + if format == "json" { + return listAsJSON(cmd.Context(), client, resourceType, regions, allRegions) + } + if format != "" && format != "table" { + return fmt.Errorf("unsupported --format %q (use 'table' or 'json')", listFormat) + } + switch resourceType { case "cvm", "instance", "instances", "vm", "vms": return listCVM(client, regions) @@ -147,6 +167,7 @@ to cos, which uses a service-global endpoint).`, }, } listCmd.Flags().BoolVar(&allRegions, "all-regions", false, "Query every available Tencent region and merge the results") + listCmd.Flags().StringVar(&listFormat, "format", "table", "Output format: table | json") regionsCmd := &cobra.Command{ Use: "regions",