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

113
wild-cli/Makefile Normal file
View File

@@ -0,0 +1,113 @@
# Wild CLI Makefile
.DEFAULT_GOAL := help
# Build variables
BINARY_NAME := wild
BUILD_DIR := build
VERSION := 0.1.0-dev
COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
BUILD_TIME := $(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
# Go variables
GOOS := $(shell go env GOOS)
GOARCH := $(shell go env GOARCH)
# Linker flags
LDFLAGS := -ldflags "-X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.buildTime=$(BUILD_TIME)"
.PHONY: help
help: ## Show this help message
@echo "Wild CLI Build System"
@echo ""
@echo "Available targets:"
@awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " %-15s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
.PHONY: build
build: ## Build the binary
@echo "Building $(BINARY_NAME) for $(GOOS)/$(GOARCH)..."
@mkdir -p $(BUILD_DIR)
@go build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME) ./cmd/wild
.PHONY: install
install: ## Install the binary to GOPATH/bin
@echo "Installing $(BINARY_NAME)..."
@go install $(LDFLAGS) ./cmd/wild
.PHONY: clean
clean: ## Clean build artifacts
@echo "Cleaning build artifacts..."
@rm -rf $(BUILD_DIR)
.PHONY: test
test: ## Run tests
@echo "Running tests..."
@go test -v ./...
.PHONY: test-coverage
test-coverage: ## Run tests with coverage
@echo "Running tests with coverage..."
@go test -v -coverprofile=coverage.out ./...
@go tool cover -html=coverage.out -o coverage.html
.PHONY: lint
lint: ## Run linter (requires golangci-lint)
@echo "Running linter..."
@if command -v golangci-lint >/dev/null 2>&1; then \
golangci-lint run; \
else \
echo "golangci-lint not installed, skipping lint check"; \
echo "Install with: go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest"; \
fi
.PHONY: fmt
fmt: ## Format code
@echo "Formatting code..."
@go fmt ./...
.PHONY: mod-tidy
mod-tidy: ## Tidy go modules
@echo "Tidying go modules..."
@go mod tidy
.PHONY: deps
deps: ## Download dependencies
@echo "Downloading dependencies..."
@go mod download
.PHONY: build-all
build-all: ## Build for all platforms
@echo "Building for all platforms..."
@mkdir -p $(BUILD_DIR)
@echo "Building for linux/amd64..."
@GOOS=linux GOARCH=amd64 go build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-amd64 ./cmd/wild
@echo "Building for linux/arm64..."
@GOOS=linux GOARCH=arm64 go build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-linux-arm64 ./cmd/wild
@echo "Building for darwin/amd64..."
@GOOS=darwin GOARCH=amd64 go build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-amd64 ./cmd/wild
@echo "Building for darwin/arm64..."
@GOOS=darwin GOARCH=arm64 go build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-darwin-arm64 ./cmd/wild
@echo "Building for windows/amd64..."
@GOOS=windows GOARCH=amd64 go build $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME)-windows-amd64.exe ./cmd/wild
.PHONY: dev
dev: build ## Build and run in development mode
@echo "Running $(BINARY_NAME) in development mode..."
@$(BUILD_DIR)/$(BINARY_NAME) --help
.PHONY: check
check: fmt lint test ## Run all checks (format, lint, test)
# Development workflow targets
.PHONY: quick
quick: fmt build ## Quick development build
.PHONY: watch
watch: ## Watch for changes and rebuild (requires entr)
@echo "Watching for changes... (requires 'entr' to be installed)"
@find . -name "*.go" | entr -r make quick

161
wild-cli/README.md Normal file
View File

@@ -0,0 +1,161 @@
# Wild CLI
A unified Go CLI tool for managing Wild Cloud personal infrastructure, replacing the collection of wild-* bash scripts with a single, modern CLI application.
## Overview
Wild CLI provides comprehensive management of your personal cloud infrastructure built on Talos Linux and Kubernetes. It offers better error handling, progress tracking, cross-platform support, and improved user experience compared to the original bash scripts.
## Features
- **Unified interface** - Single `wild` command instead of many `wild-*` scripts
- **Better error handling** - Detailed error messages with suggestions
- **Cross-platform support** - Works on Linux, macOS, and Windows
- **Progress tracking** - Visual progress indicators for long-running operations
- **Improved validation** - Input validation and environment checks
- **Native Go performance** - Fast startup and execution
- **Comprehensive testing** - Unit and integration tests
## Installation
### From Source
```bash
git clone <repository-url>
cd wild-cli
make build
make install
```
### Pre-built Binaries
Download the latest release from the releases page and place the binary in your PATH.
## Usage
### Basic Commands
```bash
# Show help
wild --help
# Configuration management
wild config get cluster.name
wild config set cluster.domain example.com
# Secret management
wild secret get database.password
wild secret set database.password mySecretPassword
# Application management
wild app list
wild app fetch nextcloud
wild app add nextcloud
wild app deploy nextcloud
# Cluster management
wild cluster nodes list
wild cluster config generate
# System setup
wild setup scaffold
wild setup cluster
wild setup services
```
### Global Flags
- `--verbose, -v` - Enable verbose logging
- `--dry-run` - Show what would be done without making changes
- `--no-color` - Disable colored output
- `--config-dir` - Specify configuration directory
- `--wc-root` - Wild Cloud installation directory
- `--wc-home` - Wild Cloud project directory
## Development
### Prerequisites
- Go 1.22 or later
- Make
### Building
```bash
# Build for current platform
make build
# Build for all platforms
make build-all
# Development build with formatting
make quick
```
### Testing
```bash
# Run tests
make test
# Run tests with coverage
make test-coverage
# Run linter
make lint
# Run all checks
make check
```
### Project Structure
```
wild-cli/
├── cmd/wild/ # CLI commands
│ ├── app/ # App management commands
│ ├── cluster/ # Cluster management commands
│ ├── config/ # Configuration commands
│ ├── secret/ # Secret management commands
│ ├── setup/ # Setup commands
│ └── util/ # Utility commands
├── internal/ # Internal packages
│ ├── config/ # Configuration management
│ ├── environment/ # Environment detection
│ ├── output/ # Output formatting
│ └── ...
├── test/ # Test files
├── docs/ # Documentation
└── scripts/ # Build scripts
```
## Migration from Bash Scripts
Wild CLI maintains compatibility with the existing Wild Cloud workflow:
| Bash Script | Wild CLI Command |
|-------------|------------------|
| `wild-config <path>` | `wild config get <path>` |
| `wild-config-set <path> <value>` | `wild config set <path> <value>` |
| `wild-secret <path>` | `wild secret get <path>` |
| `wild-app-list` | `wild app list` |
| `wild-app-deploy <name>` | `wild app deploy <name>` |
| `wild-setup-cluster` | `wild setup cluster` |
## Environment Variables
- `WC_ROOT` - Wild Cloud installation directory
- `WC_HOME` - Wild Cloud project directory (auto-detected if not set)
## Contributing
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Add tests
5. Run `make check` to ensure quality
6. Submit a pull request
## License
This project follows the same license as the Wild Cloud project.

BIN
wild-cli/build/wild Executable file

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

47
wild-cli/go.mod Normal file
View File

@@ -0,0 +1,47 @@
module github.com/wild-cloud/wild-cli
go 1.22.0
toolchain go1.24.5
require (
github.com/Masterminds/sprig/v3 v3.3.0
github.com/fatih/color v1.17.0
github.com/spf13/cobra v1.8.1
github.com/spf13/viper v1.19.0
go.uber.org/zap v1.27.0
gopkg.in/yaml.v3 v3.0.1
)
require (
dario.cat/mergo v1.0.1 // indirect
github.com/Masterminds/goutils v1.1.1 // indirect
github.com/Masterminds/semver/v3 v3.3.0 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/huandu/xstrings v1.5.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/rogpeppe/go-internal v1.12.0 // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
github.com/shopspring/decimal v1.4.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.7.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
golang.org/x/crypto v0.26.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/sys v0.25.0 // indirect
golang.org/x/text v0.17.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
)

