First commit of golang CLI.

This commit is contained in:
2025-08-31 11:51:11 -07:00
parent 4ca06aecb6
commit f0a2098f11
51 changed files with 8840 additions and 0 deletions

View File

@@ -0,0 +1,180 @@
package app
import (
"fmt"
"os"
"path/filepath"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"
"github.com/wild-cloud/wild-cli/internal/apps"
"github.com/wild-cloud/wild-cli/internal/config"
"github.com/wild-cloud/wild-cli/internal/environment"
"github.com/wild-cloud/wild-cli/internal/output"
)
func newAddCommand() *cobra.Command {
return &cobra.Command{
Use: "add <name>",
Short: "Add an application to the project",
Long: `Add an application to the project with configuration.
This copies the cached application template to your project's apps/
directory and creates initial configuration.
Examples:
wild app add nextcloud
wild app add postgresql`,
Args: cobra.ExactArgs(1),
RunE: runAdd,
}
}
func runAdd(cmd *cobra.Command, args []string) error {
appName := args[0]
output.Header("Adding Application")
output.Info("App: " + appName)
// Initialize environment
env := environment.New()
if err := env.RequiresProject(); err != nil {
return err
}
// Create catalog
catalog := apps.NewCatalog(env.CacheDir())
// Check if app is cached
if !catalog.IsAppCached(appName) {
output.Warning("App '" + appName + "' is not cached locally")
output.Info("Run 'wild app fetch " + appName + "' first")
return nil
}
// Check if app already exists in project
appDir := filepath.Join(env.AppsDir(), appName)
if _, err := os.Stat(appDir); err == nil {
output.Warning("App '" + appName + "' already exists in project")
output.Info("App directory: " + appDir)
return nil
}
// Get app info
app, err := catalog.FindApp(appName)
if err != nil {
return fmt.Errorf("getting app info: %w", err)
}
output.Info("Description: " + app.Description)
output.Info("Version: " + app.Version)
// Check dependencies
if len(app.Requires) > 0 {
output.Info("Dependencies: " + fmt.Sprintf("%v", app.Requires))
// Check if dependencies are available
for _, dep := range app.Requires {
depDir := filepath.Join(env.AppsDir(), dep)
if _, err := os.Stat(depDir); os.IsNotExist(err) {
output.Warning("Dependency '" + dep + "' not found in project")
output.Info("Consider adding it first: wild app add " + dep)
} else {
output.Success("Dependency '" + dep + "' found")
}
}
}
// Copy app template from cache to project
cacheDir := filepath.Join(env.CacheDir(), "apps", appName)
if err := copyDir(cacheDir, appDir); err != nil {
return fmt.Errorf("copying app template: %w", err)
}
// Create initial configuration in config.yaml
if err := addAppConfig(env, appName, app); err != nil {
output.Warning("Failed to add app configuration: " + err.Error())
} else {
output.Success("Added default configuration to config.yaml")
}
output.Success("App '" + appName + "' added to project")
output.Info("")
output.Info("App directory: " + appDir)
output.Info("Next steps:")
output.Info(" wild config set apps." + appName + ".enabled true")
output.Info(" wild app deploy " + appName)
return nil
}
// copyDir recursively copies a directory
func copyDir(src, dst string) error {
entries, err := os.ReadDir(src)
if err != nil {
return err
}
if err := os.MkdirAll(dst, 0755); err != nil {
return err
}
for _, entry := range entries {
srcPath := filepath.Join(src, entry.Name())
dstPath := filepath.Join(dst, entry.Name())
if entry.IsDir() {
if err := copyDir(srcPath, dstPath); err != nil {
return err
}
} else {
if err := copyFile(srcPath, dstPath); err != nil {
return err
}
}
}
return nil
}
// copyFile copies a single file
func copyFile(src, dst string) error {
data, err := os.ReadFile(src)
if err != nil {
return err
}
return os.WriteFile(dst, data, 0644)
}
// addAppConfig adds default app configuration to config.yaml
func addAppConfig(env *environment.Environment, appName string, app *apps.App) error {
mgr := config.NewManager(env.ConfigPath(), env.SecretsPath())
// Create default app configuration
appConfig := map[string]interface{}{
"enabled": false,
"image": appName + ":latest",
}
// Add any default config from the app manifest
if app.Config != nil {
for key, value := range app.Config {
appConfig[key] = value
}
}
// Convert to YAML and set in config
configData, err := yaml.Marshal(appConfig)
if err != nil {
return fmt.Errorf("marshaling config: %w", err)
}
configPath := "apps." + appName
if err := mgr.Set(configPath, string(configData)); err != nil {
return fmt.Errorf("setting config: %w", err)
}
return nil
}

View File

@@ -0,0 +1,43 @@
package app
import (
"github.com/spf13/cobra"
)
// NewAppCommand creates the app command and its subcommands
func NewAppCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "app",
Short: "Manage Wild Cloud applications",
Long: `Manage applications in your Wild Cloud cluster.
Applications are deployed as Kubernetes workloads with associated configuration,
secrets, and persistent storage as needed.`,
}
// Add subcommands
cmd.AddCommand(
newListCommand(),
newFetchCommand(),
newAddCommand(),
newDeployCommand(),
newDeleteCommand(),
newBackupCommand(),
newRestoreCommand(),
newDoctorCommand(),
)
return cmd
}
// newListCommand is implemented in list.go
// newFetchCommand is implemented in fetch.go
// newAddCommand is implemented in add.go
// newDeployCommand is implemented in deploy.go
// newDeleteCommand is implemented in delete.go
// newBackupCommand is implemented in backup.go
// newRestoreCommand is implemented in restore.go
// newDoctorCommand is implemented in doctor.go

View File

@@ -0,0 +1,116 @@
package app
import (
"fmt"
"os"
"path/filepath"
"github.com/spf13/cobra"
"github.com/wild-cloud/wild-cli/internal/environment"
"github.com/wild-cloud/wild-cli/internal/external"
"github.com/wild-cloud/wild-cli/internal/output"
)
var (
backupAll bool
)
func newBackupCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "backup <name>",
Short: "Backup application data",
Long: `Backup application data to the configured backup storage.
This command backs up application databases and persistent volume data using restic
and the existing backup infrastructure.
Examples:
wild app backup nextcloud
wild app backup --all`,
Args: cobra.MaximumNArgs(1),
RunE: runAppBackup,
}
cmd.Flags().BoolVar(&backupAll, "all", false, "backup all applications")
return cmd
}
func runAppBackup(cmd *cobra.Command, args []string) error {
if !backupAll && len(args) == 0 {
return fmt.Errorf("app name required or use --all flag")
}
var appName string
if len(args) > 0 {
appName = args[0]
}
if backupAll {
output.Header("Backing Up All Applications")
} else {
output.Header("Backing Up Application")
output.Info("App: " + appName)
}
// Initialize environment
env := environment.New()
if err := env.RequiresProject(); err != nil {
return err
}
// For now, delegate to the existing bash script to maintain compatibility
wcRoot := env.WCRoot()
if wcRoot == "" {
return fmt.Errorf("WC_ROOT not set. Wild Cloud installation not found")
}
appBackupScript := filepath.Join(wcRoot, "bin", "wild-app-backup")
if _, err := os.Stat(appBackupScript); os.IsNotExist(err) {
return fmt.Errorf("app backup script not found at %s", appBackupScript)
}
// Execute the app backup script
bashTool := external.NewBaseTool("bash", "bash")
// Set environment variables needed by the script
oldWCRoot := os.Getenv("WC_ROOT")
oldWCHome := os.Getenv("WC_HOME")
defer func() {
if oldWCRoot != "" {
_ = os.Setenv("WC_ROOT", oldWCRoot)
}
if oldWCHome != "" {
_ = os.Setenv("WC_HOME", oldWCHome)
}
}()
_ = os.Setenv("WC_ROOT", wcRoot)
_ = os.Setenv("WC_HOME", env.WCHome())
var scriptArgs []string
if backupAll {
scriptArgs = []string{appBackupScript, "--all"}
} else {
// Check if app exists in project
appDir := filepath.Join(env.AppsDir(), appName)
if _, err := os.Stat(appDir); os.IsNotExist(err) {
return fmt.Errorf("app '%s' not found in project. Run 'wild app add %s' first", appName, appName)
}
scriptArgs = []string{appBackupScript, appName}
}
output.Info("Running application backup script...")
if _, err := bashTool.Execute(cmd.Context(), scriptArgs...); err != nil {
return fmt.Errorf("application backup failed: %w", err)
}
if backupAll {
output.Success("All applications backed up successfully")
} else {
output.Success(fmt.Sprintf("Application '%s' backed up successfully", appName))
}
return nil
}

View File

@@ -0,0 +1,138 @@
package app
import (
"bufio"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/spf13/cobra"
"github.com/wild-cloud/wild-cli/internal/environment"
"github.com/wild-cloud/wild-cli/internal/external"
"github.com/wild-cloud/wild-cli/internal/output"
)
var (
deleteForce bool
deleteDryRun bool
)
func newDeleteCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "delete <name>",
Short: "Delete an application from the cluster",
Long: `Delete a Wild Cloud app and all its resources.
This will delete:
- App deployment, services, and other Kubernetes resources
- App secrets from the app's namespace
- App namespace (if empty after resource deletion)
- Local app configuration files from apps/<app_name>
Examples:
wild app delete nextcloud
wild app delete nextcloud --force
wild app delete nextcloud --dry-run`,
Args: cobra.ExactArgs(1),
RunE: runAppDelete,
}
cmd.Flags().BoolVar(&deleteForce, "force", false, "skip confirmation prompts")
cmd.Flags().BoolVar(&deleteDryRun, "dry-run", false, "show what would be deleted without actually deleting")
return cmd
}
func runAppDelete(cmd *cobra.Command, args []string) error {
appName := args[0]
// Initialize environment
env := environment.New()
if err := env.RequiresProject(); err != nil {
return err
}
// Check if app exists
appDir := filepath.Join(env.AppsDir(), appName)
if _, err := os.Stat(appDir); os.IsNotExist(err) {
return fmt.Errorf("app directory 'apps/%s' not found", appName)
}
// Initialize external tools
toolManager := external.NewManager()
if err := toolManager.CheckTools(cmd.Context(), []string{"kubectl"}); err != nil {
return fmt.Errorf("required tools not available: %w", err)
}
kubectl := toolManager.Kubectl()
// Confirmation prompt (unless --force or --dry-run)
if !deleteForce && !deleteDryRun {
output.Warning(fmt.Sprintf("This will delete all resources for app '%s'", appName))
output.Info("This includes:")
output.Info(" - Kubernetes deployments, services, secrets, and other resources")
output.Info(" - App namespace (if empty after deletion)")
output.Info(" - Local configuration files in apps/" + appName + "/")
output.Info("")
reader := bufio.NewReader(os.Stdin)
output.Printf("Are you sure you want to delete app '%s'? (y/N): ", appName)
response, err := reader.ReadString('\n')
if err != nil {
return fmt.Errorf("failed to read confirmation: %w", err)
}
response = strings.TrimSpace(strings.ToLower(response))
if response != "y" && response != "yes" {
output.Info("Deletion cancelled")
return nil
}
}
if deleteDryRun {
output.Header("DRY RUN: Deleting app '" + appName + "'")
} else {
output.Header("Deleting app '" + appName + "'")
}
// Step 1: Delete namespace (this will delete ALL resources)
output.Info("Deleting namespace and all remaining resources...")
var kubectlArgs []string
if deleteDryRun {
kubectlArgs = []string{"delete", "namespace", appName, "--dry-run=client", "--ignore-not-found=true"}
} else {
kubectlArgs = []string{"delete", "namespace", appName, "--ignore-not-found=true"}
}
if _, err := kubectl.Execute(cmd.Context(), kubectlArgs...); err != nil {
return fmt.Errorf("failed to delete namespace: %w", err)
}
// Wait for namespace deletion to complete (only if not dry-run)
if !deleteDryRun {
output.Info("Waiting for namespace deletion to complete...")
waitArgs := []string{"wait", "--for=delete", "namespace", appName, "--timeout=60s"}
// Ignore error as namespace might already be deleted
_, _ = kubectl.Execute(cmd.Context(), waitArgs...)
}
// Step 2: Delete local app configuration files
output.Info("Deleting local app configuration...")
if deleteDryRun {
output.Info(fmt.Sprintf("DRY RUN: Would delete directory 'apps/%s/'", appName))
} else {
if err := os.RemoveAll(appDir); err != nil {
return fmt.Errorf("failed to delete local configuration directory: %w", err)
}
output.Info(fmt.Sprintf("Deleted local configuration directory: apps/%s/", appName))
}
output.Success(fmt.Sprintf("App '%s' deletion complete!", appName))
output.Info("")
output.Info("Note: Dependency apps (if any) were not deleted.")
output.Info("If you want to delete dependencies, run wild app delete for each dependency separately.")
return nil
}

View File

@@ -0,0 +1,223 @@
package app
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"
"github.com/wild-cloud/wild-cli/internal/config"
"github.com/wild-cloud/wild-cli/internal/environment"
"github.com/wild-cloud/wild-cli/internal/external"
"github.com/wild-cloud/wild-cli/internal/output"
)
var (
force bool
dryRun bool
)
func newDeployCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "deploy <name>",
Short: "Deploy an application to the cluster",
Long: `Deploy an application to the Kubernetes cluster.
This processes the app templates with current configuration and
deploys them using kubectl and kustomize.
Examples:
wild app deploy nextcloud
wild app deploy postgresql --force
wild app deploy myapp --dry-run`,
Args: cobra.ExactArgs(1),
RunE: runDeploy,
}
cmd.Flags().BoolVar(&force, "force", false, "force deployment (replace existing resources)")
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "show what would be deployed without making changes")
return cmd
}
func runDeploy(cmd *cobra.Command, args []string) error {
appName := args[0]
output.Header("Deploying Application")
output.Info("App: " + appName)
// Initialize environment
env := environment.New()
if err := env.RequiresProject(); err != nil {
return err
}
// Check if app exists in project
appDir := filepath.Join(env.AppsDir(), appName)
if _, err := os.Stat(appDir); os.IsNotExist(err) {
return fmt.Errorf("app '%s' not found in project. Run 'wild app add %s' first", appName, appName)
}
// Check external tools
toolManager := external.NewManager()
if err := toolManager.CheckTools(cmd.Context(), []string{"kubectl"}); err != nil {
return fmt.Errorf("required tools not available: %w", err)
}
// Load configuration
configMgr := config.NewManager(env.ConfigPath(), env.SecretsPath())
// Check if app is enabled
enabledValue, err := configMgr.Get("apps." + appName + ".enabled")
if err != nil || enabledValue == nil {
output.Warning("App '" + appName + "' is not configured")
output.Info("Run: wild config set apps." + appName + ".enabled true")
return nil
}
enabled, ok := enabledValue.(bool)
if !ok || !enabled {
output.Warning("App '" + appName + "' is disabled")
output.Info("Run: wild config set apps." + appName + ".enabled true")
return nil
}
// Process templates with configuration
output.Info("Processing templates...")
processedDir := filepath.Join(env.WildCloudDir(), "processed", appName)
if err := os.RemoveAll(processedDir); err != nil {
return fmt.Errorf("cleaning processed directory: %w", err)
}
if err := processAppTemplates(appDir, processedDir, configMgr); err != nil {
return fmt.Errorf("processing templates: %w", err)
}
// Deploy secrets if required
if err := deployAppSecrets(cmd.Context(), appName, appDir, configMgr, toolManager.Kubectl()); err != nil {
return fmt.Errorf("deploying secrets: %w", err)
}
// Deploy using kubectl + kustomize
output.Info("Deploying to cluster...")
kubectl := toolManager.Kubectl()
if err := kubectl.ApplyKustomize(cmd.Context(), processedDir, "", dryRun); err != nil {
return fmt.Errorf("deploying with kubectl: %w", err)
}
if dryRun {
output.Success("Dry run completed - no changes made")
} else {
output.Success("App '" + appName + "' deployed successfully")
}
// Show next steps
output.Info("")
output.Info("Monitor deployment:")
output.Info(" kubectl get pods -n " + appName)
output.Info(" kubectl logs -f deployment/" + appName + " -n " + appName)
return nil
}
// processAppTemplates processes app templates with configuration
func processAppTemplates(appDir, processedDir string, configMgr *config.Manager) error {
// Create template engine
engine, err := config.NewTemplateEngine(configMgr)
if err != nil {
return fmt.Errorf("creating template engine: %w", err)
}
// Walk through app directory
return filepath.Walk(appDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// Calculate relative path
relPath, err := filepath.Rel(appDir, path)
if err != nil {
return err
}
destPath := filepath.Join(processedDir, relPath)
if info.IsDir() {
return os.MkdirAll(destPath, info.Mode())
}
// Read file content
content, err := os.ReadFile(path)
if err != nil {
return err
}
// Process as template if it's a YAML file
if strings.HasSuffix(path, ".yaml") || strings.HasSuffix(path, ".yml") {
processed, err := engine.Process(string(content))
if err != nil {
return fmt.Errorf("processing template %s: %w", relPath, err)
}
content = []byte(processed)
}
// Write processed content
if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
return err
}
return os.WriteFile(destPath, content, info.Mode())
})
}
// deployAppSecrets deploys application secrets
func deployAppSecrets(ctx context.Context, appName, appDir string, configMgr *config.Manager, kubectl *external.KubectlTool) error {
// Check for manifest.yaml with required secrets
manifestPath := filepath.Join(appDir, "manifest.yaml")
manifestData, err := os.ReadFile(manifestPath)
if os.IsNotExist(err) {
return nil // No manifest, no secrets needed
}
if err != nil {
return fmt.Errorf("reading manifest: %w", err)
}
var manifest struct {
RequiredSecrets []string `yaml:"requiredSecrets"`
}
if err := yaml.Unmarshal(manifestData, &manifest); err != nil {
return fmt.Errorf("parsing manifest: %w", err)
}
if len(manifest.RequiredSecrets) == 0 {
return nil // No secrets required
}
output.Info("Deploying secrets...")
// Collect secret data
secretData := make(map[string]string)
for _, secretPath := range manifest.RequiredSecrets {
value, err := configMgr.GetSecret(secretPath)
if err != nil || value == nil {
return fmt.Errorf("required secret '%s' not found", secretPath)
}
secretData[secretPath] = fmt.Sprintf("%v", value)
}
// Create secret in cluster
secretName := appName + "-secrets"
if err := kubectl.CreateSecret(ctx, secretName, appName, secretData); err != nil {
return fmt.Errorf("creating secret: %w", err)
}
output.Success("Secrets deployed")
return nil
}

