Streamline CLI: remove Wild Central commands, merge overlapping features

Remove commands that belong to Wild Central (dns, proxy, firewall,
ddns, crowdsec, secret central) since those APIs live on Central now.

Merge service into app (install, logs, config), merge iso into asset
(delete), move orphan commands (health, node-ip) under cluster.

Clean up redundant commands (backup start, node cancel-discovery)
and standardize deletion confirmation on --yes/-y across all commands.
This commit is contained in:
2026-08-01 20:34:49 +00:00
parent 6b760072d2
commit 77c393bf44
16 changed files with 449 additions and 1843 deletions

View File

@@ -1,9 +1,18 @@
package cmd
import (
"bufio"
"context"
"fmt"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"github.com/spf13/cobra"
"github.com/wild-cloud/wild-central/wild/internal/config"
)
// App commands
@@ -252,14 +261,364 @@ var appUpgradePlanCmd = &cobra.Command{
},
}
var (
appFetchFlag bool
appNoDeployFlag bool
// App logs flags
appTailLines int
appFollowLogs bool
appContainerName string
appPreviousLogs bool
appSinceDuration string
// App config update flags
appSetFlags []string
appNoRedeploy bool
)
var appInstallCmd = &cobra.Command{
Use: "install <app>",
Short: "Add and deploy an app in one step",
Long: `Install an app by adding it to the instance and deploying it.
This command combines 'app add' and 'app deploy' into a single step:
1. Add app to instance (fetches from Wild Directory, compiles templates)
2. Deploy app to cluster (unless --no-deploy)
Examples:
wild app install gitea
wild app install gitea --no-deploy
wild app install gitea --fetch`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
appName := args[0]
inst, err := getInstanceName()
if err != nil {
return err
}
fmt.Printf("Installing app: %s\n", appName)
// Fetch manifest for display
manifestResp, err := apiClient.Get(fmt.Sprintf("/api/v1/apps/%s/manifest", appName))
if err != nil {
return fmt.Errorf("failed to fetch manifest: %w", err)
}
name := manifestResp.GetString("name")
desc := manifestResp.GetString("description")
if name != "" {
fmt.Printf("App: %s - %s\n", name, desc)
}
// Check if config overrides needed
if defaultConfig, ok := manifestResp.Data["defaultConfig"].(map[string]any); ok && len(defaultConfig) > 0 {
configResp, err := apiClient.Get(fmt.Sprintf("/api/v1/instances/%s/config", inst))
if err == nil {
existingConfig := config.GetValue(configResp.Data, fmt.Sprintf("apps.%s", appName))
if existingConfig == nil || existingConfig == "null" {
fmt.Println("\nDefault configuration will be applied:")
for key, val := range defaultConfig {
fmt.Printf(" %s: %v\n", key, val)
}
}
}
}
// Add app to instance
fmt.Println("\nAdding app to instance...")
_, err = apiClient.Post(
fmt.Sprintf("/api/v1/instances/%s/apps", inst),
map[string]any{
"name": appName,
},
)
if err != nil {
return fmt.Errorf("failed to add app: %w", err)
}
// Re-fetch if requested
if appFetchFlag {
fmt.Println("Fetching fresh templates...")
_, err = apiClient.Post(fmt.Sprintf("/api/v1/instances/%s/apps/%s/fetch", inst, appName), nil)
if err != nil {
return fmt.Errorf("failed to fetch app: %w", err)
}
}
if appNoDeployFlag {
fmt.Printf("\nApp added: %s\n", appName)
fmt.Printf(" Templates compiled and ready to deploy\n")
fmt.Printf(" To deploy: wild app deploy %s\n", appName)
return nil
}
// Deploy
fmt.Println("\nDeploying app...")
deployResp, err := apiClient.Post(
fmt.Sprintf("/api/v1/instances/%s/apps/%s/deploy", inst, appName),
nil,
)
if err != nil {
return fmt.Errorf("failed to deploy app: %w", err)
}
opID := deployResp.GetString("operation_id")
if opID != "" {
if err := streamOperationOutput(opID); err != nil {
fmt.Printf("\nCouldn't stream output: %v\n", err)
fmt.Printf("Operation ID: %s\n", opID)
fmt.Printf("Monitor with: wild operation get %s\n", opID)
} else {
fmt.Printf("\nApp installed successfully: %s\n", appName)
}
}
return nil
},
}
var appLogsCmd = &cobra.Command{
Use: "logs <app>",
Short: "View app logs",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
appName := args[0]
inst, err := getInstanceName()
if err != nil {
return err
}
// Build query parameters
params := []string{}
if appTailLines > 0 {
params = append(params, fmt.Sprintf("tail=%d", appTailLines))
}
if appContainerName != "" {
params = append(params, fmt.Sprintf("container=%s", appContainerName))
}
if appPreviousLogs {
params = append(params, "previous=true")
}
if appSinceDuration != "" {
params = append(params, fmt.Sprintf("since=%s", appSinceDuration))
}
queryString := ""
if len(params) > 0 {
queryString = "?" + strings.Join(params, "&")
}
if appFollowLogs {
return streamAppLogs(inst, appName, queryString)
}
// Buffered mode
resp, err := apiClient.Get(fmt.Sprintf("/api/v1/instances/%s/apps/%s/logs%s", inst, appName, queryString))
if err != nil {
return err
}
if lines, ok := resp.Data["lines"].([]any); ok {
for _, line := range lines {
if lineStr, ok := line.(string); ok {
fmt.Println(lineStr)
}
}
}
return nil
},
}
var appConfigUpdateCmd = &cobra.Command{
Use: "config <app>",
Short: "Update app configuration",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
appName := args[0]
inst, err := getInstanceName()
if err != nil {
return err
}
updates := make(map[string]string)
// Parse --set flags
for _, flag := range appSetFlags {
parts := strings.SplitN(flag, "=", 2)
if len(parts) != 2 {
return fmt.Errorf("invalid --set format: %s (expected key=value)", flag)
}
updates[parts[0]] = parts[1]
}
// Interactive mode if no --set flags provided
if len(updates) == 0 {
fmt.Printf("Updating app config: %s\n", appName)
fmt.Println("Enter configuration values (key=value), empty line to finish:")
scanner := bufio.NewScanner(os.Stdin)
for {
fmt.Print("> ")
if !scanner.Scan() {
break
}
line := strings.TrimSpace(scanner.Text())
if line == "" {
break
}
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
fmt.Println("Invalid format, expected key=value")
continue
}
updates[parts[0]] = parts[1]
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("error reading input: %w", err)
}
if len(updates) == 0 {
fmt.Println("No updates provided")
return nil
}
}
// Build request body
requestBody := map[string]any{
"config": updates,
"redeploy": !appNoRedeploy,
}
fmt.Printf("Updating configuration for app: %s\n", appName)
resp, err := apiClient.Patch(
fmt.Sprintf("/api/v1/instances/%s/apps/%s/config", inst, appName),
requestBody,
)
if err != nil {
return err
}
fmt.Printf("Configuration updated (%d values)\n", len(updates))
for key, value := range updates {
fmt.Printf(" %s: %s\n", key, value)
}
if !appNoRedeploy {
if opID := resp.GetString("operation_id"); opID != "" {
fmt.Println("\nRedeploying app...")
if err := streamOperationOutput(opID); err != nil {
fmt.Printf("\nCouldn't stream output: %v\n", err)
fmt.Printf("Operation ID: %s\n", opID)
fmt.Printf("Monitor with: wild operation get %s\n", opID)
} else {
fmt.Printf("\nApp redeployed successfully\n")
}
}
} else {
fmt.Println("\nConfiguration updated without redeployment")
}
return nil
},
}
func streamAppLogs(instance, appName, queryString string) error {
baseURL := daemonURL
if baseURL == "" {
baseURL = config.GetDaemonURL()
}
url := fmt.Sprintf("%s/api/v1/instances/%s/apps/%s/logs%s", baseURL, instance, appName, queryString)
if strings.Contains(url, "?") {
url += "&follow=true"
} else {
url += "?follow=true"
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
go func() {
<-sigChan
cancel()
}()
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
client := &http.Client{Timeout: 0}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("request failed with status %d", resp.StatusCode)
}
scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() {
select {
case <-ctx.Done():
return nil
default:
line := scanner.Text()
if data, ok := strings.CutPrefix(line, "data: "); ok {
fmt.Println(data)
} else if line != "" {
fmt.Println(line)
}
}
}
if err := scanner.Err(); err != nil {
if ctx.Err() == context.Canceled {
return nil
}
return fmt.Errorf("error reading stream: %w", err)
}
return nil
}
func init() {
appCmd.AddCommand(appListCmd)
appCmd.AddCommand(appListDeployedCmd)
appCmd.AddCommand(appAddCmd)
appAddCmd.Flags().String("version", "", "Install a specific version from .versions/ (e.g. '1.0.0-1')")
appCmd.AddCommand(appDeployCmd)
appCmd.AddCommand(appInstallCmd)
appCmd.AddCommand(appUpdateCmd)
appCmd.AddCommand(appDeleteCmd)
appCmd.AddCommand(appStatusCmd)
appCmd.AddCommand(appLogsCmd)
appCmd.AddCommand(appConfigUpdateCmd)
appCmd.AddCommand(appUpgradePlanCmd)
// install flags
appInstallCmd.Flags().BoolVar(&appFetchFlag, "fetch", false, "Fetch fresh templates from directory before installing")
appInstallCmd.Flags().BoolVar(&appNoDeployFlag, "no-deploy", false, "Configure and compile only, skip deployment")
// logs flags
appLogsCmd.Flags().IntVar(&appTailLines, "tail", 100, "Number of lines to show")
appLogsCmd.Flags().BoolVarP(&appFollowLogs, "follow", "f", false, "Stream logs in real-time")
appLogsCmd.Flags().StringVar(&appContainerName, "container", "", "Specific container (if app has multiple)")
appLogsCmd.Flags().BoolVar(&appPreviousLogs, "previous", false, "Show logs from previous container instance")
appLogsCmd.Flags().StringVar(&appSinceDuration, "since", "", "Show logs since duration (e.g., \"5m\", \"1h\")")
// config update flags
appConfigUpdateCmd.Flags().StringArrayVar(&appSetFlags, "set", []string{}, "Set a configuration value (key=value), can be repeated")
appConfigUpdateCmd.Flags().BoolVar(&appNoRedeploy, "no-redeploy", false, "Don't trigger redeployment after update")
}