107
wild-cli/go.sum Normal file
View File

@@ -0,0 +1,107 @@
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU=
github.com/Masterminds/semver/v3 v3.3.0 h1:B8LGeaivUe71a5qox1ICM/JLl0NqZSW5CHyL+hmvYS0=
github.com/Masterminds/semver/v3 v3.3.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs=
github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4=
github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI=
github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w=
github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -0,0 +1,378 @@
package apps
import (
"fmt"
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
)
// App represents an application in the catalog
type App struct {
Name string `yaml:"name"`
Description string `yaml:"description"`
Version string `yaml:"version"`
Category string `yaml:"category"`
Homepage string `yaml:"homepage"`
Source string `yaml:"source"`
Tags []string `yaml:"tags"`
Requires []string `yaml:"requires"`
Provides map[string]string `yaml:"provides"`
Config map[string]interface{} `yaml:"config"`
}
// Catalog manages the application catalog
type Catalog struct {
cacheDir string
apps []App
loaded bool
}
// NewCatalog creates a new app catalog
func NewCatalog(cacheDir string) *Catalog {
return &Catalog{
cacheDir: cacheDir,
}
}
// LoadCatalog loads the app catalog from cache or remote source
func (c *Catalog) LoadCatalog() error {
if c.loaded {
return nil
}
// Try to load from cache first
catalogPath := filepath.Join(c.cacheDir, "catalog.yaml")
if err := c.loadFromFile(catalogPath); err == nil {
c.loaded = true
return nil
}
// If cache fails, try to fetch from remote
if err := c.fetchRemoteCatalog(); err != nil {
return fmt.Errorf("failed to load catalog: %w", err)
}
c.loaded = true
return nil
}
// loadFromFile loads catalog from a local file
func (c *Catalog) loadFromFile(path string) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("reading catalog file: %w", err)
}
var catalogData struct {
Apps []App `yaml:"apps"`
}
if err := yaml.Unmarshal(data, &catalogData); err != nil {
return fmt.Errorf("parsing catalog YAML: %w", err)
}
c.apps = catalogData.Apps
return nil
}
// fetchRemoteCatalog fetches catalog from remote source
func (c *Catalog) fetchRemoteCatalog() error {
// For now, create a default catalog
// In production, this would fetch from a remote URL
defaultCatalog := []App{
{
Name: "nextcloud",
Description: "Self-hosted file sync and share platform",
Version: "latest",
Category: "productivity",
Homepage: "https://nextcloud.com",
Source: "https://github.com/wild-cloud/app-nextcloud",
Tags: []string{"files", "sync", "collaboration"},
Requires: []string{"postgresql"},
Provides: map[string]string{"files": "nextcloud"},
},
{
Name: "postgresql",
Description: "Powerful, open source object-relational database",
Version: "15",
Category: "database",
Homepage: "https://postgresql.org",
Source: "https://github.com/wild-cloud/app-postgresql",
Tags: []string{"database", "sql"},
Provides: map[string]string{"database": "postgresql"},
},
{
Name: "traefik",
Description: "Modern HTTP reverse proxy and load balancer",
Version: "v3.0",
Category: "infrastructure",
Homepage: "https://traefik.io",
Source: "https://github.com/wild-cloud/app-traefik",
Tags: []string{"proxy", "loadbalancer", "ingress"},
Provides: map[string]string{"ingress": "traefik"},
},
{
Name: "monitoring",
Description: "Prometheus and Grafana monitoring stack",
Version: "latest",
Category: "infrastructure",
Homepage: "https://prometheus.io",
Source: "https://github.com/wild-cloud/app-monitoring",
Tags: []string{"monitoring", "metrics", "alerting"},
Provides: map[string]string{"monitoring": "prometheus"},
},
}
c.apps = defaultCatalog
// Save to cache
return c.saveCatalogToCache()
}
// saveCatalogToCache saves the catalog to cache
func (c *Catalog) saveCatalogToCache() error {
catalogData := struct {
Apps []App `yaml:"apps"`
}{
Apps: c.apps,
}
data, err := yaml.Marshal(catalogData)
if err != nil {
return fmt.Errorf("marshaling catalog: %w", err)
}
catalogPath := filepath.Join(c.cacheDir, "catalog.yaml")
if err := os.MkdirAll(filepath.Dir(catalogPath), 0755); err != nil {
return fmt.Errorf("creating cache directory: %w", err)
}
if err := os.WriteFile(catalogPath, data, 0644); err != nil {
return fmt.Errorf("writing catalog file: %w", err)
}
return nil
}
// ListApps returns all apps in the catalog
func (c *Catalog) ListApps() ([]App, error) {
if err := c.LoadCatalog(); err != nil {
return nil, err
}
return c.apps, nil
}
// FindApp finds an app by name
func (c *Catalog) FindApp(name string) (*App, error) {
if err := c.LoadCatalog(); err != nil {
return nil, err
}
for _, app := range c.apps {
if app.Name == name {
return &app, nil
}
}
return nil, fmt.Errorf("app '%s' not found in catalog", name)
}
// SearchApps searches for apps by name or tag
func (c *Catalog) SearchApps(query string) ([]App, error) {
if err := c.LoadCatalog(); err != nil {
return nil, err
}
var results []App
query = strings.ToLower(query)
for _, app := range c.apps {
// Check name
if strings.Contains(strings.ToLower(app.Name), query) {
results = append(results, app)
continue
}
// Check description
if strings.Contains(strings.ToLower(app.Description), query) {
results = append(results, app)
continue
}
// Check tags
for _, tag := range app.Tags {
if strings.Contains(strings.ToLower(tag), query) {
results = append(results, app)
break
}
}
}
return results, nil
}
// FetchApp downloads an app template to cache
func (c *Catalog) FetchApp(name string) error {
app, err := c.FindApp(name)
if err != nil {
return err
}
appCacheDir := filepath.Join(c.cacheDir, "apps", name)
if err := os.MkdirAll(appCacheDir, 0755); err != nil {
return fmt.Errorf("creating app cache directory: %w", err)
}
// For now, create a basic app template
// In production, this would clone from app.Source
if err := c.createAppTemplate(app, appCacheDir); err != nil {
return fmt.Errorf("creating app template: %w", err)
}
return nil
}
// createAppTemplate creates a basic app template structure
func (c *Catalog) createAppTemplate(app *App, dir string) error {
// Create manifest.yaml
manifest := map[string]interface{}{
"name": app.Name,
"version": app.Version,
"description": app.Description,
"requires": app.Requires,
"provides": app.Provides,
"config": app.Config,
}
manifestData, err := yaml.Marshal(manifest)
if err != nil {
return fmt.Errorf("marshaling manifest: %w", err)
}
manifestPath := filepath.Join(dir, "manifest.yaml")
if err := os.WriteFile(manifestPath, manifestData, 0644); err != nil {
return fmt.Errorf("writing manifest: %w", err)
}
// Create basic kubernetes manifests
if err := c.createKubernetesManifests(app, dir); err != nil {
return fmt.Errorf("creating kubernetes manifests: %w", err)
}
return nil
}
// createKubernetesManifests creates basic Kubernetes manifest templates
func (c *Catalog) createKubernetesManifests(app *App, dir string) error {
// Create namespace.yaml
namespace := fmt.Sprintf(`apiVersion: v1
kind: Namespace
metadata:
name: %s
labels:
app: %s
`, app.Name, app.Name)
if err := os.WriteFile(filepath.Join(dir, "namespace.yaml"), []byte(namespace), 0644); err != nil {
return fmt.Errorf("writing namespace.yaml: %w", err)
}
// Create basic deployment template
deployment := fmt.Sprintf(`apiVersion: apps/v1
kind: Deployment
metadata:
name: %s
namespace: %s
spec:
replicas: {{.config.%s.replicas | default 1}}
selector:
matchLabels:
app: %s
template:
metadata:
labels:
app: %s
spec:
containers:
- name: %s
image: {{.config.%s.image | default "%s:latest"}}
ports:
- containerPort: 8080
`, app.Name, app.Name, app.Name, app.Name, app.Name, app.Name, app.Name, app.Name)
if err := os.WriteFile(filepath.Join(dir, "deployment.yaml"), []byte(deployment), 0644); err != nil {
return fmt.Errorf("writing deployment.yaml: %w", err)
}
// Create service.yaml
service := fmt.Sprintf(`apiVersion: v1
kind: Service
metadata:
name: %s
namespace: %s
spec:
selector:
app: %s
ports:
- port: 80
targetPort: 8080
type: ClusterIP
`, app.Name, app.Name, app.Name)
if err := os.WriteFile(filepath.Join(dir, "service.yaml"), []byte(service), 0644); err != nil {
return fmt.Errorf("writing service.yaml: %w", err)
}
// Create kustomization.yaml
kustomization := `apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- namespace.yaml
- deployment.yaml
- service.yaml
`
if err := os.WriteFile(filepath.Join(dir, "kustomization.yaml"), []byte(kustomization), 0644); err != nil {
return fmt.Errorf("writing kustomization.yaml: %w", err)
}
return nil
}
// IsAppCached checks if an app is cached locally
func (c *Catalog) IsAppCached(name string) bool {
appCacheDir := filepath.Join(c.cacheDir, "apps", name)
manifestPath := filepath.Join(appCacheDir, "manifest.yaml")
_, err := os.Stat(manifestPath)
return err == nil
}
// GetCachedApps returns list of cached apps
func (c *Catalog) GetCachedApps() ([]string, error) {
appsDir := filepath.Join(c.cacheDir, "apps")
entries, err := os.ReadDir(appsDir)
if err != nil {
if os.IsNotExist(err) {
return []string{}, nil
}
return nil, fmt.Errorf("reading apps directory: %w", err)
}
var cachedApps []string
for _, entry := range entries {
if entry.IsDir() {
manifestPath := filepath.Join(appsDir, entry.Name(), "manifest.yaml")
if _, err := os.Stat(manifestPath); err == nil {
cachedApps = append(cachedApps, entry.Name())
}
}
}
return cachedApps, nil
}