View File

@@ -0,0 +1,246 @@
package app
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/wild-cloud/wild-cli/internal/environment"
"github.com/wild-cloud/wild-cli/internal/external"
"github.com/wild-cloud/wild-cli/internal/output"
)
var (
doctorKeep bool
doctorFollow bool
doctorTimeout int
)
func newDoctorCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "doctor [name]",
Short: "Check application health",
Long: `Run diagnostic tests for an application.
This command runs diagnostic tests for applications that have a doctor/ directory
with a kustomization.yaml file. The tests run as Kubernetes jobs and provide
detailed health and connectivity information.
Arguments:
name Name of the app to diagnose (must have apps/name/doctor/ directory)
Options:
--keep Keep diagnostic resources after completion (don't auto-cleanup)
--follow Follow logs in real-time instead of waiting for completion
--timeout SECONDS Timeout for job completion (default: 120 seconds)
Examples:
wild app doctor postgres
wild app doctor postgres --follow
wild app doctor postgres --keep --timeout 300`,
Args: cobra.MaximumNArgs(1),
RunE: runAppDoctor,
}
cmd.Flags().BoolVar(&doctorKeep, "keep", false, "keep diagnostic resources after completion (don't auto-cleanup)")
cmd.Flags().BoolVar(&doctorFollow, "follow", false, "follow logs in real-time instead of waiting for completion")
cmd.Flags().IntVar(&doctorTimeout, "timeout", 120, "timeout for job completion in seconds")
return cmd
}
func runAppDoctor(cmd *cobra.Command, args []string) error {
// Initialize environment
env := environment.New()
if err := env.RequiresProject(); err != nil {
return err
}
// Initialize external tools
toolManager := external.NewManager()
if err := toolManager.CheckTools(cmd.Context(), []string{"kubectl"}); err != nil {
return fmt.Errorf("required tools not available: %w", err)
}
kubectl := toolManager.Kubectl()
// If no app name provided, list available doctors
if len(args) == 0 {
return listAvailableDoctors(env.AppsDir())
}
appName := args[0]
// Check if doctor directory exists
doctorDir := filepath.Join(env.AppsDir(), appName, "doctor")
if _, err := os.Stat(doctorDir); os.IsNotExist(err) {
output.Error(fmt.Sprintf("Doctor directory not found: %s", doctorDir))
output.Info("")
return listAvailableDoctors(env.AppsDir())
}
// Check if kustomization.yaml exists
kustomizationFile := filepath.Join(doctorDir, "kustomization.yaml")
if _, err := os.Stat(kustomizationFile); os.IsNotExist(err) {
return fmt.Errorf("kustomization.yaml not found in %s", doctorDir)
}
output.Header(fmt.Sprintf("Running diagnostics for: %s", appName))
output.Info(fmt.Sprintf("Doctor directory: %s", doctorDir))
output.Info("")
// Extract namespace and job name before applying
namespace, jobName, err := extractJobInfo(cmd.Context(), kubectl, doctorDir)
if err != nil {
return fmt.Errorf("failed to extract job information: %w", err)
}
// Set up cleanup function
cleanup := func() {
if !doctorKeep {
output.Info("Cleaning up diagnostic resources...")
deleteArgs := []string{"delete", "-k", doctorDir}
if _, err := kubectl.Execute(cmd.Context(), deleteArgs...); err != nil {
output.Info(" (No resources to clean up)")
}
} else {
output.Info("Keeping diagnostic resources (--keep flag specified)")
output.Info(fmt.Sprintf(" To manually cleanup later: kubectl delete -k %s", doctorDir))
}
}
defer cleanup()
// Delete existing job if it exists (to avoid conflicts)
deleteJobArgs := []string{"delete", "job", jobName, "-n", namespace}
_, _ = kubectl.Execute(cmd.Context(), deleteJobArgs...)
// Apply the doctor kustomization
output.Info("Deploying diagnostic resources...")
applyArgs := []string{"apply", "-k", doctorDir}
if _, err := kubectl.Execute(cmd.Context(), applyArgs...); err != nil {
return fmt.Errorf("failed to apply diagnostic resources: %w", err)
}
output.Info(fmt.Sprintf("Monitoring job: %s (namespace: %s)", jobName, namespace))
if doctorFollow {
output.Info("Following logs in real-time (Ctrl+C to stop)...")
output.Info("────────────────────────────────────────────────────────────────")
// Wait a moment for the pod to be created
time.Sleep(5 * time.Second)
logsArgs := []string{"logs", "-f", "job/" + jobName, "-n", namespace}
_, _ = kubectl.Execute(cmd.Context(), logsArgs...)
} else {
// Wait for job completion
output.Info(fmt.Sprintf("Waiting for diagnostics to complete (timeout: %ds)...", doctorTimeout))
waitArgs := []string{"wait", "--for=condition=complete", "job/" + jobName, "-n", namespace, fmt.Sprintf("--timeout=%ds", doctorTimeout)}
if _, err := kubectl.Execute(cmd.Context(), waitArgs...); err != nil {
output.Warning(fmt.Sprintf("Job did not complete within %d seconds", doctorTimeout))
output.Info("Showing current logs:")
output.Info("────────────────────────────────────────────────────────────────")
logsArgs := []string{"logs", "job/" + jobName, "-n", namespace}
if logsOutput, err := kubectl.Execute(cmd.Context(), logsArgs...); err == nil {
output.Printf("%s\n", string(logsOutput))
} else {
output.Info("Could not retrieve logs")
}
return fmt.Errorf("diagnostic job did not complete within timeout")
}
// Show the results
output.Success("Diagnostics completed successfully!")
output.Info("Results:")
output.Info("────────────────────────────────────────────────────────────────")
logsArgs := []string{"logs", "job/" + jobName, "-n", namespace}
if logsOutput, err := kubectl.Execute(cmd.Context(), logsArgs...); err == nil {
output.Printf("%s\n", string(logsOutput))
}
}
output.Info("────────────────────────────────────────────────────────────────")
output.Success(fmt.Sprintf("Diagnostics for %s completed!", appName))
return nil
}
func listAvailableDoctors(appsDir string) error {
output.Info("Available doctors:")
entries, err := os.ReadDir(appsDir)
if err != nil {
return fmt.Errorf("failed to read apps directory: %w", err)
}
found := false
for _, entry := range entries {
if entry.IsDir() {
doctorDir := filepath.Join(appsDir, entry.Name(), "doctor")
if _, err := os.Stat(doctorDir); err == nil {
output.Info(fmt.Sprintf(" - %s", entry.Name()))
found = true
}
}
}
if !found {
output.Info(" (none found)")
}
return fmt.Errorf("app name required")
}
func extractJobInfo(ctx context.Context, kubectl *external.KubectlTool, doctorDir string) (string, string, error) {
// Run kubectl kustomize to get the rendered YAML
kustomizeArgs := []string{"kustomize", doctorDir}
output, err := kubectl.Execute(ctx, kustomizeArgs...)
if err != nil {
return "", "", fmt.Errorf("failed to run kubectl kustomize: %w", err)
}
yamlStr := string(output)
lines := strings.Split(yamlStr, "\n")
var namespace, jobName string
// Look for namespace
for _, line := range lines {
if strings.Contains(line, "namespace:") {
parts := strings.Fields(line)
if len(parts) >= 2 {
namespace = parts[1]
break
}
}
}
if namespace == "" {
namespace = "default"
}
// Look for job name
inJob := false
for _, line := range lines {
if strings.Contains(line, "kind: Job") {
inJob = true
continue
}
if inJob && strings.Contains(line, "name:") {
parts := strings.Fields(line)
if len(parts) >= 2 {
jobName = parts[1]
break
}
}
}
if jobName == "" {
return "", "", fmt.Errorf("could not find job name in kustomization")
}
return namespace, jobName, nil
}

View File

@@ -0,0 +1,168 @@
package app
import (
"fmt"
"io"
"os"
"path/filepath"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"
"github.com/wild-cloud/wild-cli/internal/environment"
"github.com/wild-cloud/wild-cli/internal/output"
)
var (
updateCache bool
)
func newFetchCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "fetch <name>",
Short: "Fetch an application template",
Long: `Fetch an app template from the Wild Cloud repository to cache.
This command copies an application template from WC_ROOT/apps to your
project's cache directory (.wildcloud/cache/apps) for configuration and deployment.
Examples:
wild app fetch postgres
wild app fetch immich
wild app fetch redis --update`,
Args: cobra.ExactArgs(1),
RunE: runFetch,
}
cmd.Flags().BoolVar(&updateCache, "update", false, "overwrite existing cached files without confirmation")
return cmd
}
func runFetch(cmd *cobra.Command, args []string) error {
appName := args[0]
output.Header("Fetching Application")
output.Info("App: " + appName)
// Initialize environment
env := environment.New()
if err := env.RequiresInstallation(); err != nil {
return err
}
if err := env.RequiresProject(); err != nil {
return err
}
// Check if source app exists
sourceAppDir := filepath.Join(env.WCRoot(), "apps", appName)
if _, err := os.Stat(sourceAppDir); os.IsNotExist(err) {
return fmt.Errorf("app '%s' not found at %s", appName, sourceAppDir)
}
// Read app manifest for info
manifestPath := filepath.Join(sourceAppDir, "manifest.yaml")
if manifestData, err := os.ReadFile(manifestPath); err == nil {
var manifest AppManifest
if err := yaml.Unmarshal(manifestData, &manifest); err == nil {
output.Info("Description: " + manifest.Description)
output.Info("Version: " + manifest.Version)
}
}
// Set up cache directory
cacheAppDir := filepath.Join(env.WCHome(), ".wildcloud", "cache", "apps", appName)
// Create cache directory structure
if err := os.MkdirAll(filepath.Join(env.WCHome(), ".wildcloud", "cache", "apps"), 0755); err != nil {
return fmt.Errorf("creating cache directory: %w", err)
}
// Check if already cached
if _, err := os.Stat(cacheAppDir); err == nil {
if updateCache {
output.Info("Updating cached app '" + appName + "'")
if err := os.RemoveAll(cacheAppDir); err != nil {
return fmt.Errorf("removing existing cache: %w", err)
}
} else {
output.Warning("Cache directory " + cacheAppDir + " already exists")
output.Printf("Do you want to overwrite it? (y/N): ")
var response string
if _, err := fmt.Scanln(&response); err != nil || (response != "y" && response != "Y") {
output.Info("Fetch cancelled")
return nil
}
if err := os.RemoveAll(cacheAppDir); err != nil {
return fmt.Errorf("removing existing cache: %w", err)
}
}
}
output.Info(fmt.Sprintf("Fetching app '%s' from %s to %s", appName, sourceAppDir, cacheAppDir))
// Copy the entire directory structure
if err := copyDirFetch(sourceAppDir, cacheAppDir); err != nil {
return fmt.Errorf("copying app directory: %w", err)
}
output.Success("Successfully fetched app '" + appName + "' to cache")
output.Info("")
output.Info("Next steps:")
output.Info(" wild app add " + appName + " # Add to project with configuration")
output.Info(" wild app deploy " + appName + " # Deploy to cluster")
return nil
}
// copyDirFetch recursively copies a directory from src to dst
func copyDirFetch(src, dst string) error {
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
// Calculate relative path
relPath, err := filepath.Rel(src, path)
if err != nil {
return err
}
// Calculate destination path
dstPath := filepath.Join(dst, relPath)
if info.IsDir() {
// Create directory
return os.MkdirAll(dstPath, info.Mode())
}
// Copy file
return copyFileFetch(path, dstPath)
})
}
// copyFileFetch copies a single file from src to dst
func copyFileFetch(src, dst string) error {
// Create destination directory if it doesn't exist
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
return err
}
// Open source file
srcFile, err := os.Open(src)
if err != nil {
return err
}
defer func() { _ = srcFile.Close() }()
// Create destination file
dstFile, err := os.Create(dst)
if err != nil {
return err
}
defer func() { _ = dstFile.Close() }()
// Copy file contents
_, err = io.Copy(dstFile, srcFile)
return err
}

View File