View File

@@ -1,7 +1,9 @@
package cmd
import (
"bufio"
"fmt"
"strings"
"github.com/spf13/cobra"
)
@@ -163,12 +165,54 @@ var assetInfoCmd = &cobra.Command{
},
}
var assetDeleteForce bool
var assetDeleteCmd = &cobra.Command{
Use: "delete <schematic-id> <version>",
Short: "Delete an asset and all its files",
Long: `Delete a specific schematic@version asset and all its downloaded files.
This operation cannot be undone.`,
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
schematicID := args[0]
version := args[1]
if !assetDeleteForce {
fmt.Printf("Are you sure you want to delete %s@%s and all its assets? (y/N): ", schematicID, version)
reader := bufio.NewReader(cmd.InOrStdin())
response, err := reader.ReadString('\n')
if err != nil {
return err
}
response = strings.TrimSpace(strings.ToLower(response))
if response != "yes" && response != "y" {
fmt.Println("Deletion cancelled")
return nil
}
}
resp, err := apiClient.Delete(fmt.Sprintf("/api/v1/assets/%s/%s", schematicID, version))
if err != nil {
return err
}
fmt.Printf("Asset deleted: %s@%s\n", schematicID, version)
if msg := resp.GetString("message"); msg != "" {
fmt.Printf("Status: %s\n", msg)
}
return nil
},
}
func init() {
assetDownloadCmd.Flags().StringVarP(&assetPlatform, "platform", "p", "amd64", "Platform architecture (amd64, arm64)")
assetDownloadCmd.Flags().StringSliceVarP(&assetAssetTypes, "assets", "a", []string{}, "Asset types to download (kernel, initramfs, iso). Default: all")
assetDeleteCmd.Flags().BoolVarP(&assetDeleteForce, "yes", "y", false, "Skip confirmation prompt")
assetCmd.AddCommand(assetListCmd)
assetCmd.AddCommand(assetDownloadCmd)
assetCmd.AddCommand(assetStatusCmd)
assetCmd.AddCommand(assetInfoCmd)
assetCmd.AddCommand(assetDeleteCmd)
}

View File

@@ -45,31 +45,6 @@ Use subcommands for other operations (list, verify, delete, etc.).`,
},
}
// Backup start command (default action when app name provided)
var backupStartCmd = &cobra.Command{
Use: "start <app>",
Short: "Start a backup for an app",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
inst, err := getInstanceName()
if err != nil {
return err
}
resp, err := apiClient.Post(fmt.Sprintf("/api/v1/instances/%s/apps/%s/backup", inst, args[0]), nil)
if err != nil {
return err
}
fmt.Printf("✓ Backup started for app: %s\n", args[0])
if opID := resp.GetString("operation_id"); opID != "" {
fmt.Printf(" Operation ID: %s\n", opID)
fmt.Printf("\nUse 'wild operation status %s' to monitor progress\n", opID)
}
return nil
},
}
// Backup list command
var backupListCmd = &cobra.Command{
Use: "list <app>",
@@ -863,7 +838,6 @@ func capitalize(s string) string {
// init registers all backup subcommands and flags
func init() {
// Add subcommands to backup
backupCmd.AddCommand(backupStartCmd)
backupCmd.AddCommand(backupListCmd)
backupCmd.AddCommand(backupVerifyCmd)
backupCmd.AddCommand(backupDeleteCmd)

View File

@@ -287,6 +287,25 @@ Examples:
},
}
var clusterControlplaneIPCmd = &cobra.Command{
Use: "controlplane-ip",
Short: "Get control plane IP",
RunE: func(cmd *cobra.Command, args []string) error {
inst, err := getInstanceName()
if err != nil {
return err
}
resp, err := apiClient.Get(fmt.Sprintf("/api/v1/instances/%s/utilities/controlplane/ip", inst))
if err != nil {
return err
}
fmt.Println(resp.GetString("ip"))
return nil
},
}
func init() {
clusterCmd.AddCommand(clusterBootstrapCmd)
clusterCmd.AddCommand(clusterStatusCmd)
@@ -296,6 +315,7 @@ func init() {
clusterCmd.AddCommand(clusterTalosconfigCmd)
clusterTalosconfigCmd.AddCommand(clusterTalosconfigRenewCmd)
clusterCmd.AddCommand(clusterEndpointsCmd)
clusterCmd.AddCommand(clusterControlplaneIPCmd)
clusterEndpointsCmd.Flags().Bool("nodes", false, "Include all control node IPs as fallback endpoints")

View File

@@ -1,259 +0,0 @@
package cmd
import (
"fmt"
"sort"
"github.com/spf13/cobra"
)
// crowdsec commands — manage Wild Central's CrowdSec LAPI
var crowdsecCmd = &cobra.Command{
Use: "crowdsec",
Short: "Manage CrowdSec LAPI",
Long: `Manage Wild Central's CrowdSec Local API (LAPI).
Wild Central runs the CrowdSec LAPI — the hub that k8s cluster agents report
threats to and that bouncers (e.g. Traefik) query for blocklists.`,
}
var crowdsecStatusCmd = &cobra.Command{
Use: "status",
Short: "Show LAPI status, agents, and bouncers",
RunE: func(cmd *cobra.Command, args []string) error {
resp, err := apiClient.Get("/api/v1/crowdsec/status")
if err != nil {
return fmt.Errorf("failed to get CrowdSec status: %w", err)
}
if outputFormat == "json" {
return printJSON(resp.Data)
}
if outputFormat == "yaml" {
return printYAML(resp.Data)
}
active, _ := resp.Data["active"].(bool)
if !active {
fmt.Println("✗ CrowdSec LAPI is not running")
fmt.Println(" Install with: apt install crowdsec")
return nil
}
fmt.Println("✓ CrowdSec LAPI is running")
machines, _ := resp.Data["machines"].([]any)
fmt.Printf("\nAgents (%d registered):\n", len(machines))
for _, m := range machines {
machine, ok := m.(map[string]any)
if !ok {
continue
}
id, _ := machine["machineId"].(string)
validated, _ := machine["isValidated"].(bool)
heartbeat, _ := machine["last_heartbeat"].(string)
status := "pending"
if validated {
status = "validated"
}
if heartbeat != "" {
fmt.Printf(" %-30s %s (heartbeat: %s)\n", id, status, heartbeat)
} else {
fmt.Printf(" %-30s %s\n", id, status)
}
}
bouncers, _ := resp.Data["bouncers"].([]any)
fmt.Printf("\nBouncers (%d registered):\n", len(bouncers))
for _, b := range bouncers {
bouncer, ok := b.(map[string]any)
if !ok {
continue
}
name, _ := bouncer["name"].(string)
revoked, _ := bouncer["revoked"].(bool)
status := "active"
if revoked {
status = "revoked"
}
fmt.Printf(" %-40s %s\n", name, status)
}
return nil
},
}
var crowdsecSummaryCmd = &cobra.Command{
Use: "summary",
Short: "Show active ban counts by threat category",
RunE: func(cmd *cobra.Command, args []string) error {
resp, err := apiClient.Get("/api/v1/crowdsec/summary")
if err != nil {
return fmt.Errorf("failed to get ban summary: %w", err)
}
if outputFormat == "json" {
return printJSON(resp.Data)
}
if outputFormat == "yaml" {
return printYAML(resp.Data)
}
total, _ := resp.Data["total"].(float64)
fmt.Printf("Active bans: %d\n\n", int(total))
byReason, _ := resp.Data["byReason"].(map[string]any)
if len(byReason) == 0 {
fmt.Println("No active bans.")
return nil
}
// Sort by count descending
type entry struct {
reason string
count int
}
entries := make([]entry, 0, len(byReason))
for reason, v := range byReason {
count, _ := v.(float64)
entries = append(entries, entry{reason, int(count)})
}
sort.Slice(entries, func(i, j int) bool {
return entries[i].count > entries[j].count
})
fmt.Printf("%-50s %s\n", "Reason", "Count")
fmt.Printf("%-50s %s\n", "------", "-----")
for _, e := range entries {
fmt.Printf("%-50s %d\n", e.reason, e.count)
}
return nil
},
}
var crowdsecMachinesCmd = &cobra.Command{
Use: "machines",
Short: "List registered CrowdSec agents",
RunE: func(cmd *cobra.Command, args []string) error {
resp, err := apiClient.Get("/api/v1/crowdsec/machines")
if err != nil {
return fmt.Errorf("failed to get machines: %w", err)
}
if outputFormat == "json" {
return printJSON(resp.Data)
}
if outputFormat == "yaml" {
return printYAML(resp.Data)
}
machines := resp.GetArray("machines")
if len(machines) == 0 {
fmt.Println("No agents registered.")
return nil
}
for _, m := range machines {
machine, ok := m.(map[string]any)
if !ok {
continue
}
id, _ := machine["machineId"].(string)
validated, _ := machine["isValidated"].(bool)
heartbeat, _ := machine["last_heartbeat"].(string)
version, _ := machine["version"].(string)
status := "pending"
if validated {
status = "validated"
}
fmt.Printf("%-30s %-10s %-25s %s\n", id, status, heartbeat, version)
}
return nil
},
}
var crowdsecBouncersCmd = &cobra.Command{
Use: "bouncers",
Short: "List registered bouncers",
RunE: func(cmd *cobra.Command, args []string) error {
resp, err := apiClient.Get("/api/v1/crowdsec/bouncers")
if err != nil {
return fmt.Errorf("failed to get bouncers: %w", err)
}
if outputFormat == "json" {
return printJSON(resp.Data)
}
if outputFormat == "yaml" {
return printYAML(resp.Data)
}
bouncers := resp.GetArray("bouncers")
if len(bouncers) == 0 {
fmt.Println("No bouncers registered.")
return nil
}
for _, b := range bouncers {
bouncer, ok := b.(map[string]any)
if !ok {
continue
}
name, _ := bouncer["name"].(string)
revoked, _ := bouncer["revoked"].(bool)
btype, _ := bouncer["type"].(string)
version, _ := bouncer["version"].(string)
status := "active"
if revoked {
status = "revoked"
}
fmt.Printf("%-40s %-8s %-15s %s\n", name, status, btype, version)
}
return nil
},
}
var crowdsecProvisionCmd = &cobra.Command{
Use: "provision <instance>",
Short: "Provision a Wild Cloud instance as a CrowdSec agent",
Long: `Provision connects a Wild Cloud instance's CrowdSec agent to this LAPI.
It generates credentials, registers the instance as both an agent and a bouncer,
and writes the LAPI URL into the instance's CrowdSec app config.
After provisioning, redeploy the CrowdSec app on the instance:
wild app deploy crowdsec
Safe to run multiple times — credentials are preserved, registrations refreshed.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
instance := args[0]
resp, err := apiClient.Post(fmt.Sprintf("/api/v1/crowdsec/provision/%s", instance), nil)
if err != nil {
return fmt.Errorf("failed to provision %s: %w", instance, err)
}
if outputFormat == "json" {
return printJSON(resp.Data)
}
if outputFormat == "yaml" {
return printYAML(resp.Data)
}
fmt.Printf("✓ %s\n", resp.GetString("message"))
if url := resp.GetString("centralLapiUrl"); url != "" {
fmt.Printf(" LAPI URL: %s\n", url)
}
if username := resp.GetString("agentUsername"); username != "" {
fmt.Printf(" Agent username: %s\n", username)
}
fmt.Println("\nNext step: redeploy the crowdsec app on the instance:")
fmt.Printf(" wild --instance %s app deploy crowdsec\n", instance)
return nil
},
}
func init() {
crowdsecCmd.AddCommand(crowdsecStatusCmd)
crowdsecCmd.AddCommand(crowdsecSummaryCmd)
crowdsecCmd.AddCommand(crowdsecMachinesCmd)
crowdsecCmd.AddCommand(crowdsecBouncersCmd)
crowdsecCmd.AddCommand(crowdsecProvisionCmd)
}

