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
|
||||
}
|
Reference in New Issue
Block a user