@@ -0,0 +1,319 @@
package app
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"
"github.com/wild-cloud/wild-cli/internal/environment"
"github.com/wild-cloud/wild-cli/internal/output"
)
// AppManifest represents the structure of manifest.yaml files
type AppManifest struct {
Name string `yaml:"name"`
Version string `yaml:"version"`
Description string `yaml:"description"`
Install bool `yaml:"install"`
Icon string `yaml:"icon"`
Requires []struct {
Name string `yaml:"name"`
} `yaml:"requires"`
}
// AppInfo represents an installable app with its status
type AppInfo struct {
Name string
Version string
Description string
Icon string
Requires []string
Installed bool
InstalledVersion string
}
var (
searchQuery string
verbose bool
outputFormat string
)
func newListCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Short: "List available applications",
Long: `List all available Wild Cloud apps with their metadata.
This command shows applications from the Wild Cloud installation directory.
Apps are read from WC_ROOT/apps and filtered to show only installable ones.
Examples:
wild app list
wild app list --search database
wild app list --verbose`,
RunE: runList,
}
cmd.Flags().StringVar(&searchQuery, "search", "", "search applications by name or description")
cmd.Flags().BoolVarP(&verbose, "verbose", "v", false, "show additional metadata (icon, requires)")
cmd.Flags().StringVar(&outputFormat, "format", "table", "output format: table, json, yaml")
return cmd
}
func runList(cmd *cobra.Command, args []string) error {
// Initialize environment
env := environment.New()
if err := env.RequiresInstallation(); err != nil {
return err
}
// Get apps directory from WC_ROOT
appsDir := filepath.Join(env.WCRoot(), "apps")
if _, err := os.Stat(appsDir); os.IsNotExist(err) {
return fmt.Errorf("apps directory not found at %s", appsDir)
}
// Get project apps directory if available
var projectAppsDir string
if env.WCHome() != "" {
projectAppsDir = filepath.Join(env.WCHome(), "apps")
}
// Read all installable apps
apps, err := getInstallableApps(appsDir, projectAppsDir)
if err != nil {
return fmt.Errorf("failed to read apps: %w", err)
}
// Filter by search query
if searchQuery != "" {
apps = filterApps(apps, searchQuery)
}
if len(apps) == 0 {
output.Warning("No applications found matching criteria")
return nil
}
// Display results based on format
switch outputFormat {
case "json":
return outputJSON(apps)
case "yaml":
return outputYAML(apps)
default:
return outputTable(apps, verbose)
}
}
// getInstallableApps reads apps from WC_ROOT/apps directory and checks installation status
func getInstallableApps(appsDir, projectAppsDir string) ([]AppInfo, error) {
var apps []AppInfo
entries, err := os.ReadDir(appsDir)
if err != nil {
return nil, fmt.Errorf("reading apps directory: %w", err)
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
appName := entry.Name()
appDir := filepath.Join(appsDir, appName)
manifestPath := filepath.Join(appDir, "manifest.yaml")
// Skip if no manifest.yaml
if _, err := os.Stat(manifestPath); os.IsNotExist(err) {
continue
}
// Parse manifest
manifestData, err := os.ReadFile(manifestPath)
if err != nil {
continue
}
var manifest AppManifest
if err := yaml.Unmarshal(manifestData, &manifest); err != nil {
continue
}
// Skip if not installable
if !manifest.Install {
continue
}
// Extract requires list
var requires []string
for _, req := range manifest.Requires {
requires = append(requires, req.Name)
}
// Check installation status
installed := false
installedVersion := ""
if projectAppsDir != "" {
projectManifestPath := filepath.Join(projectAppsDir, appName, "manifest.yaml")
if projectManifestData, err := os.ReadFile(projectManifestPath); err == nil {
var projectManifest AppManifest
if err := yaml.Unmarshal(projectManifestData, &projectManifest); err == nil {
installed = true
installedVersion = projectManifest.Version
}
}
}
app := AppInfo{
Name: manifest.Name,
Version: manifest.Version,
Description: manifest.Description,
Icon: manifest.Icon,
Requires: requires,
Installed: installed,
InstalledVersion: installedVersion,
}
apps = append(apps, app)
}
return apps, nil
}
// filterApps filters apps by search query (name or description)
func filterApps(apps []AppInfo, query string) []AppInfo {
query = strings.ToLower(query)
var filtered []AppInfo
for _, app := range apps {
if strings.Contains(strings.ToLower(app.Name), query) ||
strings.Contains(strings.ToLower(app.Description), query) {
filtered = append(filtered, app)
}
}
return filtered
}
// outputTable displays apps in table format
func outputTable(apps []AppInfo, verbose bool) error {
if verbose {
output.Header("Available Wild Cloud Apps (verbose)")
output.Printf("%-15s %-10s %-12s %-40s %-15s %s\n", "NAME", "VERSION", "INSTALLED", "DESCRIPTION", "REQUIRES", "ICON")
output.Printf("%-15s %-10s %-12s %-40s %-15s %s\n", "----", "-------", "---------", "-----------", "--------", "----")
} else {
output.Header("Available Wild Cloud Apps")
output.Printf("%-15s %-10s %-12s %s\n", "NAME", "VERSION", "INSTALLED", "DESCRIPTION")
output.Printf("%-15s %-10s %-12s %s\n", "----", "-------", "---------", "-----------")
}
for _, app := range apps {
installedStatus := "NO"
if app.Installed {
installedStatus = app.InstalledVersion
}
description := app.Description
if len(description) > 40 && !verbose {
description = description[:37] + "..."
}
if verbose {
requiresList := strings.Join(app.Requires, ",")
if len(requiresList) > 15 {
requiresList = requiresList[:12] + "..."
}
icon := app.Icon
if len(icon) > 30 {
icon = icon[:27] + "..."
}
output.Printf("%-15s %-10s %-12s %-40s %-15s %s\n", app.Name, app.Version, installedStatus, description, requiresList, icon)
} else {
output.Printf("%-15s %-10s %-12s %s\n", app.Name, app.Version, installedStatus, description)
}
}
output.Info("")
output.Info(fmt.Sprintf("Total installable apps: %d", len(apps)))
output.Info("")
output.Info("Usage:")
output.Info(" wild app fetch <app> # Fetch app template to project")
output.Info(" wild app deploy <app> # Deploy app to Kubernetes")
return nil
}
// outputJSON displays apps in JSON format
func outputJSON(apps []AppInfo) error {
output.Printf("{\n")
output.Printf(" \"apps\": [\n")
for i, app := range apps {
output.Printf(" {\n")
output.Printf(" \"name\": \"%s\",\n", app.Name)
output.Printf(" \"version\": \"%s\",\n", app.Version)
output.Printf(" \"description\": \"%s\",\n", app.Description)
output.Printf(" \"icon\": \"%s\",\n", app.Icon)
output.Printf(" \"requires\": [")
for j, req := range app.Requires {
output.Printf("\"%s\"", req)
if j < len(app.Requires)-1 {
output.Printf(", ")
}
}
output.Printf("],\n")
if app.Installed {
output.Printf(" \"installed\": \"%s\",\n", app.InstalledVersion)
} else {
output.Printf(" \"installed\": \"NO\",\n")
}
output.Printf(" \"installed_version\": \"%s\"\n", app.InstalledVersion)
output.Printf(" }")
if i < len(apps)-1 {
output.Printf(",")
}
output.Printf("\n")
}
output.Printf(" ],\n")
output.Printf(" \"total\": %d\n", len(apps))
output.Printf("}\n")
return nil
}
// outputYAML displays apps in YAML format
func outputYAML(apps []AppInfo) error {
output.Printf("apps:\n")
for _, app := range apps {
output.Printf("- name: %s\n", app.Name)
output.Printf(" version: %s\n", app.Version)
output.Printf(" description: %s\n", app.Description)
if app.Installed {
output.Printf(" installed: %s\n", app.InstalledVersion)
} else {
output.Printf(" installed: NO\n")
}
if app.InstalledVersion != "" {
output.Printf(" installed_version: %s\n", app.InstalledVersion)
}
if app.Icon != "" {
output.Printf(" icon: %s\n", app.Icon)
}
if len(app.Requires) > 0 {
output.Printf(" requires:\n")
for _, req := range app.Requires {
output.Printf(" - %s\n", req)
}
}
}
return nil
}

View File

@@ -0,0 +1,116 @@
package app
import (
"fmt"
"os"
"path/filepath"
"github.com/spf13/cobra"
"github.com/wild-cloud/wild-cli/internal/environment"
"github.com/wild-cloud/wild-cli/internal/external"
"github.com/wild-cloud/wild-cli/internal/output"
)
var (
restoreAll bool
)
func newRestoreCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "restore <name>",
Short: "Restore application data",
Long: `Restore application data from the configured backup storage.
This command restores application databases and persistent volume data using restic
and the existing backup infrastructure.
Examples:
wild app restore nextcloud
wild app restore --all`,
Args: cobra.MaximumNArgs(1),
RunE: runAppRestore,
}
cmd.Flags().BoolVar(&restoreAll, "all", false, "restore all applications")
return cmd
}
func runAppRestore(cmd *cobra.Command, args []string) error {
if !restoreAll && len(args) == 0 {
return fmt.Errorf("app name required or use --all flag")
}
var appName string
if len(args) > 0 {
appName = args[0]
}
if restoreAll {
output.Header("Restoring All Applications")
} else {
output.Header("Restoring Application")
output.Info("App: " + appName)
}
// Initialize environment
env := environment.New()
if err := env.RequiresProject(); err != nil {
return err
}
// For now, delegate to the existing bash script to maintain compatibility
wcRoot := env.WCRoot()
if wcRoot == "" {
return fmt.Errorf("WC_ROOT not set. Wild Cloud installation not found")
}
appRestoreScript := filepath.Join(wcRoot, "bin", "wild-app-restore")
if _, err := os.Stat(appRestoreScript); os.IsNotExist(err) {
return fmt.Errorf("app restore script not found at %s", appRestoreScript)
}
// Execute the app restore script
bashTool := external.NewBaseTool("bash", "bash")
// Set environment variables needed by the script
oldWCRoot := os.Getenv("WC_ROOT")
oldWCHome := os.Getenv("WC_HOME")
defer func() {
if oldWCRoot != "" {
_ = os.Setenv("WC_ROOT", oldWCRoot)
}
if oldWCHome != "" {
_ = os.Setenv("WC_HOME", oldWCHome)
}
}()
_ = os.Setenv("WC_ROOT", wcRoot)
_ = os.Setenv("WC_HOME", env.WCHome())
var scriptArgs []string
if restoreAll {
scriptArgs = []string{appRestoreScript, "--all"}
} else {
// Check if app exists in project
appDir := filepath.Join(env.AppsDir(), appName)
if _, err := os.Stat(appDir); os.IsNotExist(err) {
return fmt.Errorf("app '%s' not found in project. Run 'wild app add %s' first", appName, appName)
}
scriptArgs = []string{appRestoreScript, appName}
}
output.Info("Running application restore script...")
if _, err := bashTool.Execute(cmd.Context(), scriptArgs...); err != nil {
return fmt.Errorf("application restore failed: %w", err)
}
if restoreAll {
output.Success("All applications restored successfully")
} else {
output.Success(fmt.Sprintf("Application '%s' restored successfully", appName))
}
return nil
}

View File

@@ -0,0 +1,29 @@
package cluster
import (
"github.com/spf13/cobra"
)
// NewClusterCommand creates the cluster command and its subcommands
func NewClusterCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "cluster",
Short: "Manage Wild Cloud cluster",
Long: `Manage the Kubernetes cluster infrastructure.
This includes node management, configuration generation, and service deployment.`,
}
// Add subcommands
cmd.AddCommand(
newConfigCommand(),
newNodesCommand(),
newServicesCommand(),
)
return cmd
}
// newConfigCommand is implemented in config.go
// newNodesCommand is implemented in nodes.go
// newServicesCommand is implemented in services.go

View File

@@ -0,0 +1,161 @@
package cluster
import (
"fmt"
"os"
"path/filepath"
"github.com/spf13/cobra"
"github.com/wild-cloud/wild-cli/internal/config"
"github.com/wild-cloud/wild-cli/internal/environment"
"github.com/wild-cloud/wild-cli/internal/external"
"github.com/wild-cloud/wild-cli/internal/output"
)
func newConfigCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "config",
Short: "Manage cluster configuration",
Long: `Generate and manage cluster configuration files.`,
}
cmd.AddCommand(
newConfigGenerateCommand(),
)
return cmd
}
func newConfigGenerateCommand() *cobra.Command {
return &cobra.Command{
Use: "generate",
Short: "Generate cluster configuration",
Long: `Generate Talos configuration files for the cluster.
This command creates initial cluster secrets and configuration files using talosctl.
Examples:
wild cluster config generate`,
RunE: runConfigGenerate,
}
}
func runConfigGenerate(cmd *cobra.Command, args []string) error {
output.Header("Talos Cluster Configuration Generation")
// Initialize environment
env := environment.New()
if err := env.RequiresProject(); err != nil {
return err
}
// Check external tools
toolManager := external.NewManager()
if err := toolManager.CheckTools(cmd.Context(), []string{"talosctl"}); err != nil {
return fmt.Errorf("required tools not available: %w", err)
}
// Load configuration
configMgr := config.NewManager(env.ConfigPath(), env.SecretsPath())
// Ensure required directories exist
nodeSetupDir := filepath.Join(env.WildCloudDir(), "setup", "cluster-nodes")
generatedDir := filepath.Join(nodeSetupDir, "generated")
// Check if generated directory already exists and has content
if entries, err := os.ReadDir(generatedDir); err == nil && len(entries) > 0 {
output.Success("Cluster configuration already exists in " + generatedDir)
output.Info("Skipping cluster configuration generation")
return nil
}
if err := os.MkdirAll(generatedDir, 0755); err != nil {
return fmt.Errorf("creating generated directory: %w", err)
}
// Get required configuration values
clusterName, err := getRequiredConfig(configMgr, "cluster.name", "wild-cluster")
if err != nil {
return err
}
vip, err := getRequiredConfig(configMgr, "cluster.nodes.control.vip", "")
if err != nil {
return err
}
output.Info("Generating new cluster secrets...")
// Remove existing secrets directory if it exists
if _, err := os.Stat(generatedDir); err == nil {
output.Warning("Removing existing secrets directory...")
if err := os.RemoveAll(generatedDir); err != nil {
return fmt.Errorf("removing existing generated directory: %w", err)
}
}
if err := os.MkdirAll(generatedDir, 0755); err != nil {
return fmt.Errorf("creating generated directory: %w", err)
}
// Generate cluster configuration
output.Info("Generating initial cluster configuration...")
output.Info("Cluster name: " + clusterName)
output.Info("Control plane endpoint: https://" + vip + ":6443")
// Change to generated directory for talosctl operations
oldDir, err := os.Getwd()
if err != nil {
return fmt.Errorf("getting current directory: %w", err)
}
if err := os.Chdir(generatedDir); err != nil {
return fmt.Errorf("changing to generated directory: %w", err)
}
defer func() {
_ = os.Chdir(oldDir)
}()
talosctl := toolManager.Talosctl()
// Generate secrets first
if err := talosctl.GenerateSecrets(cmd.Context()); err != nil {
return fmt.Errorf("generating secrets: %w", err)
}
// Generate configuration with secrets
endpoint := "https://" + vip + ":6443"
if err := talosctl.GenerateConfigWithSecrets(cmd.Context(), clusterName, endpoint, "secrets.yaml"); err != nil {
return fmt.Errorf("generating config with secrets: %w", err)
}
output.Success("Cluster configuration generation completed!")
output.Info("Generated files in: " + generatedDir)
output.Info(" - controlplane.yaml # Control plane node configuration")
output.Info(" - worker.yaml # Worker node configuration")
output.Info(" - talosconfig # Talos client configuration")
output.Info(" - secrets.yaml # Cluster secrets")
return nil
}
// getRequiredConfig gets a required configuration value, prompting if not set
func getRequiredConfig(configMgr *config.Manager, path, defaultValue string) (string, error) {
value, err := configMgr.Get(path)
if err != nil || value == nil || value.(string) == "" {
if defaultValue != "" {
output.Warning(fmt.Sprintf("Config '%s' not set, using default: %s", path, defaultValue))
return defaultValue, nil
} else {
return "", fmt.Errorf("required configuration '%s' not set", path)
}
}
strValue, ok := value.(string)
if !ok {
return "", fmt.Errorf("configuration '%s' is not a string", path)
}
return strValue, nil
}

View File