View File

@@ -1,82 +0,0 @@
package cmd
import (
"fmt"
"github.com/spf13/cobra"
)
// ddns commands — manage Wild Central's built-in dynamic DNS updater
var ddnsCmd = &cobra.Command{
Use: "ddns",
Short: "Manage dynamic DNS",
Long: `Manage Wild Central's built-in dynamic DNS service, which keeps your public DNS A records updated when your IP address changes.`,
}
var ddnsStatusCmd = &cobra.Command{
Use: "status",
Short: "Show DDNS status",
RunE: func(cmd *cobra.Command, args []string) error {
resp, err := apiClient.Get("/api/v1/ddns/status")
if err != nil {
return fmt.Errorf("failed to get DDNS status: %w", err)
}
if outputFormat == "json" {
return printJSON(resp.Data)
}
if outputFormat == "yaml" {
return printYAML(resp.Data)
}
enabled, _ := resp.Data["enabled"].(bool)
if !enabled {
fmt.Println("DDNS is disabled")
if msg := resp.GetString("message"); msg != "" {
fmt.Printf(" %s\n", msg)
}
return nil
}
fmt.Println("✓ DDNS is active")
if ip := resp.GetString("currentIP"); ip != "" {
fmt.Printf(" Public IP: %s\n", ip)
}
if t := resp.GetString("lastChecked"); t != "" {
fmt.Printf(" Last checked: %s\n", t)
}
if t := resp.GetString("lastUpdated"); t != "" {
fmt.Printf(" Last updated: %s\n", t)
}
if e := resp.GetString("lastError"); e != "" {
fmt.Printf(" Last error: %s\n", e)
}
return nil
},
}
var ddnsTriggerCmd = &cobra.Command{
Use: "trigger",
Short: "Force an immediate IP check and update",
RunE: func(cmd *cobra.Command, args []string) error {
resp, err := apiClient.Post("/api/v1/ddns/trigger", nil)
if err != nil {
return fmt.Errorf("failed to trigger DDNS update: %w", err)
}
if outputFormat == "json" {
return printJSON(resp.Data)
}
if outputFormat == "yaml" {
return printYAML(resp.Data)
}
fmt.Printf("✓ %s\n", resp.GetString("message"))
return nil
},
}
func init() {
ddnsCmd.AddCommand(ddnsStatusCmd)
ddnsCmd.AddCommand(ddnsTriggerCmd)
}

View File

