First commit of golang CLI.

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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