@@ -0,0 +1,319 @@
package cluster
import (
"fmt"
"net/http"
"net/url"
"os"
"path/filepath"
"github.com/spf13/cobra"
"github.com/wild-cloud/wild-cli/internal/config"
"github.com/wild-cloud/wild-cli/internal/environment"
"github.com/wild-cloud/wild-cli/internal/external"
"github.com/wild-cloud/wild-cli/internal/output"
)
func newNodesCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "nodes",
Short: "Manage cluster nodes",
Long: `Manage Kubernetes cluster nodes.`,
}
cmd.AddCommand(
newNodesListCommand(),
newNodesBootCommand(),
)
return cmd
}
func newNodesListCommand() *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List cluster nodes",
Long: `List and show status of cluster nodes.
This command shows the status of both configured nodes and running cluster nodes.
Examples:
wild cluster nodes list`,
RunE: runNodesList,
}
}
func newNodesBootCommand() *cobra.Command {
return &cobra.Command{
Use: "boot",
Short: "Boot cluster nodes",
Long: `Boot and configure cluster nodes by downloading boot assets.
This command downloads Talos boot assets including kernel, initramfs, and ISO images
for PXE booting or USB creation.
Examples:
wild cluster nodes boot`,
RunE: runNodesBoot,
}
}
func runNodesList(cmd *cobra.Command, args []string) error {
output.Header("Cluster Nodes Status")
// Initialize environment
env := environment.New()
if err := env.RequiresProject(); err != nil {
return err
}
// Load configuration
configMgr := config.NewManager(env.ConfigPath(), env.SecretsPath())
// Show configured nodes
output.Info("=== Configured Nodes ===")
nodesConfig, err := configMgr.Get("cluster.nodes")
if err != nil || nodesConfig == nil {
output.Warning("No nodes configured")
output.Info("Add nodes to config: wild config set cluster.nodes '[{\"ip\": \"192.168.1.10\", \"role\": \"controlplane\"}]'")
} else {
nodes, ok := nodesConfig.([]interface{})
if !ok || len(nodes) == 0 {
output.Warning("No nodes configured")
} else {
for i, nodeConfig := range nodes {
nodeMap, ok := nodeConfig.(map[string]interface{})
if !ok {
output.Warning(fmt.Sprintf("Invalid node %d configuration", i))
continue
}
nodeIP := nodeMap["ip"]
nodeRole := nodeMap["role"]
if nodeRole == nil {
nodeRole = "worker"
}
output.Info(fmt.Sprintf(" Node %d: %v (%v)", i+1, nodeIP, nodeRole))
}
}
}
// Try to show running cluster nodes if kubectl is available
toolManager := external.NewManager()
if err := toolManager.CheckTools(cmd.Context(), []string{"kubectl"}); err == nil {
kubectl := toolManager.Kubectl()
if nodesOutput, err := kubectl.GetNodes(cmd.Context()); err == nil {
output.Info("\n=== Running Cluster Nodes ===")
output.Info(string(nodesOutput))
} else {
output.Info("\n=== Running Cluster Nodes ===")
output.Warning("Could not connect to cluster: " + err.Error())
}
} else {
output.Info("\n=== Running Cluster Nodes ===")
output.Warning("kubectl not available - cannot show running cluster status")
}
return nil
}
func runNodesBoot(cmd *cobra.Command, args []string) error {
output.Header("Talos Installer Image Generation and Asset Download")
// Initialize environment
env := environment.New()
if err := env.RequiresProject(); err != nil {
return err
}
// Load configuration
configMgr := config.NewManager(env.ConfigPath(), env.SecretsPath())
// Check for required configuration
talosVersion, err := configMgr.Get("cluster.nodes.talos.version")
if err != nil || talosVersion == nil {
return fmt.Errorf("missing required configuration: cluster.nodes.talos.version")
}
schematicID, err := configMgr.Get("cluster.nodes.talos.schematicId")
if err != nil || schematicID == nil {
return fmt.Errorf("missing required configuration: cluster.nodes.talos.schematicId")
}
talosVersionStr := talosVersion.(string)
schematicIDStr := schematicID.(string)
if talosVersionStr == "" || schematicIDStr == "" {
return fmt.Errorf("talos version and schematic ID cannot be empty")
}
output.Info("Creating custom Talos installer image...")
output.Info("Talos version: " + talosVersionStr)
output.Info("Schematic ID: " + schematicIDStr)
// Show schematic extensions if available
if extensions, err := configMgr.Get("cluster.nodes.talos.schematic.customization.systemExtensions.officialExtensions"); err == nil && extensions != nil {
if extList, ok := extensions.([]interface{}); ok && len(extList) > 0 {
output.Info("\nSchematic includes:")
for _, ext := range extList {
output.Info(" - " + fmt.Sprintf("%v", ext))
}
output.Info("")
}
}
// Generate installer image URL
installerURL := fmt.Sprintf("factory.talos.dev/metal-installer/%s:%s", schematicIDStr, talosVersionStr)
output.Success("Custom installer image URL generated!")
output.Info("")
output.Info("Installer URL: " + installerURL)
// Download and cache assets
output.Header("Downloading and Caching PXE Boot Assets")
// Create cache directories organized by schematic ID
cacheDir := filepath.Join(env.WildCloudDir())
schematicCacheDir := filepath.Join(cacheDir, "node-boot-assets", schematicIDStr)
pxeCacheDir := filepath.Join(schematicCacheDir, "pxe")
ipxeCacheDir := filepath.Join(schematicCacheDir, "ipxe")
isoCacheDir := filepath.Join(schematicCacheDir, "iso")
if err := os.MkdirAll(filepath.Join(pxeCacheDir, "amd64"), 0755); err != nil {
return fmt.Errorf("creating cache directories: %w", err)
}
if err := os.MkdirAll(ipxeCacheDir, 0755); err != nil {
return fmt.Errorf("creating cache directories: %w", err)
}
if err := os.MkdirAll(isoCacheDir, 0755); err != nil {
return fmt.Errorf("creating cache directories: %w", err)
}
// Download Talos kernel and initramfs for PXE boot
output.Info("Downloading Talos PXE assets...")
kernelURL := fmt.Sprintf("https://pxe.factory.talos.dev/image/%s/%s/kernel-amd64", schematicIDStr, talosVersionStr)
initramfsURL := fmt.Sprintf("https://pxe.factory.talos.dev/image/%s/%s/initramfs-amd64.xz", schematicIDStr, talosVersionStr)
kernelPath := filepath.Join(pxeCacheDir, "amd64", "vmlinuz")
initramfsPath := filepath.Join(pxeCacheDir, "amd64", "initramfs.xz")
// Download assets
if err := downloadAsset(kernelURL, kernelPath, "Talos kernel"); err != nil {
return fmt.Errorf("downloading kernel: %w", err)
}
if err := downloadAsset(initramfsURL, initramfsPath, "Talos initramfs"); err != nil {
return fmt.Errorf("downloading initramfs: %w", err)
}
// Download iPXE bootloader files
output.Info("Downloading iPXE bootloader assets...")
ipxeAssets := map[string]string{
"http://boot.ipxe.org/ipxe.efi": filepath.Join(ipxeCacheDir, "ipxe.efi"),
"http://boot.ipxe.org/undionly.kpxe": filepath.Join(ipxeCacheDir, "undionly.kpxe"),
"http://boot.ipxe.org/arm64-efi/ipxe.efi": filepath.Join(ipxeCacheDir, "ipxe-arm64.efi"),
}
for downloadURL, path := range ipxeAssets {
description := fmt.Sprintf("iPXE %s", filepath.Base(path))
if err := downloadAsset(downloadURL, path, description); err != nil {
output.Warning(fmt.Sprintf("Failed to download %s: %v", description, err))
}
}
// Download Talos ISO
output.Info("Downloading Talos ISO...")
isoURL := fmt.Sprintf("https://factory.talos.dev/image/%s/%s/metal-amd64.iso", schematicIDStr, talosVersionStr)
isoFilename := fmt.Sprintf("talos-%s-metal-amd64.iso", talosVersionStr)
isoPath := filepath.Join(isoCacheDir, isoFilename)
if err := downloadAsset(isoURL, isoPath, "Talos ISO"); err != nil {
return fmt.Errorf("downloading ISO: %w", err)
}
output.Success("All assets downloaded and cached!")
output.Info("")
output.Info(fmt.Sprintf("Cached assets for schematic %s:", schematicIDStr))
output.Info(fmt.Sprintf(" Talos kernel: %s", kernelPath))
output.Info(fmt.Sprintf(" Talos initramfs: %s", initramfsPath))
output.Info(fmt.Sprintf(" Talos ISO: %s", isoPath))
output.Info(fmt.Sprintf(" iPXE EFI: %s", filepath.Join(ipxeCacheDir, "ipxe.efi")))
output.Info(fmt.Sprintf(" iPXE BIOS: %s", filepath.Join(ipxeCacheDir, "undionly.kpxe")))
output.Info(fmt.Sprintf(" iPXE ARM64: %s", filepath.Join(ipxeCacheDir, "ipxe-arm64.efi")))
output.Info("")
output.Info(fmt.Sprintf("Cache location: %s", schematicCacheDir))
output.Info("")
output.Info("Use these assets for:")
output.Info(" - PXE boot: Use kernel and initramfs from cache")
output.Info(" - USB creation: Use ISO file for dd or imaging tools")
output.Info(fmt.Sprintf(" Example: sudo dd if=%s of=/dev/sdX bs=4M status=progress", isoPath))
output.Info(fmt.Sprintf(" - Custom installer: https://%s", installerURL))
output.Success("Installer image generation and asset caching completed!")
return nil
}
// downloadAsset downloads a file with progress indication
func downloadAsset(downloadURL, path, description string) error {
// Check if file already exists
if _, err := os.Stat(path); err == nil {
output.Info(fmt.Sprintf("%s already cached at %s", description, path))
return nil
}
output.Info(fmt.Sprintf("Downloading %s...", description))
output.Info(fmt.Sprintf("URL: %s", downloadURL))
// Parse URL to validate
parsedURL, err := url.Parse(downloadURL)
if err != nil {
return fmt.Errorf("invalid URL: %w", err)
}
// Create HTTP client
client := &http.Client{}
req, err := http.NewRequest("GET", parsedURL.String(), nil)
if err != nil {
return fmt.Errorf("creating request: %w", err)
}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("downloading: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("download failed with status: %s", resp.Status)
}
// Create destination file
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return fmt.Errorf("creating directory: %w", err)
}
file, err := os.Create(path)
if err != nil {
return fmt.Errorf("creating file: %w", err)
}
defer func() {
_ = file.Close()
}()
// Copy data
if _, err := file.ReadFrom(resp.Body); err != nil {
return fmt.Errorf("writing file: %w", err)
}
// Verify download
if stat, err := os.Stat(path); err != nil || stat.Size() == 0 {
_ = os.Remove(path)
return fmt.Errorf("download failed or file is empty")
}
output.Success(fmt.Sprintf("%s downloaded successfully", description))
return nil
}

View File

@@ -0,0 +1,585 @@
package cluster
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/spf13/cobra"
"github.com/wild-cloud/wild-cli/internal/config"
"github.com/wild-cloud/wild-cli/internal/environment"
"github.com/wild-cloud/wild-cli/internal/external"
"github.com/wild-cloud/wild-cli/internal/output"
)
var (
servicesSkipInstall bool
)
func newServicesCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "services",
Short: "Manage cluster services",
Long: `Deploy and manage essential cluster services.
This command provides cluster service management including generation and deployment.
Examples:
wild cluster services deploy
wild cluster services deploy --skip-install`,
}
cmd.AddCommand(
newServicesDeployCommand(),
)
return cmd
}
func newServicesDeployCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "deploy",
Short: "Deploy cluster services",
Long: `Deploy essential cluster services like ingress, DNS, and monitoring.
This generates service configurations and installs core Kubernetes services
including MetalLB, Traefik, cert-manager, and others.
Examples:
wild cluster services deploy
wild cluster services deploy --skip-install`,
RunE: runServicesDeploy,
}
cmd.Flags().BoolVar(&servicesSkipInstall, "skip-install", false, "generate service configs but skip installation")
return cmd
}
func runServicesDeploy(cmd *cobra.Command, args []string) error {
output.Header("Cluster Services Deployment")
// Initialize environment
env := environment.New()
if err := env.RequiresProject(); err != nil {
return err
}
// Check external tools
toolManager := external.NewManager()
if err := toolManager.CheckTools(cmd.Context(), []string{"kubectl"}); err != nil {
return fmt.Errorf("required tools not available: %w", err)
}
// Load configuration
configMgr := config.NewManager(env.ConfigPath(), env.SecretsPath())
// Check cluster configuration
clusterName, err := getRequiredConfig(configMgr, "cluster.name", "")
if err != nil {
return fmt.Errorf("cluster configuration is missing: %w", err)
}
output.Info("Cluster: " + clusterName)
// Check kubectl connectivity
kubectl := toolManager.Kubectl()
if err := checkKubectlConnectivity(cmd.Context(), kubectl); err != nil {
return fmt.Errorf("kubectl is not configured or cluster is not accessible: %w", err)
}
output.Success("Cluster is accessible")
// Phase 1: Generate cluster services setup files
output.Info("\n=== Phase 1: Generating Service Configurations ===")
if err := generateClusterServices(cmd.Context(), env, configMgr); err != nil {
return fmt.Errorf("generating service configurations: %w", err)
}
// Phase 2: Install cluster services
if !servicesSkipInstall {
output.Info("\n=== Phase 2: Installing Cluster Services ===")
if err := installClusterServices(cmd.Context(), env, kubectl); err != nil {
return fmt.Errorf("installing cluster services: %w", err)
}
} else {
output.Info("Skipping cluster services installation (--skip-install specified)")
output.Info("You can install them later with: wild cluster services deploy")
}
// Summary output
output.Success("Cluster Services Deployment Complete!")
output.Info("")
if !servicesSkipInstall {
// Get internal domain for next steps
internalDomain, err := configMgr.Get("cloud.internalDomain")
domain := "your-internal-domain"
if err == nil && internalDomain != nil {
if domainStr, ok := internalDomain.(string); ok {
domain = domainStr
}
}
output.Info("Next steps:")
output.Info(" 1. Access the dashboard at: https://dashboard." + domain)
output.Info(" 2. Get the dashboard token with: wild dashboard token")
output.Info("")
output.Info("To verify components, run:")
output.Info(" - kubectl get pods -n cert-manager")
output.Info(" - kubectl get pods -n externaldns")
output.Info(" - kubectl get pods -n kubernetes-dashboard")
output.Info(" - kubectl get clusterissuers")
} else {
output.Info("Next steps:")
output.Info(" 1. Ensure your cluster is running and kubectl is configured")
output.Info(" 2. Install services with: wild cluster services deploy")
output.Info(" 3. Verify components are running correctly")
}
return nil
}
// checkKubectlConnectivity checks if kubectl can connect to the cluster
func checkKubectlConnectivity(ctx context.Context, kubectl *external.KubectlTool) error {
// Try to get cluster info
_, err := kubectl.Execute(ctx, "cluster-info")
if err != nil {
return fmt.Errorf("cluster not accessible: %w", err)
}
return nil
}
// generateClusterServices generates cluster service configurations
func generateClusterServices(ctx context.Context, env *environment.Environment, configMgr *config.Manager) error {
// This function replicates wild-cluster-services-generate functionality
output.Info("Generating cluster services setup files...")
wcRoot := env.WCRoot()
if wcRoot == "" {
return fmt.Errorf("WC_ROOT not set")
}
sourceDir := filepath.Join(wcRoot, "setup", "cluster-services")
destDir := filepath.Join(env.WildCloudDir(), "setup", "cluster-services")
// Check if source directory exists
if _, err := os.Stat(sourceDir); os.IsNotExist(err) {
return fmt.Errorf("cluster setup source directory not found: %s", sourceDir)
}
// Force regeneration, removing existing files
if _, err := os.Stat(destDir); err == nil {
output.Info("Force regeneration enabled, removing existing files...")
if err := os.RemoveAll(destDir); err != nil {
return fmt.Errorf("removing existing setup directory: %w", err)
}
}
// Create destination directory
setupBaseDir := filepath.Join(env.WildCloudDir(), "setup")
if err := os.MkdirAll(setupBaseDir, 0755); err != nil {
return fmt.Errorf("creating setup directory: %w", err)
}
// Copy README if it doesn't exist
readmePath := filepath.Join(setupBaseDir, "README.md")
if _, err := os.Stat(readmePath); os.IsNotExist(err) {
sourceReadme := filepath.Join(wcRoot, "setup", "README.md")
if _, err := os.Stat(sourceReadme); err == nil {
if err := copyFile(sourceReadme, readmePath); err != nil {
output.Warning("Failed to copy README.md: " + err.Error())
}
}
}
// Create destination directory
if err := os.MkdirAll(destDir, 0755); err != nil {
return fmt.Errorf("creating destination directory: %w", err)
}
// Copy and compile cluster setup files
output.Info("Copying and compiling cluster setup files from repository...")
// First, copy root-level files from setup/cluster-services/
if err := copyRootServiceFiles(sourceDir, destDir); err != nil {
return fmt.Errorf("copying root service files: %w", err)
}
// Then, process each service directory
if err := processServiceDirectories(sourceDir, destDir, configMgr); err != nil {
return fmt.Errorf("processing service directories: %w", err)
}
// Verify required configuration
if err := verifyServiceConfiguration(configMgr); err != nil {
output.Warning("Configuration verification warnings: " + err.Error())
}
output.Success("Cluster setup files copied and compiled")
output.Info("Generated setup directory: " + destDir)
// List available services
services, err := getAvailableServices(destDir)
if err != nil {
return fmt.Errorf("listing available services: %w", err)
}
output.Info("Available services:")
for _, service := range services {
output.Info(" - " + service)
}
return nil
}
// installClusterServices installs the cluster services
func installClusterServices(ctx context.Context, env *environment.Environment, kubectl *external.KubectlTool) error {
setupDir := filepath.Join(env.WildCloudDir(), "setup", "cluster-services")
// Check if cluster setup directory exists
if _, err := os.Stat(setupDir); os.IsNotExist(err) {
return fmt.Errorf("cluster services setup directory not found: %s", setupDir)
}
output.Info("Installing cluster services...")
// Install services in dependency order
servicesToInstall := []string{
"metallb",
"longhorn",
"traefik",
"coredns",
"cert-manager",
"externaldns",
"kubernetes-dashboard",
"nfs",
"docker-registry",
}
// Filter to only include services that actually exist
existingServices := []string{}
for _, service := range servicesToInstall {
installScript := filepath.Join(setupDir, service, "install.sh")
if _, err := os.Stat(installScript); err == nil {
existingServices = append(existingServices, service)
}
}
if len(existingServices) == 0 {
return fmt.Errorf("no installable services found")
}
output.Info(fmt.Sprintf("Services to install: %v", existingServices))
// Install services
installedCount := 0
failedCount := 0
for _, service := range existingServices {
output.Info(fmt.Sprintf("\n--- Installing %s ---", service))
installScript := filepath.Join(setupDir, service, "install.sh")
if err := runServiceInstaller(ctx, setupDir, service, installScript); err != nil {
output.Error(fmt.Sprintf("%s installation failed: %v", service, err))
failedCount++
} else {
output.Success(fmt.Sprintf("%s installed successfully", service))
installedCount++
}
}
// Summary
output.Info("\nInstallation Summary:")
output.Success(fmt.Sprintf("Successfully installed: %d services", installedCount))
if failedCount > 0 {
output.Warning(fmt.Sprintf("Failed to install: %d services", failedCount))
}
if failedCount == 0 {
output.Success("All cluster services installed successfully!")
} else {
return fmt.Errorf("some services failed to install")
}
return nil
}
// Helper functions (reused from setup/services.go with minimal modifications)
// copyRootServiceFiles copies root-level files from source to destination
func copyRootServiceFiles(sourceDir, destDir string) error {
entries, err := os.ReadDir(sourceDir)
if err != nil {
return err
}
for _, entry := range entries {
if !entry.IsDir() {
srcPath := filepath.Join(sourceDir, entry.Name())
dstPath := filepath.Join(destDir, entry.Name())
output.Info(" Copying: " + entry.Name())
if err := copyFile(srcPath, dstPath); err != nil {
return err
}
}
}
return nil
}
// processServiceDirectories processes each service directory
func processServiceDirectories(sourceDir, destDir string, configMgr *config.Manager) error {
entries, err := os.ReadDir(sourceDir)
if err != nil {
return err
}
// Create template engine
engine, err := config.NewTemplateEngine(configMgr)
if err != nil {
return fmt.Errorf("creating template engine: %w", err)
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
serviceName := entry.Name()
serviceDir := filepath.Join(sourceDir, serviceName)
destServiceDir := filepath.Join(destDir, serviceName)
output.Info("Processing service: " + serviceName)
// Create destination service directory
if err := os.MkdirAll(destServiceDir, 0755); err != nil {
return err
}
// Process service files
if err := processServiceFiles(serviceDir, destServiceDir, engine); err != nil {
return fmt.Errorf("processing service %s: %w", serviceName, err)
}
}
return nil
}
// processServiceFiles processes files in a service directory
func processServiceFiles(serviceDir, destServiceDir string, engine *config.TemplateEngine) error {
entries, err := os.ReadDir(serviceDir)
if err != nil {
return err
}
for _, entry := range entries {
srcPath := filepath.Join(serviceDir, entry.Name())
dstPath := filepath.Join(destServiceDir, entry.Name())
if entry.Name() == "kustomize.template" {
// Compile kustomize.template to kustomize directory
if entry.IsDir() {
output.Info(" Compiling kustomize templates")
kustomizeDir := filepath.Join(destServiceDir, "kustomize")
if err := processTemplateDirectory(srcPath, kustomizeDir, engine); err != nil {
return err
}
}
} else if entry.IsDir() {
// Copy other directories recursively
if err := copyDir(srcPath, dstPath); err != nil {
return err
}
} else {
// Process individual files
if err := processServiceFile(srcPath, dstPath, engine); err != nil {
return err
}
}
}
return nil
}
// processServiceFile processes a single service file
func processServiceFile(srcPath, dstPath string, engine *config.TemplateEngine) error {
content, err := os.ReadFile(srcPath)
if err != nil {
return err
}
// Check if file contains template syntax
if strings.Contains(string(content), "{{") {
output.Info(" Compiling: " + filepath.Base(srcPath))
processed, err := engine.Process(string(content))
if err != nil {
return fmt.Errorf("processing template: %w", err)
}
return os.WriteFile(dstPath, []byte(processed), 0644)
} else {
return copyFile(srcPath, dstPath)
}
}
// processTemplateDirectory processes an entire template directory
func processTemplateDirectory(srcDir, dstDir string, engine *config.TemplateEngine) error {
if err := os.RemoveAll(dstDir); err != nil && !os.IsNotExist(err) {
return err
}
return filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
relPath, err := filepath.Rel(srcDir, path)
if err != nil {
return err
}
dstPath := filepath.Join(dstDir, relPath)
if info.IsDir() {
return os.MkdirAll(dstPath, info.Mode())
}
content, err := os.ReadFile(path)
if err != nil {
return err
}
// Process template content
if strings.Contains(string(content), "{{") {
processed, err := engine.Process(string(content))
if err != nil {
return fmt.Errorf("processing template %s: %w", relPath, err)
}
content = []byte(processed)
}
// Create parent directory
if err := os.MkdirAll(filepath.Dir(dstPath), 0755); err != nil {
return err
}
return os.WriteFile(dstPath, content, info.Mode())
})
}
// verifyServiceConfiguration verifies required configuration
func verifyServiceConfiguration(configMgr *config.Manager) error {
missingConfig := []string{}
// Check essential configuration values
requiredConfigs := []string{
"cluster.name",
"cloud.domain",
"cluster.ipAddressPool",
"operator.email",
}
for _, configPath := range requiredConfigs {
if value, err := configMgr.Get(configPath); err != nil || value == nil {
missingConfig = append(missingConfig, configPath)
}
}
if len(missingConfig) > 0 {
return fmt.Errorf("missing required configuration values: %v", missingConfig)
}
return nil
}
// getAvailableServices returns list of available services
func getAvailableServices(setupDir string) ([]string, error) {
var services []string
entries, err := os.ReadDir(setupDir)
if err != nil {
return nil, err
}
for _, entry := range entries {
if entry.IsDir() {
installScript := filepath.Join(setupDir, entry.Name(), "install.sh")
if _, err := os.Stat(installScript); err == nil {
services = append(services, entry.Name())
}
}
}
return services, nil
}
// runServiceInstaller runs a service installation script
func runServiceInstaller(ctx context.Context, setupDir, serviceName, installScript string) error {
// Change to the service directory and run install.sh
serviceDir := filepath.Join(setupDir, serviceName)
// Execute the install script using bash
bashTool := external.NewBaseTool("bash", "bash")
// Change to the service directory by setting working directory in the execution context
oldDir, err := os.Getwd()
if err != nil {
return fmt.Errorf("getting current directory: %w", err)
}
if err := os.Chdir(serviceDir); err != nil {
return fmt.Errorf("changing to service directory: %w", err)
}
defer func() {
_ = os.Chdir(oldDir)
}()
_, err = bashTool.Execute(ctx, "install.sh")
if err != nil {
return fmt.Errorf("install script failed: %w", err)
}
return nil
}
// copyFile copies a single file
func copyFile(src, dst string) error {
data, err := os.ReadFile(src)
if err != nil {
return err
}
return os.WriteFile(dst, data, 0644)
}
// copyDir recursively copies a directory
func copyDir(src, dst string) error {
entries, err := os.ReadDir(src)
if err != nil {
return err
}
if err := os.MkdirAll(dst, 0755); err != nil {
return err
}
for _, entry := range entries {
srcPath := filepath.Join(src, entry.Name())
dstPath := filepath.Join(dst, entry.Name())
if entry.IsDir() {
if err := copyDir(srcPath, dstPath); err != nil {
return err
}
} else {
if err := copyFile(srcPath, dstPath); err != nil {
return err
}
}
}
return nil
}