View File

@@ -0,0 +1,298 @@
package config
import (
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"gopkg.in/yaml.v3"
)
// Manager handles configuration and secrets files
type Manager struct {
configPath string
secretsPath string
}
// NewManager creates a new configuration manager
func NewManager(configPath, secretsPath string) *Manager {
return &Manager{
configPath: configPath,
secretsPath: secretsPath,
}
}
// Get retrieves a value from the config file using dot-notation path
func (m *Manager) Get(path string) (interface{}, error) {
return m.getValue(m.configPath, path)
}
// Set sets a value in the config file using dot-notation path
func (m *Manager) Set(path, value string) error {
return m.setValue(m.configPath, path, value)
}
// GetSecret retrieves a value from the secrets file using dot-notation path
func (m *Manager) GetSecret(path string) (interface{}, error) {
return m.getValue(m.secretsPath, path)
}
// SetSecret sets a value in the secrets file using dot-notation path
func (m *Manager) SetSecret(path, value string) error {
return m.setValue(m.secretsPath, path, value)
}
// getValue retrieves a value from a YAML file using dot-notation path
func (m *Manager) getValue(filePath, path string) (interface{}, error) {
// Check if file exists
if _, err := os.Stat(filePath); os.IsNotExist(err) {
return nil, fmt.Errorf("file not found: %s", filePath)
}
// Read file
data, err := os.ReadFile(filePath)
if err != nil {
return nil, fmt.Errorf("reading file: %w", err)
}
// Parse YAML
var yamlData interface{}
if err := yaml.Unmarshal(data, &yamlData); err != nil {
return nil, fmt.Errorf("parsing YAML: %w", err)
}
// Navigate to the specified path
value, err := m.navigatePath(yamlData, path)
if err != nil {
return nil, err
}
return value, nil
}
// setValue sets a value in a YAML file using dot-notation path
func (m *Manager) setValue(filePath, path, value string) error {
// Ensure directory exists
if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil {
return fmt.Errorf("creating directory: %w", err)
}
// Read existing file or create empty structure
var yamlData interface{}
if data, err := os.ReadFile(filePath); err == nil {
if err := yaml.Unmarshal(data, &yamlData); err != nil {
return fmt.Errorf("parsing existing YAML: %w", err)
}
} else if !os.IsNotExist(err) {
return fmt.Errorf("reading file: %w", err)
}
// If no existing data, start with empty map
if yamlData == nil {
yamlData = make(map[string]interface{})
}
// Parse the value as YAML to handle different types
var parsedValue interface{}
if err := yaml.Unmarshal([]byte(value), &parsedValue); err != nil {
// If it fails to parse as YAML, treat as string
parsedValue = value
}
// Set the value at the specified path
if err := m.setValueAtPath(yamlData, path, parsedValue); err != nil {
return fmt.Errorf("setting value at path: %w", err)
}
// Marshal back to YAML
data, err := yaml.Marshal(yamlData)
if err != nil {
return fmt.Errorf("marshaling YAML: %w", err)
}
// Write file
if err := os.WriteFile(filePath, data, 0600); err != nil {
return fmt.Errorf("writing file: %w", err)
}
return nil
}
// navigatePath navigates through a nested data structure using dot-notation path
func (m *Manager) navigatePath(data interface{}, path string) (interface{}, error) {
if path == "" {
return data, nil
}
parts := m.parsePath(path)
current := data
for _, part := range parts {
if part.isArray {
// Handle array access like "items[0]"
slice, ok := current.([]interface{})
if !ok {
return nil, fmt.Errorf("path component %s is not an array", part.key)
}
if part.index < 0 || part.index >= len(slice) {
return nil, fmt.Errorf("array index %d out of range for %s", part.index, part.key)
}
current = slice[part.index]
} else {
// Handle map access
m, ok := current.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("path component %s is not a map", part.key)
}
var exists bool
current, exists = m[part.key]
if !exists {
return nil, nil // Key not found
}
}
}
return current, nil
}
// setValueAtPath sets a value at the specified path, creating nested structures as needed
func (m *Manager) setValueAtPath(data interface{}, path string, value interface{}) error {
if path == "" {
return fmt.Errorf("empty path")
}
parts := m.parsePath(path)
current := data
// Navigate to the parent of the target
for _, part := range parts[:len(parts)-1] {
if part.isArray {
slice, ok := current.([]interface{})
if !ok {
return fmt.Errorf("path component %s is not an array", part.key)
}
if part.index < 0 || part.index >= len(slice) {
return fmt.Errorf("array index %d out of range for %s", part.index, part.key)
}
current = slice[part.index]
} else {
m, ok := current.(map[string]interface{})
if !ok {
return fmt.Errorf("path component %s is not a map", part.key)
}
next, exists := m[part.key]
if !exists {
// Create new map for next level
next = make(map[string]interface{})
m[part.key] = next
}
current = next
}
}
// Set the final value
finalPart := parts[len(parts)-1]
if finalPart.isArray {
return fmt.Errorf("cannot set array element directly")
} else {
m, ok := current.(map[string]interface{})
if !ok {
return fmt.Errorf("cannot set value on non-map")
}
m[finalPart.key] = value
}
return nil
}
// pathPart represents a single component in a dot-notation path
type pathPart struct {
key string
isArray bool
index int
}
// parsePath parses a dot-notation path into components
func (m *Manager) parsePath(path string) []pathPart {
var parts []pathPart
components := strings.Split(path, ".")
for _, component := range components {
if strings.Contains(component, "[") && strings.Contains(component, "]") {
// Handle array syntax like "items[0]"
openBracket := strings.Index(component, "[")
closeBracket := strings.Index(component, "]")
key := component[:openBracket]
indexStr := component[openBracket+1 : closeBracket]
if key != "" {
parts = append(parts, pathPart{
key: key,
isArray: false,
})
}
if index, err := strconv.Atoi(indexStr); err == nil {
parts = append(parts, pathPart{
key: key,
isArray: true,
index: index,
})
}
} else {
parts = append(parts, pathPart{
key: component,
isArray: false,
})
}
}
return parts
}
// LoadConfig loads the entire config file as a map
func (m *Manager) LoadConfig() (map[string]interface{}, error) {
data, err := m.getValue(m.configPath, "")
if err != nil {
return nil, err
}
if data == nil {
return make(map[string]interface{}), nil
}
configMap, ok := data.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("config file is not a valid YAML map")
}
return configMap, nil
}
// LoadSecrets loads the entire secrets file as a map
func (m *Manager) LoadSecrets() (map[string]interface{}, error) {
data, err := m.getValue(m.secretsPath, "")
if err != nil {
return nil, err
}
if data == nil {
return make(map[string]interface{}), nil
}
secretsMap, ok := data.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("secrets file is not a valid YAML map")
}
return secretsMap, nil
}

