Skip to content

Commit 641ebdd

Browse files
rafeegnashnash
andauthored
feat(tencent): add --format json to clanker tencent list <type> (#179)
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": "<code>", "data": <json>}, ...], "errors": [{"region": "<code>", "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 <type> --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. Co-authored-by: nash <nash@clankercloud.ai>
1 parent aa11af2 commit 641ebdd

3 files changed

Lines changed: 215 additions & 0 deletions

File tree

internal/tencent/list_json.go

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
package tencent
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"os"
8+
"strings"
9+
)
10+
11+
// listAsJSON renders the `clanker tencent list <type> --format json`
12+
// output. It reuses the existing JSON* methods on *Client (the same
13+
// ones the HTTP API surfaces in clanker-cloud) so the wire format is
14+
// shared between CLI and HTTP consumers.
15+
//
16+
// Output shape:
17+
//
18+
// Single region (default):
19+
// <json-array-or-empty-string-from-JSONX>
20+
//
21+
// With --all-regions:
22+
// {"regions": [{"region": "<code>", "data": <json-from-JSONX>}, ...]}
23+
//
24+
// The single-region shape preserves backward compatibility with
25+
// anything that already consumes `JSONCVMs` etc. directly (HTTP API).
26+
// The multi-region envelope is new — explicit so downstream tools can
27+
// trivially filter per region.
28+
func listAsJSON(ctx context.Context, client *Client, resourceType string, regions []string, allRegions bool) error {
29+
// Single-region fast path: most resource types only make sense in
30+
// one region anyway, and the HTTP API shape matches this exactly.
31+
if !allRegions || len(regions) <= 1 {
32+
region := client.Region()
33+
if len(regions) == 1 {
34+
region = regions[0]
35+
}
36+
scoped := client.WithRegion(region)
37+
body, err := emitTypedJSON(ctx, scoped, resourceType)
38+
if err != nil {
39+
return err
40+
}
41+
fmt.Println(body)
42+
return nil
43+
}
44+
45+
// Multi-region fan-out: walk every region serially. Errors are
46+
// captured per-region so a single regional outage doesn't poison
47+
// the whole sweep — callers see {"errors":[{"region":..., "error":...}]}.
48+
type regionData struct {
49+
Region string `json:"region"`
50+
Data json.RawMessage `json:"data"`
51+
}
52+
type regionErr struct {
53+
Region string `json:"region"`
54+
Error string `json:"error"`
55+
}
56+
var entries []regionData
57+
var errors []regionErr
58+
for _, r := range regions {
59+
scoped := client.WithRegion(r)
60+
body, err := emitTypedJSON(ctx, scoped, resourceType)
61+
if err != nil {
62+
errors = append(errors, regionErr{Region: r, Error: err.Error()})
63+
continue
64+
}
65+
// Empty body → empty array so the consumer doesn't need a
66+
// special case for "no resources here."
67+
if strings.TrimSpace(body) == "" {
68+
body = "[]"
69+
}
70+
entries = append(entries, regionData{Region: r, Data: json.RawMessage(body)})
71+
}
72+
73+
envelope := map[string]interface{}{
74+
"regions": entries,
75+
}
76+
if len(errors) > 0 {
77+
envelope["errors"] = errors
78+
}
79+
enc := json.NewEncoder(os.Stdout)
80+
enc.SetIndent("", " ")
81+
return enc.Encode(envelope)
82+
}
83+
84+
// emitTypedJSON dispatches the resource-type string to the matching
85+
// JSON method on Client. Returns the raw JSON string (which may be an
86+
// empty string when the SDK returned no resources for the type).
87+
//
88+
// Service-global types (cos, ssl, cam, cdn, edgeone, waf, antiddos,
89+
// ccn, cloudaudit) ignore the client's region; they're listed here for
90+
// completeness so the dispatch is exhaustive against the table-mode
91+
// switch above.
92+
func emitTypedJSON(ctx context.Context, client *Client, resourceType string) (string, error) {
93+
switch resourceType {
94+
case "cvm", "instance", "instances", "vm", "vms":
95+
return client.JSONCVMs(ctx)
96+
case "vpc", "vpcs":
97+
return client.JSONVPCs(ctx)
98+
case "sg", "sgs", "security-group", "security-groups":
99+
return client.JSONSecurityGroups(ctx)
100+
case "mysql", "cdb":
101+
return client.JSONMySQL(ctx)
102+
case "postgres", "postgresql", "pg":
103+
return client.JSONPostgres(ctx)
104+
case "cos", "bucket", "buckets":
105+
return client.JSONCOS(ctx)
106+
case "tke", "k8s", "cluster", "clusters", "kubernetes":
107+
return client.JSONTKE(ctx)
108+
case "clb", "lb", "lbs", "load-balancer", "load-balancers":
109+
return client.JSONCLB(ctx)
110+
case "eip", "eips", "address", "addresses":
111+
return client.JSONEIP(ctx)
112+
case "cbs", "disk", "disks", "volume", "volumes":
113+
return client.JSONCBS(ctx)
114+
case "ssl", "cert", "certs", "certificate", "certificates":
115+
return client.JSONSSL(ctx)
116+
case "cam", "iam", "user", "users":
117+
return client.JSONCAM(ctx)
118+
case "redis", "valkey":
119+
return client.JSONRedis(ctx)
120+
case "mongo", "mongodb":
121+
return client.JSONMongoDB(ctx)
122+
case "cynosdb", "tdsql-c", "tdsqlc":
123+
return client.JSONCynosDB(ctx)
124+
case "cdn", "cdn-domains":
125+
return client.JSONCDN(ctx)
126+
case "edgeone", "teo", "zones":
127+
return client.JSONEdgeOne(ctx)
128+
case "waf", "waf-hosts":
129+
return client.JSONWAF(ctx)
130+
case "antiddos", "ddos":
131+
return client.JSONAntiDDoS(ctx)
132+
case "nat", "nat-gateway", "natgateway":
133+
return client.JSONNATGateways(ctx)
134+
case "vpn", "vpn-gateway", "vpngateway":
135+
return client.JSONVPNGateways(ctx)
136+
case "ccn", "cloud-connect":
137+
return client.JSONCCNs(ctx)
138+
case "dc", "direct-connect", "directconnect":
139+
return client.JSONDirectConnects(ctx)
140+
case "monitor", "alarm", "alarms", "alarm-policy":
141+
return client.JSONAlarmPolicies(ctx)
142+
case "cls", "log", "logs", "log-topics":
143+
return client.JSONCLSTopics(ctx)
144+
case "cloudaudit", "audit", "tracks":
145+
return client.JSONCloudAudit(ctx)
146+
case "subnet", "subnets":
147+
// Subnets don't have a dedicated JSON method; they're embedded
148+
// in JSONVPCs. Returning an explicit message lets the user
149+
// know rather than silently emitting the VPCs payload.
150+
return "", fmt.Errorf("--format json is not yet supported for subnets (use `tencent list vpc --format json` and read the embedded Subnets field)")
151+
default:
152+
return "", fmt.Errorf("unknown resource type: %s", resourceType)
153+
}
154+
}

