Skip to content

Commit 2aa4d34

Browse files
authored
feat(tencent): add Tencent Cloud provider (#165)
* feat(tencent): add Tencent Cloud provider Adds a Tencent Cloud provider to the Clanker CLI alongside the existing AWS/GCP/Azure/Cloudflare/Fly/Verda/Vercel/Railway providers. Built up in 15 phases over the past month against the bgdnvk/clanker upstream; this PR consolidates the full provider as one contribution. Coverage * Compute cvm + lighthouse (lightweight cloud server) * Network vpc, subnet, security-group + rule audit, eip, clb, nat, vpn, ccn, direct-connect * Storage cbs (Cloud Block Storage), cos (Object Storage) * Database mysql (cdb), postgres, redis, mongodb, cynosdb (tdsql-c) * Container tke clusters + kubeconfig fetch * Edge cdn, edgeone, waf, anti-ddos * Identity cam users * Observability cloud monitor metrics (CVM + Lighthouse), cls log topics, cloud audit tracks, alarm policies * Cost monthly billing by product + top-N resources * Tags flat map[string]string surfaced on all summary structs that the SDK returns tags for (CVM, Lighthouse, VPC, Postgres); reflection-based helper handles the SDK's inconsistent tag-field naming across services. Security audits * public-cvm-exposure CVMs with sensitive ports open to 0.0.0.0/0 * clb-exposure public CLBs with risky listeners * db-exposure managed DBs reachable from the public internet * idle-eips EIPs unbound but billed * unencrypted-cbs CBS volumes without encryption * cert-expiry SSL certs expiring within N days * cam-hygiene CAM users missing phone/email * waf-coverage CDN/EdgeOne hosts not covered by WAF * antiddos-coverage account anti-DDoS posture + per-region targets * audit-coverage Cloud Audit tracks status HTTP API Hooks into the existing `clanker server` route table with bearer-auth endpoints for inventory (/api/v1/tencent/resources/{type}), scans (/api/v1/tencent/scan/{kind}), monitoring (/api/v1/tencent/metrics/ {cvm|lighthouse}), cost (/api/v1/tencent/cost/by-product, /resources), and topology (/api/v1/tencent/topology). Maker plan and apply share the existing /api/v1/maker/{plan,apply} endpoints. Maker integration * tencent-api verb 5-arg form [tencent-api, service, action, region, json-params] dispatches to a generic SendRaw over Tencent's CommonRequest signed transport. No tccli dependency. * tencent_prompts.go planner system prompt with chain shapes A-H, anti-patterns, static-spec vs runtime metrics rules, and the bgdnvk-style filter verb example. * filter verb new [filter, sourceIdx, arrayPath, field, op, value] post-processor returns the matching subset of a prior command's output. Operators: > < >= <= == != contains startsWith matches. Lets Maker answer "find X by criteria" queries directly instead of dumping full inventory. * [*] array placeholders jsonPathString in internal/maker/exec.go now handles $.X[*].Y wildcard paths, binding to a JSON array literal so "InstanceIds":<CVM_IDS> chains work. * Action denylist knownHallucinatedActions catches common LLM-invented Tencent action names with "did you mean..." hints before the round- trip (GetProductMetricData -> GetMonitorData, cvm.ListInstances -> DescribeInstances, etc). Credentials Reads in this order: viper tencent.{secret_id,secret_key,region}, then TENCENTCLOUD_SECRET_* (official Tencent SDK env names), then TENCENT_SECRET_* short aliases. Default region ap-singapore. Dependencies * tencentcloud/tencentcloud-sdk-go/{cvm,vpc,cbs,clb,cdb,postgres, redis,mongodb,tke,tag,cam,monitor,cls,billing,lighthouse,...} The provider is fully read-by-default; write operations (Create/Modify/Run) go through Maker's existing plan+apply gate, with destructive Terminate/Delete/Reset/Release/Discontinue actions additionally requiring the existing --destroyer flag. * fix(tencent): Lighthouse metric dimension must be PascalCase InstanceId Tencent's two monitor APIs disagree about the canonical dimension casing for namespace QCE/LIGHTHOUSE: - DescribeBaseMetrics (metadata) reports "instanceid" (lowercase) - GetMonitorData (data) accepts "InstanceId" (PascalCase) We trusted DescribeBaseMetrics when building lighthouse.go, which is why every Lighthouse metric call has been returning Tencent's misleading "[InvalidParameterValue] : unauthorized operation or the instance has been destroyed" — same error code Tencent reuses for genuine permission gaps and lifecycle issues, which sent the debug down two days of false leads (CAM, agent install, account type, sub-user vs root, ...). The user's CAM is genuinely AdministratorAccess. The Cloud Monitor agent is installed. The Tencent Console displays the metrics fine. The data was always there — Tencent's data API just rejected our spelling of the dimension name with a wildly inappropriate error code. PascalCase is the same form CVM uses, so the fix is a one-character change to the lighthouseDimensionKey constant. Added a comment block explaining the discrepancy so the next person who reads this code doesn't repeat the investigation. Verified live against lhins-fprj6w5h (ap-singapore): CpuUsage 0.80% (avg 0.96%, 59 samples) MemUsage 38.79% (avg 38.72%, 59 samples) DiskUsage 22.18% (60 samples) LighthouseOutpkg 1 (avg 1.22, 59 samples) * fix(tencent): cost API reports tax via DescribeCostExplorerSummary DescribeBillSummaryByProduct (the per-product cost call) returns RealCost but no tax field, so Clanker cost totals never matched the Tencent console tax-inclusive headline. RealCost is total consumption (voucher + cash + tax); the console headline is cash out of pocket. Adds billFeeSummary() — calls DescribeCostExplorerSummary with Dimensions=feeType, FeeType=cost, the only billing API that breaks out tax. BillByProductJSON now embeds a summary object: consumption total RealCost (voucher + cash + tax) voucher amount covered by vouchers cash_before_tax cash portion, pre-tax tax tax amount cash_incl_tax cash_before_tax + tax (matches console headline) The Detail item names are localized display strings; the cash line is "Total Amount After Discount (Excluding Tax)" which contains the word "tax", so the substring match checks "discount" before "tax" to avoid misclassifying the cash line as tax. Verified against a real April 2026 bill: consumption 11,146.37 voucher 5,701.68 cash_before_tax 4,905.13 tax 539.56 cash_incl_tax 5,444.70 = console "Total Cost (Incl Tax)" * feat(tencent): voucher inventory + per-owner voucher deduction - DescribeVoucherInfo / DescribeVoucherUsageDetails: voucher inventory, balances, per-voucher usage history, and a per-owner-UIN breakdown of voucher spend (nominal - balance). Voucher APIs only answer on the account's home region, so they use a region-aware billing client. - VoucherByOwnerJSON: month-scoped voucher deduction grouped by the owner account UIN of each billed resource, via DescribeBillResourceSummary (the voucher APIs carry no per-record UIN). - CLI: `clanker tencent cost vouchers` / `cost voucher-usage`. - HTTP API: /cost/vouchers, /cost/voucher-usage/{id}, /cost/voucher-by-owner. * feat(tencent): show billing_mode + expires_at on list endpoints Extend the slim JSON shape returned by /api/v1/tencent/resources/* for every subscription-capable resource: CVM, Lighthouse, CBS, MySQL, Postgres, Redis, MongoDB, CynosDB, CLB, AntiDDoS. PREPAID entries now carry the renewal deadline so callers can see what is about to expire without a separate billing call. Tencent uses two billing-mode conventions (string vs int) and the int form has NO consistent mapping across services — CDB inverts vs the others. internal/tencent/charge_mode.go centralizes the normalization into 'PREPAID' / 'POSTPAID' strings so the JSON shape is uniform. * fix(tencent): generate maker SendRaw allowlist from vendored SDK The maker plan executor (SendRaw) gated calls behind a hand-maintained service map in raw.go, which silently lagged behind the SDK — calling lighthouse.DescribeInstances failed with 'unsupported tencent service' even though every other code path (typed clients, dashboard) handled it fine. Eight other services (antiddos, billing, cdn, cloudaudit, cynosdb, dc, ssl, teo, waf) were also missing from the map. Replace the static map with a go:generate-driven one. gen_services.go walks GOMODCACHE/.../tencentcloud-sdk-go/tencentcloud/*/v* and emits service_versions_gen.go with one entry per service (latest version when multiple are vendored). The cos sentinel is preserved via a manualOverrides map in the generator. Upgrading the SDK now just needs 'go generate ./internal/tencent/...'. The error message in SendRaw now enumerates services from the generated map so it stays accurate. * feat(tencent): show auto_renew on list endpoints Add auto_renew (*bool, omitempty) to the slim JSON returned by /api/v1/tencent/resources/* for every prepaid-capable resource where the SDK exposes the renew flag: CVM, Lighthouse, CBS, MySQL, Postgres, Redis, CynosDB, and CLB. Consumers can now check whether an expiring resource will auto-renew or needs manual action — without the field, expiring auto-renewers would generate false-positive alerts. Skipped MongoDB and AntiDDoS — their list endpoints don't expose the renew flag (would require a separate DescribeAutoRenew-style call). Tencent's renew encoding has three flavors handled by new normalizers in charge_mode.go: string 'NOTIFY_AND_AUTO_RENEW' (CVM/CBS/Lighthouse) and 'AUTO_RENEW' (CLB nested in PrepaidAttributes), int64 1 (CDB/Redis/ CynosDB), uint64 1 (Postgres). Using *bool + omitempty so consumers can distinguish 'not on auto-renew' from 'no info available'. * fix(api,maker): address upstream PR #165 security review Three critical findings from rafeegnash's review. 1. Constant-time token comparison (api/middleware.go) The previous '!=' compare leaked information through response timing, letting an attacker recover the bearer token byte-by-byte by measuring latency across many requests. Switch to crypto/subtle.ConstantTimeCompare. 2. Refuse to start without a token (api/server.go, cmd/server.go) An empty --token previously disabled auth on every route, including POST /api/v1/maker/apply which can mutate real cloud resources. The server now aborts startup unless --insecure is explicit (or CLANKER_API_TOKEN is set). Help text now shows the token-gated invocation as the default example. 3. Bound the filter 'matches' operator (maker/exec_tencent_filter.go) regexp.Compile took an unbounded user-controlled pattern via the maker plan. Go's RE2 is linear so a true ReDoS is not realistic, but we now cap pattern length at 256 chars and run MatchString under a 100ms wall-clock deadline as defense-in-depth — the filter value originates from LLM output and reaches us through the HTTP API, so tighter bounds keep that surface predictable. * fix(api,maker): address upstream PR #165 high-severity findings Three high-severity items from rafeegnash's review. 1. Validate ?region= before it reaches the Tencent SDK (api/routes.go) Previously any string was passed verbatim into the client's region — enabling enumeration of arbitrary Tencent endpoints and potentially driving unintended API charges. Added a regex covering all current region prefixes (ap|na|eu|sa|cn)-name(-suffix)?, with a typed *errInvalidRegion so the handler chokepoint surfaces 400 instead of the catch-all 401. 23 handler call sites switched to a small writeTencentClientErr helper. 2. CORS default no longer wildcard (api/server.go, cmd/server.go) Default Access-Control-Allow-Origin was "*", letting any page read API responses. Bearer auth via header mitigates CSRF but a hostile origin could still siphon data when a user pastes their token there. Default is now http://localhost:4173 (the bundled dashboard); pass --cors-origin explicitly for non-localhost deployments. 3. Switch destructive check to read-only allowlist (maker/exec_tencent.go) isTencentDestructive previously prefix-matched only Terminate|Delete| Destroy|Reset|Release|Discontinue. CAM mutations like AddUser, CreateAccessKey, AttachUserPolicy slipped through without --destroyer. Flipped to an allowlist of read-only verb prefixes (Describe, Get, List, Query, Lookup, Search, Check, Inquiry). Anything else now requires --destroyer — fail-safe by default. Behavior change to be aware of: verbs that don't match a read prefix (Create*, Run*, Add*, Modify*, Set*, Enable*, Bind*, Associate*, Allocate*, etc.) now require --destroyer. ResetInstancesPassword was previously whitelisted as 'only changes the password' — that whitelist is gone because a password reset locks out anyone using the previous credential, which is a security-affecting mutation. * fix(tencent,maker): address upstream PR #165 should-fix tier Three items from rafeegnash's review. 1. ctx propagation + per-request timeout (tencent/profile.go, all clients) The Tencent SDK has no WithContext variants — caller ctx cancellation cannot interrupt a request in flight. As a defense, every typed client now flows through newClientProfile(endpoint), which sets HttpProfile.ReqTimeout = 30s. Combined with a ctxDone() check between pagination pages and between GetRelevantContext sections, Ctrl-C now bounds the wall-clock cost of cancellation to the single in-flight SDK call (was: indefinite). All 22 profile.NewClientProfile() + Endpoint sites were converted to the helper; the now-unused profile import was stripped from those files. 2. Paginate GetRelevantContext past 100 (tencent/context.go) contextCVMs, contextVPCs, contextSecurityGroups now loop through pages with offset/limit until TotalCount is exhausted, capped at gatherMaxItems (1000) with a logGatherTruncated() warning when the cap fires. These are the highest-cardinality types — production accounts commonly cross 100 here. Other gather functions still single-call at limit=100; bringing them up is mechanical follow-up. 3. Tests for filter validator and matcher (maker/exec_tencent_filter_test.go) Table-driven coverage for validateFilterCommand (arg count, sourceIdx, op enum) and filterMatch (every operator + every JSON value type). Includes the ReDoS-defense cases added in the critical-tier commit: oversize pattern returns false, malformed regex returns false, PCRE- style catastrophic-backtrack patterns don't hang. * fix(tencent,api): address upstream PR #165 nits Four small items from the review. 1. Credentials redaction (tencent/client.go) Added String() and MarshalJSON() on Credentials so %v / %+v / Println and json.Marshal all render SecretKey as **** instead of leaking the raw key. Direct field access (the SDK signature path) is unchanged. 2. Drop doubled gather call (api/routes.go) handleTencentResources called GetRelevantContext (full multi-section gather) and discarded the result, then called gatherTencentByType for the requested type — doubling SDK calls per request. Removed the GetRelevantContext call. 3. NewClientWithCredentials factory for parity (tencent/client.go) Added BackendTencentCredentials struct and NewClientWithCredentials constructor matching the shape AWS / GCP / Fly.io / etc. already use. Not wired into the backend credential flow yet — kept for consistency so the dispatch layer can treat Tencent the same as every other provider. 4. Per-field size cap on paramsJSON (tencent/raw.go) maxParamsJSONBytes (256 KiB total) + maxParamsFieldBytes (64 KiB per string field, walked recursively into nested maps and slices). The effective cap was the 1 MiB HTTP body limit, which is far larger than any legitimate Tencent action payload — 64 KiB still fits user-data scripts and policy documents while rejecting accidentally-pasted dumps from an LLM plan. * feat(tencent): expiry report — CLI + HTTP Cron-facing alert that walks every PREPAID-capable resource (CVM, Lighthouse, CBS, MySQL, Postgres, Redis, MongoDB, CynosDB, CLB, AntiDDoS, and SSL with --include-ssl) across the requested regions and returns items at or below a renewal threshold. • CLI: clanker tencent expiry --regions=ap-x,ap-y --threshold=14 Exit 0 = nothing flagged, 1 = items in window, 2 = already expired. Drop-in for crontab + MAILTO, GitHub Actions, etc. • HTTP: GET /api/v1/tencent/expiry?regions=&threshold=&manual_only=&include_ssl= Returns the full report with counts breakdown (total / flagged / expired / auto_renew). Region query param is validated through the same regex SendRaw uses, so SSRF-shaped inputs are rejected. manual_only defaults to true so the cron only surfaces items that won't auto-renew (the actionable subset); auto-renewers are still counted in counts.auto_renew for visibility. * fix(api,maker): address upstream PR #165 round-2 review Six items from rafeegnash's second-pass review. The first is a real production-killer; the rest are correctness + hygiene. 1. writeTencentClientErr no longer infinite-recurses (api/routes.go) The catch-all branch from the security commit called itself instead of writeError — stack overflow on the very first credential-missing request, taking the process with it. New regression test in api/routes_test.go (httptest + direct helper invocation, including the wrapped-error errors.As path) locks the fix in. 2. countDestructiveCommands uses the live classifier (api/routes_maker.go) The plan audit count was still computed from the old prefix denylist, so AddUser / CreateAccessKey / AttachUserPolicy gated by --destroyer recorded as 0 destructive commands. Exported IsTencentDestructive from the maker package; routes_maker now delegates to it so the audit count never drifts away from what the executor's safety gate enforces. 3. 401s no longer silent (api/middleware.go) authMiddleware wraps logMiddleware so a rejected request short-circuits before any access log fires. Added s.log401() that records method + path + remote_addr + reason on every failed bearer attempt, so prod credential rotations or auth attacks show up in stderr. 4. routes_plan returns 422 on unparseable LLM output (api/routes_plan.go) Was 200 + 'warning' field, which forced clients to inspect the body to know it failed. 422 Unprocessable Entity is the right semantic — we understood the request but the upstream result was unprocessable. The raw cleaned text is still in data.plan so the dashboard editor can offer hand-correction. 5. Comments brought in line with code (api/server.go, maker/exec_tencent.go) Config.CORSOrigin doc said '"*" by default'; now says http://localhost:4173. validateTencentCommand docstring described the old prefix denylist; now describes the allowlist. --------- Co-authored-by: rephapeng <rephapeng@users.noreply.github.com>
1 parent d4d94f9 commit 2aa4d34

59 files changed

Lines changed: 11356 additions & 4 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

cmd/ask.go

Lines changed: 96 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
cfzerotrust "github.com/bgdnvk/clanker/internal/cloudflare/zerotrust"
2424
"github.com/bgdnvk/clanker/internal/dbcontext"
2525
"github.com/bgdnvk/clanker/internal/digitalocean"
26+
"github.com/bgdnvk/clanker/internal/tencent"
2627
"github.com/bgdnvk/clanker/internal/flyio"
2728
"github.com/bgdnvk/clanker/internal/gcp"
2829
ghclient "github.com/bgdnvk/clanker/internal/github"
@@ -118,6 +119,7 @@ Examples:
118119
includeFlyio, _ := cmd.Flags().GetBool("flyio")
119120
includeRailway, _ := cmd.Flags().GetBool("railway")
120121
includeVerda, _ := cmd.Flags().GetBool("verda")
122+
includeTencent, _ := cmd.Flags().GetBool("tencent")
121123
sreMode, _ := cmd.Flags().GetBool("sre")
122124
includeTerraform, _ := cmd.Flags().GetBool("terraform")
123125
includeIAM, _ := cmd.Flags().GetBool("iam")
@@ -384,6 +386,21 @@ Examples:
384386
})
385387
}
386388

389+
if strings.EqualFold(strings.TrimSpace(makerPlan.Provider), "tencent") {
390+
tcCreds := tencent.ResolveCredentials()
391+
if tcCreds.SecretID == "" || tcCreds.SecretKey == "" {
392+
return fmt.Errorf("tencent credentials are required for --apply (set tencent.secret_id / tencent.secret_key, TENCENTCLOUD_SECRET_ID / TENCENTCLOUD_SECRET_KEY, or TENCENT_SECRET_ID / TENCENT_SECRET_KEY)")
393+
}
394+
return maker.ExecuteTencentPlan(ctx, makerPlan, maker.ExecOptions{
395+
TencentSecretID: tcCreds.SecretID,
396+
TencentSecretKey: tcCreds.SecretKey,
397+
TencentRegion: tcCreds.Region,
398+
Writer: os.Stdout,
399+
Destroyer: destroyer,
400+
Debug: debug,
401+
})
402+
}
403+
387404
// Resolve AWS profile/region for execution.
388405
targetProfile := resolveAWSProfile(profile)
389406

@@ -521,6 +538,7 @@ Examples:
521538
explicitVercel := cmd.Flags().Changed("vercel") && includeVercel
522539
explicitRailway := cmd.Flags().Changed("railway") && includeRailway
523540
explicitVerda := cmd.Flags().Changed("verda") && includeVerda
541+
explicitTencent := cmd.Flags().Changed("tencent") && includeTencent
524542
explicitCount := 0
525543
if explicitGCP {
526544
explicitCount++
@@ -549,8 +567,11 @@ Examples:
549567
if explicitVerda {
550568
explicitCount++
551569
}
570+
if explicitTencent {
571+
explicitCount++
572+
}
552573
if explicitCount > 1 {
553-
return fmt.Errorf("cannot use multiple provider flags (--aws, --gcp, --azure, --cloudflare, --digitalocean, --hetzner, --vercel, --railway, --verda) together with --maker")
574+
return fmt.Errorf("cannot use multiple provider flags (--aws, --gcp, --azure, --cloudflare, --digitalocean, --hetzner, --vercel, --railway, --verda, --tencent) together with --maker")
554575
}
555576
switch {
556577
case explicitHetzner:
@@ -580,6 +601,9 @@ Examples:
580601
case explicitVerda:
581602
makerProvider = "verda"
582603
makerProviderReason = "explicit"
604+
case explicitTencent:
605+
makerProvider = "tencent"
606+
makerProviderReason = "explicit"
583607
default:
584608
svcCtx := routing.InferContext(questionForRouting(question))
585609
if svcCtx.Cloudflare {
@@ -634,6 +658,8 @@ Examples:
634658
prompt = maker.RailwayPlanPromptWithMode(question, destroyer)
635659
case "verda":
636660
prompt = maker.VerdaPlanPromptWithMode(question, destroyer)
661+
case "tencent":
662+
prompt = maker.TencentPlanPromptWithMode(question, destroyer)
637663
default:
638664
prompt = maker.PlanPromptWithMode(question, destroyer)
639665
}
@@ -696,7 +722,7 @@ Examples:
696722

697723
// Handle GCP, Azure, Cloudflare, Digital Ocean, Hetzner, Vercel, Verda, and Railway plans (output directly, no enrichment)
698724
providerLower := strings.ToLower(strings.TrimSpace(plan.Provider))
699-
if providerLower == "gcp" || providerLower == "azure" || providerLower == "cloudflare" || providerLower == "digitalocean" || providerLower == "hetzner" || providerLower == "vercel" || providerLower == "verda" || providerLower == "railway" {
725+
if providerLower == "gcp" || providerLower == "azure" || providerLower == "cloudflare" || providerLower == "digitalocean" || providerLower == "hetzner" || providerLower == "vercel" || providerLower == "verda" || providerLower == "railway" || providerLower == "tencent" {
700726
if plan.CreatedAt.IsZero() {
701727
plan.CreatedAt = time.Now().UTC()
702728
}
@@ -847,6 +873,11 @@ Format as a professional compliance table suitable for government security docum
847873
return handleVerdaQuery(cmd.Context(), question, debug)
848874
}
849875

876+
// Handle explicit --tencent flag
877+
if includeTencent && !makerMode {
878+
return handleTencentQuery(context.Background(), question, debug)
879+
}
880+
850881
if !includeAWS && !includeGitHub && !includeTerraform && !includeGCP && !includeAzure && !includeCloudflare && !includeDigitalOcean && !includeHetzner && !includeVercel && !includeFlyio && !includeRailway && !includeVerda && !includeDB {
851882
routingQuestion := questionForRouting(question)
852883

@@ -1425,6 +1456,7 @@ func init() {
14251456
askCmd.Flags().Bool("flyio", false, "Include Fly.io context")
14261457
askCmd.Flags().Bool("railway", false, "Include Railway context")
14271458
askCmd.Flags().Bool("verda", false, "Include Verda Cloud (GPU/AI) infrastructure context")
1459+
askCmd.Flags().Bool("tencent", false, "Include Tencent Cloud infrastructure context")
14281460
askCmd.Flags().Bool("sre", false, "Use adaptive Clanker SRE discovery context")
14291461
askCmd.Flags().Bool("github", false, "Include GitHub repository context")
14301462
askCmd.Flags().Bool("cicd", false, "Include CI/CD context (currently GitHub Actions)")
@@ -2268,6 +2300,68 @@ Provide a clear, concise answer based on the data above. If the data doesn't con
22682300
return nil
22692301
}
22702302

2303+
// handleTencentQuery delegates a Tencent Cloud query to the tencent client.
2304+
// Mirrors handleDigitalOceanQuery: gather relevant context from the SDK,
2305+
// stuff it into the prompt, hand to the configured AI provider.
2306+
func handleTencentQuery(ctx context.Context, question string, debug bool) error {
2307+
if debug {
2308+
fmt.Println("Delegating query to Tencent Cloud agent...")
2309+
}
2310+
2311+
creds := tencent.ResolveCredentials()
2312+
client, err := tencent.NewClient(creds, debug)
2313+
if err != nil {
2314+
return err
2315+
}
2316+
2317+
tcContext, err := client.GetRelevantContext(ctx, question)
2318+
if err != nil {
2319+
return fmt.Errorf("failed to get Tencent Cloud context: %w", err)
2320+
}
2321+
2322+
provider := viper.GetString("ai.default_provider")
2323+
if provider == "" {
2324+
provider = "openai"
2325+
}
2326+
2327+
var apiKey string
2328+
switch provider {
2329+
case "gemini", "gemini-api":
2330+
apiKey = ""
2331+
case "openai":
2332+
apiKey = resolveOpenAIKey("")
2333+
case "anthropic":
2334+
apiKey = resolveAnthropicKey("")
2335+
case "cohere":
2336+
apiKey = resolveCohereKey("")
2337+
case "deepseek":
2338+
apiKey = resolveDeepSeekKey("")
2339+
case "minimax":
2340+
apiKey = resolveMiniMaxKey("")
2341+
default:
2342+
apiKey = viper.GetString("ai.api_key")
2343+
}
2344+
2345+
aiClient := ai.NewClient(provider, apiKey, debug, provider)
2346+
2347+
prompt := fmt.Sprintf(`You are a Tencent Cloud infrastructure expert. Answer the user's question using the inventory data below. Tencent service abbreviations: CVM=Cloud Virtual Machine, VPC=Virtual Private Cloud, SG=Security Group, COS=Cloud Object Storage, CLB=Cloud Load Balancer, TKE=Tencent Kubernetes Engine, CDB=TencentDB for MySQL.
2348+
2349+
Tencent Cloud Context:
2350+
%s
2351+
2352+
User Question: %s
2353+
2354+
Provide a clear, concise answer based on the data above. Cite specific resource IDs (ins-*, vpc-*, sg-*) when relevant. If the data is insufficient, say what is missing and suggest the specific clanker subcommand that would surface it (e.g. clanker tencent list cvm --all-regions, clanker tencent sg-rules <sg-id>).`, tcContext, question)
2355+
2356+
response, err := aiClient.AskPrompt(ctx, prompt)
2357+
if err != nil {
2358+
return fmt.Errorf("failed to get AI response: %w", err)
2359+
}
2360+
2361+
fmt.Println(response)
2362+
return nil
2363+
}
2364+
22712365
func resolveHetznerToken(ctx context.Context, debug bool) (string, error) {
22722366
apiToken := hetzner.ResolveAPIToken()
22732367
if apiToken != "" {

cmd/root.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/bgdnvk/clanker/internal/gcp"
1313
"github.com/bgdnvk/clanker/internal/hetzner"
1414
"github.com/bgdnvk/clanker/internal/railway"
15+
"github.com/bgdnvk/clanker/internal/tencent"
1516
"github.com/bgdnvk/clanker/internal/vercel"
1617
"github.com/bgdnvk/clanker/internal/verda"
1718
"github.com/spf13/cobra"
@@ -136,6 +137,11 @@ func init() {
136137
verdaCmd := verda.CreateVerdaCommands()
137138
AddVerdaAskCommand(verdaCmd)
138139
rootCmd.AddCommand(verdaCmd)
140+
141+
// Register Tencent Cloud static commands. Natural-language queries
142+
// (`clanker ask --tencent ...`) will land in a later phase.
143+
tencentCmd := tencent.CreateTencentCommands()
144+
rootCmd.AddCommand(tencentCmd)
139145
}
140146

141147
// initConfig reads in config file and ENV variables if set.

cmd/server.go

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"log"
7+
"os"
8+
"os/signal"
9+
"strings"
10+
"syscall"
11+
12+
"github.com/bgdnvk/clanker/internal/api"
13+
"github.com/spf13/cobra"
14+
"github.com/spf13/viper"
15+
)
16+
17+
func init() {
18+
var (
19+
port int
20+
host string
21+
token string
22+
insecure bool
23+
corsOrigin string
24+
debug bool
25+
aiProfile string
26+
openaiKey string
27+
openaiModel string
28+
anthropicKey string
29+
geminiKey string
30+
localModelInferenceURL string
31+
noThinking bool
32+
)
33+
34+
serverCmd := &cobra.Command{
35+
Use: "server",
36+
Short: "Run the Clanker HTTP API server",
37+
Long: `Start the HTTP API server that wraps the Clanker agent.
38+
39+
This is the gateway for the Clanker web dashboard. Inventory + maker
40+
apply + plan-generation endpoints all live here.
41+
42+
Auth: pass --token or set CLANKER_API_TOKEN. The server refuses to start
43+
without one — POST /api/v1/maker/apply can mutate real cloud resources, so
44+
unauthenticated startup is gated behind an explicit --insecure flag.
45+
46+
AI provider: pass the same --ai-profile / --openai-key / --openai-model
47+
/ --local-model-inference-url flags you'd give to ` + "`clanker ask`" + ` so the
48+
plan-generation endpoint can call your configured LLM. Server reads from
49+
~/.clanker.yaml as well, so flags only override what's already there.
50+
51+
Examples:
52+
# Token-gated server (recommended)
53+
clanker server --port 8080 --token "$(openssl rand -hex 32)"
54+
55+
# Trusted-network mode (no auth) — NEVER on a public address
56+
clanker server --port 8080 --host 127.0.0.1 --insecure
57+
58+
# With vLLM-backed plan generation
59+
clanker server --port 8080 \
60+
--ai-profile openai \
61+
--openai-model qwen3.6-27b-fp8 \
62+
--openai-key "$VLLM_API_KEY" \
63+
--local-model-inference-url "$VLLM_BASE_URL/v1"`,
64+
RunE: func(cmd *cobra.Command, args []string) error {
65+
resolved := strings.TrimSpace(token)
66+
if resolved == "" {
67+
resolved = strings.TrimSpace(os.Getenv("CLANKER_API_TOKEN"))
68+
}
69+
addr := fmt.Sprintf("%s:%d", host, port)
70+
api.SetVersion(Version)
71+
72+
// Push AI config into viper so api handlers building ai.NewClient
73+
// pick the same values the CLI does. Empty flags leave existing
74+
// config (from ~/.clanker.yaml) untouched.
75+
if strings.TrimSpace(aiProfile) != "" {
76+
viper.Set("ai.default_provider", aiProfile)
77+
}
78+
if strings.TrimSpace(openaiKey) != "" {
79+
viper.Set("ai.providers.openai.api_key", openaiKey)
80+
}
81+
if strings.TrimSpace(openaiModel) != "" {
82+
viper.Set("ai.providers.openai.model", openaiModel)
83+
}
84+
if strings.TrimSpace(anthropicKey) != "" {
85+
viper.Set("ai.providers.anthropic.api_key", anthropicKey)
86+
}
87+
if strings.TrimSpace(geminiKey) != "" {
88+
viper.Set("ai.providers.gemini-api.api_key", geminiKey)
89+
}
90+
if strings.TrimSpace(localModelInferenceURL) != "" {
91+
viper.Set("ai.providers.openai.local_model_inference_url", strings.TrimSpace(localModelInferenceURL))
92+
}
93+
if noThinking {
94+
viper.Set("ai.providers.openai.chat_template_kwargs", map[string]interface{}{"enable_thinking": false})
95+
}
96+
97+
srv := api.New(api.Config{
98+
Addr: addr,
99+
Token: resolved,
100+
Insecure: insecure,
101+
CORSOrigin: corsOrigin,
102+
Debug: debug,
103+
}, log.New(os.Stderr, "", log.LstdFlags))
104+
105+
ctx, cancel := context.WithCancel(context.Background())
106+
defer cancel()
107+
sigCh := make(chan os.Signal, 1)
108+
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
109+
go func() {
110+
<-sigCh
111+
fmt.Fprintln(os.Stderr, "[server] shutting down")
112+
cancel()
113+
}()
114+
return srv.Run(ctx)
115+
},
116+
}
117+
118+
serverCmd.Flags().IntVar(&port, "port", 8080, "Port to listen on")
119+
serverCmd.Flags().StringVar(&host, "host", "127.0.0.1", "Host to bind on (use 0.0.0.0 for all interfaces)")
120+
serverCmd.Flags().StringVar(&token, "token", "", "Bearer token required for /api/v1/* (or set CLANKER_API_TOKEN). Required unless --insecure is passed.")
121+
serverCmd.Flags().BoolVar(&insecure, "insecure", false, "Allow startup without a bearer token. NEVER use on a publicly reachable address — /api/v1/maker/apply mutates real cloud resources.")
122+
serverCmd.Flags().StringVar(&corsOrigin, "cors-origin", "", "Value for Access-Control-Allow-Origin (defaults to http://localhost:4173 — pass an explicit value for non-localhost dashboards; \"*\" allowed but discouraged)")
123+
serverCmd.Flags().BoolVar(&debug, "server-debug", false, "Log every request, not just errors")
124+
125+
// LLM provider flags — push into viper so the plan-generation endpoint
126+
// has the same options the CLI exposes.
127+
serverCmd.Flags().StringVar(&aiProfile, "ai-profile", "", "AI provider profile (openai, gemini-api, anthropic, cohere, ...)")
128+
serverCmd.Flags().StringVar(&openaiKey, "openai-key", "", "OpenAI API key (or any OpenAI-compatible endpoint key, e.g. vLLM)")
129+
serverCmd.Flags().StringVar(&openaiModel, "openai-model", "", "OpenAI / OpenAI-compatible model name")
130+
serverCmd.Flags().StringVar(&anthropicKey, "anthropic-key", "", "Anthropic API key")
131+
serverCmd.Flags().StringVar(&geminiKey, "gemini-key", "", "Gemini API key")
132+
serverCmd.Flags().StringVar(&localModelInferenceURL, "local-model-inference-url", "", "OpenAI-compatible base URL for local/self-hosted models (e.g. https://x.runpod.net/v1)")
133+
serverCmd.Flags().BoolVar(&noThinking, "no-thinking", false, "Disable Qwen3-style internal reasoning trace via chat_template_kwargs.enable_thinking=false. 14x faster plan generation with reasoning-capable models.")
134+
135+
rootCmd.AddCommand(serverCmd)
136+
}

go.mod

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,30 @@ require (
3131
github.com/mark3labs/mcp-go v0.46.0
3232
github.com/spf13/cobra v1.8.0
3333
github.com/spf13/viper v1.18.2
34+
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/antiddos v1.3.89
35+
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/billing v1.3.84
36+
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cam v1.3.42
37+
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cbs v1.3.96
38+
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cdb v1.3.94
39+
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cdn v1.3.90
40+
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/clb v1.3.83
41+
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cloudaudit v1.3.40
42+
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cls v1.3.97
43+
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.98
44+
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cvm v1.3.89
45+
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/cynosdb v1.3.98
46+
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/dc v1.3.96
47+
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/lighthouse v1.3.91
48+
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/mongodb v1.3.93
49+
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/monitor v1.3.96
50+
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/postgres v1.3.89
51+
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/redis v1.3.79
52+
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/ssl v1.3.94
53+
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/teo v1.3.93
54+
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/tke v1.3.86
55+
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/vpc v1.3.83
56+
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/waf v1.3.95
57+
github.com/tencentyun/cos-go-sdk-v5 v0.7.73
3458
golang.org/x/crypto v0.49.0
3559
golang.org/x/oauth2 v0.36.0
3660
golang.org/x/sync v0.20.0
@@ -67,6 +91,7 @@ require (
6791
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.5 // indirect
6892
github.com/aws/smithy-go v1.24.1 // indirect
6993
github.com/cespare/xxhash/v2 v2.3.0 // indirect
94+
github.com/clbanning/mxj v1.8.4 // indirect
7095
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 // indirect
7196
github.com/dustin/go-humanize v1.0.1 // indirect
7297
github.com/envoyproxy/go-control-plane/envoy v1.36.0 // indirect
@@ -94,6 +119,7 @@ require (
94119
github.com/magiconair/properties v1.8.7 // indirect
95120
github.com/mattn/go-isatty v0.0.20 // indirect
96121
github.com/mitchellh/mapstructure v1.5.0 // indirect
122+
github.com/mozillazg/go-httpheader v0.2.1 // indirect
97123
github.com/ncruces/go-strftime v1.0.0 // indirect
98124
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
99125
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect

0 commit comments

Comments
 (0)