Initial commit.

This commit is contained in:
2025-10-11 17:06:14 +00:00
commit ec521c3c91
45 changed files with 9798 additions and 0 deletions

37
internal/tools/context.go Normal file
View File

@@ -0,0 +1,37 @@
package tools
import (
"os"
"os/exec"
"path/filepath"
)
// WithTalosconfig sets the TALOSCONFIG environment variable for a command
// This allows talosctl commands to use the correct context without global state
func WithTalosconfig(cmd *exec.Cmd, talosconfigPath string) *exec.Cmd {
if cmd.Env == nil {
cmd.Env = os.Environ()
}
cmd.Env = append(cmd.Env, "TALOSCONFIG="+talosconfigPath)
return cmd
}
// WithKubeconfig sets the KUBECONFIG environment variable for a command
// This allows kubectl commands to use the correct context without global state
func WithKubeconfig(cmd *exec.Cmd, kubeconfigPath string) *exec.Cmd {
if cmd.Env == nil {
cmd.Env = os.Environ()
}
cmd.Env = append(cmd.Env, "KUBECONFIG="+kubeconfigPath)
return cmd
}
// GetTalosconfigPath returns the path to the talosconfig for an instance
func GetTalosconfigPath(dataDir, instanceName string) string {
return filepath.Join(dataDir, "instances", instanceName, "talos", "generated", "talosconfig")
}
// GetKubeconfigPath returns the path to the kubeconfig for an instance
func GetKubeconfigPath(dataDir, instanceName string) string {
return filepath.Join(dataDir, "instances", instanceName, "kubeconfig")
}

111
internal/tools/gomplate.go Normal file
View File

@@ -0,0 +1,111 @@
package tools
import (
"bytes"
"fmt"
"os/exec"
"strings"
)
// Gomplate provides a wrapper around the gomplate command-line tool
type Gomplate struct {
gomplatePath string
}
// NewGomplate creates a new Gomplate wrapper
func NewGomplate() *Gomplate {
// Find gomplate in PATH
path, err := exec.LookPath("gomplate")
if err != nil {
// Default to "gomplate" and let exec handle the error
path = "gomplate"
}
return &Gomplate{gomplatePath: path}
}
// Render renders a template file with the given data sources
func (g *Gomplate) Render(templatePath, outputPath string, dataSources map[string]string) error {
args := []string{
"-f", templatePath,
"-o", outputPath,
}
// Add data sources
for name, path := range dataSources {
args = append(args, "-d", fmt.Sprintf("%s=%s", name, path))
}
cmd := exec.Command(g.gomplatePath, args...)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("gomplate render failed: %w, stderr: %s", err, stderr.String())
}
return nil
}
// RenderString renders a template string with the given data sources
func (g *Gomplate) RenderString(template string, dataSources map[string]string) (string, error) {
args := []string{
"-i", template,
}
// Add data sources
for name, path := range dataSources {
args = append(args, "-d", fmt.Sprintf("%s=%s", name, path))
}
cmd := exec.Command(g.gomplatePath, args...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("gomplate render string failed: %w, stderr: %s", err, stderr.String())
}
return strings.TrimSpace(stdout.String()), nil
}
// RenderWithContext renders a template with context values passed as arguments
func (g *Gomplate) RenderWithContext(templatePath, outputPath string, context map[string]string) error {
args := []string{
"-f", templatePath,
"-o", outputPath,
}
// Add context values
for key, value := range context {
args = append(args, "-c", fmt.Sprintf("%s=%s", key, value))
}
cmd := exec.Command(g.gomplatePath, args...)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("gomplate render with context failed: %w, stderr: %s", err, stderr.String())
}
return nil
}
// Exec executes gomplate with arbitrary arguments
func (g *Gomplate) Exec(args ...string) (string, error) {
cmd := exec.Command(g.gomplatePath, args...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("gomplate exec failed: %w, stderr: %s", err, stderr.String())
}
return strings.TrimSpace(stdout.String()), nil
}

33
internal/tools/kubectl.go Normal file
View File

@@ -0,0 +1,33 @@
package tools
import (
"os/exec"
)
// Kubectl provides a thin wrapper around the kubectl command-line tool
type Kubectl struct {
kubeconfigPath string
}
// NewKubectl creates a new Kubectl wrapper
func NewKubectl(kubeconfigPath string) *Kubectl {
return &Kubectl{
kubeconfigPath: kubeconfigPath,
}
}
// DeploymentExists checks if a deployment exists in the specified namespace
func (k *Kubectl) DeploymentExists(name, namespace string) bool {
args := []string{
"get", "deployment", name,
"-n", namespace,
}
if k.kubeconfigPath != "" {
args = append([]string{"--kubeconfig", k.kubeconfigPath}, args...)
}
cmd := exec.Command("kubectl", args...)
err := cmd.Run()
return err == nil
}

