package helm import ( "context" "errors" "fmt" "os" "os/exec" "regexp" "slices" "strconv" "strings" "time" "github.com/sophotechlabs/spinoza/internal/api" "github.com/sophotechlabs/spinoza/internal/auth" "github.com/sophotechlabs/spinoza/internal/commandbuffer" ) const ( DefaultBinary = "helm" actionTimeout = 5 * time.Minute maxHelmOutput = 16 << 20 maxHelmError = 64 << 10 driverEnv = "HELM_DRIVER" ActionRollback = "rollback" ActionUninstal = "uninstall" ) var nameFormat = regexp.MustCompile(`^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$`) var ErrNoHelmBinary = errors.New("helm was not found on PATH") type Runner interface { Run(ctx context.Context, args, env []string) (string, error) Available() error } type helmRunner struct { binary string outputLimit int errorLimit int } func NewRunner(binary string) Runner { if binary == "" { binary = DefaultBinary } return &helmRunner{binary: binary, outputLimit: maxHelmOutput, errorLimit: maxHelmError} } func (h *helmRunner) Available() error { _, err := exec.LookPath(h.binary) if err != nil { return fmt.Errorf("%w: %s", ErrNoHelmBinary, h.binary) } return nil } func (h *helmRunner) Run(ctx context.Context, args, env []string) (string, error) { //nolint:gosec // the binary is a flag, every argument is validated below and passed as argv, never through a shell command := exec.CommandContext(ctx, h.binary, args...) stdout := commandbuffer.Head(h.outputLimit) stderr := commandbuffer.Tail(h.errorLimit) command.Stdout = stdout command.Stderr = stderr command.Stdin = nil command.Env = helmEnvironment(env) err := command.Run() if stdout.Exceeded() { return "", fmt.Errorf("helm output exceeded %d bytes", h.outputLimit) } if err == nil { return strings.TrimSpace(stdout.String()), nil } if _, missing := errors.AsType[*exec.Error](err); missing { return "", fmt.Errorf("%w: %s", ErrNoHelmBinary, h.binary) } message := firstMeaningful(stderr.String()) if stderr.Exceeded() { return "", fmt.Errorf("helm error output exceeded %d bytes: %s", h.errorLimit, message) } if message == "" { return "", err } return "", errors.New(message) } func helmEnvironment(extra []string) []string { held := map[string]string{} for _, entry := range os.Environ() { key, _, ok := strings.Cut(entry, "=") if !ok { continue } if !allowedHelmEnvironment(key) { continue } held[key] = entry } for _, entry := range extra { key, _, ok := strings.Cut(entry, "=") if !ok { continue } held[key] = entry } keys := make([]string, 0, len(held)) for key := range held { keys = append(keys, key) } slices.Sort(keys) out := make([]string, 0, len(keys)) for _, key := range keys { out = append(out, held[key]) } return out } var helmEnvironmentPrefixes = []string{"HELM_", "XDG_", "AWS_", "CLOUDSDK_", "GOOGLE_", "AZURE_"} func allowedHelmEnvironment(key string) bool { for _, prefix := range helmEnvironmentPrefixes { if strings.HasPrefix(key, prefix) { return true } } switch key { case "HOME", "KUBECONFIG", "PATH", "TMPDIR", "SSL_CERT_DIR", "SSL_CERT_FILE", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy": return true default: return false } } func firstMeaningful(stderr string) string { lines := strings.Split(strings.TrimSpace(stderr), "\n") kept := make([]string, 0, len(lines)) for _, line := range lines { trimmed := strings.TrimSpace(line) if trimmed == "" { continue } kept = append(kept, trimmed) } if len(kept) == 0 { return "" } return strings.Join(kept, "; ") } func (s *Service) Support() api.HelmSupport { support := api.HelmSupport{Binary: DefaultBinary} if s.runner == nil { support.Reason = "helm actions are not wired up" return support } err := s.runner.Available() if err != nil { support.Reason = err.Error() return support } support.Available = true return support } func (s *Service) Rollback(ctx context.Context, namespace, name string, revision int64) (api.HelmActionResult, error) { err := s.admits(namespace, name) if err != nil { return api.HelmActionResult{}, err } if revision < 1 { return api.HelmActionResult{}, errors.New("a rollback needs the revision to go back to") } driver, driverErr := s.driverFor(ctx, namespace, name) if driverErr != nil { return api.HelmActionResult{}, driverErr } args := s.args(ctx, "rollback", name, strconv.FormatInt(revision, 10), "--namespace", namespace) out, runErr := s.run(ctx, args, driver) if runErr != nil { return api.HelmActionResult{}, runErr } return api.HelmActionResult{ Action: ActionRollback, Message: orDefault(out, fmt.Sprintf("rolled %s back to revision %d", name, revision)), Revision: revision, }, nil } func (s *Service) Uninstall(ctx context.Context, namespace, name string) (api.HelmActionResult, error) { err := s.admits(namespace, name) if err != nil { return api.HelmActionResult{}, err } driver, driverErr := s.driverFor(ctx, namespace, name) if driverErr != nil { return api.HelmActionResult{}, driverErr } args := s.args(ctx, "uninstall", name, "--namespace", namespace) out, runErr := s.run(ctx, args, driver) if runErr != nil { return api.HelmActionResult{}, runErr } return api.HelmActionResult{ Action: ActionUninstal, Message: orDefault(out, fmt.Sprintf("uninstalled %s from %s", name, namespace)), }, nil } func (s *Service) admits(namespace, name string) error { if s.runner == nil { return errors.New("helm actions are not wired up") } if !nameFormat.MatchString(namespace) || !nameFormat.MatchString(name) { return errors.New("namespace and release must be valid kubernetes names") } return nil } func (s *Service) args(ctx context.Context, rest ...string) []string { args := append([]string{}, rest...) if s.kubeRef.Name != "" { args = append(args, "--kube-context", s.kubeRef.Name) } if s.kubeRef.Kubeconfig != "" { args = append(args, "--kubeconfig", s.kubeRef.Kubeconfig) } return append(args, auth.HelmFlags(ctx)...) } func (s *Service) run(ctx context.Context, args []string, driver string) (string, error) { bounded, cancel := context.WithTimeout(ctx, actionTimeout) defer cancel() env := []string{} if driver == DriverConfigMap { env = append(env, driverEnv+"="+DriverConfigMap) } return s.runner.Run(bounded, args, env) } func (s *Service) driverFor(ctx context.Context, namespace, name string) (string, error) { bounded, cancel := context.WithTimeout(ctx, listTimeout) defer cancel() if s.meta == nil { revisions, err := revisionsIn(bounded, s.cs, namespace, name) if err != nil { return "", err } if len(revisions) == 0 { return "", fmt.Errorf("%w: %s/%s", ErrNoRelease, namespace, name) } latest := revisions[0] for _, revision := range revisions[1:] { if revision.revision > latest.revision { latest = revision continue } if revision.revision == latest.revision { return "", fmt.Errorf("%w: revision %d", errAmbiguousRevision, revision.revision) } } return latest.driver, nil } latest, err := s.latestRef(bounded, namespace, name) if err != nil { return "", err } return latest.driver, nil } func orDefault(out, fallback string) string { if out == "" { return fallback } return out }