View File

@@ -0,0 +1,139 @@
package config
import (
"bytes"
"fmt"
"strings"
"text/template"
"github.com/Masterminds/sprig/v3"
)
// TemplateEngine handles template processing with Wild Cloud context
type TemplateEngine struct {
configData map[string]interface{}
secretsData map[string]interface{}
}
// NewTemplateEngine creates a new template engine with config and secrets context
func NewTemplateEngine(configMgr *Manager) (*TemplateEngine, error) {
configData, err := configMgr.LoadConfig()
if err != nil {
return nil, fmt.Errorf("loading config: %w", err)
}
secretsData, err := configMgr.LoadSecrets()
if err != nil {
return nil, fmt.Errorf("loading secrets: %w", err)
}
return &TemplateEngine{
configData: configData,
secretsData: secretsData,
}, nil
}
// Process processes template content with Wild Cloud context
func (t *TemplateEngine) Process(templateContent string) (string, error) {
// Create template with sprig functions
tmpl := template.New("wild").Funcs(sprig.TxtFuncMap())
// Add Wild Cloud specific functions
tmpl = tmpl.Funcs(template.FuncMap{
// Config access function - matches gomplate .config
"config": func(path string) interface{} {
return t.getValueByPath(t.configData, path)
},
// Secret access function - matches gomplate .secrets
"secret": func(path string) interface{} {
return t.getValueByPath(t.secretsData, path)
},
// Direct access to config data - matches gomplate behavior
"getConfig": func(path string) interface{} {
return t.getValueByPath(t.configData, path)
},
// Direct access to secret data - matches gomplate behavior
"getSecret": func(path string) interface{} {
return t.getValueByPath(t.secretsData, path)
},
})
// Parse template
parsed, err := tmpl.Parse(templateContent)
if err != nil {
return "", fmt.Errorf("parsing template: %w", err)
}
// Execute template with context
var buf bytes.Buffer
context := map[string]interface{}{
"config": t.configData,
"secrets": t.secretsData,
}
err = parsed.Execute(&buf, context)
if err != nil {
return "", fmt.Errorf("executing template: %w", err)
}
return buf.String(), nil
}
// ProcessFile processes a template file with Wild Cloud context
func (t *TemplateEngine) ProcessFile(templateFile string) (string, error) {
// Read file content
content, err := readFile(templateFile)
if err != nil {
return "", fmt.Errorf("reading template file: %w", err)
}
return t.Process(string(content))
}
// getValueByPath retrieves a value from nested data using dot-notation path
func (t *TemplateEngine) getValueByPath(data interface{}, path string) interface{} {
if path == "" {
return data
}
parts := strings.Split(path, ".")
current := data
for _, part := range parts {
switch v := current.(type) {
case map[string]interface{}:
var exists bool
current, exists = v[part]
if !exists {
return nil
}
case map[interface{}]interface{}:
var exists bool
current, exists = v[part]
if !exists {
return nil
}
default:
return nil
}
}
return current
}
// readFile is a helper to read file contents
func readFile(filename string) ([]byte, error) {
// This would be implemented to read from filesystem
// For now, returning empty to avoid import cycles
return nil, fmt.Errorf("file reading not implemented yet")
}
// CompileTemplate is a convenience function for one-off template processing
func CompileTemplate(templateContent string, configMgr *Manager) (string, error) {
engine, err := NewTemplateEngine(configMgr)
if err != nil {
return "", err
}
return engine.Process(templateContent)
}

View File

@@ -0,0 +1,215 @@
package environment
import (
"context"
"fmt"
"os"
"path/filepath"
)
// Environment manages Wild Cloud environment variables and paths
type Environment struct {
wcRoot string
wcHome string
}
// New creates a new Environment instance
func New() *Environment {
env := &Environment{}
// Initialize from environment variables set by root command
if wcRoot := os.Getenv("WC_ROOT"); wcRoot != "" {
env.wcRoot = wcRoot
}
if wcHome := os.Getenv("WC_HOME"); wcHome != "" {
env.wcHome = wcHome
}
// If WC_HOME is not set, try to detect it
if env.wcHome == "" {
if detected, err := env.DetectWCHome(); err == nil && detected != "" {
env.wcHome = detected
// Set environment variable for child processes
_ = os.Setenv("WC_HOME", detected)
}
}
return env
}
// WCRoot returns the Wild Cloud installation directory
func (e *Environment) WCRoot() string {
return e.wcRoot
}
// WCHome returns the Wild Cloud project directory
func (e *Environment) WCHome() string {
return e.wcHome
}
// SetWCRoot sets the Wild Cloud installation directory
func (e *Environment) SetWCRoot(path string) {
e.wcRoot = path
}
// SetWCHome sets the Wild Cloud project directory
func (e *Environment) SetWCHome(path string) {
e.wcHome = path
}
// DetectWCHome attempts to find the Wild Cloud project directory by looking for .wildcloud marker
func (e *Environment) DetectWCHome() (string, error) {
// Start from current working directory
dir, err := os.Getwd()
if err != nil {
return "", fmt.Errorf("getting current directory: %w", err)
}
// Walk up the directory tree looking for .wildcloud marker
for {
markerPath := filepath.Join(dir, ".wildcloud")
if info, err := os.Stat(markerPath); err == nil && info.IsDir() {
return dir, nil
}
parent := filepath.Dir(dir)
if parent == dir {
// Reached root directory
break
}
dir = parent
}
return "", nil
}
// Validate checks that the environment is properly configured
func (e *Environment) Validate(ctx context.Context) error {
// Validate WC_ROOT if set
if e.wcRoot != "" {
if err := e.validateWCRoot(); err != nil {
return fmt.Errorf("invalid WC_ROOT: %w", err)
}
}
// Validate WC_HOME if set
if e.wcHome != "" {
if err := e.validateWCHome(); err != nil {
return fmt.Errorf("invalid WC_HOME: %w", err)
}
}
return nil
}
// validateWCRoot checks that WC_ROOT is a valid Wild Cloud installation
func (e *Environment) validateWCRoot() error {
if e.wcRoot == "" {
return nil
}
// Check if directory exists
info, err := os.Stat(e.wcRoot)
if err != nil {
return fmt.Errorf("directory does not exist: %w", err)
}
if !info.IsDir() {
return fmt.Errorf("path is not a directory: %s", e.wcRoot)
}
// Check for bin directory (contains wild-* scripts)
binDir := filepath.Join(e.wcRoot, "bin")
if info, err := os.Stat(binDir); err != nil || !info.IsDir() {
return fmt.Errorf("bin directory not found, this may not be a Wild Cloud installation")
}
// Note: We skip the PATH check for CLI usage as it's not required
// The original bash scripts expect WC_ROOT/bin to be in PATH, but the CLI can work without it
return nil
}
// validateWCHome checks that WC_HOME is a valid Wild Cloud project
func (e *Environment) validateWCHome() error {
if e.wcHome == "" {
return nil
}
// Check if directory exists
info, err := os.Stat(e.wcHome)
if err != nil {
return fmt.Errorf("directory does not exist: %w", err)
}
if !info.IsDir() {
return fmt.Errorf("path is not a directory: %s", e.wcHome)
}
// Check for .wildcloud marker directory
markerDir := filepath.Join(e.wcHome, ".wildcloud")
if info, err := os.Stat(markerDir); err != nil || !info.IsDir() {
return fmt.Errorf("not a Wild Cloud project directory (missing .wildcloud marker)")
}
return nil
}
// ConfigPath returns the path to the config.yaml file
func (e *Environment) ConfigPath() string {
if e.wcHome == "" {
return ""
}
return filepath.Join(e.wcHome, "config.yaml")
}
// SecretsPath returns the path to the secrets.yaml file
func (e *Environment) SecretsPath() string {
if e.wcHome == "" {
return ""
}
return filepath.Join(e.wcHome, "secrets.yaml")
}
// AppsDir returns the path to the apps directory
func (e *Environment) AppsDir() string {
if e.wcHome == "" {
return ""
}
return filepath.Join(e.wcHome, "apps")
}
// WildCloudDir returns the path to the .wildcloud directory
func (e *Environment) WildCloudDir() string {
if e.wcHome == "" {
return ""
}
return filepath.Join(e.wcHome, ".wildcloud")
}
// CacheDir returns the path to the cache directory
func (e *Environment) CacheDir() string {
if e.wcHome == "" {
return ""
}
return filepath.Join(e.wcHome, ".wildcloud", "cache")
}
// IsConfigured returns true if both WC_ROOT and WC_HOME are set and valid
func (e *Environment) IsConfigured() bool {
return e.wcRoot != "" && e.wcHome != ""
}
// RequiresProject returns an error if WC_HOME is not configured
func (e *Environment) RequiresProject() error {
if e.wcHome == "" {
return fmt.Errorf("this command requires a Wild Cloud project directory. Run 'wild setup scaffold' to create one, or run from within an existing project")
}
return nil
}
// RequiresInstallation returns an error if WC_ROOT is not configured
func (e *Environment) RequiresInstallation() error {
if e.wcRoot == "" {
return fmt.Errorf("WC_ROOT is not set. Please set the WC_ROOT environment variable to your Wild Cloud installation directory")
}
return nil
}