@@ -1,279 +0,0 @@
package cmd
import (
"fmt"
"github.com/spf13/cobra"
)
// DNS commands
var dnsCmd = &cobra.Command{
Use: "dns",
Short: "Manage DNS services",
Long: `Manage the dnsmasq DNS service that provides network bootstrapping for Wild Cloud nodes.`,
}
var dnsStatusCmd = &cobra.Command{
Use: "status",
Short: "Show DNS service status",
Long: `Display the current status of the dnsmasq service including health, process info, and configuration state.`,
RunE: func(cmd *cobra.Command, args []string) error {
resp, err := apiClient.Get("/api/v1/dnsmasq/status")
if err != nil {
return fmt.Errorf("failed to get DNS status: %w", err)
}
if outputFormat == "json" {
return printJSON(resp.Data)
}
if outputFormat == "yaml" {
return printYAML(resp.Data)
}
// Text output - follow daemon.go pattern
status := resp.GetString("status")
if status == "active" {
fmt.Println("✓ DNS service is running")
} else {
fmt.Printf("DNS service status: %s\n", status)
}
// PID
if pid, ok := resp.Data["pid"].(float64); ok && pid > 0 {
fmt.Printf(" PID: %d\n", int(pid))
}
// Instances configured
if instances, ok := resp.Data["instances_configured"].(float64); ok {
fmt.Printf(" Instances: %d\n", int(instances))
}
// Config file
if configFile := resp.GetString("config_file"); configFile != "" {
fmt.Printf(" Config: %s\n", configFile)
}
return nil
},
}
var dnsConfigCmd = &cobra.Command{
Use: "config",
Short: "View DNS configuration",
Long: `Display the current dnsmasq configuration file.`,
RunE: func(cmd *cobra.Command, args []string) error {
resp, err := apiClient.Get("/api/v1/dnsmasq/config")
if err != nil {
return fmt.Errorf("failed to get DNS config: %w", err)
}
if outputFormat == "json" {
return printJSON(resp.Data)
}
if outputFormat == "yaml" {
return printYAML(resp.Data)
}
// Text output
if configFile := resp.GetString("config_file"); configFile != "" {
fmt.Printf("Config file: %s\n\n", configFile)
}
if content := resp.GetString("content"); content != "" {
fmt.Println(content)
}
return nil
},
}
var dnsRestartCmd = &cobra.Command{
Use: "restart",
Short: "Restart DNS service",
Long: `Restart the dnsmasq service. This will briefly interrupt DNS resolution on the network.`,
RunE: func(cmd *cobra.Command, args []string) error {
resp, err := apiClient.Post("/api/v1/dnsmasq/restart", nil)
if err != nil {
return fmt.Errorf("failed to restart DNS service: %w", err)
}
if outputFormat == "json" {
return printJSON(resp.Data)
}
if outputFormat == "yaml" {
return printYAML(resp.Data)
}
// Text output
if message := resp.GetString("message"); message != "" {
fmt.Printf("✓ %s\n", message)
}
return nil
},
}
var dnsUpdateCmd = &cobra.Command{
Use: "update",
Short: "Update DNS configuration",
Long: `Regenerate dnsmasq configuration from all instances and restart the service.
--dry-run See what config would be applied without writing it.`,
RunE: func(cmd *cobra.Command, args []string) error {
dryRun, _ := cmd.Flags().GetBool("dry-run")
var endpoint string
if dryRun {
endpoint = "/api/v1/dnsmasq/generate"
} else {
endpoint = "/api/v1/dnsmasq/generate?overwrite=true"
}
resp, err := apiClient.Post(endpoint, nil)
if err != nil {
return fmt.Errorf("failed to generate DNS configuration: %w", err)
}
if outputFormat == "json" {
return printJSON(resp.Data)
}
if outputFormat == "yaml" {
return printYAML(resp.Data)
}
// Text output
if dryRun {
fmt.Println("Dry-run mode: Configuration preview")
fmt.Println("======================================")
if config := resp.GetString("config"); config != "" {
fmt.Println(config)
}
} else {
if message := resp.GetString("message"); message != "" {
fmt.Printf("✓ %s\n", message)
}
}
return nil
},
}
// dhcp subcommand group
var dnsDhcpCmd = &cobra.Command{
Use: "dhcp",
Short: "Manage DHCP leases",
Long: `View active DHCP leases and manage static assignments.`,
}
var dnsDhcpLeasesCmd = &cobra.Command{
Use: "leases",
Short: "List active DHCP leases",
RunE: func(cmd *cobra.Command, args []string) error {
resp, err := apiClient.Get("/api/v1/dnsmasq/dhcp/leases")
if err != nil {
return fmt.Errorf("failed to get DHCP leases: %w", err)
}
if outputFormat == "json" {
return printJSON(resp.Data)
}
if outputFormat == "yaml" {
return printYAML(resp.Data)
}
leases, _ := resp.Data["leases"].([]any)
if len(leases) == 0 {
fmt.Println("No active leases")
return nil
}
fmt.Printf("%-20s %-18s %-16s %s\n", "MAC", "IP", "HOSTNAME", "EXPIRES")
fmt.Println("------------------------------------------------------------------------")
for _, l := range leases {
m, ok := l.(map[string]any)
if !ok {
continue
}
mac, _ := m["mac"].(string)
ip, _ := m["ip"].(string)
hostname, _ := m["hostname"].(string)
if hostname == "" {
hostname = "—"
}
expiry, _ := m["expiry"].(string)
fmt.Printf("%-20s %-18s %-16s %s\n", mac, ip, hostname, expiry)
}
return nil
},
}
var dnsDhcpAddStaticCmd = &cobra.Command{
Use: "add-static <mac> <ip> [hostname]",
Short: "Add a static DHCP lease",
Args: cobra.RangeArgs(2, 3),
RunE: func(cmd *cobra.Command, args []string) error {
body := map[string]string{
"mac": args[0],
"ip": args[1],
}
if len(args) == 3 {
body["hostname"] = args[2]
}
resp, err := apiClient.Post("/api/v1/dnsmasq/dhcp/static", body)
if err != nil {
return fmt.Errorf("failed to add static lease: %w", err)
}
if outputFormat == "json" {
return printJSON(resp.Data)
}
if outputFormat == "yaml" {
return printYAML(resp.Data)
}
fmt.Printf("✓ %s\n", resp.GetString("message"))
return nil
},
}
var dnsDhcpDeleteStaticCmd = &cobra.Command{
Use: "delete-static <mac>",
Short: "Remove a static DHCP lease",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
resp, err := apiClient.Delete(fmt.Sprintf("/api/v1/dnsmasq/dhcp/static/%s", args[0]))
if err != nil {
return fmt.Errorf("failed to delete static lease: %w", err)
}
if outputFormat == "json" {
return printJSON(resp.Data)
}
if outputFormat == "yaml" {
return printYAML(resp.Data)
}
fmt.Printf("✓ %s\n", resp.GetString("message"))
return nil
},
}
func init() {
dnsCmd.AddCommand(dnsStatusCmd)
dnsCmd.AddCommand(dnsConfigCmd)
dnsCmd.AddCommand(dnsRestartCmd)
dnsCmd.AddCommand(dnsUpdateCmd)
dnsCmd.AddCommand(dnsDhcpCmd)
dnsDhcpCmd.AddCommand(dnsDhcpLeasesCmd)
dnsDhcpCmd.AddCommand(dnsDhcpAddStaticCmd)
dnsDhcpCmd.AddCommand(dnsDhcpDeleteStaticCmd)
// Add --dry-run flag to update command
dnsUpdateCmd.Flags().Bool("dry-run", false, "Preview configuration without applying changes")
}

View File

@@ -1,126 +0,0 @@
package cmd
import (
"fmt"
"github.com/spf13/cobra"
)
// firewall commands — manage the nftables firewall on Wild Central
var firewallCmd = &cobra.Command{
Use: "firewall",
Short: "Manage the Wild Central firewall",
Long: `Manage the nftables firewall that protects Wild Central.
Extra allowed ports and WAN interface filtering are configured through the
global config. Changing these settings automatically regenerates and applies
the firewall rules.`,
}
var firewallStatusCmd = &cobra.Command{
Use: "status",
Short: "Show current firewall rules",
RunE: func(cmd *cobra.Command, args []string) error {
resp, err := apiClient.Get("/api/v1/nftables/status")
if err != nil {
return fmt.Errorf("failed to get firewall status: %w", err)
}
if outputFormat == "json" {
return printJSON(resp.Data)
}
if outputFormat == "yaml" {
return printYAML(resp.Data)
}
rules := resp.GetString("rules")
if rules == "" {
fmt.Println("No firewall rules loaded.")
return nil
}
fmt.Printf("Rules file: %s\n\n", resp.GetString("rulesFile"))
fmt.Println(rules)
return nil
},
}
var firewallEnableCmd = &cobra.Command{
Use: "enable",
Short: "Enable the firewall (apply rules)",
RunE: func(cmd *cobra.Command, args []string) error {
return setFirewallEnabled(true)
},
}
var firewallDisableCmd = &cobra.Command{
Use: "disable",
Short: "Disable the firewall (flush all Wild Cloud rules)",
RunE: func(cmd *cobra.Command, args []string) error {
return setFirewallEnabled(false)
},
}
var firewallApplyCmd = &cobra.Command{
Use: "apply",
Short: "Reapply the current firewall rules from disk",
RunE: func(cmd *cobra.Command, args []string) error {
resp, err := apiClient.Post("/api/v1/nftables/apply", nil)
if err != nil {
return fmt.Errorf("failed to apply firewall rules: %w", err)
}
if outputFormat == "json" {
return printJSON(resp.Data)
}
if outputFormat == "yaml" {
return printYAML(resp.Data)
}
fmt.Printf("✓ %s\n", resp.GetString("message"))
return nil
},
}
// setFirewallEnabled reads the current global config, sets enabled, and saves it.
// The API auto-applies nftables rules after saving.
func setFirewallEnabled(enabled bool) error {
resp, err := apiClient.Get("/api/v1/config")
if err != nil {
return fmt.Errorf("failed to get config: %w", err)
}
cfg := resp.GetMap("config")
if cfg == nil {
cfg = map[string]any{}
}
cloud, _ := cfg["cloud"].(map[string]any)
if cloud == nil {
cloud = map[string]any{}
}
nftables, _ := cloud["nftables"].(map[string]any)
if nftables == nil {
nftables = map[string]any{}
}
nftables["enabled"] = enabled
cloud["nftables"] = nftables
cfg["cloud"] = cloud
if _, err := apiClient.Put("/api/v1/config", cfg); err != nil {
return fmt.Errorf("failed to update config: %w", err)
}
if enabled {
fmt.Println("✓ Firewall enabled — rules applied")
} else {
fmt.Println("✓ Firewall disabled — Wild Cloud nftables rules flushed")
}
return nil
}
func init() {
firewallCmd.AddCommand(firewallStatusCmd)
firewallCmd.AddCommand(firewallEnableCmd)
firewallCmd.AddCommand(firewallDisableCmd)
firewallCmd.AddCommand(firewallApplyCmd)
}