internal/tencent/list_json_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package tencent
2+
3+
import (
4+
"context"
5+
"strings"
6+
"testing"
7+
)
8+
9+
// TestEmitTypedJSON_DispatchRejectsUnknownTypes verifies the dispatch
10+
// table errors out clearly on a resource-type the caller mistyped.
11+
// We can't easily exercise the JSON* methods themselves without a real
12+
// Tencent client + SDK roundtrip, but we can pin the dispatch arms.
13+
func TestEmitTypedJSON_DispatchRejectsUnknownTypes(t *testing.T) {
14+
// nil client is safe here because we expect to short-circuit on
15+
// the unknown-type case before any method dispatch.
16+
_, err := emitTypedJSON(context.Background(), nil, "definitely-not-a-real-type")
17+
if err == nil {
18+
t.Fatal("expected error for unknown resource type")
19+
}
20+
if !strings.Contains(err.Error(), "unknown resource type") {
21+
t.Errorf("error should mention 'unknown resource type', got %q", err.Error())
22+
}
23+
if !strings.Contains(err.Error(), "definitely-not-a-real-type") {
24+
t.Errorf("error should echo the bad value for debuggability, got %q", err.Error())
25+
}
26+
}
27+
28+
// TestEmitTypedJSON_SubnetReturnsHelpfulMessage verifies that the
29+
// subnets case — which doesn't have a dedicated JSON method (it's
30+
// embedded in JSONVPCs) — returns a guidance message rather than a
31+
// generic "unknown type" so the user knows where to look.
32+
func TestEmitTypedJSON_SubnetReturnsHelpfulMessage(t *testing.T) {
33+
_, err := emitTypedJSON(context.Background(), nil, "subnets")
34+
if err == nil {
35+
t.Fatal("expected error for subnets type")
36+
}
37+
if !strings.Contains(err.Error(), "tencent list vpc --format json") {
38+
t.Errorf("error should redirect the user to `vpc --format json`, got %q", err.Error())
39+
}
40+
}