130
wild-cli/internal/external/base.go vendored Normal file
View File

@@ -0,0 +1,130 @@
package external
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"time"
)
// Tool represents an external command-line tool
type Tool interface {
Name() string
BinaryName() string
IsInstalled() bool
Version() (string, error)
Execute(ctx context.Context, args ...string) ([]byte, error)
ExecuteWithInput(ctx context.Context, input string, args ...string) ([]byte, error)
}
// BaseTool provides common functionality for external tools
type BaseTool struct {
name string
binaryName string
binaryPath string
timeout time.Duration
}
// NewBaseTool creates a new base tool
func NewBaseTool(name, binaryName string) *BaseTool {
return &BaseTool{
name: name,
binaryName: binaryName,
timeout: 5 * time.Minute, // Default timeout
}
}
// Name returns the tool name
func (t *BaseTool) Name() string {
return t.name
}
// BinaryName returns the binary name
func (t *BaseTool) BinaryName() string {
return t.binaryName
}
// IsInstalled checks if the tool is available in PATH
func (t *BaseTool) IsInstalled() bool {
if t.binaryPath == "" {
path, err := exec.LookPath(t.binaryName)
if err != nil {
return false
}
t.binaryPath = path
}
return true
}
// Version returns the tool version
func (t *BaseTool) Version() (string, error) {
if !t.IsInstalled() {
return "", fmt.Errorf("tool %s not installed", t.name)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
output, err := t.Execute(ctx, "--version")
if err != nil {
// Try alternative version flags
output, err = t.Execute(ctx, "version")
if err != nil {
output, err = t.Execute(ctx, "-v")
if err != nil {
return "", fmt.Errorf("getting version: %w", err)
}
}
}
return string(output), nil
}
// Execute runs the tool with given arguments
func (t *BaseTool) Execute(ctx context.Context, args ...string) ([]byte, error) {
return t.ExecuteWithInput(ctx, "", args...)
}
// ExecuteWithInput runs the tool with stdin input
func (t *BaseTool) ExecuteWithInput(ctx context.Context, input string, args ...string) ([]byte, error) {
if !t.IsInstalled() {
return nil, fmt.Errorf("tool %s not installed", t.name)
}
// Create command with timeout
ctx, cancel := context.WithTimeout(ctx, t.timeout)
defer cancel()
cmd := exec.CommandContext(ctx, t.binaryPath, args...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if input != "" {
cmd.Stdin = bytes.NewBufferString(input)
}
// Set environment
cmd.Env = os.Environ()
err := cmd.Run()
if err != nil {
return nil, fmt.Errorf("executing %s %v: %w\nstderr: %s",
t.name, args, err, stderr.String())
}
return stdout.Bytes(), nil
}
// SetTimeout sets the execution timeout
func (t *BaseTool) SetTimeout(timeout time.Duration) {
t.timeout = timeout
}
// SetBinaryPath explicitly sets the binary path (useful for testing)
func (t *BaseTool) SetBinaryPath(path string) {
t.binaryPath = path
}

226
wild-cli/internal/external/kubectl.go vendored Normal file
View File

@@ -0,0 +1,226 @@
package external
import (
"context"
"fmt"
"strings"
)
// KubectlTool wraps kubectl operations
type KubectlTool struct {
*BaseTool
kubeconfig string
}
// NewKubectlTool creates a new kubectl tool wrapper
func NewKubectlTool() *KubectlTool {
return &KubectlTool{
BaseTool: NewBaseTool("kubectl", "kubectl"),
}
}
// SetKubeconfig sets the kubeconfig file path
func (k *KubectlTool) SetKubeconfig(path string) {
k.kubeconfig = path
}
// Apply applies Kubernetes manifests
func (k *KubectlTool) Apply(ctx context.Context, manifests []string, namespace string, dryRun bool) error {
for _, manifest := range manifests {
args := []string{"apply", "-f", "-"}
if namespace != "" {
args = append(args, "--namespace", namespace)
}
if dryRun {
args = append(args, "--dry-run=client")
}
if k.kubeconfig != "" {
args = append([]string{"--kubeconfig", k.kubeconfig}, args...)
}
_, err := k.ExecuteWithInput(ctx, manifest, args...)
if err != nil {
return fmt.Errorf("applying manifest: %w", err)
}
}
return nil
}
// ApplyKustomize applies using kustomize
func (k *KubectlTool) ApplyKustomize(ctx context.Context, path string, namespace string, dryRun bool) error {
args := []string{"apply", "-k", path}
if namespace != "" {
args = append(args, "--namespace", namespace)
}
if dryRun {
args = append(args, "--dry-run=client")
}
if k.kubeconfig != "" {
args = append([]string{"--kubeconfig", k.kubeconfig}, args...)
}
_, err := k.Execute(ctx, args...)
if err != nil {
return fmt.Errorf("applying kustomize: %w", err)
}
return nil
}
// Delete deletes Kubernetes resources
func (k *KubectlTool) Delete(ctx context.Context, resource, name, namespace string, ignoreNotFound bool) error {
args := []string{"delete", resource, name}
if namespace != "" {
args = append(args, "--namespace", namespace)
}
if ignoreNotFound {
args = append(args, "--ignore-not-found=true")
}
if k.kubeconfig != "" {
args = append([]string{"--kubeconfig", k.kubeconfig}, args...)
}
_, err := k.Execute(ctx, args...)
if err != nil {
return fmt.Errorf("deleting resource: %w", err)
}
return nil
}
// CreateSecret creates a Kubernetes secret
func (k *KubectlTool) CreateSecret(ctx context.Context, name, namespace string, data map[string]string) error {
// First try to delete existing secret
_ = k.Delete(ctx, "secret", name, namespace, true)
args := []string{"create", "secret", "generic", name}
for key, value := range data {
args = append(args, "--from-literal="+key+"="+value)
}
if namespace != "" {
args = append(args, "--namespace", namespace)
}
if k.kubeconfig != "" {
args = append([]string{"--kubeconfig", k.kubeconfig}, args...)
}
_, err := k.Execute(ctx, args...)
if err != nil {
return fmt.Errorf("creating secret: %w", err)
}
return nil
}
// GetResource gets a Kubernetes resource
func (k *KubectlTool) GetResource(ctx context.Context, resource, name, namespace string) ([]byte, error) {
args := []string{"get", resource}
if name != "" {
args = append(args, name)
}
if namespace != "" {
args = append(args, "--namespace", namespace)
}
args = append(args, "-o", "yaml")
if k.kubeconfig != "" {
args = append([]string{"--kubeconfig", k.kubeconfig}, args...)
}
output, err := k.Execute(ctx, args...)
if err != nil {
return nil, fmt.Errorf("getting resource: %w", err)
}
return output, nil
}
// WaitForDeletion waits for a resource to be deleted
func (k *KubectlTool) WaitForDeletion(ctx context.Context, resource, name, namespace string, timeout string) error {
args := []string{"wait", "--for=delete", resource + "/" + name}
if namespace != "" {
args = append(args, "--namespace", namespace)
}
if timeout != "" {
args = append(args, "--timeout="+timeout)
}
if k.kubeconfig != "" {
args = append([]string{"--kubeconfig", k.kubeconfig}, args...)
}
_, err := k.Execute(ctx, args...)
if err != nil {
return fmt.Errorf("waiting for deletion: %w", err)
}
return nil
}
// GetNodes returns information about cluster nodes
func (k *KubectlTool) GetNodes(ctx context.Context) ([]byte, error) {
args := []string{"get", "nodes", "-o", "wide"}
if k.kubeconfig != "" {
args = append([]string{"--kubeconfig", k.kubeconfig}, args...)
}
output, err := k.Execute(ctx, args...)
if err != nil {
return nil, fmt.Errorf("getting nodes: %w", err)
}
return output, nil
}
// GetServiceAccount gets a service account token
func (k *KubectlTool) GetServiceAccountToken(ctx context.Context, serviceAccount, namespace string) (string, error) {
// Get the service account
args := []string{"get", "serviceaccount", serviceAccount, "--namespace", namespace, "-o", "jsonpath={.secrets[0].name}"}
if k.kubeconfig != "" {
args = append([]string{"--kubeconfig", k.kubeconfig}, args...)
}
secretName, err := k.Execute(ctx, args...)
if err != nil {
return "", fmt.Errorf("getting service account secret: %w", err)
}
secretNameStr := strings.TrimSpace(string(secretName))
if secretNameStr == "" {
return "", fmt.Errorf("no secret found for service account %s", serviceAccount)
}
// Get the token from the secret
args = []string{"get", "secret", secretNameStr, "--namespace", namespace, "-o", "jsonpath={.data.token}"}
if k.kubeconfig != "" {
args = append([]string{"--kubeconfig", k.kubeconfig}, args...)
}
tokenBytes, err := k.Execute(ctx, args...)
if err != nil {
return "", fmt.Errorf("getting token from secret: %w", err)
}
return string(tokenBytes), nil
}

93
wild-cli/internal/external/manager.go vendored Normal file
View File

@@ -0,0 +1,93 @@
package external
import (
"context"
"fmt"
)
// Manager coordinates external tools
type Manager struct {
kubectl *KubectlTool
talosctl *TalosctlTool
restic *ResticTool
tools map[string]Tool
}
// NewManager creates a new tool manager
func NewManager() *Manager {
kubectl := NewKubectlTool()
talosctl := NewTalosctlTool()
restic := NewResticTool()
tools := map[string]Tool{
"kubectl": kubectl,
"talosctl": talosctl,
"restic": restic,
}
return &Manager{
kubectl: kubectl,
talosctl: talosctl,
restic: restic,
tools: tools,
}
}
// Kubectl returns the kubectl tool
func (m *Manager) Kubectl() *KubectlTool {
return m.kubectl
}
// Talosctl returns the talosctl tool
func (m *Manager) Talosctl() *TalosctlTool {
return m.talosctl
}
// Restic returns the restic tool
func (m *Manager) Restic() *ResticTool {
return m.restic
}
// CheckTools verifies that required tools are available
func (m *Manager) CheckTools(ctx context.Context, required []string) error {
missing := make([]string, 0)
for _, toolName := range required {
tool, exists := m.tools[toolName]
if !exists {
missing = append(missing, toolName)
continue
}
if !tool.IsInstalled() {
missing = append(missing, toolName)
}
}
if len(missing) > 0 {
return fmt.Errorf("missing required tools: %v", missing)
}
return nil
}
// GetToolVersion returns the version of a tool
func (m *Manager) GetToolVersion(toolName string) (string, error) {
tool, exists := m.tools[toolName]
if !exists {
return "", fmt.Errorf("tool %s not found", toolName)
}
return tool.Version()
}
// ListTools returns information about all tools
func (m *Manager) ListTools() map[string]bool {
status := make(map[string]bool)
for name, tool := range m.tools {
status[name] = tool.IsInstalled()
}
return status
}

288
wild-cli/internal/external/restic.go vendored Normal file
View File

@@ -0,0 +1,288 @@
package external
import (
"context"
"fmt"
"os"
"strings"
)
// ResticTool wraps restic backup operations
type ResticTool struct {
*BaseTool
repository string
password string
}
// NewResticTool creates a new restic tool wrapper
func NewResticTool() *ResticTool {
return &ResticTool{
BaseTool: NewBaseTool("restic", "restic"),
}
}
// SetRepository sets the restic repository
func (r *ResticTool) SetRepository(repo string) {
r.repository = repo
}
// SetPassword sets the restic password
func (r *ResticTool) SetPassword(password string) {
r.password = password
}
// InitRepository initializes a new restic repository
func (r *ResticTool) InitRepository(ctx context.Context) error {
env := r.getEnvironment()
cmd := r.BaseTool
originalEnv := os.Environ()
defer func() {
os.Clearenv()
for _, kv := range originalEnv {
if k, v, found := strings.Cut(kv, "="); found {
_ = os.Setenv(k, v)
}
}
}()
// Set environment variables
for k, v := range env {
_ = os.Setenv(k, v)
}
args := []string{"init"}
if r.repository != "" {
args = append(args, "--repo", r.repository)
}
_, err := cmd.Execute(ctx, args...)
if err != nil {
return fmt.Errorf("initializing repository: %w", err)
}
return nil
}
// Backup creates a backup
func (r *ResticTool) Backup(ctx context.Context, paths []string, excludes []string, tags []string) error {
env := r.getEnvironment()
cmd := r.BaseTool
originalEnv := os.Environ()
defer func() {
os.Clearenv()
for _, kv := range originalEnv {
if k, v, found := strings.Cut(kv, "="); found {
_ = os.Setenv(k, v)
}
}
}()
// Set environment variables
for k, v := range env {
_ = os.Setenv(k, v)
}
args := []string{"backup"}
if r.repository != "" {
args = append(args, "--repo", r.repository)
}
// Add paths
args = append(args, paths...)
// Add excludes
for _, exclude := range excludes {
args = append(args, "--exclude", exclude)
}
// Add tags
for _, tag := range tags {
args = append(args, "--tag", tag)
}
_, err := cmd.Execute(ctx, args...)
if err != nil {
return fmt.Errorf("creating backup: %w", err)
}
return nil
}
// Restore restores from backup
func (r *ResticTool) Restore(ctx context.Context, snapshotID, target string) error {
env := r.getEnvironment()
cmd := r.BaseTool
originalEnv := os.Environ()
defer func() {
os.Clearenv()
for _, kv := range originalEnv {
if k, v, found := strings.Cut(kv, "="); found {
_ = os.Setenv(k, v)
}
}
}()
// Set environment variables
for k, v := range env {
_ = os.Setenv(k, v)
}
args := []string{"restore", snapshotID, "--target", target}
if r.repository != "" {
args = append(args, "--repo", r.repository)
}
_, err := cmd.Execute(ctx, args...)
if err != nil {
return fmt.Errorf("restoring backup: %w", err)
}
return nil
}
// ListSnapshots lists available snapshots
func (r *ResticTool) ListSnapshots(ctx context.Context, tags []string) ([]byte, error) {
env := r.getEnvironment()
cmd := r.BaseTool
originalEnv := os.Environ()
defer func() {
os.Clearenv()
for _, kv := range originalEnv {
if k, v, found := strings.Cut(kv, "="); found {
_ = os.Setenv(k, v)
}
}
}()
// Set environment variables
for k, v := range env {
_ = os.Setenv(k, v)
}
args := []string{"snapshots", "--json"}
if r.repository != "" {
args = append(args, "--repo", r.repository)
}
// Add tag filters
for _, tag := range tags {
args = append(args, "--tag", tag)
}
output, err := cmd.Execute(ctx, args...)
if err != nil {
return nil, fmt.Errorf("listing snapshots: %w", err)
}
return output, nil
}
// Check verifies repository integrity
func (r *ResticTool) Check(ctx context.Context) error {
env := r.getEnvironment()
cmd := r.BaseTool
originalEnv := os.Environ()
defer func() {
os.Clearenv()
for _, kv := range originalEnv {
if k, v, found := strings.Cut(kv, "="); found {
_ = os.Setenv(k, v)
}
}
}()
// Set environment variables
for k, v := range env {
_ = os.Setenv(k, v)
}
args := []string{"check"}
if r.repository != "" {
args = append(args, "--repo", r.repository)
}
_, err := cmd.Execute(ctx, args...)
if err != nil {
return fmt.Errorf("checking repository: %w", err)
}
return nil
}
// Forget removes snapshots
func (r *ResticTool) Forget(ctx context.Context, keepLast int, keepDaily int, keepWeekly int, keepMonthly int, prune bool) error {
env := r.getEnvironment()
cmd := r.BaseTool
originalEnv := os.Environ()
defer func() {
os.Clearenv()
for _, kv := range originalEnv {
if k, v, found := strings.Cut(kv, "="); found {
_ = os.Setenv(k, v)
}
}
}()
// Set environment variables
for k, v := range env {
_ = os.Setenv(k, v)
}
args := []string{"forget"}
if r.repository != "" {
args = append(args, "--repo", r.repository)
}
if keepLast > 0 {
args = append(args, "--keep-last", fmt.Sprintf("%d", keepLast))
}
if keepDaily > 0 {
args = append(args, "--keep-daily", fmt.Sprintf("%d", keepDaily))
}
if keepWeekly > 0 {
args = append(args, "--keep-weekly", fmt.Sprintf("%d", keepWeekly))
}
if keepMonthly > 0 {
args = append(args, "--keep-monthly", fmt.Sprintf("%d", keepMonthly))
}
if prune {
args = append(args, "--prune")
}
_, err := cmd.Execute(ctx, args...)
if err != nil {
return fmt.Errorf("forgetting snapshots: %w", err)
}
return nil
}
// getEnvironment returns environment variables for restic
func (r *ResticTool) getEnvironment() map[string]string {
env := make(map[string]string)
if r.repository != "" {
env["RESTIC_REPOSITORY"] = r.repository
}
if r.password != "" {
env["RESTIC_PASSWORD"] = r.password
}
return env
}

285
wild-cli/internal/external/talosctl.go vendored Normal file
View File

@@ -0,0 +1,285 @@
package external
import (
"context"
"fmt"
)
// TalosctlTool wraps talosctl operations
type TalosctlTool struct {
*BaseTool
endpoints []string
nodes []string
talosconfig string
}
// NewTalosctlTool creates a new talosctl tool wrapper
func NewTalosctlTool() *TalosctlTool {
return &TalosctlTool{
BaseTool: NewBaseTool("talosctl", "talosctl"),
}
}
// SetEndpoints sets the Talos API endpoints
func (t *TalosctlTool) SetEndpoints(endpoints []string) {
t.endpoints = endpoints
}
// SetNodes sets the target nodes
func (t *TalosctlTool) SetNodes(nodes []string) {
t.nodes = nodes
}
// SetTalosconfig sets the talosconfig file path
func (t *TalosctlTool) SetTalosconfig(path string) {
t.talosconfig = path
}
// GenerateConfig generates Talos configuration files
func (t *TalosctlTool) GenerateConfig(ctx context.Context, clusterName, clusterEndpoint, outputDir string) error {
args := []string{
"gen", "config",
clusterName,
clusterEndpoint,
"--output-dir", outputDir,
}
if t.talosconfig != "" {
args = append([]string{"--talosconfig", t.talosconfig}, args...)
}
_, err := t.Execute(ctx, args...)
if err != nil {
return fmt.Errorf("generating config: %w", err)
}
return nil
}
// ApplyConfig applies configuration to nodes
func (t *TalosctlTool) ApplyConfig(ctx context.Context, configFile string, insecure bool) error {
args := []string{"apply-config"}
if insecure {
args = append(args, "--insecure")
}
args = append(args, "--file", configFile)
if len(t.endpoints) > 0 {
args = append(args, "--endpoints")
args = append(args, t.endpoints...)
}
if len(t.nodes) > 0 {
args = append(args, "--nodes")
args = append(args, t.nodes...)
}
if t.talosconfig != "" {
args = append([]string{"--talosconfig", t.talosconfig}, args...)
}
_, err := t.Execute(ctx, args...)
if err != nil {
return fmt.Errorf("applying config: %w", err)
}
return nil
}
// Bootstrap bootstraps the cluster
func (t *TalosctlTool) Bootstrap(ctx context.Context) error {
args := []string{"bootstrap"}
if len(t.endpoints) > 0 {
args = append(args, "--endpoints")
args = append(args, t.endpoints...)
}
if len(t.nodes) > 0 {
args = append(args, "--nodes")
args = append(args, t.nodes...)
}
if t.talosconfig != "" {
args = append([]string{"--talosconfig", t.talosconfig}, args...)
}
_, err := t.Execute(ctx, args...)
if err != nil {
return fmt.Errorf("bootstrapping cluster: %w", err)
}
return nil
}
// Kubeconfig retrieves the kubeconfig
func (t *TalosctlTool) Kubeconfig(ctx context.Context, outputFile string, force bool) error {
args := []string{"kubeconfig"}
if outputFile != "" {
args = append(args, outputFile)
}
if force {
args = append(args, "--force")
}
if len(t.endpoints) > 0 {
args = append(args, "--endpoints")
args = append(args, t.endpoints...)
}
if len(t.nodes) > 0 {
args = append(args, "--nodes")
args = append(args, t.nodes...)
}
if t.talosconfig != "" {
args = append([]string{"--talosconfig", t.talosconfig}, args...)
}
_, err := t.Execute(ctx, args...)
if err != nil {
return fmt.Errorf("getting kubeconfig: %w", err)
}
return nil
}
// Health checks the health of nodes
func (t *TalosctlTool) Health(ctx context.Context) ([]byte, error) {
args := []string{"health"}
if len(t.endpoints) > 0 {
args = append(args, "--endpoints")
args = append(args, t.endpoints...)
}
if t.talosconfig != "" {
args = append([]string{"--talosconfig", t.talosconfig}, args...)
}
output, err := t.Execute(ctx, args...)
if err != nil {
return nil, fmt.Errorf("checking health: %w", err)
}
return output, nil
}
// List lists Talos resources
func (t *TalosctlTool) List(ctx context.Context, resource string) ([]byte, error) {
args := []string{"list", resource}
if len(t.endpoints) > 0 {
args = append(args, "--endpoints")
args = append(args, t.endpoints...)
}
if len(t.nodes) > 0 {
args = append(args, "--nodes")
args = append(args, t.nodes...)
}
if t.talosconfig != "" {
args = append([]string{"--talosconfig", t.talosconfig}, args...)
}
output, err := t.Execute(ctx, args...)
if err != nil {
return nil, fmt.Errorf("listing %s: %w", resource, err)
}
return output, nil
}
// Patch applies patches to node configuration
func (t *TalosctlTool) Patch(ctx context.Context, patchFile string, configType string) error {
args := []string{"patch", configType, "--patch-file", patchFile}
if len(t.endpoints) > 0 {
args = append(args, "--endpoints")
args = append(args, t.endpoints...)
}
if len(t.nodes) > 0 {
args = append(args, "--nodes")
args = append(args, t.nodes...)
}
if t.talosconfig != "" {
args = append([]string{"--talosconfig", t.talosconfig}, args...)
}
_, err := t.Execute(ctx, args...)
if err != nil {
return fmt.Errorf("patching config: %w", err)
}
return nil
}
// Reboot reboots nodes
func (t *TalosctlTool) Reboot(ctx context.Context) error {
args := []string{"reboot"}
if len(t.endpoints) > 0 {
args = append(args, "--endpoints")
args = append(args, t.endpoints...)
}
if len(t.nodes) > 0 {
args = append(args, "--nodes")
args = append(args, t.nodes...)
}
if t.talosconfig != "" {
args = append([]string{"--talosconfig", t.talosconfig}, args...)
}
_, err := t.Execute(ctx, args...)
if err != nil {
return fmt.Errorf("rebooting nodes: %w", err)
}
return nil
}
// GenerateSecrets generates cluster secrets
func (t *TalosctlTool) GenerateSecrets(ctx context.Context) error {
args := []string{"gen", "secrets"}
if t.talosconfig != "" {
args = append([]string{"--talosconfig", t.talosconfig}, args...)
}
_, err := t.Execute(ctx, args...)
if err != nil {
return fmt.Errorf("generating secrets: %w", err)
}
return nil
}
// GenerateConfigWithSecrets generates configuration with existing secrets
func (t *TalosctlTool) GenerateConfigWithSecrets(ctx context.Context, clusterName, clusterEndpoint, secretsFile string) error {
args := []string{
"gen", "config",
"--with-secrets", secretsFile,
clusterName,
clusterEndpoint,
}
if t.talosconfig != "" {
args = append([]string{"--talosconfig", t.talosconfig}, args...)
}
_, err := t.Execute(ctx, args...)
if err != nil {
return fmt.Errorf("generating config with secrets: %w", err)
}
return nil
}

View File

@@ -0,0 +1,185 @@
package output
import (
"fmt"
"os"
"github.com/fatih/color"
"go.uber.org/zap"
)
var (
// Global state for output formatting
colorEnabled = true
verboseMode = false
// Colors
colorInfo = color.New(color.FgBlue)
colorSuccess = color.New(color.FgGreen)
colorWarning = color.New(color.FgYellow)
colorError = color.New(color.FgRed)
colorHeader = color.New(color.FgBlue, color.Bold)
)
// Logger provides structured logging with colored output
type Logger struct {
zap *zap.Logger
}
// NewLogger creates a new logger instance
func NewLogger() *Logger {
config := zap.NewDevelopmentConfig()
config.DisableStacktrace = true
config.Level = zap.NewAtomicLevelAt(zap.InfoLevel)
if verboseMode {
config.Level = zap.NewAtomicLevelAt(zap.DebugLevel)
}
logger, _ := config.Build()
return &Logger{
zap: logger,
}
}
// Sync flushes any buffered log entries
func (l *Logger) Sync() error {
return l.zap.Sync()
}
// Info logs an info message
func (l *Logger) Info(msg string, keysAndValues ...interface{}) {
l.zap.Sugar().Infow(msg, keysAndValues...)
// Also print to stdout with formatting
if colorEnabled {
_, _ = fmt.Fprintf(os.Stdout, "%s %s\n", colorInfo.Sprint("INFO:"), msg)
} else {
_, _ = fmt.Fprintf(os.Stdout, "INFO: %s\n", msg)
}
}
// Success logs a success message
func (l *Logger) Success(msg string, keysAndValues ...interface{}) {
l.zap.Sugar().Infow(msg, keysAndValues...)
if colorEnabled {
_, _ = fmt.Fprintf(os.Stdout, "%s %s\n", colorSuccess.Sprint("SUCCESS:"), msg)
} else {
_, _ = fmt.Fprintf(os.Stdout, "SUCCESS: %s\n", msg)
}
}
// Warning logs a warning message
func (l *Logger) Warning(msg string, keysAndValues ...interface{}) {
l.zap.Sugar().Warnw(msg, keysAndValues...)
if colorEnabled {
fmt.Fprintf(os.Stderr, "%s %s\n", colorWarning.Sprint("WARNING:"), msg)
} else {
fmt.Fprintf(os.Stderr, "WARNING: %s\n", msg)
}
}
// Error logs an error message
func (l *Logger) Error(msg string, keysAndValues ...interface{}) {
l.zap.Sugar().Errorw(msg, keysAndValues...)
if colorEnabled {
fmt.Fprintf(os.Stderr, "%s %s\n", colorError.Sprint("ERROR:"), msg)
} else {
fmt.Fprintf(os.Stderr, "ERROR: %s\n", msg)
}
}
// Debug logs a debug message (only shown in verbose mode)
func (l *Logger) Debug(msg string, keysAndValues ...interface{}) {
l.zap.Sugar().Debugw(msg, keysAndValues...)
if verboseMode {
if colorEnabled {
_, _ = fmt.Fprintf(os.Stdout, "%s %s\n", color.New(color.FgMagenta).Sprint("DEBUG:"), msg)
} else {
_, _ = fmt.Fprintf(os.Stdout, "DEBUG: %s\n", msg)
}
}
}
// Header prints a formatted header
func (l *Logger) Header(msg string) {
if colorEnabled {
_, _ = fmt.Fprintf(os.Stdout, "\n%s\n\n", colorHeader.Sprintf("=== %s ===", msg))
} else {
_, _ = fmt.Fprintf(os.Stdout, "\n=== %s ===\n\n", msg)
}
}
// Printf provides formatted output
func (l *Logger) Printf(format string, args ...interface{}) {
fmt.Printf(format, args...)
}
// Print provides simple output
func (l *Logger) Print(msg string) {
fmt.Println(msg)
}
// Global functions for package-level access
// DisableColor disables colored output
func DisableColor() {
colorEnabled = false
color.NoColor = true
}
// SetVerbose enables or disables verbose mode
func SetVerbose(enabled bool) {
verboseMode = enabled
}
// Package-level convenience functions
func Info(msg string) {
if colorEnabled {
_, _ = fmt.Fprintf(os.Stdout, "%s %s\n", colorInfo.Sprint("INFO:"), msg)
} else {
_, _ = fmt.Fprintf(os.Stdout, "INFO: %s\n", msg)
}
}
func Success(msg string) {
if colorEnabled {
_, _ = fmt.Fprintf(os.Stdout, "%s %s\n", colorSuccess.Sprint("SUCCESS:"), msg)
} else {
_, _ = fmt.Fprintf(os.Stdout, "SUCCESS: %s\n", msg)
}
}
func Warning(msg string) {
if colorEnabled {
fmt.Fprintf(os.Stderr, "%s %s\n", colorWarning.Sprint("WARNING:"), msg)
} else {
fmt.Fprintf(os.Stderr, "WARNING: %s\n", msg)
}
}
func Error(msg string) {
if colorEnabled {
fmt.Fprintf(os.Stderr, "%s %s\n", colorError.Sprint("ERROR:"), msg)
} else {
fmt.Fprintf(os.Stderr, "ERROR: %s\n", msg)
}
}
func Header(msg string) {
if colorEnabled {
_, _ = fmt.Fprintf(os.Stdout, "\n%s\n\n", colorHeader.Sprintf("=== %s ===", msg))
} else {
_, _ = fmt.Fprintf(os.Stdout, "\n=== %s ===\n\n", msg)
}
}
// Printf provides formatted output (package-level function)
func Printf(format string, args ...interface{}) {
fmt.Printf(format, args...)
}