View File

@@ -129,6 +129,8 @@ var instanceShowCmd = &cobra.Command{
},
}
var instanceDeleteConfirm bool
var instanceDeleteCmd = &cobra.Command{
Use: "delete <name>",
Short: "Delete an instance",
@@ -136,13 +138,14 @@ var instanceDeleteCmd = &cobra.Command{
RunE: func(cmd *cobra.Command, args []string) error {
name := args[0]
// Confirm deletion
fmt.Printf("Are you sure you want to delete instance '%s'? (yes/no): ", name)
var confirm string
fmt.Scanln(&confirm)
if confirm != "yes" {
fmt.Println("Deletion cancelled")
return nil
if !instanceDeleteConfirm {
fmt.Printf("Are you sure you want to delete instance '%s'? (y/N): ", name)
var confirm string
fmt.Scanln(&confirm)
if confirm != "y" && confirm != "Y" {
fmt.Println("Deletion cancelled")
return nil
}
}
resp, err := apiClient.Delete(fmt.Sprintf("/api/v1/instances/%s", name))
@@ -267,6 +270,7 @@ func init() {
instanceCmd.AddCommand(instanceListCmd)
instanceCmd.AddCommand(instanceShowCmd)
instanceCmd.AddCommand(instanceDeleteCmd)
instanceDeleteCmd.Flags().BoolVarP(&instanceDeleteConfirm, "yes", "y", false, "Skip confirmation prompt")
instanceCmd.AddCommand(instanceCurrentCmd)
instanceCmd.AddCommand(instanceUseCmd)
instanceCmd.AddCommand(instanceEnvCmd)

View File

@@ -1,255 +0,0 @@
package cmd
import (
"bufio"
"fmt"
"path/filepath"
"regexp"
"strings"
"github.com/spf13/cobra"
)
// ISO-specific commands for managing Talos ISO images
var isoCmd = &cobra.Command{
Use: "iso",
Short: "Manage Talos ISO images",
Long: `Manage Talos ISO images for booting bare metal machines.
ISOs are organized by schematic ID and can be downloaded for different platforms.`,
}
var (
isoPlatform string
isoForce bool
)
var isoListCmd = &cobra.Command{
Use: "list",
Short: "List all downloaded ISO images",
Long: `List all downloaded ISO images with their schematic IDs, versions, and platforms.`,
RunE: func(cmd *cobra.Command, args []string) error {
resp, err := apiClient.Get("/api/v1/assets")
if err != nil {
return err
}
if outputFormat == "json" {
return printJSON(resp.Data)
}
assets := resp.GetArray("assets")
if len(assets) == 0 {
fmt.Println("No assets found")
return nil
}
// Collect all ISO assets
type isoInfo struct {
SchematicID string
Version string
Platform string
Size int64
}
var isos []isoInfo
for _, asset := range assets {
if m, ok := asset.(map[string]any); ok {
schematicID := fmt.Sprintf("%v", m["schematic_id"])
version := fmt.Sprintf("%v", m["version"])
assetsList := m["assets"]
if assetsArray, ok := assetsList.([]any); ok {
for _, assetItem := range assetsArray {
if assetMap, ok := assetItem.(map[string]any); ok {
if assetType, _ := assetMap["type"].(string); assetType == "iso" {
if downloaded, _ := assetMap["downloaded"].(bool); downloaded {
path := fmt.Sprintf("%v", assetMap["path"])
platform := extractPlatform(path)
size := int64(0)
if s, ok := assetMap["size"].(float64); ok {
size = int64(s)
}
isos = append(isos, isoInfo{
SchematicID: schematicID,
Version: version,
Platform: platform,
Size: size,
})
}
}
}
}
}
}
}
if len(isos) == 0 {
fmt.Println("No ISOs downloaded")
return nil
}
fmt.Printf("%-66s %-10s %-10s %-10s\n", "SCHEMATIC ID", "VERSION", "PLATFORM", "SIZE")
fmt.Println("--------------------------------------------------------------------------------------------------------")
for _, iso := range isos {
sizeMB := float64(iso.Size) / 1024 / 1024
fmt.Printf("%-66s %-10s %-10s %.2f MB\n", iso.SchematicID, iso.Version, iso.Platform, sizeMB)
}
return nil
},
}
var isoDownloadCmd = &cobra.Command{
Use: "download <schematic-id> <version>",
Short: "Download an ISO image",
Long: `Download a Talos ISO image for a given schematic ID and version.
The ISO can be used to boot bare metal machines.`,
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
schematicID := args[0]
version := args[1]
payload := map[string]any{
"platform": isoPlatform,
"asset_types": []string{"iso"},
}
if isoForce {
payload["force"] = true
}
resp, err := apiClient.Post(fmt.Sprintf("/api/v1/assets/%s/%s/download", schematicID, version), payload)
if err != nil {
return err
}
fmt.Printf("Downloading ISO:\n")
fmt.Printf(" Schematic: %s\n", schematicID)
fmt.Printf(" Version: %s\n", version)
fmt.Printf(" Platform: %s\n", isoPlatform)
if msg := resp.GetString("message"); msg != "" {
fmt.Printf("\nStatus: %s\n", msg)
}
return nil
},
}
var isoDeleteCmd = &cobra.Command{
Use: "delete <schematic-id> <version>",
Short: "Delete an asset and all its files",
Long: `Delete a specific schematic@version asset and all its downloaded files including ISOs.
This operation cannot be undone.`,
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
schematicID := args[0]
version := args[1]
// Prompt for confirmation unless --force is used
if !isoForce {
fmt.Printf("Are you sure you want to delete %s@%s and all its assets? (yes/no): ", schematicID, version)
reader := bufio.NewReader(cmd.InOrStdin())
response, err := reader.ReadString('\n')
if err != nil {
return err
}
response = strings.TrimSpace(strings.ToLower(response))
if response != "yes" && response != "y" {
fmt.Println("Deletion cancelled")
return nil
}
}
resp, err := apiClient.Delete(fmt.Sprintf("/api/v1/assets/%s/%s", schematicID, version))
if err != nil {
return err
}
fmt.Printf("Asset deleted: %s@%s\n", schematicID, version)
if msg := resp.GetString("message"); msg != "" {
fmt.Printf("Status: %s\n", msg)
}
return nil
},
}
var isoInfoCmd = &cobra.Command{
Use: "info <schematic-id> <version>",
Short: "Show detailed information about a specific asset",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
schematicID := args[0]
version := args[1]
resp, err := apiClient.Get(fmt.Sprintf("/api/v1/assets/%s/%s", schematicID, version))
if err != nil {
return err
}
if outputFormat == "json" {
return printJSON(resp.Data)
}
fmt.Printf("Schematic ID: %s\n", resp.GetString("schematic_id"))
fmt.Printf("Version: %s\n", resp.GetString("version"))
fmt.Printf("Path: %s\n", resp.GetString("path"))
if assets := resp.GetArray("assets"); len(assets) > 0 {
fmt.Println("\nAssets:")
for _, asset := range assets {
if a, ok := asset.(map[string]any); ok {
assetType, _ := a["type"].(string)
downloaded, _ := a["downloaded"].(bool)
path := fmt.Sprintf("%v", a["path"])
if downloaded {
size := int64(0)
if s, ok := a["size"].(float64); ok {
size = int64(s)
}
sizeMB := float64(size) / 1024 / 1024
if assetType == "iso" {
platform := extractPlatform(path)
fmt.Printf(" ✓ ISO (%s): %.2f MB\n", platform, sizeMB)
} else {
fmt.Printf(" ✓ %s: %.2f MB\n", assetType, sizeMB)
}
fmt.Printf(" Path: %s\n", path)
} else {
fmt.Printf(" ✗ %s: Not downloaded\n", assetType)
}
}
}
}
return nil
},
}
// extractPlatform extracts platform from filename (e.g., "metal-amd64.iso" -> "amd64")
func extractPlatform(path string) string {
filename := filepath.Base(path)
re := regexp.MustCompile(`-(amd64|arm64)\.`)
matches := re.FindStringSubmatch(filename)
if len(matches) > 1 {
return matches[1]
}
return "unknown"
}
func init() {
// Flags for download command
isoDownloadCmd.Flags().StringVarP(&isoPlatform, "platform", "p", "amd64", "Platform architecture (amd64, arm64)")
isoDownloadCmd.Flags().BoolVarP(&isoForce, "force", "f", false, "Force re-download if already exists")
// Flags for delete command
isoDeleteCmd.Flags().BoolVarP(&isoForce, "force", "f", false, "Skip confirmation prompt")
isoCmd.AddCommand(isoListCmd)
isoCmd.AddCommand(isoDownloadCmd)
isoCmd.AddCommand(isoDeleteCmd)
isoCmd.AddCommand(isoInfoCmd)
}

View File

@@ -501,30 +501,7 @@ You can use it manually to update templates.`,
},
}
var nodeCancelDiscoveryCmd = &cobra.Command{
Use: "cancel-discovery",
Short: "Cancel active node discovery",
Long: `Cancel an active node discovery operation.
Use this if discovery gets stuck or you want to stop a running scan.
Example:
wild node cancel-discovery`,
RunE: func(cmd *cobra.Command, args []string) error {
inst, err := getInstanceName()
if err != nil {
return err
}
_, err = apiClient.Post(fmt.Sprintf("/api/v1/instances/%s/discovery/cancel", inst), nil)
if err != nil {
return err
}
fmt.Println("Discovery cancelled successfully")
return nil
},
}
var nodeDeleteConfirm bool
var nodeDeleteCmd = &cobra.Command{
Use: "delete <hostname>",
@@ -536,6 +513,16 @@ var nodeDeleteCmd = &cobra.Command{
return err
}
if !nodeDeleteConfirm {
fmt.Printf("Are you sure you want to delete node '%s'? (y/N): ", args[0])
var response string
fmt.Scanln(&response)
if response != "y" && response != "Y" {
fmt.Println("Deletion cancelled")
return nil
}
}
_, err = apiClient.Delete(fmt.Sprintf("/api/v1/instances/%s/nodes/%s", inst, args[0]))
if err != nil {
return err
@@ -752,7 +739,6 @@ Examples:
func init() {
nodeCmd.AddCommand(nodeDiscoverCmd)
nodeCmd.AddCommand(nodeCancelDiscoveryCmd)
nodeCmd.AddCommand(nodeDetectCmd)
nodeCmd.AddCommand(nodeListCmd)
nodeCmd.AddCommand(nodeShowCmd)
@@ -761,6 +747,7 @@ func init() {
nodeCmd.AddCommand(nodeUpdateCmd)
nodeCmd.AddCommand(nodeFetchTemplatesCmd)
nodeCmd.AddCommand(nodeDeleteCmd)
nodeDeleteCmd.Flags().BoolVarP(&nodeDeleteConfirm, "yes", "y", false, "Skip confirmation prompt")
nodeCmd.AddCommand(nodeHealthCmd)
nodeCmd.AddCommand(nodeRebootCmd)
nodeCmd.AddCommand(nodeResetCmd)

View File

@@ -1,143 +0,0 @@
package cmd
import (
"fmt"
"github.com/spf13/cobra"
)
// proxy commands — manage the HAProxy ingress proxy on Wild Central
var proxyCmd = &cobra.Command{
Use: "proxy",
Short: "Manage the ingress proxy",
Long: `Manage the HAProxy ingress proxy that routes external traffic to Wild Cloud instances by SNI hostname.`,
}
var proxyStatusCmd = &cobra.Command{
Use: "status",
Short: "Show ingress proxy status",
RunE: func(cmd *cobra.Command, args []string) error {
resp, err := apiClient.Get("/api/v1/haproxy/status")
if err != nil {
return fmt.Errorf("failed to get proxy status: %w", err)
}
if outputFormat == "json" {
return printJSON(resp.Data)
}
if outputFormat == "yaml" {
return printYAML(resp.Data)
}
status := resp.GetString("status")
if status == "active" {
fmt.Println("✓ Ingress proxy is running")
} else {
fmt.Printf("Ingress proxy status: %s\n", status)
}
if pid, ok := resp.Data["pid"].(float64); ok && pid > 0 {
fmt.Printf(" PID: %d\n", int(pid))
}
if cf := resp.GetString("configFile"); cf != "" {
fmt.Printf(" Config: %s\n", cf)
}
return nil
},
}
var proxyGenerateCmd = &cobra.Command{
Use: "generate",
Short: "Regenerate proxy config and apply",
Long: `Rebuild the ingress proxy configuration from all Wild Cloud instances and custom routes, then reload the proxy and update firewall rules.`,
RunE: func(cmd *cobra.Command, args []string) error {
resp, err := apiClient.Post("/api/v1/haproxy/generate", nil)
if err != nil {
return fmt.Errorf("failed to generate proxy config: %w", err)
}
if outputFormat == "json" {
return printJSON(resp.Data)
}
if outputFormat == "yaml" {
return printYAML(resp.Data)
}
fmt.Printf("✓ %s\n", resp.GetString("message"))
if routes, ok := resp.Data["routes"].(float64); ok {
fmt.Printf(" Instance routes: %d\n", int(routes))
}
if custom, ok := resp.Data["customRoutes"].(float64); ok && custom > 0 {
fmt.Printf(" Custom routes: %d\n", int(custom))
}
return nil
},
}
var proxyRestartCmd = &cobra.Command{
Use: "restart",
Short: "Restart the ingress proxy",
RunE: func(cmd *cobra.Command, args []string) error {
resp, err := apiClient.Post("/api/v1/haproxy/restart", nil)
if err != nil {
return fmt.Errorf("failed to restart proxy: %w", err)
}
if outputFormat == "json" {
return printJSON(resp.Data)
}
if outputFormat == "yaml" {
return printYAML(resp.Data)
}
fmt.Printf("✓ %s\n", resp.GetString("message"))
return nil
},
}
var proxyStatsCmd = &cobra.Command{
Use: "stats",
Short: "Show live backend connection stats",
RunE: func(cmd *cobra.Command, args []string) error {
resp, err := apiClient.Get("/api/v1/haproxy/stats")
if err != nil {
return fmt.Errorf("failed to get proxy stats: %w", err)
}
if outputFormat == "json" {
return printJSON(resp.Data)
}
if outputFormat == "yaml" {
return printYAML(resp.Data)
}
backends, _ := resp.Data["backends"].([]any)
if len(backends) == 0 {
fmt.Println("No backends (proxy may not be running)")
return nil
}
fmt.Printf("%-30s %-10s %-8s %-12s %-8s\n", "BACKEND", "STATUS", "ACTIVE", "TOTAL", "ERRORS")
fmt.Println("-----------------------------------------------------------------------")
for _, b := range backends {
m, ok := b.(map[string]any)
if !ok {
continue
}
name, _ := m["name"].(string)
status, _ := m["status"].(string)
active, _ := m["activeConns"].(float64)
total, _ := m["totalConns"].(float64)
errors, _ := m["errors"].(float64)
fmt.Printf("%-30s %-10s %-8d %-12d %-8d\n",
name, status, int(active), int(total), int(errors))
}
return nil
},
}
func init() {
proxyCmd.AddCommand(proxyStatusCmd)
proxyCmd.AddCommand(proxyGenerateCmd)
proxyCmd.AddCommand(proxyRestartCmd)
proxyCmd.AddCommand(proxyStatsCmd)
}

View File

@@ -25,11 +25,9 @@ var (
var rootCmd = &cobra.Command{
Use: "wild",
Short: "Wild Cloud CLI",
Long: `wild-cli is the command-line interface for Wild Central.
Long: `wild-cli is the command-line interface for Wild Cloud.
Manage Wild Cloud instances, nodes, clusters, services, and applications,
as well as Wild Central's built-in network appliance features: DNS/DHCP
(dnsmasq), ingress proxy (HAProxy), dynamic DNS (DDNS), and secrets.`,
Manage Wild Cloud instances, nodes, clusters, and applications.`,
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
// Skip for commands that don't need API client
if cmd.Name() == "version" || cmd.Name() == "help" {
@@ -70,33 +68,24 @@ func Execute() {
func init() {
// Global flags
rootCmd.PersistentFlags().StringVar(&daemonURL, "daemon-url", "", "Daemon URL (default: $WILD_API_URI or http://localhost:5055)")
rootCmd.PersistentFlags().StringVar(&daemonURL, "daemon-url", "", "Daemon URL (default: $WILD_API_URI or http://localhost:5056)")
rootCmd.PersistentFlags().StringVar(&instanceName, "instance", "", "Instance name (overrides current instance)")
rootCmd.PersistentFlags().StringVarP(&outputFormat, "output", "o", "text", "Output format (text, json, yaml)")
// Add subcommands
rootCmd.AddCommand(versionCmd)
rootCmd.AddCommand(daemonCmd)
rootCmd.AddCommand(dnsCmd)
rootCmd.AddCommand(instanceCmd)
rootCmd.AddCommand(configCmd)
rootCmd.AddCommand(secretCmd)
rootCmd.AddCommand(nodeCmd)
rootCmd.AddCommand(assetCmd)
rootCmd.AddCommand(isoCmd)
rootCmd.AddCommand(clusterCmd)
rootCmd.AddCommand(serviceCmd)
rootCmd.AddCommand(appCmd)
rootCmd.AddCommand(backupCmd)
rootCmd.AddCommand(restoreCmd)
rootCmd.AddCommand(healthCmd)
rootCmd.AddCommand(nodeIPCmd)
rootCmd.AddCommand(operationCmd)
rootCmd.AddCommand(talosCmd)
rootCmd.AddCommand(proxyCmd)
rootCmd.AddCommand(ddnsCmd)
rootCmd.AddCommand(crowdsecCmd)
rootCmd.AddCommand(firewallCmd)
}
// getInstanceName returns the current instance name using the priority cascade

View File

@@ -2,7 +2,6 @@ package cmd
import (
"fmt"
"strings"
"github.com/spf13/cobra"
@@ -67,69 +66,7 @@ var secretSetCmd = &cobra.Command{
},
}
// secretCentralCmd manages Wild Central's own secrets (e.g., DDNS API tokens).
var secretCentralCmd = &cobra.Command{
Use: "central",
Short: "Manage Wild Central secrets",
Long: `Manage Wild Central's own secrets (e.g., DDNS API tokens). These are separate from instance secrets.`,
}
var secretCentralGetCmd = &cobra.Command{
Use: "get <key>",
Short: "Get a Wild Central secret value (raw)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
key := args[0]
resp, err := apiClient.Get("/api/v1/secrets?raw=true")
if err != nil {
return fmt.Errorf("failed to get secrets: %w", err)
}
val := config.GetValue(resp.Data, key)
if val != nil {
fmt.Println(val)
} else {
return fmt.Errorf("secret '%s' not found", key)
}
return nil
},
}
var secretCentralSetCmd = &cobra.Command{
Use: "set <key> <value>",
Short: "Set a Wild Central secret value",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
key, value := args[0], args[1]
// Build nested map from dot-notation key (e.g., ddns.cloudflare.apiToken)
body := buildNestedSecretMap(key, value)
_, err := apiClient.Put("/api/v1/secrets", body)
if err != nil {
return fmt.Errorf("failed to set secret: %w", err)
}
fmt.Printf("Secret updated: %s\n", key)
return nil
},
}
// buildNestedSecretMap converts a dot-notation key and value to a nested map.
// e.g., "ddns.cloudflare.apiToken", "abc" → {"ddns": {"cloudflare": {"apiToken": "abc"}}}
func buildNestedSecretMap(key, value string) map[string]any {
parts := strings.SplitN(key, ".", 2)
if len(parts) == 1 {
return map[string]any{key: value}
}
return map[string]any{parts[0]: buildNestedSecretMap(parts[1], value)}
}
func init() {
secretCmd.AddCommand(secretGetCmd)
secretCmd.AddCommand(secretSetCmd)
secretCmd.AddCommand(secretCentralCmd)
secretCentralCmd.AddCommand(secretCentralGetCmd)
secretCentralCmd.AddCommand(secretCentralSetCmd)
}