internal/tencent/static_commands.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,18 @@ func CreateTencentCommands() *cobra.Command {
2121
tencentCmd.PersistentFlags().StringVar(&region, "region", "", "Tencent Cloud region (default from config / TENCENTCLOUD_REGION / TENCENT_REGION / ap-singapore)")
2222

2323
var allRegions bool
24+
var listFormat string
2425
listCmd := &cobra.Command{
2526
Use: "list [resource]",
2627
Short: "List Tencent Cloud resources",
2728
Long: `List Tencent Cloud resources of a specific type.
2829
30+
Output formats (--format):
31+
table Human-readable tabwriter output (default).
32+
json JSON-encoded summary suitable for piping into jq, scripts, or
33+
the clanker-cloud HTTP API. Single-region by default; with
34+
--all-regions the output is {"regions":[{"region":"...","data":[...]}, ...]}.
35+
2936
Supported resources:
3037
cvm, instances - Cloud Virtual Machine instances
3138
vpc, vpcs - Virtual Private Clouds
@@ -86,6 +93,19 @@ to cos, which uses a service-global endpoint).`,
8693
}
8794
}
8895

96+
// JSON output path. Uses the existing JSON* methods on Client
97+
// (the same ones the HTTP API surfaces), so the wire format is
98+
// shared between `clanker tencent list ... --format json` and
99+
// `GET /api/v1/tencent/resources/...`. Multi-region fan-out
100+
// emits an explicit envelope so consumers can correlate.
101+
format := strings.ToLower(strings.TrimSpace(listFormat))
102+
if format == "json" {
103+
return listAsJSON(cmd.Context(), client, resourceType, regions, allRegions)
104+
}
105+
if format != "" && format != "table" {
106+
return fmt.Errorf("unsupported --format %q (use 'table' or 'json')", listFormat)
107+
}
108+
89109
switch resourceType {
90110
case "cvm", "instance", "instances", "vm", "vms":
91111
return listCVM(client, regions)
@@ -147,6 +167,7 @@ to cos, which uses a service-global endpoint).`,
147167
},
148168
}
149169
listCmd.Flags().BoolVar(&allRegions, "all-regions", false, "Query every available Tencent region and merge the results")
170+
listCmd.Flags().StringVar(&listFormat, "format", "table", "Output format: table | json")
150171

151172
regionsCmd := &cobra.Command{
152173
Use: "regions",

0 commit comments

Comments
 (0)