Files
2026-08-09 20:59:04 +02:00

196 lines
6.5 KiB
Go

// Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
// Licensed under the MIT License. See LICENSE file in project root for details.
// Package kubeexec wraps kubectl/aws-cli invocations used by kctl-tui.
// All functions here have side effects (they run external processes) and
// are therefore not covered by unit tests; the pure logic they depend on
// lives in the kctl package instead.
//
// Every kubectl-related function takes an explicit context argument
// (passed as --context) instead of relying on/mutating the globally
// active kubectl context. This lets the panel act on multiple resolved
// contexts (e.g. beta and prod) without switching global state back and
// forth.
package kubeexec
import (
"encoding/base64"
"encoding/json"
"fmt"
"os/exec"
"strings"
)
func runOutput(name string, args ...string) (string, error) {
logCmd(name, args...)
cmd := exec.Command(name, args...)
var stdout, stderr strings.Builder
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
if err != nil {
logErr(name, err)
prefix := fmt.Sprintf("%s %s failed: %v", name, strings.Join(args, " "), err)
msg := prefix
if s := strings.TrimSpace(stderr.String()); s != "" {
msg += "\n" + s
}
if s := strings.TrimSpace(stdout.String()); s != "" {
msg += "\n" + s
}
return "", fmt.Errorf("%s", msg)
}
out := strings.TrimSpace(stdout.String())
logOutput(name, out)
return out, nil
}
// kubectlArgs prepends a --context flag when context is non-empty.
func kubectlArgs(context string, args ...string) []string {
if context == "" {
return args
}
return append([]string{"--context", context}, args...)
}
type nsItem struct {
Metadata struct {
Name string `json:"name"`
Labels map[string]string `json:"labels"`
} `json:"metadata"`
}
type nsList struct {
Items []nsItem `json:"items"`
}
// GetNamespacesWithLabels returns a map of namespace name -> labels for all
// namespaces visible in the given context.
func GetNamespacesWithLabels(context string) (map[string]map[string]string, error) {
args := kubectlArgs(context, "get", "ns", "-o", "json")
out, err := runOutput("kubectl", args...)
if err != nil {
return nil, err
}
var list nsList
if err := json.Unmarshal([]byte(out), &list); err != nil {
return nil, err
}
result := make(map[string]map[string]string, len(list.Items))
for _, item := range list.Items {
result[item.Metadata.Name] = item.Metadata.Labels
}
return result, nil
}
// GetDeployments lists deployment names in the given context/namespace.
func GetDeployments(context, namespace string) ([]string, error) {
args := kubectlArgs(context, "-n", namespace, "get", "deploy",
"-o", `jsonpath={range .items[*]}{.metadata.name}{"\n"}{end}`)
out, err := runOutput("kubectl", args...)
if err != nil {
return nil, err
}
if out == "" {
return []string{}, nil
}
return strings.Split(out, "\n"), nil
}
// RolloutRestart triggers a rolling restart of a deployment.
func RolloutRestart(context, namespace, deployment string) (string, error) {
args := kubectlArgs(context, "-n", namespace, "rollout", "restart", "deploy/"+deployment)
return runOutput("kubectl", args...)
}
// RolloutStatus waits for and returns the rollout status of a deployment.
func RolloutStatus(context, namespace, deployment string) (string, error) {
args := kubectlArgs(context, "-n", namespace, "rollout", "status", "deploy/"+deployment)
return runOutput("kubectl", args...)
}
// GetSecretAllFields returns all fields of a Kubernetes secret, already
// base64-decoded into plain values.
func GetSecretAllFields(context, namespace, secretName string) (map[string]string, error) {
args := kubectlArgs(context, "-n", namespace, "get", "secret", secretName, "-o", "json")
out, err := runOutput("kubectl", args...)
if err != nil {
return nil, err
}
var parsed struct {
Data map[string]string `json:"data"`
}
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
return nil, err
}
result := make(map[string]string, len(parsed.Data))
for k, v := range parsed.Data {
decoded, err := DecodeBase64(v)
if err != nil {
return nil, fmt.Errorf("failed to decode field %q: %w", k, err)
}
result[k] = decoded
}
return result, nil
}
// DecodeBase64 decodes a base64-encoded Kubernetes secret value.
func DecodeBase64(value string) (string, error) {
decoded, err := base64.StdEncoding.DecodeString(value)
if err != nil {
return "", err
}
return string(decoded), nil
}
// AnnotateForceSync sets the force-sync annotation on an ExternalSecret
// object to trigger an immediate re-sync from the upstream secret store.
func AnnotateForceSync(context, namespace, externalSecretName string, unixTimestamp int64) (string, error) {
annotation := fmt.Sprintf("force-sync=%d", unixTimestamp)
args := kubectlArgs(context, "-n", namespace, "annotate", "externalsecret",
externalSecretName, annotation, "--overwrite")
return runOutput("kubectl", args...)
}
// GetAWSSecretString fetches the SecretString of an AWS Secrets Manager
// secret via the aws-cli. The secret ID is computed from config templates
// (see internal/config), not looked up interactively.
func GetAWSSecretString(secretID, region string) (string, error) {
return runOutput("aws", "secretsmanager", "get-secret-value",
"--secret-id", secretID, "--region", region,
"--query", "SecretString", "--output", "text")
}
// CheckAWSAuth performs a cheap, fast call to verify the current AWS
// credentials/SSO session are valid. Returns nil if authenticated, or the
// underlying error (e.g. an expired SSO session) otherwise.
func CheckAWSAuth() error {
_, err := runOutput("aws", "sts", "get-caller-identity", "--query", "Account", "--output", "text")
return err
}
// RunAWSLogin returns an *exec.Cmd for the given login command (e.g.
// "aws sso login"), split on whitespace. The caller is responsible for
// running it interactively (e.g. via tea.ExecProcess) since SSO login
// typically requires opening a browser and confirming a device code.
func RunAWSLogin(loginCommand string) *exec.Cmd {
parts := strings.Fields(loginCommand)
if len(parts) == 0 {
parts = []string{"aws", "sso", "login"}
}
return exec.Command(parts[0], parts[1:]...)
}
// CheckTool verifies that a named executable is available in PATH.
// Returns nil if found, or a descriptive error if not.
func CheckTool(name string) error {
_, err := exec.LookPath(name)
if err == nil {
return nil
}
if name == "tmux" {
return fmt.Errorf("%q not found in PATH — install tmux (Linux/macOS) or psmux (Windows: scoop install psmux or cargo install psmux)", name)
}
return fmt.Errorf("%q not found in PATH — please install it first", name)
}