View File

@@ -1,515 +0,0 @@
package cmd
import (
"bufio"
"context"
"fmt"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"github.com/spf13/cobra"
"github.com/wild-cloud/wild-central/wild/internal/config"
)
// Service commands
var serviceCmd = &cobra.Command{
Use: "service",
Short: "Manage services",
}
var serviceListCmd = &cobra.Command{
Use: "list",
Short: "List services",
RunE: func(cmd *cobra.Command, args []string) error {
inst, err := getInstanceName()
if err != nil {
return err
}
resp, err := apiClient.Get(fmt.Sprintf("/api/v1/instances/%s/apps?category=infrastructure", inst))
if err != nil {
return err
}
if outputFormat == "json" {
return printJSON(resp.Data)
}
services := resp.GetArray("apps")
if len(services) == 0 {
fmt.Println("No services found")
return nil
}
fmt.Printf("%-20s %-12s\n", "NAME", "STATUS")
fmt.Println("----------------------------------")
for _, svc := range services {
if m, ok := svc.(map[string]any); ok {
fmt.Printf("%-20s %-12s\n", m["name"], m["status"])
}
}
return nil
},
}
var (
fetchFlag bool
noDeployFlag bool
// Service logs flags
tailLines int
followLogs bool
containerName string
previousLogs bool
sinceDuration string
// Service update flags
setFlags []string
noRedeployFlag bool
)
var serviceInstallCmd = &cobra.Command{
Use: "install <service>",
Short: "Install a service",
Long: `Install and configure a cluster service.
This command adds a service to the instance and optionally deploys it:
1. Add service to instance (fetches from Wild Directory, compiles templates)
2. Deploy service to cluster (unless --no-deploy)
Examples:
# Add and deploy (most common)
wild service install metallb
# Add only, skip deployment
wild service install metallb --no-deploy
# Force re-fetch from Wild Directory
wild service install metallb --fetch
`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
serviceName := args[0]
inst, err := getInstanceName()
if err != nil {
return err
}
fmt.Printf("Installing service: %s\n", serviceName)
// Fetch manifest for display
manifestResp, err := apiClient.Get(fmt.Sprintf("/api/v1/apps/%s/manifest", serviceName))
if err != nil {
return fmt.Errorf("failed to fetch manifest: %w", err)
}
name := manifestResp.GetString("name")
desc := manifestResp.GetString("description")
if name != "" {
fmt.Printf("Service: %s - %s\n", name, desc)
}
// Check if config overrides needed
if defaultConfig, ok := manifestResp.Data["defaultConfig"].(map[string]any); ok && len(defaultConfig) > 0 {
// Check existing config for this service
configResp, err := apiClient.Get(fmt.Sprintf("/api/v1/instances/%s/config", inst))
if err == nil {
existingConfig := config.GetValue(configResp.Data, fmt.Sprintf("apps.%s", serviceName))
if existingConfig == nil || existingConfig == "null" {
fmt.Println("\nDefault configuration will be applied:")
for key, val := range defaultConfig {
fmt.Printf(" %s: %v\n", key, val)
}
}
}
}
// Add service to instance
fmt.Println("\nAdding service to instance...")
_, err = apiClient.Post(
fmt.Sprintf("/api/v1/instances/%s/apps", inst),
map[string]any{
"name": serviceName,
},
)
if err != nil {
return fmt.Errorf("failed to add service: %w", err)
}
// Re-fetch if requested
if fetchFlag {
fmt.Println("Fetching fresh templates...")
_, err = apiClient.Post(fmt.Sprintf("/api/v1/instances/%s/apps/%s/fetch", inst, serviceName), nil)
if err != nil {
return fmt.Errorf("failed to fetch service: %w", err)
}
}
if noDeployFlag {
fmt.Printf("\nService added: %s\n", serviceName)
fmt.Printf(" Templates compiled and ready to deploy\n")
fmt.Printf(" To deploy: wild service install %s\n", serviceName)
return nil
}
// Deploy
fmt.Println("\nDeploying service...")
deployResp, err := apiClient.Post(
fmt.Sprintf("/api/v1/instances/%s/apps/%s/deploy", inst, serviceName),
nil,
)
if err != nil {
return fmt.Errorf("failed to deploy service: %w", err)
}
opID := deployResp.GetString("operation_id")
if opID != "" {
if err := streamOperationOutput(opID); err != nil {
fmt.Printf("\nCouldn't stream output: %v\n", err)
fmt.Printf("Operation ID: %s\n", opID)
fmt.Printf("Monitor with: wild operation get %s\n", opID)
} else {
fmt.Printf("\nService installed successfully: %s\n", serviceName)
}
}
return nil
},
}
var serviceStatusCmd = &cobra.Command{
Use: "status <service>",
Short: "Show detailed status of a service",
Args: cobra.ExactArgs(1),
RunE: runServiceStatus,
}
var serviceLogsCmd = &cobra.Command{
Use: "logs <service>",
Short: "View service logs",
Args: cobra.ExactArgs(1),
RunE: runServiceLogs,
}
var serviceUpdateCmd = &cobra.Command{
Use: "update <service>",
Short: "Update service configuration",
Args: cobra.ExactArgs(1),
RunE: runServiceUpdate,
}
func runServiceStatus(cmd *cobra.Command, args []string) error {
serviceName := args[0]
inst, err := getInstanceName()
if err != nil {
return err
}
resp, err := apiClient.Get(fmt.Sprintf("/api/v1/instances/%s/apps/%s/status", inst, serviceName))
if err != nil {
return err
}
if outputFormat == "json" {
return printJSON(resp.Data)
}
if outputFormat == "yaml" {
return printYAML(resp.Data)
}
// Pretty print status
fmt.Printf("Service: %s\n", resp.GetString("name"))
if namespace := resp.GetString("namespace"); namespace != "" {
fmt.Printf("Namespace: %s\n", namespace)
}
if status := resp.GetString("status"); status != "" {
fmt.Printf("Status: %s\n", status)
}
// Show replica information
if replicas := resp.GetMap("replicas"); replicas != nil {
fmt.Println("\nReplicas:")
if desired, ok := replicas["desired"].(float64); ok {
fmt.Printf(" Desired: %.0f\n", desired)
}
if current, ok := replicas["current"].(float64); ok {
fmt.Printf(" Current: %.0f\n", current)
}
if ready, ok := replicas["ready"].(float64); ok {
fmt.Printf(" Ready: %.0f\n", ready)
}
if available, ok := replicas["available"].(float64); ok {
fmt.Printf(" Available: %.0f\n", available)
}
}
// Show pod information
if pods := resp.GetArray("pods"); len(pods) > 0 {
fmt.Println("\nPods:")
fmt.Printf(" %-40s %-12s %-8s %-10s %-10s\n", "NAME", "STATUS", "READY", "RESTARTS", "AGE")
fmt.Println(" " + strings.Repeat("-", 90))
for _, pod := range pods {
if p, ok := pod.(map[string]any); ok {
name := p["name"]
status := p["status"]
ready := p["ready"]
restarts := p["restarts"]
age := p["age"]
fmt.Printf(" %-40s %-12s %-8v %-10v %-10s\n", name, status, ready, restarts, age)
}
}
}
// Show current configuration
if configMap := resp.GetMap("config"); len(configMap) > 0 {
fmt.Println("\nConfiguration:")
for key, value := range configMap {
fmt.Printf(" %s: %v\n", key, value)
}
}
return nil
}
func runServiceLogs(cmd *cobra.Command, args []string) error {
serviceName := args[0]
inst, err := getInstanceName()
if err != nil {
return err
}
// Build query parameters
params := []string{}
if tailLines > 0 {
params = append(params, fmt.Sprintf("tail=%d", tailLines))
}
if containerName != "" {
params = append(params, fmt.Sprintf("container=%s", containerName))
}
if previousLogs {
params = append(params, "previous=true")
}
if sinceDuration != "" {
params = append(params, fmt.Sprintf("since=%s", sinceDuration))
}
queryString := ""
if len(params) > 0 {
queryString = "?" + strings.Join(params, "&")
}
if followLogs {
// Streaming mode
return streamServiceLogs(inst, serviceName, queryString)
}
// Buffered mode
resp, err := apiClient.Get(fmt.Sprintf("/api/v1/instances/%s/apps/%s/logs%s", inst, serviceName, queryString))
if err != nil {
return err
}
// Print logs - API returns logs as an array of lines
if lines, ok := resp.Data["lines"].([]any); ok {
for _, line := range lines {
if lineStr, ok := line.(string); ok {
fmt.Println(lineStr)
}
}
}
return nil
}
func streamServiceLogs(instance, serviceName, queryString string) error {
// Get base URL
baseURL := daemonURL
if baseURL == "" {
baseURL = config.GetDaemonURL()
}
// Build URL with follow=true parameter
url := fmt.Sprintf("%s/api/v1/instances/%s/apps/%s/logs%s", baseURL, instance, serviceName, queryString)
if strings.Contains(url, "?") {
url += "&follow=true"
} else {
url += "?follow=true"
}
// Create context that can be cancelled
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Set up signal handling for graceful shutdown
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
go func() {
<-sigChan
cancel()
}()
// Create HTTP request
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}
// Make request
client := &http.Client{Timeout: 0} // No timeout for streaming
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("request failed with status %d", resp.StatusCode)
}
// Stream response line by line
scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() {
select {
case <-ctx.Done():
return nil
default:
line := scanner.Text()
// SSE events are prefixed with "data: "
if data, ok := strings.CutPrefix(line, "data: "); ok {
fmt.Println(data)
} else if line != "" {
fmt.Println(line)
}
}
}
if err := scanner.Err(); err != nil {
if ctx.Err() == context.Canceled {
return nil
}
return fmt.Errorf("error reading stream: %w", err)
}
return nil
}
func runServiceUpdate(cmd *cobra.Command, args []string) error {
serviceName := args[0]
inst, err := getInstanceName()
if err != nil {
return err
}
updates := make(map[string]string)
// Parse --set flags
for _, setFlag := range setFlags {
parts := strings.SplitN(setFlag, "=", 2)
if len(parts) != 2 {
return fmt.Errorf("invalid --set format: %s (expected key=value)", setFlag)
}
updates[parts[0]] = parts[1]
}
// Interactive mode if no --set flags provided
if len(updates) == 0 {
fmt.Printf("Updating service: %s\n", serviceName)
fmt.Println("Enter configuration values (key=value), empty line to finish:")
scanner := bufio.NewScanner(os.Stdin)
for {
fmt.Print("> ")
if !scanner.Scan() {
break
}
line := strings.TrimSpace(scanner.Text())
if line == "" {
break
}
parts := strings.SplitN(line, "=", 2)
if len(parts) != 2 {
fmt.Println("Invalid format, expected key=value")
continue
}
updates[parts[0]] = parts[1]
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("error reading input: %w", err)
}
if len(updates) == 0 {
fmt.Println("No updates provided")
return nil
}
}
// Build request body
requestBody := map[string]any{
"config": updates,
"redeploy": !noRedeployFlag,
}
// Make API call
fmt.Printf("Updating configuration for service: %s\n", serviceName)
resp, err := apiClient.Patch(
fmt.Sprintf("/api/v1/instances/%s/apps/%s/config", inst, serviceName),
requestBody,
)
if err != nil {
return err
}
// Show result
fmt.Printf("Configuration updated (%d values)\n", len(updates))
for key, value := range updates {
fmt.Printf(" %s: %s\n", key, value)
}
// If redeployment triggered, show operation
if !noRedeployFlag {
if opID := resp.GetString("operation_id"); opID != "" {
fmt.Println("\nRedeploying service...")
if err := streamOperationOutput(opID); err != nil {
fmt.Printf("\nCouldn't stream output: %v\n", err)
fmt.Printf("Operation ID: %s\n", opID)
fmt.Printf("Monitor with: wild operation get %s\n", opID)
} else {
fmt.Printf("\n✓ Service redeployed successfully\n")
}
}
} else {
fmt.Println("\nConfiguration updated without redeployment")
}
return nil
}
func init() {
serviceInstallCmd.Flags().BoolVar(&fetchFlag, "fetch", false, "Fetch fresh templates from directory before installing")
serviceInstallCmd.Flags().BoolVar(&noDeployFlag, "no-deploy", false, "Configure and compile only, skip deployment")
serviceLogsCmd.Flags().IntVar(&tailLines, "tail", 100, "Number of lines to show")
serviceLogsCmd.Flags().BoolVarP(&followLogs, "follow", "f", false, "Stream logs in real-time")
serviceLogsCmd.Flags().StringVar(&containerName, "container", "", "Specific container (if service has multiple)")
serviceLogsCmd.Flags().BoolVar(&previousLogs, "previous", false, "Show logs from previous container instance")
serviceLogsCmd.Flags().StringVar(&sinceDuration, "since", "", "Show logs since duration (e.g., \"5m\", \"1h\")")
serviceUpdateCmd.Flags().StringArrayVar(&setFlags, "set", []string{}, "Set a configuration value (key=value), can be repeated")
serviceUpdateCmd.Flags().BoolVar(&noRedeployFlag, "no-redeploy", false, "Don't trigger redeployment after update")
serviceCmd.AddCommand(serviceListCmd)
serviceCmd.AddCommand(serviceInstallCmd)
serviceCmd.AddCommand(serviceStatusCmd)
serviceCmd.AddCommand(serviceLogsCmd)
serviceCmd.AddCommand(serviceUpdateCmd)
}

View File

@@ -1,49 +0,0 @@
package cmd
import (
"fmt"
"github.com/spf13/cobra"
)
// Utility commands
var healthCmd = &cobra.Command{
Use: "health",
Short: "Check cluster health",
RunE: func(cmd *cobra.Command, args []string) error {
inst, err := getInstanceName()
if err != nil {
return err
}
resp, err := apiClient.Get(fmt.Sprintf("/api/v1/instances/%s/utilities/health", inst))
if err != nil {
return err
}
if outputFormat == "json" {
return printJSON(resp.Data)
}
return printYAML(resp.Data)
},
}
var nodeIPCmd = &cobra.Command{
Use: "node-ip",
Short: "Get control plane IP",
RunE: func(cmd *cobra.Command, args []string) error {
inst, err := getInstanceName()
if err != nil {
return err
}
resp, err := apiClient.Get(fmt.Sprintf("/api/v1/instances/%s/utilities/controlplane/ip", inst))
if err != nil {
return err
}
fmt.Println(resp.GetString("ip"))
return nil
},
}