First commit of golang CLI.
This commit is contained in:
29
wild-cli/cmd/wild/cluster/cluster.go
Normal file
29
wild-cli/cmd/wild/cluster/cluster.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// NewClusterCommand creates the cluster command and its subcommands
|
||||
func NewClusterCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "cluster",
|
||||
Short: "Manage Wild Cloud cluster",
|
||||
Long: `Manage the Kubernetes cluster infrastructure.
|
||||
|
||||
This includes node management, configuration generation, and service deployment.`,
|
||||
}
|
||||
|
||||
// Add subcommands
|
||||
cmd.AddCommand(
|
||||
newConfigCommand(),
|
||||
newNodesCommand(),
|
||||
newServicesCommand(),
|
||||
)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// newConfigCommand is implemented in config.go
|
||||
// newNodesCommand is implemented in nodes.go
|
||||
// newServicesCommand is implemented in services.go
|
161
wild-cli/cmd/wild/cluster/config.go
Normal file
161
wild-cli/cmd/wild/cluster/config.go
Normal file
@@ -0,0 +1,161 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/wild-cloud/wild-cli/internal/config"
|
||||
"github.com/wild-cloud/wild-cli/internal/environment"
|
||||
"github.com/wild-cloud/wild-cli/internal/external"
|
||||
"github.com/wild-cloud/wild-cli/internal/output"
|
||||
)
|
||||
|
||||
func newConfigCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "config",
|
||||
Short: "Manage cluster configuration",
|
||||
Long: `Generate and manage cluster configuration files.`,
|
||||
}
|
||||
|
||||
cmd.AddCommand(
|
||||
newConfigGenerateCommand(),
|
||||
)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newConfigGenerateCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "generate",
|
||||
Short: "Generate cluster configuration",
|
||||
Long: `Generate Talos configuration files for the cluster.
|
||||
|
||||
This command creates initial cluster secrets and configuration files using talosctl.
|
||||
|
||||
Examples:
|
||||
wild cluster config generate`,
|
||||
RunE: runConfigGenerate,
|
||||
}
|
||||
}
|
||||
|
||||
func runConfigGenerate(cmd *cobra.Command, args []string) error {
|
||||
output.Header("Talos Cluster Configuration Generation")
|
||||
|
||||
// Initialize environment
|
||||
env := environment.New()
|
||||
if err := env.RequiresProject(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check external tools
|
||||
toolManager := external.NewManager()
|
||||
if err := toolManager.CheckTools(cmd.Context(), []string{"talosctl"}); err != nil {
|
||||
return fmt.Errorf("required tools not available: %w", err)
|
||||
}
|
||||
|
||||
// Load configuration
|
||||
configMgr := config.NewManager(env.ConfigPath(), env.SecretsPath())
|
||||
|
||||
// Ensure required directories exist
|
||||
nodeSetupDir := filepath.Join(env.WildCloudDir(), "setup", "cluster-nodes")
|
||||
generatedDir := filepath.Join(nodeSetupDir, "generated")
|
||||
|
||||
// Check if generated directory already exists and has content
|
||||
if entries, err := os.ReadDir(generatedDir); err == nil && len(entries) > 0 {
|
||||
output.Success("Cluster configuration already exists in " + generatedDir)
|
||||
output.Info("Skipping cluster configuration generation")
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(generatedDir, 0755); err != nil {
|
||||
return fmt.Errorf("creating generated directory: %w", err)
|
||||
}
|
||||
|
||||
// Get required configuration values
|
||||
clusterName, err := getRequiredConfig(configMgr, "cluster.name", "wild-cluster")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
vip, err := getRequiredConfig(configMgr, "cluster.nodes.control.vip", "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
output.Info("Generating new cluster secrets...")
|
||||
|
||||
// Remove existing secrets directory if it exists
|
||||
if _, err := os.Stat(generatedDir); err == nil {
|
||||
output.Warning("Removing existing secrets directory...")
|
||||
if err := os.RemoveAll(generatedDir); err != nil {
|
||||
return fmt.Errorf("removing existing generated directory: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(generatedDir, 0755); err != nil {
|
||||
return fmt.Errorf("creating generated directory: %w", err)
|
||||
}
|
||||
|
||||
// Generate cluster configuration
|
||||
output.Info("Generating initial cluster configuration...")
|
||||
output.Info("Cluster name: " + clusterName)
|
||||
output.Info("Control plane endpoint: https://" + vip + ":6443")
|
||||
|
||||
// Change to generated directory for talosctl operations
|
||||
oldDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting current directory: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Chdir(generatedDir); err != nil {
|
||||
return fmt.Errorf("changing to generated directory: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = os.Chdir(oldDir)
|
||||
}()
|
||||
|
||||
talosctl := toolManager.Talosctl()
|
||||
|
||||
// Generate secrets first
|
||||
if err := talosctl.GenerateSecrets(cmd.Context()); err != nil {
|
||||
return fmt.Errorf("generating secrets: %w", err)
|
||||
}
|
||||
|
||||
// Generate configuration with secrets
|
||||
endpoint := "https://" + vip + ":6443"
|
||||
if err := talosctl.GenerateConfigWithSecrets(cmd.Context(), clusterName, endpoint, "secrets.yaml"); err != nil {
|
||||
return fmt.Errorf("generating config with secrets: %w", err)
|
||||
}
|
||||
|
||||
output.Success("Cluster configuration generation completed!")
|
||||
output.Info("Generated files in: " + generatedDir)
|
||||
output.Info(" - controlplane.yaml # Control plane node configuration")
|
||||
output.Info(" - worker.yaml # Worker node configuration")
|
||||
output.Info(" - talosconfig # Talos client configuration")
|
||||
output.Info(" - secrets.yaml # Cluster secrets")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getRequiredConfig gets a required configuration value, prompting if not set
|
||||
func getRequiredConfig(configMgr *config.Manager, path, defaultValue string) (string, error) {
|
||||
value, err := configMgr.Get(path)
|
||||
if err != nil || value == nil || value.(string) == "" {
|
||||
if defaultValue != "" {
|
||||
output.Warning(fmt.Sprintf("Config '%s' not set, using default: %s", path, defaultValue))
|
||||
return defaultValue, nil
|
||||
} else {
|
||||
return "", fmt.Errorf("required configuration '%s' not set", path)
|
||||
}
|
||||
}
|
||||
|
||||
strValue, ok := value.(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("configuration '%s' is not a string", path)
|
||||
}
|
||||
|
||||
return strValue, nil
|
||||
}
|
319
wild-cli/cmd/wild/cluster/nodes.go
Normal file
319
wild-cli/cmd/wild/cluster/nodes.go
Normal file
@@ -0,0 +1,319 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/wild-cloud/wild-cli/internal/config"
|
||||
"github.com/wild-cloud/wild-cli/internal/environment"
|
||||
"github.com/wild-cloud/wild-cli/internal/external"
|
||||
"github.com/wild-cloud/wild-cli/internal/output"
|
||||
)
|
||||
|
||||
func newNodesCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "nodes",
|
||||
Short: "Manage cluster nodes",
|
||||
Long: `Manage Kubernetes cluster nodes.`,
|
||||
}
|
||||
|
||||
cmd.AddCommand(
|
||||
newNodesListCommand(),
|
||||
newNodesBootCommand(),
|
||||
)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newNodesListCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List cluster nodes",
|
||||
Long: `List and show status of cluster nodes.
|
||||
|
||||
This command shows the status of both configured nodes and running cluster nodes.
|
||||
|
||||
Examples:
|
||||
wild cluster nodes list`,
|
||||
RunE: runNodesList,
|
||||
}
|
||||
}
|
||||
|
||||
func newNodesBootCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "boot",
|
||||
Short: "Boot cluster nodes",
|
||||
Long: `Boot and configure cluster nodes by downloading boot assets.
|
||||
|
||||
This command downloads Talos boot assets including kernel, initramfs, and ISO images
|
||||
for PXE booting or USB creation.
|
||||
|
||||
Examples:
|
||||
wild cluster nodes boot`,
|
||||
RunE: runNodesBoot,
|
||||
}
|
||||
}
|
||||
|
||||
func runNodesList(cmd *cobra.Command, args []string) error {
|
||||
output.Header("Cluster Nodes Status")
|
||||
|
||||
// Initialize environment
|
||||
env := environment.New()
|
||||
if err := env.RequiresProject(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Load configuration
|
||||
configMgr := config.NewManager(env.ConfigPath(), env.SecretsPath())
|
||||
|
||||
// Show configured nodes
|
||||
output.Info("=== Configured Nodes ===")
|
||||
nodesConfig, err := configMgr.Get("cluster.nodes")
|
||||
if err != nil || nodesConfig == nil {
|
||||
output.Warning("No nodes configured")
|
||||
output.Info("Add nodes to config: wild config set cluster.nodes '[{\"ip\": \"192.168.1.10\", \"role\": \"controlplane\"}]'")
|
||||
} else {
|
||||
nodes, ok := nodesConfig.([]interface{})
|
||||
if !ok || len(nodes) == 0 {
|
||||
output.Warning("No nodes configured")
|
||||
} else {
|
||||
for i, nodeConfig := range nodes {
|
||||
nodeMap, ok := nodeConfig.(map[string]interface{})
|
||||
if !ok {
|
||||
output.Warning(fmt.Sprintf("Invalid node %d configuration", i))
|
||||
continue
|
||||
}
|
||||
|
||||
nodeIP := nodeMap["ip"]
|
||||
nodeRole := nodeMap["role"]
|
||||
if nodeRole == nil {
|
||||
nodeRole = "worker"
|
||||
}
|
||||
|
||||
output.Info(fmt.Sprintf(" Node %d: %v (%v)", i+1, nodeIP, nodeRole))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to show running cluster nodes if kubectl is available
|
||||
toolManager := external.NewManager()
|
||||
if err := toolManager.CheckTools(cmd.Context(), []string{"kubectl"}); err == nil {
|
||||
kubectl := toolManager.Kubectl()
|
||||
if nodesOutput, err := kubectl.GetNodes(cmd.Context()); err == nil {
|
||||
output.Info("\n=== Running Cluster Nodes ===")
|
||||
output.Info(string(nodesOutput))
|
||||
} else {
|
||||
output.Info("\n=== Running Cluster Nodes ===")
|
||||
output.Warning("Could not connect to cluster: " + err.Error())
|
||||
}
|
||||
} else {
|
||||
output.Info("\n=== Running Cluster Nodes ===")
|
||||
output.Warning("kubectl not available - cannot show running cluster status")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runNodesBoot(cmd *cobra.Command, args []string) error {
|
||||
output.Header("Talos Installer Image Generation and Asset Download")
|
||||
|
||||
// Initialize environment
|
||||
env := environment.New()
|
||||
if err := env.RequiresProject(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Load configuration
|
||||
configMgr := config.NewManager(env.ConfigPath(), env.SecretsPath())
|
||||
|
||||
// Check for required configuration
|
||||
talosVersion, err := configMgr.Get("cluster.nodes.talos.version")
|
||||
if err != nil || talosVersion == nil {
|
||||
return fmt.Errorf("missing required configuration: cluster.nodes.talos.version")
|
||||
}
|
||||
|
||||
schematicID, err := configMgr.Get("cluster.nodes.talos.schematicId")
|
||||
if err != nil || schematicID == nil {
|
||||
return fmt.Errorf("missing required configuration: cluster.nodes.talos.schematicId")
|
||||
}
|
||||
|
||||
talosVersionStr := talosVersion.(string)
|
||||
schematicIDStr := schematicID.(string)
|
||||
|
||||
if talosVersionStr == "" || schematicIDStr == "" {
|
||||
return fmt.Errorf("talos version and schematic ID cannot be empty")
|
||||
}
|
||||
|
||||
output.Info("Creating custom Talos installer image...")
|
||||
output.Info("Talos version: " + talosVersionStr)
|
||||
output.Info("Schematic ID: " + schematicIDStr)
|
||||
|
||||
// Show schematic extensions if available
|
||||
if extensions, err := configMgr.Get("cluster.nodes.talos.schematic.customization.systemExtensions.officialExtensions"); err == nil && extensions != nil {
|
||||
if extList, ok := extensions.([]interface{}); ok && len(extList) > 0 {
|
||||
output.Info("\nSchematic includes:")
|
||||
for _, ext := range extList {
|
||||
output.Info(" - " + fmt.Sprintf("%v", ext))
|
||||
}
|
||||
output.Info("")
|
||||
}
|
||||
}
|
||||
|
||||
// Generate installer image URL
|
||||
installerURL := fmt.Sprintf("factory.talos.dev/metal-installer/%s:%s", schematicIDStr, talosVersionStr)
|
||||
output.Success("Custom installer image URL generated!")
|
||||
output.Info("")
|
||||
output.Info("Installer URL: " + installerURL)
|
||||
|
||||
// Download and cache assets
|
||||
output.Header("Downloading and Caching PXE Boot Assets")
|
||||
|
||||
// Create cache directories organized by schematic ID
|
||||
cacheDir := filepath.Join(env.WildCloudDir())
|
||||
schematicCacheDir := filepath.Join(cacheDir, "node-boot-assets", schematicIDStr)
|
||||
pxeCacheDir := filepath.Join(schematicCacheDir, "pxe")
|
||||
ipxeCacheDir := filepath.Join(schematicCacheDir, "ipxe")
|
||||
isoCacheDir := filepath.Join(schematicCacheDir, "iso")
|
||||
|
||||
if err := os.MkdirAll(filepath.Join(pxeCacheDir, "amd64"), 0755); err != nil {
|
||||
return fmt.Errorf("creating cache directories: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(ipxeCacheDir, 0755); err != nil {
|
||||
return fmt.Errorf("creating cache directories: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(isoCacheDir, 0755); err != nil {
|
||||
return fmt.Errorf("creating cache directories: %w", err)
|
||||
}
|
||||
|
||||
// Download Talos kernel and initramfs for PXE boot
|
||||
output.Info("Downloading Talos PXE assets...")
|
||||
kernelURL := fmt.Sprintf("https://pxe.factory.talos.dev/image/%s/%s/kernel-amd64", schematicIDStr, talosVersionStr)
|
||||
initramfsURL := fmt.Sprintf("https://pxe.factory.talos.dev/image/%s/%s/initramfs-amd64.xz", schematicIDStr, talosVersionStr)
|
||||
|
||||
kernelPath := filepath.Join(pxeCacheDir, "amd64", "vmlinuz")
|
||||
initramfsPath := filepath.Join(pxeCacheDir, "amd64", "initramfs.xz")
|
||||
|
||||
// Download assets
|
||||
if err := downloadAsset(kernelURL, kernelPath, "Talos kernel"); err != nil {
|
||||
return fmt.Errorf("downloading kernel: %w", err)
|
||||
}
|
||||
|
||||
if err := downloadAsset(initramfsURL, initramfsPath, "Talos initramfs"); err != nil {
|
||||
return fmt.Errorf("downloading initramfs: %w", err)
|
||||
}
|
||||
|
||||
// Download iPXE bootloader files
|
||||
output.Info("Downloading iPXE bootloader assets...")
|
||||
ipxeAssets := map[string]string{
|
||||
"http://boot.ipxe.org/ipxe.efi": filepath.Join(ipxeCacheDir, "ipxe.efi"),
|
||||
"http://boot.ipxe.org/undionly.kpxe": filepath.Join(ipxeCacheDir, "undionly.kpxe"),
|
||||
"http://boot.ipxe.org/arm64-efi/ipxe.efi": filepath.Join(ipxeCacheDir, "ipxe-arm64.efi"),
|
||||
}
|
||||
|
||||
for downloadURL, path := range ipxeAssets {
|
||||
description := fmt.Sprintf("iPXE %s", filepath.Base(path))
|
||||
if err := downloadAsset(downloadURL, path, description); err != nil {
|
||||
output.Warning(fmt.Sprintf("Failed to download %s: %v", description, err))
|
||||
}
|
||||
}
|
||||
|
||||
// Download Talos ISO
|
||||
output.Info("Downloading Talos ISO...")
|
||||
isoURL := fmt.Sprintf("https://factory.talos.dev/image/%s/%s/metal-amd64.iso", schematicIDStr, talosVersionStr)
|
||||
isoFilename := fmt.Sprintf("talos-%s-metal-amd64.iso", talosVersionStr)
|
||||
isoPath := filepath.Join(isoCacheDir, isoFilename)
|
||||
|
||||
if err := downloadAsset(isoURL, isoPath, "Talos ISO"); err != nil {
|
||||
return fmt.Errorf("downloading ISO: %w", err)
|
||||
}
|
||||
|
||||
output.Success("All assets downloaded and cached!")
|
||||
output.Info("")
|
||||
output.Info(fmt.Sprintf("Cached assets for schematic %s:", schematicIDStr))
|
||||
output.Info(fmt.Sprintf(" Talos kernel: %s", kernelPath))
|
||||
output.Info(fmt.Sprintf(" Talos initramfs: %s", initramfsPath))
|
||||
output.Info(fmt.Sprintf(" Talos ISO: %s", isoPath))
|
||||
output.Info(fmt.Sprintf(" iPXE EFI: %s", filepath.Join(ipxeCacheDir, "ipxe.efi")))
|
||||
output.Info(fmt.Sprintf(" iPXE BIOS: %s", filepath.Join(ipxeCacheDir, "undionly.kpxe")))
|
||||
output.Info(fmt.Sprintf(" iPXE ARM64: %s", filepath.Join(ipxeCacheDir, "ipxe-arm64.efi")))
|
||||
output.Info("")
|
||||
output.Info(fmt.Sprintf("Cache location: %s", schematicCacheDir))
|
||||
output.Info("")
|
||||
output.Info("Use these assets for:")
|
||||
output.Info(" - PXE boot: Use kernel and initramfs from cache")
|
||||
output.Info(" - USB creation: Use ISO file for dd or imaging tools")
|
||||
output.Info(fmt.Sprintf(" Example: sudo dd if=%s of=/dev/sdX bs=4M status=progress", isoPath))
|
||||
output.Info(fmt.Sprintf(" - Custom installer: https://%s", installerURL))
|
||||
|
||||
output.Success("Installer image generation and asset caching completed!")
|
||||
return nil
|
||||
}
|
||||
|
||||
// downloadAsset downloads a file with progress indication
|
||||
func downloadAsset(downloadURL, path, description string) error {
|
||||
// Check if file already exists
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
output.Info(fmt.Sprintf("%s already cached at %s", description, path))
|
||||
return nil
|
||||
}
|
||||
|
||||
output.Info(fmt.Sprintf("Downloading %s...", description))
|
||||
output.Info(fmt.Sprintf("URL: %s", downloadURL))
|
||||
|
||||
// Parse URL to validate
|
||||
parsedURL, err := url.Parse(downloadURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
|
||||
// Create HTTP client
|
||||
client := &http.Client{}
|
||||
req, err := http.NewRequest("GET", parsedURL.String(), nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("downloading: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = resp.Body.Close()
|
||||
}()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("download failed with status: %s", resp.Status)
|
||||
}
|
||||
|
||||
// Create destination file
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return fmt.Errorf("creating directory: %w", err)
|
||||
}
|
||||
|
||||
file, err := os.Create(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating file: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = file.Close()
|
||||
}()
|
||||
|
||||
// Copy data
|
||||
if _, err := file.ReadFrom(resp.Body); err != nil {
|
||||
return fmt.Errorf("writing file: %w", err)
|
||||
}
|
||||
|
||||
// Verify download
|
||||
if stat, err := os.Stat(path); err != nil || stat.Size() == 0 {
|
||||
_ = os.Remove(path)
|
||||
return fmt.Errorf("download failed or file is empty")
|
||||
}
|
||||
|
||||
output.Success(fmt.Sprintf("%s downloaded successfully", description))
|
||||
return nil
|
||||
}
|
585
wild-cli/cmd/wild/cluster/services.go
Normal file
585
wild-cli/cmd/wild/cluster/services.go
Normal file
@@ -0,0 +1,585 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/wild-cloud/wild-cli/internal/config"
|
||||
"github.com/wild-cloud/wild-cli/internal/environment"
|
||||
"github.com/wild-cloud/wild-cli/internal/external"
|
||||
"github.com/wild-cloud/wild-cli/internal/output"
|
||||
)
|
||||
|
||||
var (
|
||||
servicesSkipInstall bool
|
||||
)
|
||||
|
||||
func newServicesCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "services",
|
||||
Short: "Manage cluster services",
|
||||
Long: `Deploy and manage essential cluster services.
|
||||
|
||||
This command provides cluster service management including generation and deployment.
|
||||
|
||||
Examples:
|
||||
wild cluster services deploy
|
||||
wild cluster services deploy --skip-install`,
|
||||
}
|
||||
|
||||
cmd.AddCommand(
|
||||
newServicesDeployCommand(),
|
||||
)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newServicesDeployCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "deploy",
|
||||
Short: "Deploy cluster services",
|
||||
Long: `Deploy essential cluster services like ingress, DNS, and monitoring.
|
||||
|
||||
This generates service configurations and installs core Kubernetes services
|
||||
including MetalLB, Traefik, cert-manager, and others.
|
||||
|
||||
Examples:
|
||||
wild cluster services deploy
|
||||
wild cluster services deploy --skip-install`,
|
||||
RunE: runServicesDeploy,
|
||||
}
|
||||
|
||||
cmd.Flags().BoolVar(&servicesSkipInstall, "skip-install", false, "generate service configs but skip installation")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runServicesDeploy(cmd *cobra.Command, args []string) error {
|
||||
output.Header("Cluster Services Deployment")
|
||||
|
||||
// Initialize environment
|
||||
env := environment.New()
|
||||
if err := env.RequiresProject(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check external tools
|
||||
toolManager := external.NewManager()
|
||||
if err := toolManager.CheckTools(cmd.Context(), []string{"kubectl"}); err != nil {
|
||||
return fmt.Errorf("required tools not available: %w", err)
|
||||
}
|
||||
|
||||
// Load configuration
|
||||
configMgr := config.NewManager(env.ConfigPath(), env.SecretsPath())
|
||||
|
||||
// Check cluster configuration
|
||||
clusterName, err := getRequiredConfig(configMgr, "cluster.name", "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("cluster configuration is missing: %w", err)
|
||||
}
|
||||
|
||||
output.Info("Cluster: " + clusterName)
|
||||
|
||||
// Check kubectl connectivity
|
||||
kubectl := toolManager.Kubectl()
|
||||
if err := checkKubectlConnectivity(cmd.Context(), kubectl); err != nil {
|
||||
return fmt.Errorf("kubectl is not configured or cluster is not accessible: %w", err)
|
||||
}
|
||||
|
||||
output.Success("Cluster is accessible")
|
||||
|
||||
// Phase 1: Generate cluster services setup files
|
||||
output.Info("\n=== Phase 1: Generating Service Configurations ===")
|
||||
if err := generateClusterServices(cmd.Context(), env, configMgr); err != nil {
|
||||
return fmt.Errorf("generating service configurations: %w", err)
|
||||
}
|
||||
|
||||
// Phase 2: Install cluster services
|
||||
if !servicesSkipInstall {
|
||||
output.Info("\n=== Phase 2: Installing Cluster Services ===")
|
||||
if err := installClusterServices(cmd.Context(), env, kubectl); err != nil {
|
||||
return fmt.Errorf("installing cluster services: %w", err)
|
||||
}
|
||||
} else {
|
||||
output.Info("Skipping cluster services installation (--skip-install specified)")
|
||||
output.Info("You can install them later with: wild cluster services deploy")
|
||||
}
|
||||
|
||||
// Summary output
|
||||
output.Success("Cluster Services Deployment Complete!")
|
||||
output.Info("")
|
||||
|
||||
if !servicesSkipInstall {
|
||||
// Get internal domain for next steps
|
||||
internalDomain, err := configMgr.Get("cloud.internalDomain")
|
||||
domain := "your-internal-domain"
|
||||
if err == nil && internalDomain != nil {
|
||||
if domainStr, ok := internalDomain.(string); ok {
|
||||
domain = domainStr
|
||||
}
|
||||
}
|
||||
|
||||
output.Info("Next steps:")
|
||||
output.Info(" 1. Access the dashboard at: https://dashboard." + domain)
|
||||
output.Info(" 2. Get the dashboard token with: wild dashboard token")
|
||||
output.Info("")
|
||||
output.Info("To verify components, run:")
|
||||
output.Info(" - kubectl get pods -n cert-manager")
|
||||
output.Info(" - kubectl get pods -n externaldns")
|
||||
output.Info(" - kubectl get pods -n kubernetes-dashboard")
|
||||
output.Info(" - kubectl get clusterissuers")
|
||||
} else {
|
||||
output.Info("Next steps:")
|
||||
output.Info(" 1. Ensure your cluster is running and kubectl is configured")
|
||||
output.Info(" 2. Install services with: wild cluster services deploy")
|
||||
output.Info(" 3. Verify components are running correctly")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkKubectlConnectivity checks if kubectl can connect to the cluster
|
||||
func checkKubectlConnectivity(ctx context.Context, kubectl *external.KubectlTool) error {
|
||||
// Try to get cluster info
|
||||
_, err := kubectl.Execute(ctx, "cluster-info")
|
||||
if err != nil {
|
||||
return fmt.Errorf("cluster not accessible: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateClusterServices generates cluster service configurations
|
||||
func generateClusterServices(ctx context.Context, env *environment.Environment, configMgr *config.Manager) error {
|
||||
// This function replicates wild-cluster-services-generate functionality
|
||||
output.Info("Generating cluster services setup files...")
|
||||
|
||||
wcRoot := env.WCRoot()
|
||||
if wcRoot == "" {
|
||||
return fmt.Errorf("WC_ROOT not set")
|
||||
}
|
||||
|
||||
sourceDir := filepath.Join(wcRoot, "setup", "cluster-services")
|
||||
destDir := filepath.Join(env.WildCloudDir(), "setup", "cluster-services")
|
||||
|
||||
// Check if source directory exists
|
||||
if _, err := os.Stat(sourceDir); os.IsNotExist(err) {
|
||||
return fmt.Errorf("cluster setup source directory not found: %s", sourceDir)
|
||||
}
|
||||
|
||||
// Force regeneration, removing existing files
|
||||
if _, err := os.Stat(destDir); err == nil {
|
||||
output.Info("Force regeneration enabled, removing existing files...")
|
||||
if err := os.RemoveAll(destDir); err != nil {
|
||||
return fmt.Errorf("removing existing setup directory: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create destination directory
|
||||
setupBaseDir := filepath.Join(env.WildCloudDir(), "setup")
|
||||
if err := os.MkdirAll(setupBaseDir, 0755); err != nil {
|
||||
return fmt.Errorf("creating setup directory: %w", err)
|
||||
}
|
||||
|
||||
// Copy README if it doesn't exist
|
||||
readmePath := filepath.Join(setupBaseDir, "README.md")
|
||||
if _, err := os.Stat(readmePath); os.IsNotExist(err) {
|
||||
sourceReadme := filepath.Join(wcRoot, "setup", "README.md")
|
||||
if _, err := os.Stat(sourceReadme); err == nil {
|
||||
if err := copyFile(sourceReadme, readmePath); err != nil {
|
||||
output.Warning("Failed to copy README.md: " + err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create destination directory
|
||||
if err := os.MkdirAll(destDir, 0755); err != nil {
|
||||
return fmt.Errorf("creating destination directory: %w", err)
|
||||
}
|
||||
|
||||
// Copy and compile cluster setup files
|
||||
output.Info("Copying and compiling cluster setup files from repository...")
|
||||
|
||||
// First, copy root-level files from setup/cluster-services/
|
||||
if err := copyRootServiceFiles(sourceDir, destDir); err != nil {
|
||||
return fmt.Errorf("copying root service files: %w", err)
|
||||
}
|
||||
|
||||
// Then, process each service directory
|
||||
if err := processServiceDirectories(sourceDir, destDir, configMgr); err != nil {
|
||||
return fmt.Errorf("processing service directories: %w", err)
|
||||
}
|
||||
|
||||
// Verify required configuration
|
||||
if err := verifyServiceConfiguration(configMgr); err != nil {
|
||||
output.Warning("Configuration verification warnings: " + err.Error())
|
||||
}
|
||||
|
||||
output.Success("Cluster setup files copied and compiled")
|
||||
output.Info("Generated setup directory: " + destDir)
|
||||
|
||||
// List available services
|
||||
services, err := getAvailableServices(destDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listing available services: %w", err)
|
||||
}
|
||||
|
||||
output.Info("Available services:")
|
||||
for _, service := range services {
|
||||
output.Info(" - " + service)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// installClusterServices installs the cluster services
|
||||
func installClusterServices(ctx context.Context, env *environment.Environment, kubectl *external.KubectlTool) error {
|
||||
setupDir := filepath.Join(env.WildCloudDir(), "setup", "cluster-services")
|
||||
|
||||
// Check if cluster setup directory exists
|
||||
if _, err := os.Stat(setupDir); os.IsNotExist(err) {
|
||||
return fmt.Errorf("cluster services setup directory not found: %s", setupDir)
|
||||
}
|
||||
|
||||
output.Info("Installing cluster services...")
|
||||
|
||||
// Install services in dependency order
|
||||
servicesToInstall := []string{
|
||||
"metallb",
|
||||
"longhorn",
|
||||
"traefik",
|
||||
"coredns",
|
||||
"cert-manager",
|
||||
"externaldns",
|
||||
"kubernetes-dashboard",
|
||||
"nfs",
|
||||
"docker-registry",
|
||||
}
|
||||
|
||||
// Filter to only include services that actually exist
|
||||
existingServices := []string{}
|
||||
for _, service := range servicesToInstall {
|
||||
installScript := filepath.Join(setupDir, service, "install.sh")
|
||||
if _, err := os.Stat(installScript); err == nil {
|
||||
existingServices = append(existingServices, service)
|
||||
}
|
||||
}
|
||||
|
||||
if len(existingServices) == 0 {
|
||||
return fmt.Errorf("no installable services found")
|
||||
}
|
||||
|
||||
output.Info(fmt.Sprintf("Services to install: %v", existingServices))
|
||||
|
||||
// Install services
|
||||
installedCount := 0
|
||||
failedCount := 0
|
||||
|
||||
for _, service := range existingServices {
|
||||
output.Info(fmt.Sprintf("\n--- Installing %s ---", service))
|
||||
|
||||
installScript := filepath.Join(setupDir, service, "install.sh")
|
||||
if err := runServiceInstaller(ctx, setupDir, service, installScript); err != nil {
|
||||
output.Error(fmt.Sprintf("%s installation failed: %v", service, err))
|
||||
failedCount++
|
||||
} else {
|
||||
output.Success(fmt.Sprintf("%s installed successfully", service))
|
||||
installedCount++
|
||||
}
|
||||
}
|
||||
|
||||
// Summary
|
||||
output.Info("\nInstallation Summary:")
|
||||
output.Success(fmt.Sprintf("Successfully installed: %d services", installedCount))
|
||||
if failedCount > 0 {
|
||||
output.Warning(fmt.Sprintf("Failed to install: %d services", failedCount))
|
||||
}
|
||||
|
||||
if failedCount == 0 {
|
||||
output.Success("All cluster services installed successfully!")
|
||||
} else {
|
||||
return fmt.Errorf("some services failed to install")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Helper functions (reused from setup/services.go with minimal modifications)
|
||||
|
||||
// copyRootServiceFiles copies root-level files from source to destination
|
||||
func copyRootServiceFiles(sourceDir, destDir string) error {
|
||||
entries, err := os.ReadDir(sourceDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
srcPath := filepath.Join(sourceDir, entry.Name())
|
||||
dstPath := filepath.Join(destDir, entry.Name())
|
||||
output.Info(" Copying: " + entry.Name())
|
||||
if err := copyFile(srcPath, dstPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// processServiceDirectories processes each service directory
|
||||
func processServiceDirectories(sourceDir, destDir string, configMgr *config.Manager) error {
|
||||
entries, err := os.ReadDir(sourceDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create template engine
|
||||
engine, err := config.NewTemplateEngine(configMgr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating template engine: %w", err)
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if !entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
serviceName := entry.Name()
|
||||
serviceDir := filepath.Join(sourceDir, serviceName)
|
||||
destServiceDir := filepath.Join(destDir, serviceName)
|
||||
|
||||
output.Info("Processing service: " + serviceName)
|
||||
|
||||
// Create destination service directory
|
||||
if err := os.MkdirAll(destServiceDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Process service files
|
||||
if err := processServiceFiles(serviceDir, destServiceDir, engine); err != nil {
|
||||
return fmt.Errorf("processing service %s: %w", serviceName, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// processServiceFiles processes files in a service directory
|
||||
func processServiceFiles(serviceDir, destServiceDir string, engine *config.TemplateEngine) error {
|
||||
entries, err := os.ReadDir(serviceDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
srcPath := filepath.Join(serviceDir, entry.Name())
|
||||
dstPath := filepath.Join(destServiceDir, entry.Name())
|
||||
|
||||
if entry.Name() == "kustomize.template" {
|
||||
// Compile kustomize.template to kustomize directory
|
||||
if entry.IsDir() {
|
||||
output.Info(" Compiling kustomize templates")
|
||||
kustomizeDir := filepath.Join(destServiceDir, "kustomize")
|
||||
if err := processTemplateDirectory(srcPath, kustomizeDir, engine); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
} else if entry.IsDir() {
|
||||
// Copy other directories recursively
|
||||
if err := copyDir(srcPath, dstPath); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
// Process individual files
|
||||
if err := processServiceFile(srcPath, dstPath, engine); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// processServiceFile processes a single service file
|
||||
func processServiceFile(srcPath, dstPath string, engine *config.TemplateEngine) error {
|
||||
content, err := os.ReadFile(srcPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check if file contains template syntax
|
||||
if strings.Contains(string(content), "{{") {
|
||||
output.Info(" Compiling: " + filepath.Base(srcPath))
|
||||
processed, err := engine.Process(string(content))
|
||||
if err != nil {
|
||||
return fmt.Errorf("processing template: %w", err)
|
||||
}
|
||||
return os.WriteFile(dstPath, []byte(processed), 0644)
|
||||
} else {
|
||||
return copyFile(srcPath, dstPath)
|
||||
}
|
||||
}
|
||||
|
||||
// processTemplateDirectory processes an entire template directory
|
||||
func processTemplateDirectory(srcDir, dstDir string, engine *config.TemplateEngine) error {
|
||||
if err := os.RemoveAll(dstDir); err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
|
||||
return filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
relPath, err := filepath.Rel(srcDir, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dstPath := filepath.Join(dstDir, relPath)
|
||||
|
||||
if info.IsDir() {
|
||||
return os.MkdirAll(dstPath, info.Mode())
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Process template content
|
||||
if strings.Contains(string(content), "{{") {
|
||||
processed, err := engine.Process(string(content))
|
||||
if err != nil {
|
||||
return fmt.Errorf("processing template %s: %w", relPath, err)
|
||||
}
|
||||
content = []byte(processed)
|
||||
}
|
||||
|
||||
// Create parent directory
|
||||
if err := os.MkdirAll(filepath.Dir(dstPath), 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(dstPath, content, info.Mode())
|
||||
})
|
||||
}
|
||||
|
||||
// verifyServiceConfiguration verifies required configuration
|
||||
func verifyServiceConfiguration(configMgr *config.Manager) error {
|
||||
missingConfig := []string{}
|
||||
|
||||
// Check essential configuration values
|
||||
requiredConfigs := []string{
|
||||
"cluster.name",
|
||||
"cloud.domain",
|
||||
"cluster.ipAddressPool",
|
||||
"operator.email",
|
||||
}
|
||||
|
||||
for _, configPath := range requiredConfigs {
|
||||
if value, err := configMgr.Get(configPath); err != nil || value == nil {
|
||||
missingConfig = append(missingConfig, configPath)
|
||||
}
|
||||
}
|
||||
|
||||
if len(missingConfig) > 0 {
|
||||
return fmt.Errorf("missing required configuration values: %v", missingConfig)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getAvailableServices returns list of available services
|
||||
func getAvailableServices(setupDir string) ([]string, error) {
|
||||
var services []string
|
||||
|
||||
entries, err := os.ReadDir(setupDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
installScript := filepath.Join(setupDir, entry.Name(), "install.sh")
|
||||
if _, err := os.Stat(installScript); err == nil {
|
||||
services = append(services, entry.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return services, nil
|
||||
}
|
||||
|
||||
// runServiceInstaller runs a service installation script
|
||||
func runServiceInstaller(ctx context.Context, setupDir, serviceName, installScript string) error {
|
||||
// Change to the service directory and run install.sh
|
||||
serviceDir := filepath.Join(setupDir, serviceName)
|
||||
|
||||
// Execute the install script using bash
|
||||
bashTool := external.NewBaseTool("bash", "bash")
|
||||
|
||||
// Change to the service directory by setting working directory in the execution context
|
||||
oldDir, err := os.Getwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting current directory: %w", err)
|
||||
}
|
||||
|
||||
if err := os.Chdir(serviceDir); err != nil {
|
||||
return fmt.Errorf("changing to service directory: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = os.Chdir(oldDir)
|
||||
}()
|
||||
|
||||
_, err = bashTool.Execute(ctx, "install.sh")
|
||||
if err != nil {
|
||||
return fmt.Errorf("install script failed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// copyFile copies a single file
|
||||
func copyFile(src, dst string) error {
|
||||
data, err := os.ReadFile(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(dst, data, 0644)
|
||||
}
|
||||
|
||||
// copyDir recursively copies a directory
|
||||
func copyDir(src, dst string) error {
|
||||
entries, err := os.ReadDir(src)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(dst, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
srcPath := filepath.Join(src, entry.Name())
|
||||
dstPath := filepath.Join(dst, entry.Name())
|
||||
|
||||
if entry.IsDir() {
|
||||
if err := copyDir(srcPath, dstPath); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if err := copyFile(srcPath, dstPath); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
Reference in New Issue
Block a user