First commit of golang CLI.
This commit is contained in:
180
wild-cli/cmd/wild/app/add.go
Normal file
180
wild-cli/cmd/wild/app/add.go
Normal 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
|
||||
}
|
43
wild-cli/cmd/wild/app/app.go
Normal file
43
wild-cli/cmd/wild/app/app.go
Normal 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
|
116
wild-cli/cmd/wild/app/backup.go
Normal file
116
wild-cli/cmd/wild/app/backup.go
Normal 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
|
||||
}
|
138
wild-cli/cmd/wild/app/delete.go
Normal file
138
wild-cli/cmd/wild/app/delete.go
Normal 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
|
||||
}
|
223
wild-cli/cmd/wild/app/deploy.go
Normal file
223
wild-cli/cmd/wild/app/deploy.go
Normal 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
|
||||
}
|
246
wild-cli/cmd/wild/app/doctor.go
Normal file
246
wild-cli/cmd/wild/app/doctor.go
Normal 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
|
||||
}
|
168
wild-cli/cmd/wild/app/fetch.go
Normal file
168
wild-cli/cmd/wild/app/fetch.go
Normal 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
|
||||
}
|
319
wild-cli/cmd/wild/app/list.go
Normal file
319
wild-cli/cmd/wild/app/list.go
Normal 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
|
||||
}
|
116
wild-cli/cmd/wild/app/restore.go
Normal file
116
wild-cli/cmd/wild/app/restore.go
Normal 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
|
||||
}
|
29
wild-cli/cmd/wild/cluster/cluster.go
Normal file
29
wild-cli/cmd/wild/cluster/cluster.go
Normal 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
|
161
wild-cli/cmd/wild/cluster/config.go
Normal file
161
wild-cli/cmd/wild/cluster/config.go
Normal 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
|
||||
}
|
319
wild-cli/cmd/wild/cluster/nodes.go
Normal file
319
wild-cli/cmd/wild/cluster/nodes.go
Normal 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
|
||||
}
|
585
wild-cli/cmd/wild/cluster/services.go
Normal file
585
wild-cli/cmd/wild/cluster/services.go
Normal 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
|
||||
}
|
29
wild-cli/cmd/wild/config/config.go
Normal file
29
wild-cli/cmd/wild/config/config.go
Normal 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
|
||||
}
|
86
wild-cli/cmd/wild/config/get.go
Normal file
86
wild-cli/cmd/wild/config/get.go
Normal 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
|
||||
}
|
53
wild-cli/cmd/wild/config/set.go
Normal file
53
wild-cli/cmd/wild/config/set.go
Normal 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
38
wild-cli/cmd/wild/main.go
Normal 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
146
wild-cli/cmd/wild/root.go
Normal 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
|
||||
}
|
74
wild-cli/cmd/wild/secret/get.go
Normal file
74
wild-cli/cmd/wild/secret/get.go
Normal 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
|
||||
}
|
30
wild-cli/cmd/wild/secret/secret.go
Normal file
30
wild-cli/cmd/wild/secret/secret.go
Normal 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
|
||||
}
|
53
wild-cli/cmd/wild/secret/set.go
Normal file
53
wild-cli/cmd/wild/secret/set.go
Normal 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
|
||||
}
|
265
wild-cli/cmd/wild/setup/cluster.go
Normal file
265
wild-cli/cmd/wild/setup/cluster.go
Normal 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
|
||||
}
|
190
wild-cli/cmd/wild/setup/scaffold.go
Normal file
190
wild-cli/cmd/wild/setup/scaffold.go
Normal 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
|
||||
}
|
564
wild-cli/cmd/wild/setup/services.go
Normal file
564
wild-cli/cmd/wild/setup/services.go
Normal 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
|
||||
}
|
30
wild-cli/cmd/wild/setup/setup.go
Normal file
30
wild-cli/cmd/wild/setup/setup.go
Normal 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
|
249
wild-cli/cmd/wild/util/backup.go
Normal file
249
wild-cli/cmd/wild/util/backup.go
Normal 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
|
||||
}
|
157
wild-cli/cmd/wild/util/dashboard.go
Normal file
157
wild-cli/cmd/wild/util/dashboard.go
Normal 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)
|
||||
}
|
126
wild-cli/cmd/wild/util/status.go
Normal file
126
wild-cli/cmd/wild/util/status.go
Normal 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()
|
||||
}
|
61
wild-cli/cmd/wild/util/template.go
Normal file
61
wild-cli/cmd/wild/util/template.go
Normal 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
|
||||
}
|
32
wild-cli/cmd/wild/util/util.go
Normal file
32
wild-cli/cmd/wild/util/util.go
Normal 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
|
52
wild-cli/cmd/wild/util/version.go
Normal file
52
wild-cli/cmd/wild/util/version.go
Normal 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
|
||||
}
|
Reference in New Issue
Block a user