View File

@@ -0,0 +1,29 @@
package config
import (
"github.com/spf13/cobra"
)
// NewConfigCommand creates the config command and its subcommands
func NewConfigCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "config",
Short: "Manage Wild Cloud configuration",
Long: `Manage Wild Cloud configuration stored in config.yaml.
Configuration values are stored as YAML and can be accessed using dot-notation paths.
Examples:
wild config get cluster.name
wild config set cluster.domain example.com
wild config get apps.myapp.replicas`,
}
// Add subcommands
cmd.AddCommand(
newGetCommand(),
newSetCommand(),
)
return cmd
}

View File

@@ -0,0 +1,86 @@
package config
import (
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/wild-cloud/wild-cli/internal/config"
"github.com/wild-cloud/wild-cli/internal/environment"
)
var checkMode bool
func newGetCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "get <path>",
Short: "Get a configuration value",
Long: `Get a configuration value from config.yaml using a dot-notation path.
Examples:
wild config get cluster.name
wild config get apps.myapp.replicas
wild config get services[0].name`,
Args: cobra.ExactArgs(1),
RunE: runGet,
}
cmd.Flags().BoolVar(&checkMode, "check", false, "exit 1 if key doesn't exist (no output)")
return cmd
}
func runGet(cmd *cobra.Command, args []string) error {
path := args[0]
// Initialize environment
env := environment.New()
// Try to detect WC_HOME from current directory or flags
if wcHome := cmd.Flag("wc-home").Value.String(); wcHome != "" {
env.SetWCHome(wcHome)
} else {
detected, err := env.DetectWCHome()
if err != nil {
return fmt.Errorf("failed to detect Wild Cloud project directory: %w", err)
}
if detected == "" {
return fmt.Errorf("this command requires a Wild Cloud project directory. Run 'wild setup scaffold' to create one, or run from within an existing project")
}
env.SetWCHome(detected)
}
if err := env.RequiresProject(); err != nil {
return err
}
// Create config manager
mgr := config.NewManager(env.ConfigPath(), env.SecretsPath())
// Get the value
value, err := mgr.Get(path)
if err != nil {
if checkMode {
os.Exit(1)
}
return fmt.Errorf("getting config value: %w", err)
}
// Handle null/missing values
if value == nil {
if checkMode {
os.Exit(1)
}
return fmt.Errorf("key path '%s' not found in config file", path)
}
// In check mode, exit 0 if key exists (don't output value)
if checkMode {
return nil
}
// Output the value
fmt.Println(value)
return nil
}

View File

@@ -0,0 +1,53 @@
package config
import (
"fmt"
"github.com/spf13/cobra"
"github.com/wild-cloud/wild-cli/internal/config"
"github.com/wild-cloud/wild-cli/internal/environment"
"github.com/wild-cloud/wild-cli/internal/output"
)
func newSetCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "set <path> <value>",
Short: "Set a configuration value",
Long: `Set a configuration value in config.yaml using a dot-notation path.
The value will be parsed as YAML, so you can set strings, numbers, booleans, or complex objects.
Examples:
wild config set cluster.name my-cluster
wild config set cluster.replicas 3
wild config set cluster.enabled true
wild config set apps.myapp.image nginx:latest`,
Args: cobra.ExactArgs(2),
RunE: runSet,
}
return cmd
}
func runSet(cmd *cobra.Command, args []string) error {
path := args[0]
value := args[1]
// Initialize environment
env := environment.New()
if err := env.RequiresProject(); err != nil {
return err
}
// Create config manager
mgr := config.NewManager(env.ConfigPath(), env.SecretsPath())
// Set the value
if err := mgr.Set(path, value); err != nil {
return fmt.Errorf("setting config value: %w", err)
}
output.Success(fmt.Sprintf("Set %s = %s", path, value))
return nil
}

38
wild-cli/cmd/wild/main.go Normal file
View File

@@ -0,0 +1,38 @@
package main
import (
"context"
"os"
"os/signal"
"syscall"
"github.com/wild-cloud/wild-cli/internal/output"
)
func main() {
// Set up context with cancellation for graceful shutdown
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// Handle interrupt signals gracefully
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigChan
cancel()
os.Exit(1)
}()
// Initialize output logger
logger := output.NewLogger()
defer func() {
_ = logger.Sync() // Ignore sync errors on program exit
}()
// Execute root command
cmd := newRootCommand()
if err := cmd.ExecuteContext(ctx); err != nil {
logger.Error("Command execution failed", "error", err)
os.Exit(1)
}
}

146
wild-cli/cmd/wild/root.go Normal file
View File

@@ -0,0 +1,146 @@
package main
import (
"context"
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/wild-cloud/wild-cli/cmd/wild/app"
"github.com/wild-cloud/wild-cli/cmd/wild/cluster"
"github.com/wild-cloud/wild-cli/cmd/wild/config"
"github.com/wild-cloud/wild-cli/cmd/wild/secret"
"github.com/wild-cloud/wild-cli/cmd/wild/setup"
"github.com/wild-cloud/wild-cli/cmd/wild/util"
"github.com/wild-cloud/wild-cli/internal/environment"
"github.com/wild-cloud/wild-cli/internal/output"
)
var (
// Global flags
cfgDir string
verbose bool
dryRun bool
noColor bool
wcRoot string
wcHome string
)
func newRootCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "wild",
Short: "Wild Cloud - Personal cloud infrastructure management",
Long: `Wild Cloud CLI provides comprehensive management of your personal cloud infrastructure
built on Talos Linux and Kubernetes.
This tool replaces the collection of wild-* bash scripts with a single, unified CLI
that provides better error handling, progress tracking, and cross-platform support.`,
Version: "0.1.0-dev",
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
return initializeConfig(cmd.Context())
},
SilenceUsage: true,
SilenceErrors: true,
}
// Add persistent flags
pflags := cmd.PersistentFlags()
pflags.StringVar(&cfgDir, "config-dir", "", "config directory (default: current directory)")
pflags.BoolVarP(&verbose, "verbose", "v", false, "enable verbose logging")
pflags.BoolVar(&dryRun, "dry-run", false, "show what would be done without making changes")
pflags.BoolVar(&noColor, "no-color", false, "disable colored output")
pflags.StringVar(&wcRoot, "wc-root", "", "Wild Cloud installation directory")
pflags.StringVar(&wcHome, "wc-home", "", "Wild Cloud project directory")
// Bind flags to viper
_ = viper.BindPFlag("verbose", pflags.Lookup("verbose"))
_ = viper.BindPFlag("dry-run", pflags.Lookup("dry-run"))
_ = viper.BindPFlag("no-color", pflags.Lookup("no-color"))
// Add subcommands
cmd.AddCommand(
setup.NewSetupCommand(),
app.NewAppCommand(),
cluster.NewClusterCommand(),
config.NewConfigCommand(),
secret.NewSecretCommand(),
util.NewBackupCommand(),
util.NewDashboardCommand(),
util.NewTemplateCommand(),
util.NewStatusCommand(),
util.NewVersionCommand(),
)
return cmd
}
func initializeConfig(ctx context.Context) error {
// Set up output formatting based on flags
if noColor {
output.DisableColor()
}
if verbose {
output.SetVerbose(true)
}
// Initialize environment
env := environment.New()
// Set WC_ROOT
if wcRoot != "" {
env.SetWCRoot(wcRoot)
} else if envRoot := os.Getenv("WC_ROOT"); envRoot != "" {
env.SetWCRoot(envRoot)
}
// Detect or set WC_HOME
if wcHome != "" {
env.SetWCHome(wcHome)
} else if cfgDir != "" {
env.SetWCHome(cfgDir)
} else {
// Try to auto-detect WC_HOME by looking for .wildcloud marker
detected, err := env.DetectWCHome()
if err != nil {
return fmt.Errorf("failed to detect Wild Cloud project directory: %w", err)
}
if detected == "" {
// Only require WC_HOME for commands that need it
// Some commands like "wild setup scaffold" don't need an existing project
return nil
}
env.SetWCHome(detected)
}
// Validate environment
if err := env.Validate(ctx); err != nil {
return fmt.Errorf("environment validation failed: %w", err)
}
// Set up viper configuration
if env.WCHome() != "" {
viper.AddConfigPath(env.WCHome())
viper.SetConfigName("config")
viper.SetConfigType("yaml")
// Try to read config file (not required)
if err := viper.ReadInConfig(); err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
return fmt.Errorf("error reading config file: %w", err)
}
}
}
// Set environment variables for child processes and internal use
if env.WCRoot() != "" {
_ = os.Setenv("WC_ROOT", env.WCRoot())
}
if env.WCHome() != "" {
_ = os.Setenv("WC_HOME", env.WCHome())
}
return nil
}

View File