362
internal/tools/talosctl.go Normal file
View File

@@ -0,0 +1,362 @@
package tools
import (
"encoding/json"
"fmt"
"os/exec"
"strings"
)
// Talosctl provides a thin wrapper around the talosctl command-line tool
type Talosctl struct {
talosconfigPath string
}
// NewTalosctl creates a new Talosctl wrapper
func NewTalosctl() *Talosctl {
return &Talosctl{}
}
// NewTalosconfigWithConfig creates a new Talosctl wrapper with a specific talosconfig
func NewTalosconfigWithConfig(talosconfigPath string) *Talosctl {
return &Talosctl{
talosconfigPath: talosconfigPath,
}
}
// buildArgs adds talosconfig to args if set
func (t *Talosctl) buildArgs(baseArgs []string) []string {
if t.talosconfigPath != "" {
return append([]string{"--talosconfig", t.talosconfigPath}, baseArgs...)
}
return baseArgs
}
// GenConfig generates Talos configuration files
func (t *Talosctl) GenConfig(clusterName, endpoint, outputDir string) error {
args := []string{
"gen", "config",
clusterName,
endpoint,
"--output-dir", outputDir,
}
cmd := exec.Command("talosctl", args...)
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("talosctl gen config failed: %w\nOutput: %s", err, string(output))
}
return nil
}
// ApplyConfig applies configuration to a node
func (t *Talosctl) ApplyConfig(nodeIP, configFile string, insecure bool, talosconfigPath string) error {
args := []string{
"apply-config",
"--nodes", nodeIP,
"--file", configFile,
}
if insecure {
args = append(args, "--insecure")
}
cmd := exec.Command("talosctl", args...)
if talosconfigPath != "" {
WithTalosconfig(cmd, talosconfigPath)
}
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("talosctl apply-config failed: %w\nOutput: %s", err, string(output))
}
return nil
}
// DiskInfo represents disk information including path and size
type DiskInfo struct {
Path string `json:"path"`
Size int64 `json:"size"`
}
// GetDisks queries available disks from a node (filters to disks > 10GB)
func (t *Talosctl) GetDisks(nodeIP string, insecure bool) ([]DiskInfo, error) {
args := []string{
"get", "disks",
"--nodes", nodeIP,
"-o", "json",
}
if insecure {
args = append(args, "--insecure")
}
// Use jq to slurp the NDJSON into an array (like v.PoC does with jq -s)
talosCmd := exec.Command("talosctl", args...)
jqCmd := exec.Command("jq", "-s", ".")
// Pipe talosctl output to jq
jqCmd.Stdin, _ = talosCmd.StdoutPipe()
if err := talosCmd.Start(); err != nil {
return nil, fmt.Errorf("failed to start talosctl: %w", err)
}
output, err := jqCmd.CombinedOutput()
if err != nil {
return nil, fmt.Errorf("failed to process disks JSON: %w\nOutput: %s", err, string(output))
}
if err := talosCmd.Wait(); err != nil {
return nil, fmt.Errorf("talosctl get disks failed: %w", err)
}
var result []map[string]interface{}
if err := json.Unmarshal(output, &result); err != nil {
return nil, fmt.Errorf("failed to parse disks JSON: %w", err)
}
disks := []DiskInfo{}
for _, item := range result {
metadata, ok := item["metadata"].(map[string]interface{})
if !ok {
continue
}
id, ok := metadata["id"].(string)
if !ok {
continue
}
spec, ok := item["spec"].(map[string]interface{})
if !ok {
continue
}
// Extract size - can be float64 or int
var size int64
switch v := spec["size"].(type) {
case float64:
size = int64(v)
case int64:
size = v
case int:
size = int64(v)
default:
continue
}
// Filter to disks > 10GB (like v.PoC does)
if size > 10000000000 {
disks = append(disks, DiskInfo{
Path: "/dev/" + id,
Size: size,
})
}
}
return disks, nil
}
// GetLinks queries network interfaces from a node
func (t *Talosctl) GetLinks(nodeIP string, insecure bool) ([]map[string]interface{}, error) {
args := []string{
"get", "links",
"--nodes", nodeIP,
"-o", "json",
}
if insecure {
args = append(args, "--insecure")
}
// Use jq to slurp the NDJSON into an array (like v.PoC does with jq -s)
talosCmd := exec.Command("talosctl", args...)
jqCmd := exec.Command("jq", "-s", ".")
// Pipe talosctl output to jq
jqCmd.Stdin, _ = talosCmd.StdoutPipe()
if err := talosCmd.Start(); err != nil {
return nil, fmt.Errorf("failed to start talosctl: %w", err)
}
output, err := jqCmd.CombinedOutput()
if err != nil {
return nil, fmt.Errorf("failed to process links JSON: %w\nOutput: %s", err, string(output))
}
if err := talosCmd.Wait(); err != nil {
return nil, fmt.Errorf("talosctl get links failed: %w", err)
}
var result []map[string]interface{}
if err := json.Unmarshal(output, &result); err != nil {
return nil, fmt.Errorf("failed to parse links JSON: %w", err)
}
return result, nil
}
// GetRoutes queries routing table from a node
func (t *Talosctl) GetRoutes(nodeIP string, insecure bool) ([]map[string]interface{}, error) {
args := []string{
"get", "routes",
"--nodes", nodeIP,
"-o", "json",
}
if insecure {
args = append(args, "--insecure")
}
// Use jq to slurp the NDJSON into an array (like v.PoC does with jq -s)
talosCmd := exec.Command("talosctl", args...)
jqCmd := exec.Command("jq", "-s", ".")
// Pipe talosctl output to jq
jqCmd.Stdin, _ = talosCmd.StdoutPipe()
if err := talosCmd.Start(); err != nil {
return nil, fmt.Errorf("failed to start talosctl: %w", err)
}
output, err := jqCmd.CombinedOutput()
if err != nil {
return nil, fmt.Errorf("failed to process routes JSON: %w\nOutput: %s", err, string(output))
}
if err := talosCmd.Wait(); err != nil {
return nil, fmt.Errorf("talosctl get routes failed: %w", err)
}
var result []map[string]interface{}
if err := json.Unmarshal(output, &result); err != nil {
return nil, fmt.Errorf("failed to parse routes JSON: %w", err)
}
return result, nil
}
// GetDefaultInterface finds the interface with the default route
func (t *Talosctl) GetDefaultInterface(nodeIP string, insecure bool) (string, error) {
routes, err := t.GetRoutes(nodeIP, insecure)
if err != nil {
return "", err
}
// Find route with destination 0.0.0.0/0 (default route)
for _, route := range routes {
if spec, ok := route["spec"].(map[string]interface{}); ok {
destination, _ := spec["destination"].(string)
gateway, _ := spec["gateway"].(string)
if destination == "0.0.0.0/0" && gateway != "" {
if outLink, ok := spec["outLinkName"].(string); ok {
return outLink, nil
}
}
}
}
return "", fmt.Errorf("no default route found")
}
// GetPhysicalInterface finds the first physical ethernet interface
func (t *Talosctl) GetPhysicalInterface(nodeIP string, insecure bool) (string, error) {
links, err := t.GetLinks(nodeIP, insecure)
if err != nil {
return "", err
}
// Look for physical ethernet interfaces (eth*, en*, eno*, ens*, enp*)
for _, link := range links {
metadata, ok := link["metadata"].(map[string]interface{})
if !ok {
continue
}
id, ok := metadata["id"].(string)
if !ok || id == "lo" {
continue
}
spec, ok := link["spec"].(map[string]interface{})
if !ok {
continue
}
// Check if it's ethernet and up
linkType, _ := spec["type"].(string)
operState, _ := spec["operationalState"].(string)
if linkType == "ether" && operState == "up" {
// Prefer interfaces starting with eth, en
if strings.HasPrefix(id, "eth") || strings.HasPrefix(id, "en") {
// Skip virtual interfaces (cni, flannel, docker, br-, veth)
if !strings.Contains(id, "cni") &&
!strings.Contains(id, "flannel") &&
!strings.Contains(id, "docker") &&
!strings.HasPrefix(id, "br-") &&
!strings.HasPrefix(id, "veth") {
return id, nil
}
}
}
}
return "", fmt.Errorf("no physical ethernet interface found")
}
// GetVersion gets Talos version from a node
func (t *Talosctl) GetVersion(nodeIP string, insecure bool) (string, error) {
args := t.buildArgs([]string{
"version",
"--nodes", nodeIP,
"--short",
})
if insecure {
args = append(args, "--insecure")
}
cmd := exec.Command("talosctl", args...)
output, err := cmd.CombinedOutput()
if err != nil {
return "", fmt.Errorf("talosctl version failed: %w\nOutput: %s", err, string(output))
}
// Parse output to extract server version
// Output format:
// Client:
// Talos v1.11.2
// Server:
// NODE: ...
// Tag: v1.11.0
lines := strings.Split(string(output), "\n")
for i, line := range lines {
if strings.Contains(line, "Tag:") {
// Extract version from "Tag: v1.11.0" format
parts := strings.Fields(line)
if len(parts) >= 2 {
return parts[len(parts)-1], nil
}
}
// Also check for simple "Talos vX.Y.Z" format
if strings.HasPrefix(strings.TrimSpace(line), "Talos v") && i < 3 {
return strings.TrimSpace(strings.TrimPrefix(line, "Talos ")), nil
}
}
return strings.TrimSpace(string(output)), nil
}
// Validate checks if talosctl is available
func (t *Talosctl) Validate() error {
cmd := exec.Command("talosctl", "version", "--client")
output, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("talosctl not found or not working: %w\nOutput: %s", err, string(output))
}
return nil
}

