Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
154 changes: 154 additions & 0 deletions internal/tencent/list_json.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
package tencent

import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
)

// listAsJSON renders the `clanker tencent list <type> --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):
// <json-array-or-empty-string-from-JSONX>
//
// With --all-regions:
// {"regions": [{"region": "<code>", "data": <json-from-JSONX>}, ...]}
//
// 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)
}
}
40 changes: 40 additions & 0 deletions internal/tencent/list_json_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
}
21 changes: 21 additions & 0 deletions internal/tencent/static_commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,18 @@ func CreateTencentCommands() *cobra.Command {
tencentCmd.PersistentFlags().StringVar(&region, "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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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",
Expand Down
Loading