@@ -0,0 +1,74 @@
package secret
import (
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/wild-cloud/wild-cli/internal/config"
"github.com/wild-cloud/wild-cli/internal/environment"
)
var checkMode bool
func newGetCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "get <path>",
Short: "Get a secret value",
Long: `Get a secret value from secrets.yaml using a dot-notation path.
For security reasons, secret values are displayed as-is. Be careful when using
in scripts or logs that might be shared.
Examples:
wild secret get database.password
wild secret get apps.myapp.api_key
wild secret get certificates.tls.key`,
Args: cobra.ExactArgs(1),
RunE: runGet,
}
cmd.Flags().BoolVar(&checkMode, "check", false, "exit 1 if key doesn't exist (no output)")
return cmd
}
func runGet(cmd *cobra.Command, args []string) error {
path := args[0]
// Initialize environment
env := environment.New()
if err := env.RequiresProject(); err != nil {
return err
}
// Create config manager
mgr := config.NewManager(env.ConfigPath(), env.SecretsPath())
// Get the secret value
value, err := mgr.GetSecret(path)
if err != nil {
if checkMode {
os.Exit(1)
}
return fmt.Errorf("getting secret value: %w", err)
}
// Handle null/missing values
if value == nil {
if checkMode {
os.Exit(1)
}
return fmt.Errorf("key path '%s' not found in secrets file", path)
}
// In check mode, exit 0 if key exists (don't output value)
if checkMode {
return nil
}
// Output the value (no logging to avoid secrets in logs)
fmt.Println(value)
return nil
}

View File

@@ -0,0 +1,30 @@
package secret
import (
"github.com/spf13/cobra"
)
// NewSecretCommand creates the secret command and its subcommands
func NewSecretCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "secret",
Short: "Manage Wild Cloud secrets",
Long: `Manage Wild Cloud secrets stored in secrets.yaml.
Secret values are stored as YAML and can be accessed using dot-notation paths.
Secret values are typically not displayed in output for security reasons.
Examples:
wild secret get database.password
wild secret set database.password mysecretpassword
wild secret get apps.myapp.api_key`,
}
// Add subcommands
cmd.AddCommand(
newGetCommand(),
newSetCommand(),
)
return cmd
}

View File

@@ -0,0 +1,53 @@
package secret
import (
"fmt"
"github.com/spf13/cobra"
"github.com/wild-cloud/wild-cli/internal/config"
"github.com/wild-cloud/wild-cli/internal/environment"
"github.com/wild-cloud/wild-cli/internal/output"
)
func newSetCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "set <path> <value>",
Short: "Set a secret value",
Long: `Set a secret value in secrets.yaml using a dot-notation path.
The value will be stored as-is in the secrets file. Be careful with sensitive data.
Examples:
wild secret set database.password mySecretPassword123
wild secret set apps.myapp.api_key abc123def456
wild secret set certificates.tls.key "-----BEGIN PRIVATE KEY-----..."`,
Args: cobra.ExactArgs(2),
RunE: runSet,
}
return cmd
}
func runSet(cmd *cobra.Command, args []string) error {
path := args[0]
value := args[1]
// Initialize environment
env := environment.New()
if err := env.RequiresProject(); err != nil {
return err
}
// Create config manager
mgr := config.NewManager(env.ConfigPath(), env.SecretsPath())
// Set the secret value
if err := mgr.SetSecret(path, value); err != nil {
return fmt.Errorf("setting secret value: %w", err)
}
// Don't show the actual value in output for security
output.Success(fmt.Sprintf("Set secret %s", path))
return nil
}

View File

@@ -0,0 +1,265 @@
package setup
import (
"context"
"fmt"
"os"
"path/filepath"
"github.com/spf13/cobra"
"github.com/wild-cloud/wild-cli/internal/config"
"github.com/wild-cloud/wild-cli/internal/environment"
"github.com/wild-cloud/wild-cli/internal/external"
"github.com/wild-cloud/wild-cli/internal/output"
)
var (
skipInstaller bool
skipHardware bool
)
func newClusterCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "cluster",
Short: "Set up Kubernetes cluster",
Long: `Set up the Kubernetes cluster infrastructure using Talos Linux.
This command configures Talos Linux nodes and bootstraps the Kubernetes cluster.
Examples:
wild setup cluster
wild setup cluster --skip-installer
wild setup cluster --skip-hardware`,
RunE: runCluster,
}
cmd.Flags().BoolVar(&skipInstaller, "skip-installer", false, "skip installer image generation")
cmd.Flags().BoolVar(&skipHardware, "skip-hardware", false, "skip node hardware detection")
return cmd
}
func runCluster(cmd *cobra.Command, args []string) error {
output.Header("Wild Cloud Cluster Setup")
// Initialize environment
env := environment.New()
if err := env.RequiresProject(); err != nil {
return err
}
// Check external tools
toolManager := external.NewManager()
if err := toolManager.CheckTools(cmd.Context(), []string{"talosctl"}); err != nil {
return fmt.Errorf("required tools not available: %w", err)
}
// Load configuration
configMgr := config.NewManager(env.ConfigPath(), env.SecretsPath())
// Get cluster configuration
clusterName, err := getConfigString(configMgr, "cluster.name")
if err != nil {
return fmt.Errorf("cluster name not configured: %w", err)
}
vip, err := getConfigString(configMgr, "cluster.vip")
if err != nil {
return fmt.Errorf("cluster VIP not configured: %w", err)
}
output.Info("Cluster: " + clusterName)
output.Info("VIP: " + vip)
// Phase 1: Generate Talos configuration
output.Info("\n=== Phase 1: Generating Talos Configuration ===")
configDir := filepath.Join(env.WildCloudDir(), "talos")
if err := os.MkdirAll(configDir, 0755); err != nil {
return fmt.Errorf("creating config directory: %w", err)
}
talosctl := toolManager.Talosctl()
clusterEndpoint := "https://" + vip + ":6443"
if err := talosctl.GenerateConfig(cmd.Context(), clusterName, clusterEndpoint, configDir); err != nil {
return fmt.Errorf("generating talos config: %w", err)
}
output.Success("Talos configuration generated")
// Phase 2: Node configuration
if !skipHardware {
output.Info("\n=== Phase 2: Detecting Nodes ===")
if err := detectAndConfigureNodes(cmd.Context(), configMgr, talosctl, configDir); err != nil {
return fmt.Errorf("configuring nodes: %w", err)
}
} else {
output.Info("Skipping node hardware detection")
}
// Phase 3: Bootstrap cluster
output.Info("\n=== Phase 3: Bootstrapping Cluster ===")
if err := bootstrapCluster(cmd.Context(), configMgr, talosctl, configDir); err != nil {
return fmt.Errorf("bootstrapping cluster: %w", err)
}
output.Success("Cluster setup completed successfully!")
output.Info("")
output.Info("Next steps:")
output.Info(" wild setup services # Install cluster services")
output.Info(" kubectl get nodes # Verify cluster")
return nil
}
// detectAndConfigureNodes detects and configures cluster nodes
func detectAndConfigureNodes(ctx context.Context, configMgr *config.Manager, talosctl *external.TalosctlTool, configDir string) error {
// Get nodes from configuration
nodesConfig, err := configMgr.Get("cluster.nodes")
if err != nil || nodesConfig == nil {
output.Warning("No nodes configured")
output.Info("Add nodes to config: wild config set cluster.nodes '[{\"ip\": \"192.168.1.10\", \"role\": \"controlplane\"}]'")
return nil
}
nodes, ok := nodesConfig.([]interface{})
if !ok {
return fmt.Errorf("invalid nodes configuration")
}
if len(nodes) == 0 {
output.Warning("No nodes configured")
return nil
}
output.Info(fmt.Sprintf("Found %d nodes in configuration", len(nodes)))
// Configure each node
for i, nodeConfig := range nodes {
nodeMap, ok := nodeConfig.(map[string]interface{})
if !ok {
output.Warning(fmt.Sprintf("Invalid node %d configuration", i))
continue
}
nodeIP, exists := nodeMap["ip"]
if !exists {
output.Warning(fmt.Sprintf("Node %d missing IP address", i))
continue
}
nodeRole, exists := nodeMap["role"]
if !exists {
nodeRole = "worker"
}
output.Info(fmt.Sprintf("Configuring node %s (%s)", nodeIP, nodeRole))
// Apply configuration to node
var configFile string
if nodeRole == "controlplane" {
configFile = filepath.Join(configDir, "controlplane.yaml")
} else {
configFile = filepath.Join(configDir, "worker.yaml")
}
talosctl.SetEndpoints([]string{fmt.Sprintf("%v", nodeIP)})
if err := talosctl.ApplyConfig(ctx, configFile, true); err != nil {
output.Warning(fmt.Sprintf("Failed to configure node %s: %v", nodeIP, err))
} else {
output.Success(fmt.Sprintf("Node %s configured", nodeIP))
}
}
return nil
}
// bootstrapCluster bootstraps the Kubernetes cluster
func bootstrapCluster(ctx context.Context, configMgr *config.Manager, talosctl *external.TalosctlTool, configDir string) error {
// Get first controlplane node
nodesConfig, err := configMgr.Get("cluster.nodes")
if err != nil || nodesConfig == nil {
return fmt.Errorf("no nodes configured")
}
nodes, ok := nodesConfig.([]interface{})
if !ok || len(nodes) == 0 {
return fmt.Errorf("invalid nodes configuration")
}
// Find first controlplane node
var bootstrapNode string
for _, nodeConfig := range nodes {
nodeMap, ok := nodeConfig.(map[string]interface{})
if !ok {
continue
}
nodeIP, exists := nodeMap["ip"]
if !exists {
continue
}
nodeRole, exists := nodeMap["role"]
if exists && nodeRole == "controlplane" {
bootstrapNode = fmt.Sprintf("%v", nodeIP)
break
}
}
if bootstrapNode == "" {
return fmt.Errorf("no controlplane node found")
}
output.Info("Bootstrap node: " + bootstrapNode)
// Set talosconfig
talosconfig := filepath.Join(configDir, "talosconfig")
talosctl.SetTalosconfig(talosconfig)
talosctl.SetEndpoints([]string{bootstrapNode})
talosctl.SetNodes([]string{bootstrapNode})
// Bootstrap cluster
if err := talosctl.Bootstrap(ctx); err != nil {
return fmt.Errorf("bootstrapping cluster: %w", err)
}
output.Success("Cluster bootstrapped")
// Generate kubeconfig
output.Info("Generating kubeconfig...")
kubeconfigPath := filepath.Join(configDir, "kubeconfig")
if err := talosctl.Kubeconfig(ctx, kubeconfigPath, true); err != nil {
output.Warning("Failed to generate kubeconfig: " + err.Error())
} else {
output.Success("Kubeconfig generated: " + kubeconfigPath)
output.Info("Set KUBECONFIG=" + kubeconfigPath)
}
return nil
}
// getConfigString gets a string value from config with validation
func getConfigString(configMgr *config.Manager, path string) (string, error) {
value, err := configMgr.Get(path)
if err != nil {
return "", err
}
if value == nil {
return "", fmt.Errorf("config value '%s' not set", path)
}
strValue, ok := value.(string)
if !ok {
return "", fmt.Errorf("config value '%s' is not a string", path)
}
if strValue == "" {
return "", fmt.Errorf("config value '%s' is empty", path)
}
return strValue, nil
}

View File

@@ -0,0 +1,190 @@
package setup
import (
"fmt"
"os"
"path/filepath"
"github.com/spf13/cobra"
"github.com/wild-cloud/wild-cli/internal/output"
)
func newScaffoldCommand() *cobra.Command {
return &cobra.Command{
Use: "scaffold",
Short: "Initialize a new Wild Cloud project",
Long: `Initialize a new Wild Cloud project directory with configuration templates.`,
RunE: runScaffold,
}
}
func runScaffold(cmd *cobra.Command, args []string) error {
output.Header("Wild Cloud Project Initialization")
// Get current directory
cwd, err := os.Getwd()
if err != nil {
return fmt.Errorf("getting current directory: %w", err)
}
// Check if already a Wild Cloud project
if _, err := os.Stat(filepath.Join(cwd, ".wildcloud")); err == nil {
return fmt.Errorf("current directory is already a Wild Cloud project")
}
output.Info("Initializing Wild Cloud project in: " + cwd)
// Create .wildcloud directory
wildcloudDir := filepath.Join(cwd, ".wildcloud")
if err := os.MkdirAll(wildcloudDir, 0755); err != nil {
return fmt.Errorf("creating .wildcloud directory: %w", err)
}
// Create cache directory
cacheDir := filepath.Join(wildcloudDir, "cache")
if err := os.MkdirAll(cacheDir, 0755); err != nil {
return fmt.Errorf("creating cache directory: %w", err)
}
// Create apps directory
appsDir := filepath.Join(cwd, "apps")
if err := os.MkdirAll(appsDir, 0755); err != nil {
return fmt.Errorf("creating apps directory: %w", err)
}
// Create config.yaml with basic structure
configPath := filepath.Join(cwd, "config.yaml")
configContent := `# Wild Cloud Configuration
cluster:
name: ""
domain: ""
vip: ""
nodes: []
apps: {}
services: {}
`
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
return fmt.Errorf("creating config.yaml: %w", err)
}
// Create secrets.yaml with basic structure
secretsPath := filepath.Join(cwd, "secrets.yaml")
secretsContent := `# Wild Cloud Secrets
# This file contains sensitive information and should not be committed to version control
cluster:
secrets: {}
apps: {}
`
if err := os.WriteFile(secretsPath, []byte(secretsContent), 0600); err != nil {
return fmt.Errorf("creating secrets.yaml: %w", err)
}
// Create .gitignore to exclude secrets
gitignorePath := filepath.Join(cwd, ".gitignore")
gitignoreContent := `# Wild Cloud secrets and sensitive data
secrets.yaml
*.key
*.crt
*.pem
# Talos configuration files
*.talosconfig
controlplane.yaml
worker.yaml
# Kubernetes config
kubeconfig
# Backup files
*.bak
*.backup
# Cache and temporary files
.wildcloud/cache/
*.tmp
*.temp
`
if err := os.WriteFile(gitignorePath, []byte(gitignoreContent), 0644); err != nil {
output.Warning("Failed to create .gitignore: " + err.Error())
}
// Create README.md with basic information
readmePath := filepath.Join(cwd, "README.md")
readmeContent := `# Wild Cloud Project
This is a Wild Cloud personal infrastructure project.
## Getting Started
1. Configure your cluster settings:
` + "```bash" + `
wild config set cluster.name my-cluster
wild config set cluster.domain example.com
wild config set cluster.vip 192.168.1.100
` + "```" + `
2. Set up your cluster:
` + "```bash" + `
wild setup cluster
` + "```" + `
3. Install cluster services:
` + "```bash" + `
wild setup services
` + "```" + `
4. Deploy applications:
` + "```bash" + `
wild app list
wild app fetch nextcloud
wild app add nextcloud
wild app deploy nextcloud
` + "```" + `
## Directory Structure
- ` + "`config.yaml`" + ` - Cluster and application configuration
- ` + "`secrets.yaml`" + ` - Sensitive data (not committed to git)
- ` + "`apps/`" + ` - Application configurations
- ` + "`.wildcloud/`" + ` - Wild Cloud metadata and cache
## Commands
- ` + "`wild config`" + ` - Manage configuration
- ` + "`wild secret`" + ` - Manage secrets
- ` + "`wild setup`" + ` - Set up infrastructure
- ` + "`wild app`" + ` - Manage applications
- ` + "`wild cluster`" + ` - Manage cluster
- ` + "`wild backup`" + ` - Backup system
For more information, run ` + "`wild --help`" + `
`
if err := os.WriteFile(readmePath, []byte(readmeContent), 0644); err != nil {
output.Warning("Failed to create README.md: " + err.Error())
}
output.Success("Wild Cloud project initialized successfully!")
output.Info("")
output.Info("Next steps:")
output.Info(" 1. Configure your cluster: wild config set cluster.name my-cluster")
output.Info(" 2. Set up your cluster: wild setup cluster")
output.Info(" 3. Deploy services: wild setup services")
output.Info("")
output.Info("Project structure created:")
output.Info(" ├── .wildcloud/ # Project metadata")
output.Info(" ├── apps/ # Application configurations")
output.Info(" ├── config.yaml # Cluster configuration")
output.Info(" ├── secrets.yaml # Sensitive data")
output.Info(" ├── .gitignore # Git ignore rules")
output.Info(" └── README.md # Project documentation")
return nil
}

View File