133
internal/tools/yq.go Normal file
View File

@@ -0,0 +1,133 @@
package tools
import (
"bytes"
"fmt"
"os/exec"
"strings"
)
// YQ provides a wrapper around the yq command-line tool
type YQ struct {
yqPath string
}
// NewYQ creates a new YQ wrapper
func NewYQ() *YQ {
// Find yq in PATH
path, err := exec.LookPath("yq")
if err != nil {
// Default to "yq" and let exec handle the error
path = "yq"
}
return &YQ{yqPath: path}
}
// Get retrieves a value from a YAML file using a yq expression
func (y *YQ) Get(filePath, expression string) (string, error) {
cmd := exec.Command(y.yqPath, expression, filePath)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("yq get failed: %w, stderr: %s", err, stderr.String())
}
return strings.TrimSpace(stdout.String()), nil
}
// Set sets a value in a YAML file using a yq expression
func (y *YQ) Set(filePath, expression, value string) error {
// yq -i '.path = "value"' file.yaml
// Ensure expression starts with '.' for yq v4 syntax
if !strings.HasPrefix(expression, ".") {
expression = "." + expression
}
// Properly quote the value to handle special characters
quotedValue := fmt.Sprintf(`"%s"`, strings.ReplaceAll(value, `"`, `\"`))
setExpr := fmt.Sprintf("%s = %s", expression, quotedValue)
cmd := exec.Command(y.yqPath, "-i", setExpr, filePath)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("yq set failed: %w, stderr: %s", err, stderr.String())
}
return nil
}
// Merge merges two YAML files
func (y *YQ) Merge(file1, file2, outputFile string) error {
// yq eval-all '. as $item ireduce ({}; . * $item)' file1.yaml file2.yaml > output.yaml
cmd := exec.Command(y.yqPath, "eval-all", ". as $item ireduce ({}; . * $item)", file1, file2)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("yq merge failed: %w, stderr: %s", err, stderr.String())
}
// Write output
return exec.Command("sh", "-c", fmt.Sprintf("echo '%s' > %s", stdout.String(), outputFile)).Run()
}
// Delete removes a key from a YAML file
func (y *YQ) Delete(filePath, expression string) error {
// yq -i 'del(.path)' file.yaml
delExpr := fmt.Sprintf("del(%s)", expression)
cmd := exec.Command(y.yqPath, "-i", delExpr, filePath)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("yq delete failed: %w, stderr: %s", err, stderr.String())
}
return nil
}
// Validate checks if a YAML file is valid
func (y *YQ) Validate(filePath string) error {
cmd := exec.Command(y.yqPath, "eval", ".", filePath)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("yaml validation failed: %w, stderr: %s", err, stderr.String())
}
return nil
}
// Exec executes an arbitrary yq expression on a file
func (y *YQ) Exec(args ...string) ([]byte, error) {
cmd := exec.Command(y.yqPath, args...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return nil, fmt.Errorf("yq exec failed: %w, stderr: %s", err, stderr.String())
}
return stdout.Bytes(), nil
}
// CleanYQOutput removes trailing newlines and "null" values from yq output
func CleanYQOutput(output string) string {
output = strings.TrimSpace(output)
if output == "null" {
return ""
}
return output
}