@@ -0,0 +1,564 @@
package setup
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/spf13/cobra"
"github.com/wild-cloud/wild-cli/internal/config"
"github.com/wild-cloud/wild-cli/internal/environment"
"github.com/wild-cloud/wild-cli/internal/external"
"github.com/wild-cloud/wild-cli/internal/output"
)
var (
skipInstall bool
)
func newServicesCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "services",
Short: "Set up cluster services",
Long: `Set up essential cluster services like ingress, DNS, and monitoring.
This command generates service configurations and installs core Kubernetes services
including MetalLB, Traefik, cert-manager, and others.
Examples:
wild setup services
wild setup services --skip-install`,
RunE: runServices,
}
cmd.Flags().BoolVar(&skipInstall, "skip-install", false, "generate service configs but skip installation")
return cmd
}
func runServices(cmd *cobra.Command, args []string) error {
output.Header("Wild Cloud Services Setup")
// Initialize environment
env := environment.New()
if err := env.RequiresProject(); err != nil {
return err
}
// Check external tools
toolManager := external.NewManager()
if err := toolManager.CheckTools(cmd.Context(), []string{"kubectl"}); err != nil {
return fmt.Errorf("required tools not available: %w", err)
}
// Load configuration
configMgr := config.NewManager(env.ConfigPath(), env.SecretsPath())
// Check cluster configuration
clusterName, err := getConfigString(configMgr, "cluster.name")
if err != nil {
return fmt.Errorf("cluster configuration is missing: %w", err)
}
output.Info("Cluster: " + clusterName)
// Check kubectl connectivity
kubectl := toolManager.Kubectl()
if err := checkKubectlConnectivity(cmd.Context(), kubectl); err != nil {
return fmt.Errorf("kubectl is not configured or cluster is not accessible: %w", err)
}
output.Success("Cluster is accessible")
// Phase 1: Generate cluster services setup files
output.Info("\n=== Phase 1: Generating Service Configurations ===")
if err := generateClusterServices(cmd.Context(), env, configMgr); err != nil {
return fmt.Errorf("generating service configurations: %w", err)
}
// Phase 2: Install cluster services
if !skipInstall {
output.Info("\n=== Phase 2: Installing Cluster Services ===")
if err := installClusterServices(cmd.Context(), env, kubectl); err != nil {
return fmt.Errorf("installing cluster services: %w", err)
}
} else {
output.Info("Skipping cluster services installation (--skip-install specified)")
output.Info("You can install them later with: wild cluster services deploy")
}
// Summary output
output.Success("Wild Cloud Services Setup Complete!")
output.Info("")
if !skipInstall {
// Get internal domain for next steps
internalDomain, err := configMgr.Get("cloud.internalDomain")
domain := "your-internal-domain"
if err == nil && internalDomain != nil {
if domainStr, ok := internalDomain.(string); ok {
domain = domainStr
}
}
output.Info("Next steps:")
output.Info(" 1. Access the dashboard at: https://dashboard." + domain)
output.Info(" 2. Get the dashboard token with: wild dashboard token")
output.Info("")
output.Info("To verify components, run:")
output.Info(" - kubectl get pods -n cert-manager")
output.Info(" - kubectl get pods -n externaldns")
output.Info(" - kubectl get pods -n kubernetes-dashboard")
output.Info(" - kubectl get clusterissuers")
} else {
output.Info("Next steps:")
output.Info(" 1. Ensure your cluster is running and kubectl is configured")
output.Info(" 2. Install services with: wild cluster services deploy")
output.Info(" 3. Verify components are running correctly")
}
output.Success("Wild Cloud setup completed!")
return nil
}
// generateClusterServices generates cluster service configurations
func generateClusterServices(ctx context.Context, env *environment.Environment, configMgr *config.Manager) error {
// This function replicates wild-cluster-services-generate functionality
output.Info("Generating cluster services setup files...")
wcRoot := env.WCRoot()
if wcRoot == "" {
return fmt.Errorf("WC_ROOT not set")
}
sourceDir := filepath.Join(wcRoot, "setup", "cluster-services")
destDir := filepath.Join(env.WildCloudDir(), "setup", "cluster-services")
// Check if source directory exists
if _, err := os.Stat(sourceDir); os.IsNotExist(err) {
return fmt.Errorf("cluster setup source directory not found: %s", sourceDir)
}
// Force regeneration, removing existing files
if _, err := os.Stat(destDir); err == nil {
output.Info("Force regeneration enabled, removing existing files...")
if err := os.RemoveAll(destDir); err != nil {
return fmt.Errorf("removing existing setup directory: %w", err)
}
}
// Create destination directory
setupBaseDir := filepath.Join(env.WildCloudDir(), "setup")
if err := os.MkdirAll(setupBaseDir, 0755); err != nil {
return fmt.Errorf("creating setup directory: %w", err)
}
// Copy README if it doesn't exist
readmePath := filepath.Join(setupBaseDir, "README.md")
if _, err := os.Stat(readmePath); os.IsNotExist(err) {
sourceReadme := filepath.Join(wcRoot, "setup", "README.md")
if _, err := os.Stat(sourceReadme); err == nil {
if err := copyFile(sourceReadme, readmePath); err != nil {
output.Warning("Failed to copy README.md: " + err.Error())
}
}
}
// Create destination directory
if err := os.MkdirAll(destDir, 0755); err != nil {
return fmt.Errorf("creating destination directory: %w", err)
}
// Copy and compile cluster setup files
output.Info("Copying and compiling cluster setup files from repository...")
// First, copy root-level files from setup/cluster-services/
if err := copyRootServiceFiles(sourceDir, destDir); err != nil {
return fmt.Errorf("copying root service files: %w", err)
}
// Then, process each service directory
if err := processServiceDirectories(sourceDir, destDir, configMgr); err != nil {
return fmt.Errorf("processing service directories: %w", err)
}
// Verify required configuration
if err := verifyServiceConfiguration(configMgr); err != nil {
output.Warning("Configuration verification warnings: " + err.Error())
}
output.Success("Cluster setup files copied and compiled")
output.Info("Generated setup directory: " + destDir)
// List available services
services, err := getAvailableServices(destDir)
if err != nil {
return fmt.Errorf("listing available services: %w", err)
}
output.Info("Available services:")
for _, service := range services {
output.Info(" - " + service)
}
return nil
}
// installClusterServices installs the cluster services
func installClusterServices(ctx context.Context, env *environment.Environment, kubectl *external.KubectlTool) error {
setupDir := filepath.Join(env.WildCloudDir(), "setup", "cluster-services")
// Check if cluster setup directory exists
if _, err := os.Stat(setupDir); os.IsNotExist(err) {
return fmt.Errorf("cluster services setup directory not found: %s", setupDir)
}
output.Info("Installing cluster services...")
// Install services in dependency order
servicesToInstall := []string{
"metallb",
"longhorn",
"traefik",
"coredns",
"cert-manager",
"externaldns",
"kubernetes-dashboard",
"nfs",
"docker-registry",
}
// Filter to only include services that actually exist
existingServices := []string{}
for _, service := range servicesToInstall {
installScript := filepath.Join(setupDir, service, "install.sh")
if _, err := os.Stat(installScript); err == nil {
existingServices = append(existingServices, service)
}
}
if len(existingServices) == 0 {
return fmt.Errorf("no installable services found")
}
output.Info(fmt.Sprintf("Services to install: %s", strings.Join(existingServices, ", ")))
// Install services
installedCount := 0
failedCount := 0
for _, service := range existingServices {
output.Info(fmt.Sprintf("\n--- Installing %s ---", service))
installScript := filepath.Join(setupDir, service, "install.sh")
if err := runServiceInstaller(ctx, setupDir, service, installScript); err != nil {
output.Error(fmt.Sprintf("%s installation failed: %v", service, err))
failedCount++
} else {
output.Success(fmt.Sprintf("%s installed successfully", service))
installedCount++
}
}
// Summary
output.Info("\nInstallation Summary:")
output.Success(fmt.Sprintf("Successfully installed: %d services", installedCount))
if failedCount > 0 {
output.Warning(fmt.Sprintf("Failed to install: %d services", failedCount))
}
if failedCount == 0 {
output.Success("All cluster services installed successfully!")
} else {
return fmt.Errorf("some services failed to install")
}
return nil
}
// copyRootServiceFiles copies root-level files from source to destination
func copyRootServiceFiles(sourceDir, destDir string) error {
entries, err := os.ReadDir(sourceDir)
if err != nil {
return err
}
for _, entry := range entries {
if !entry.IsDir() {
srcPath := filepath.Join(sourceDir, entry.Name())
dstPath := filepath.Join(destDir, entry.Name())
output.Info(" Copying: " + entry.Name())
if err := copyFile(srcPath, dstPath); err != nil {
return err
}
}
}
return nil
}
// processServiceDirectories processes each service directory
func processServiceDirectories(sourceDir, destDir string, configMgr *config.Manager) error {
entries, err := os.ReadDir(sourceDir)
if err != nil {
return err
}
// Create template engine
engine, err := config.NewTemplateEngine(configMgr)
if err != nil {
return fmt.Errorf("creating template engine: %w", err)
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
serviceName := entry.Name()
serviceDir := filepath.Join(sourceDir, serviceName)
destServiceDir := filepath.Join(destDir, serviceName)
output.Info("Processing service: " + serviceName)
// Create destination service directory
if err := os.MkdirAll(destServiceDir, 0755); err != nil {
return err
}
// Process service files
if err := processServiceFiles(serviceDir, destServiceDir, engine); err != nil {
return fmt.Errorf("processing service %s: %w", serviceName, err)
}
}
return nil
}
// processServiceFiles processes files in a service directory
func processServiceFiles(serviceDir, destServiceDir string, engine *config.TemplateEngine) error {
entries, err := os.ReadDir(serviceDir)
if err != nil {
return err
}
for _, entry := range entries {
srcPath := filepath.Join(serviceDir, entry.Name())
dstPath := filepath.Join(destServiceDir, entry.Name())
if entry.Name() == "kustomize.template" {
// Compile kustomize.template to kustomize directory
if entry.IsDir() {
output.Info(" Compiling kustomize templates")
kustomizeDir := filepath.Join(destServiceDir, "kustomize")
if err := processTemplateDirectory(srcPath, kustomizeDir, engine); err != nil {
return err
}
}
} else if entry.IsDir() {
// Copy other directories recursively
if err := copyDir(srcPath, dstPath); err != nil {
return err
}
} else {
// Process individual files
if err := processServiceFile(srcPath, dstPath, engine); err != nil {
return err
}
}
}
return nil
}
// processServiceFile processes a single service file
func processServiceFile(srcPath, dstPath string, engine *config.TemplateEngine) error {
content, err := os.ReadFile(srcPath)
if err != nil {
return err
}
// Check if file contains template syntax
if strings.Contains(string(content), "{{") {
output.Info(" Compiling: " + filepath.Base(srcPath))
processed, err := engine.Process(string(content))
if err != nil {
return fmt.Errorf("processing template: %w", err)
}
return os.WriteFile(dstPath, []byte(processed), 0644)
} else {
return copyFile(srcPath, dstPath)
}
}
// processTemplateDirectory processes an entire template directory
func processTemplateDirectory(srcDir, dstDir string, engine *config.TemplateEngine) error {
if err := os.RemoveAll(dstDir); err != nil && !os.IsNotExist(err) {
return err
}
return filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
relPath, err := filepath.Rel(srcDir, path)
if err != nil {
return err
}
dstPath := filepath.Join(dstDir, relPath)
if info.IsDir() {
return os.MkdirAll(dstPath, info.Mode())
}
content, err := os.ReadFile(path)
if err != nil {
return err
}
// Process template content
if strings.Contains(string(content), "{{") {
processed, err := engine.Process(string(content))
if err != nil {
return fmt.Errorf("processing template %s: %w", relPath, err)
}
content = []byte(processed)
}
// Create parent directory
if err := os.MkdirAll(filepath.Dir(dstPath), 0755); err != nil {
return err
}
return os.WriteFile(dstPath, content, info.Mode())
})
}
// verifyServiceConfiguration verifies required configuration
func verifyServiceConfiguration(configMgr *config.Manager) error {
missingConfig := []string{}
// Check essential configuration values
requiredConfigs := []string{
"cluster.name",
"cloud.domain",
"cluster.ipAddressPool",
"operator.email",
}
for _, configPath := range requiredConfigs {
if value, err := configMgr.Get(configPath); err != nil || value == nil {
missingConfig = append(missingConfig, configPath)
}
}
if len(missingConfig) > 0 {
return fmt.Errorf("missing required configuration values: %s", strings.Join(missingConfig, ", "))
}
return nil
}
// getAvailableServices returns list of available services
func getAvailableServices(setupDir string) ([]string, error) {
var services []string
entries, err := os.ReadDir(setupDir)
if err != nil {
return nil, err
}
for _, entry := range entries {
if entry.IsDir() {
installScript := filepath.Join(setupDir, entry.Name(), "install.sh")
if _, err := os.Stat(installScript); err == nil {
services = append(services, entry.Name())
}
}
}
return services, nil
}
// checkKubectlConnectivity checks if kubectl can connect to the cluster
func checkKubectlConnectivity(ctx context.Context, kubectl *external.KubectlTool) error {
// Try to get cluster info
_, err := kubectl.Execute(ctx, "cluster-info")
if err != nil {
return fmt.Errorf("cluster not accessible: %w", err)
}
return nil
}
// runServiceInstaller runs a service installation script
func runServiceInstaller(ctx context.Context, setupDir, serviceName, installScript string) error {
// Change to the service directory and run install.sh
serviceDir := filepath.Join(setupDir, serviceName)
// Execute the install script using bash
bashTool := external.NewBaseTool("bash", "bash")
// Change to the service directory by setting working directory in the execution context
oldDir, err := os.Getwd()
if err != nil {
return fmt.Errorf("getting current directory: %w", err)
}
if err := os.Chdir(serviceDir); err != nil {
return fmt.Errorf("changing to service directory: %w", err)
}
defer func() {
_ = os.Chdir(oldDir)
}()
_, err = bashTool.Execute(ctx, "install.sh")
if err != nil {
return fmt.Errorf("install script failed: %w", err)
}
return nil
}
// copyFile copies a single file
func copyFile(src, dst string) error {
data, err := os.ReadFile(src)
if err != nil {
return err
}
return os.WriteFile(dst, data, 0644)
}
// copyDir recursively copies a directory
func copyDir(src, dst string) error {
entries, err := os.ReadDir(src)
if err != nil {
return err
}
if err := os.MkdirAll(dst, 0755); err != nil {
return err
}
for _, entry := range entries {
srcPath := filepath.Join(src, entry.Name())
dstPath := filepath.Join(dst, entry.Name())
if entry.IsDir() {
if err := copyDir(srcPath, dstPath); err != nil {
return err
}
} else {
if err := copyFile(srcPath, dstPath); err != nil {
return err
}
}
}
return nil
}

View File

@@ -0,0 +1,30 @@
package setup
import (
"github.com/spf13/cobra"
)
// NewSetupCommand creates the setup command and its subcommands
func NewSetupCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "setup",
Short: "Set up Wild Cloud infrastructure",
Long: `Set up Wild Cloud infrastructure components.
This command provides the setup workflow for initializing and configuring
your Wild Cloud personal infrastructure.`,
}
// Add subcommands
cmd.AddCommand(
newScaffoldCommand(),
newClusterCommand(),
newServicesCommand(),
)
return cmd
}
// newScaffoldCommand is implemented in scaffold.go
// newClusterCommand is implemented in cluster.go
// newServicesCommand is implemented in services.go

View File

@@ -0,0 +1,249 @@
package util
import (
"context"
"fmt"
"os"
"path/filepath"
"time"
"github.com/spf13/cobra"
"github.com/wild-cloud/wild-cli/internal/config"
"github.com/wild-cloud/wild-cli/internal/environment"
"github.com/wild-cloud/wild-cli/internal/external"
"github.com/wild-cloud/wild-cli/internal/output"
)
var (
backupAll bool
)
func NewBackupCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "backup",
Short: "Backup Wild Cloud system",
Long: `Backup the entire Wild Cloud system including applications and data.
This command performs a comprehensive backup of your Wild Cloud system using restic,
including WC_HOME directory and all application data.
Examples:
wild backup
wild backup --all`,
RunE: runBackup,
}
cmd.Flags().BoolVar(&backupAll, "all", true, "backup all applications")
return cmd
}
func runBackup(cmd *cobra.Command, args []string) error {
output.Header("Wild Cloud System Backup")
// Initialize environment
env := environment.New()
if err := env.RequiresProject(); err != nil {
return err
}
// Check external tools
toolManager := external.NewManager()
if err := toolManager.CheckTools(cmd.Context(), []string{"restic"}); err != nil {
return fmt.Errorf("required tools not available: %w", err)
}
// Load configuration
configMgr := config.NewManager(env.ConfigPath(), env.SecretsPath())
// Get backup configuration
backupRoot, err := configMgr.Get("cloud.backup.root")
if err != nil || backupRoot == nil {
return fmt.Errorf("backup root not configured. Set cloud.backup.root in config.yaml")
}
backupPassword, err := configMgr.GetSecret("cloud.backupPassword")
if err != nil || backupPassword == nil {
return fmt.Errorf("backup password not configured. Set cloud.backupPassword in secrets.yaml")
}
stagingDir, err := configMgr.Get("cloud.backup.staging")
if err != nil || stagingDir == nil {
return fmt.Errorf("backup staging directory not configured. Set cloud.backup.staging in config.yaml")
}
repository := fmt.Sprintf("%v", backupRoot)
password := fmt.Sprintf("%v", backupPassword)
staging := fmt.Sprintf("%v", stagingDir)
output.Info("Backup repository: " + repository)
// Initialize restic tool
restic := toolManager.Restic()
restic.SetRepository(repository)
restic.SetPassword(password)
// Check if repository exists, initialize if needed
output.Info("Checking if restic repository exists...")
if err := checkOrInitializeRepository(cmd.Context(), restic); err != nil {
return fmt.Errorf("repository initialization failed: %w", err)
}
// Create staging directory
if err := os.MkdirAll(staging, 0755); err != nil {
return fmt.Errorf("creating staging directory: %w", err)
}
// Generate backup tags
today := time.Now().Format("2006-01-02")
tags := []string{"wild-cloud", "wc-home", today}
// Backup entire WC_HOME
output.Info("Backing up WC_HOME directory...")
wcHome := env.WCHome()
if wcHome == "" {
wcHome = env.WildCloudDir()
}
if err := restic.Backup(cmd.Context(), []string{wcHome}, []string{".wildcloud/cache"}, tags); err != nil {
return fmt.Errorf("backing up WC_HOME: %w", err)
}
output.Success("WC_HOME backup completed")
// Backup applications if requested
if backupAll {
output.Info("Running backup for all applications...")
if err := backupAllApplications(cmd.Context(), env, configMgr, restic, staging, today); err != nil {
return fmt.Errorf("application backup failed: %w", err)
}
}
// TODO: Future enhancements
// - Backup Kubernetes resources (kubectl get all -A -o yaml)
// - Backup persistent volumes
// - Backup secrets and configmaps
output.Success("Wild Cloud system backup completed successfully!")
return nil
}
// checkOrInitializeRepository checks if restic repository exists and initializes if needed
func checkOrInitializeRepository(ctx context.Context, restic *external.ResticTool) error {
// Try to check repository
if err := restic.Check(ctx); err != nil {
output.Warning("No existing backup repository found. Initializing restic repository...")
if err := restic.InitRepository(ctx); err != nil {
return fmt.Errorf("initializing repository: %w", err)
}
output.Success("Repository initialized successfully")
} else {
output.Info("Using existing backup repository")
}
return nil
}
// backupAllApplications backs up all applications using the app backup functionality
func backupAllApplications(ctx context.Context, env *environment.Environment, configMgr *config.Manager, restic *external.ResticTool, staging, dateTag string) error {
// Get list of applications
appsDir := env.AppsDir()
if _, err := os.Stat(appsDir); os.IsNotExist(err) {
output.Warning("No apps directory found, skipping application backups")
return nil
}
entries, err := os.ReadDir(appsDir)
if err != nil {
return fmt.Errorf("reading apps directory: %w", err)
}
var apps []string
for _, entry := range entries {
if entry.IsDir() {
apps = append(apps, entry.Name())
}
}
if len(apps) == 0 {
output.Warning("No applications found, skipping application backups")
return nil
}
output.Info(fmt.Sprintf("Found %d applications to backup: %v", len(apps), apps))
// For now, we'll use the existing bash script for application backups
// This maintains compatibility with the existing backup infrastructure
wcRoot := env.WCRoot()
if wcRoot == "" {
output.Warning("WC_ROOT not set, skipping application-specific backups")
return nil
}
appBackupScript := filepath.Join(wcRoot, "bin", "wild-app-backup")
if _, err := os.Stat(appBackupScript); os.IsNotExist(err) {
output.Warning("App backup script not found, skipping application backups")
return nil
}
// Execute the app backup script
bashTool := external.NewBaseTool("bash", "bash")
// Set environment variables needed by the script
oldWCRoot := os.Getenv("WC_ROOT")
oldWCHome := os.Getenv("WC_HOME")
defer func() {
if oldWCRoot != "" {
_ = os.Setenv("WC_ROOT", oldWCRoot)
}
if oldWCHome != "" {
_ = os.Setenv("WC_HOME", oldWCHome)
}
}()
_ = os.Setenv("WC_ROOT", wcRoot)
_ = os.Setenv("WC_HOME", env.WCHome())
output.Info("Running application backup script...")
if _, err := bashTool.Execute(ctx, appBackupScript, "--all"); err != nil {
output.Warning(fmt.Sprintf("Application backup script failed: %v", err))
return nil // Don't fail the entire backup for app backup issues
}
output.Success("Application backup script completed")
// Upload each app's backup to restic individually
stagingAppsDir := filepath.Join(staging, "apps")
if _, err := os.Stat(stagingAppsDir); err != nil {
output.Warning("No app staging directory found, skipping app backup uploads")
return nil
}
entries, err = os.ReadDir(stagingAppsDir)
if err != nil {
output.Warning(fmt.Sprintf("Reading app staging directory failed: %v", err))
return nil
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
appName := entry.Name()
appBackupDir := filepath.Join(stagingAppsDir, appName)
output.Info(fmt.Sprintf("Uploading backup for app: %s", appName))
tags := []string{"wild-cloud", appName, dateTag}
if err := restic.Backup(ctx, []string{appBackupDir}, []string{}, tags); err != nil {
output.Warning(fmt.Sprintf("Failed to backup app %s: %v", appName, err))
continue
}
output.Success(fmt.Sprintf("Backup for app '%s' completed", appName))
}
return nil
}

View File

@@ -0,0 +1,157 @@
package util
import (
"context"
"fmt"
"strings"
"github.com/spf13/cobra"
"github.com/wild-cloud/wild-cli/internal/environment"
"github.com/wild-cloud/wild-cli/internal/external"
"github.com/wild-cloud/wild-cli/internal/output"
)
func NewDashboardCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "dashboard",
Short: "Manage Kubernetes dashboard",
Long: `Manage access to the Kubernetes dashboard.`,
}
cmd.AddCommand(
newDashboardTokenCommand(),
)
return cmd
}
func newDashboardTokenCommand() *cobra.Command {
return &cobra.Command{
Use: "token",
Short: "Get dashboard access token",
Long: `Get an access token for the Kubernetes dashboard.
This command retrieves the authentication token needed to access the Kubernetes dashboard.
Examples:
wild dashboard token`,
RunE: runDashboardToken,
}
}
func runDashboardToken(cmd *cobra.Command, args []string) error {
output.Header("Kubernetes Dashboard Token")
// Initialize environment
env := environment.New()
if err := env.RequiresProject(); err != nil {
return err
}
// Check external tools
toolManager := external.NewManager()
if err := toolManager.CheckTools(cmd.Context(), []string{"kubectl"}); err != nil {
return fmt.Errorf("required tools not available: %w", err)
}
kubectl := toolManager.Kubectl()
// The namespace where the dashboard is installed
namespace := "kubernetes-dashboard"
secretName := "dashboard-admin-token"
// Try to get the token from the secret
token, err := getDashboardToken(cmd.Context(), kubectl, namespace, secretName)
if err != nil {
return fmt.Errorf("failed to get dashboard token: %w", err)
}
// Print the token with nice formatting
output.Success("Use this token to authenticate to the Kubernetes Dashboard:")
output.Info("")
output.Printf("%s\n", token)
output.Info("")
// Additional instructions
output.Info("Instructions:")
output.Info("1. Copy the token above")
output.Info("2. Navigate to your Kubernetes Dashboard URL")
output.Info("3. Select 'Token' authentication method")
output.Info("4. Paste the token and click 'Sign In'")
return nil
}
// getDashboardToken retrieves the dashboard token from Kubernetes
func getDashboardToken(ctx context.Context, kubectl *external.KubectlTool, namespace, secretName string) (string, error) {
// Try to get the secret directly
secretData, err := kubectl.GetResource(ctx, "secret", secretName, namespace)
if err != nil {
// If secret doesn't exist, try to find any admin-related secret
output.Warning("Dashboard admin token secret not found, searching for available tokens...")
return findDashboardToken(ctx, kubectl, namespace)
}
// Extract token from secret data
// The secret data is in YAML format, we need to parse it
secretStr := string(secretData)
lines := strings.Split(secretStr, "\n")
for _, line := range lines {
if strings.Contains(line, "token:") {
// Extract the base64 encoded token
parts := strings.Fields(line)
if len(parts) >= 2 {
encodedToken := parts[1]
// Decode base64 token using kubectl
tokenBytes, err := kubectl.Execute(ctx, "exec", "deploy/coredns", "-n", "kube-system", "--", "base64", "-d")
if err != nil {
// Try alternative method with echo and base64
echoCmd := fmt.Sprintf("echo '%s' | base64 -d", encodedToken)
tokenBytes, err = kubectl.Execute(ctx, "exec", "deploy/coredns", "-n", "kube-system", "--", "sh", "-c", echoCmd)
if err != nil {
// Return the encoded token as fallback
return encodedToken, nil
}
}
return strings.TrimSpace(string(tokenBytes)), nil
}
}
}
return "", fmt.Errorf("token not found in secret data")
}
// findDashboardToken searches for available dashboard tokens
func findDashboardToken(ctx context.Context, kubectl *external.KubectlTool, namespace string) (string, error) {
// List all secrets in the dashboard namespace
secrets, err := kubectl.GetResource(ctx, "secrets", "", namespace)
if err != nil {
return "", fmt.Errorf("failed to list secrets in namespace %s: %w", namespace, err)
}
// Look for tokens in the secret list
secretsStr := string(secrets)
lines := strings.Split(secretsStr, "\n")
var tokenSecrets []string
for _, line := range lines {
if strings.Contains(line, "token") && strings.Contains(line, "dashboard") {
parts := strings.Fields(line)
if len(parts) > 0 {
tokenSecrets = append(tokenSecrets, parts[0])
}
}
}
if len(tokenSecrets) == 0 {
return "", fmt.Errorf("no dashboard token secrets found in namespace %s", namespace)
}
// Try the first available token secret
secretName := tokenSecrets[0]
output.Info("Using token secret: " + secretName)
return getDashboardToken(ctx, kubectl, namespace, secretName)
}

View File

@@ -0,0 +1,126 @@
package util
import (
"context"
"fmt"
"os"
"github.com/spf13/cobra"
"github.com/wild-cloud/wild-cli/internal/environment"
"github.com/wild-cloud/wild-cli/internal/external"
"github.com/wild-cloud/wild-cli/internal/output"
)
func runStatus(cmd *cobra.Command, args []string) error {
output.Header("Wild Cloud Status")
// Initialize environment
env := environment.New()
// Check if we're in a project directory
detected, _ := env.DetectWCHome()
if detected != "" {
env.SetWCHome(detected)
output.Success("Found Wild Cloud project: " + detected)
} else {
output.Warning("Not in a Wild Cloud project directory")
}
// Check environment
output.Info("\n=== Environment ===")
if env.WCRoot() != "" {
output.Success("WC_ROOT: " + env.WCRoot())
} else {
output.Warning("WC_ROOT: Not set")
}
if env.WCHome() != "" {
output.Success("WC_HOME: " + env.WCHome())
} else {
output.Warning("WC_HOME: Not set")
}
// Check external tools
output.Info("\n=== External Tools ===")
toolManager := external.NewManager()
tools := toolManager.ListTools()
for toolName, installed := range tools {
if installed {
version, err := toolManager.GetToolVersion(toolName)
if err != nil {
output.Success(fmt.Sprintf("%-12s: Installed (version unknown)", toolName))
} else {
output.Success(fmt.Sprintf("%-12s: %s", toolName, version))
}
} else {
output.Warning(fmt.Sprintf("%-12s: Not installed", toolName))
}
}
// Check project structure if in project
if env.WCHome() != "" {
output.Info("\n=== Project Structure ===")
// Check config files
if fileExists(env.ConfigPath()) {
output.Success("config.yaml: Found")
} else {
output.Warning("config.yaml: Missing")
}
if fileExists(env.SecretsPath()) {
output.Success("secrets.yaml: Found")
} else {
output.Warning("secrets.yaml: Missing")
}
if dirExists(env.AppsDir()) {
output.Success("apps/ directory: Found")
} else {
output.Warning("apps/ directory: Missing")
}
if dirExists(env.WildCloudDir()) {
output.Success(".wildcloud/ directory: Found")
} else {
output.Warning(".wildcloud/ directory: Missing")
}
// Check cluster connectivity if tools are available
if tools["kubectl"] {
output.Info("\n=== Cluster Status ===")
kubectl := toolManager.Kubectl()
ctx := context.Background()
nodes, err := kubectl.GetNodes(ctx)
if err != nil {
output.Warning("Cluster: Not accessible (" + err.Error() + ")")
} else {
output.Success("Cluster: Connected")
output.Info("Nodes:\n" + string(nodes))
}
}
}
output.Info("\n=== Summary ===")
if detected != "" {
output.Success("Wild Cloud project is properly configured")
} else {
output.Warning("Run 'wild setup scaffold' to initialize a project")
}
return nil
}
// Helper functions
func fileExists(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}
func dirExists(path string) bool {
info, err := os.Stat(path)
return err == nil && info.IsDir()
}

View File

@@ -0,0 +1,61 @@
package util
import (
"fmt"
"io"
"os"
"github.com/spf13/cobra"
"github.com/wild-cloud/wild-cli/internal/config"
"github.com/wild-cloud/wild-cli/internal/environment"
)
func newCompileCommand() *cobra.Command {
return &cobra.Command{
Use: "compile",
Short: "Compile template from stdin",
Long: `Compile a template from stdin using Wild Cloud configuration context.
This command reads template content from stdin and processes it using the
current project's config.yaml and secrets.yaml as context.
Examples:
echo 'Hello {{.config.cluster.name}}' | wild template compile
cat template.yml | wild template compile`,
RunE: runCompileTemplate,
}
}
func runCompileTemplate(cmd *cobra.Command, args []string) error {
// Initialize environment
env := environment.New()
if err := env.RequiresProject(); err != nil {
return err
}
// Create config manager
mgr := config.NewManager(env.ConfigPath(), env.SecretsPath())
// Create template engine
engine, err := config.NewTemplateEngine(mgr)
if err != nil {
return fmt.Errorf("creating template engine: %w", err)
}
// Read template from stdin
templateContent, err := io.ReadAll(os.Stdin)
if err != nil {
return fmt.Errorf("reading template from stdin: %w", err)
}
// Process template
result, err := engine.Process(string(templateContent))
if err != nil {
return fmt.Errorf("processing template: %w", err)
}
// Output result
fmt.Print(result)
return nil
}

View File

@@ -0,0 +1,32 @@
package util
import (
"github.com/spf13/cobra"
)
// NewBackupCommand is implemented in backup.go
// NewDashboardCommand is implemented in dashboard.go
// NewTemplateCommand creates the template command
func NewTemplateCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "template",
Short: "Process templates",
Long: `Process template files with Wild Cloud configuration.`,
}
cmd.AddCommand(newCompileCommand())
return cmd
}
// NewStatusCommand creates the status command
func NewStatusCommand() *cobra.Command {
return &cobra.Command{
Use: "status",
Short: "Show Wild Cloud status",
Long: `Show the overall status of the Wild Cloud system.`,
RunE: runStatus,
}
}
// NewVersionCommand is implemented in version.go

View File

@@ -0,0 +1,52 @@
package util
import (
"fmt"
"github.com/spf13/cobra"
"github.com/wild-cloud/wild-cli/internal/output"
)
const (
Version = "0.1.0-dev"
BuildDate = "development"
)
func NewVersionCommand() *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "Show version information",
Long: `Show version information for Wild CLI and components.
This command displays version information for the Wild CLI and related components.
Examples:
wild version`,
RunE: runVersion,
}
}
func runVersion(cmd *cobra.Command, args []string) error {
output.Header("Wild CLI Version Information")
output.Info(fmt.Sprintf("Wild CLI Version: %s", Version))
output.Info(fmt.Sprintf("Build Date: %s", BuildDate))
output.Info(fmt.Sprintf("Go Version: %s", "go1.21+"))
// TODO: Add component versions
// - kubectl version
// - talosctl version
// - restic version
// - yq version
output.Info("")
output.Info("Components:")
output.Info(" - Native Go implementation replacing 35+ bash scripts")
output.Info(" - Unified CLI with Cobra framework")
output.Info(" - Cross-platform support (Linux/macOS/Windows)")
output.Info(" - Built-in template engine with sprig functions")
output.Info(" - Integrated external tool management")
return nil
}