Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
name: CI

on:
push:
branches:
- '**'
pull_request:

jobs:
test:
name: Test
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod

- name: Vet
run: go vet ./...

- name: Test
run: go test ./...
6 changes: 3 additions & 3 deletions .github/workflows/github-release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Setup Go
uses: actions/setup-go@v2
uses: actions/setup-go@v5
with:
go-version: '1.22'
go-version-file: go.mod
- name: Checkout code
uses: actions/checkout@v2
uses: actions/checkout@v4
with:
fetch-depth: 1
path: ${{ github.workspace }}/src/github.com/gemalto/helm-spray
Expand Down
159 changes: 94 additions & 65 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
Expand Down Expand Up @@ -80,68 +82,7 @@ func NewRootCmd() *cobra.Command {
Long: globalUsage,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {

if len(args) == 0 {
return errors.New("this command needs at least 1 argument: chart name")
} else if len(args) > 1 {
return errors.New("this command accepts only 1 argument: chart name")
}

s.ChartName = args[0]

if s.ChartVersion != "" {
if strings.HasSuffix(s.ChartName, "tgz") {
return errors.New("cannot use --version together with chart archive")
}

if _, err := os.Stat(s.ChartName); err == nil {
return errors.New("cannot use --version together with chart directory")
}

if strings.HasPrefix(s.ChartName, "http://") || strings.HasPrefix(s.ChartName, "https://") {
return errors.New("cannot use --version together with chart HTTP(S) URL")
}
}

if s.PrefixReleasesWithNamespace == true && s.PrefixReleases != "" {
return errors.New("cannot use both --prefix-releases and --prefix-releases-with-namespace together")
}

if len(s.Targets) > 0 && len(s.Excludes) > 0 {
return errors.New("cannot use both --target and --exclude together")
}

// If chart is specified through an URL, then fetch it from the URL.
if strings.HasPrefix(s.ChartName, "http://") || strings.HasPrefix(s.ChartName, "https://") || strings.HasPrefix(s.ChartName, "oci://") {
if s.ChartVersion != "" {
log.Info(1, "fetching chart from URL \"%s\" with version \"%s\"...", s.ChartName, s.ChartVersion)
} else {
log.Info(1, "fetching chart from URL \"%s\"...", s.ChartName)
}
var err error
fetchedChartName, err := helm.Fetch(s.ChartName, s.ChartVersion)
if err != nil {
return fmt.Errorf("fetching chart %s with version %s: %w", s.ChartName, s.ChartVersion, err)
}
s.ChartName = fetchedChartName
} else if _, err := os.Stat(s.ChartName); err != nil {
// If local file (or directory) does not exist, then fetch it from a repo.
if s.ChartVersion != "" {
log.Info(1, "fetching chart \"%s\" from repos with version \"%s\"...", s.ChartName, s.ChartVersion)
} else {
log.Info(1, "fetching chart \"%s\" from repos...", s.ChartName)
}
var err error
fetchedChartName, err := helm.Fetch(s.ChartName, s.ChartVersion)
if err != nil {
return fmt.Errorf("fetching chart %s with version %s: %w", s.ChartName, s.ChartVersion, err)
}
s.ChartName = fetchedChartName
} else {
log.Info(1, "processing chart from local file or directory \"%s\"...", s.ChartName)
}

return s.Spray()
return run(s, args)
},
}

Expand All @@ -164,6 +105,96 @@ func NewRootCmd() *cobra.Command {
f.BoolVarP(&s.Verbose, "verbose", "v", false, "enable spray verbose output")
f.BoolVar(&s.Debug, "debug", false, "enable helm debug output (also include spray verbose output)")

applyHelmEnv(s)

return cmd
}

// run validates the CLI arguments and flags, resolves the chart, and launches the spray.
func run(s *helmspray.Spray, args []string) error {
if err := validateArgs(args); err != nil {
return err
}
s.ChartName = args[0]

if err := validateVersionFlag(s); err != nil {
return err
}

if s.PrefixReleasesWithNamespace == true && s.PrefixReleases != "" {
return errors.New("cannot use both --prefix-releases and --prefix-releases-with-namespace together")
}

if len(s.Targets) > 0 && len(s.Excludes) > 0 {
return errors.New("cannot use both --target and --exclude together")
}

if err := resolveChart(s); err != nil {
return err
}

return s.Spray()
}

func validateArgs(args []string) error {
if len(args) == 0 {
return errors.New("this command needs at least 1 argument: chart name")
} else if len(args) > 1 {
return errors.New("this command accepts only 1 argument: chart name")
}
return nil
}

func validateVersionFlag(s *helmspray.Spray) error {
if s.ChartVersion == "" {
return nil
}
if strings.HasSuffix(s.ChartName, "tgz") {
return errors.New("cannot use --version together with chart archive")
}
if _, err := os.Stat(s.ChartName); err == nil {
return errors.New("cannot use --version together with chart directory")
}
if strings.HasPrefix(s.ChartName, "http://") || strings.HasPrefix(s.ChartName, "https://") {
return errors.New("cannot use --version together with chart HTTP(S) URL")
}
return nil
}

// resolveChart fetches the chart from a URL or a repo when needed, updating s.ChartName.
func resolveChart(s *helmspray.Spray) error {
// If chart is specified through an URL, then fetch it from the URL.
if strings.HasPrefix(s.ChartName, "http://") || strings.HasPrefix(s.ChartName, "https://") || strings.HasPrefix(s.ChartName, "oci://") {
if s.ChartVersion != "" {
log.Info(1, "fetching chart from URL \"%s\" with version \"%s\"...", s.ChartName, s.ChartVersion)
} else {
log.Info(1, "fetching chart from URL \"%s\"...", s.ChartName)
}
fetchedChartName, err := helm.Fetch(s.ChartName, s.ChartVersion)
if err != nil {
return fmt.Errorf("fetching chart %s with version %s: %w", s.ChartName, s.ChartVersion, err)
}
s.ChartName = fetchedChartName
} else if _, err := os.Stat(s.ChartName); err != nil {
// If local file (or directory) does not exist, then fetch it from a repo.
if s.ChartVersion != "" {
log.Info(1, "fetching chart \"%s\" from repos with version \"%s\"...", s.ChartName, s.ChartVersion)
} else {
log.Info(1, "fetching chart \"%s\" from repos...", s.ChartName)
}
fetchedChartName, err := helm.Fetch(s.ChartName, s.ChartVersion)
if err != nil {
return fmt.Errorf("fetching chart %s with version %s: %w", s.ChartName, s.ChartVersion, err)
}
s.ChartName = fetchedChartName
} else {
log.Info(1, "processing chart from local file or directory \"%s\"...", s.ChartName)
}
return nil
}

// applyHelmEnv applies the HELM_DEBUG and HELM_NAMESPACE environment variables transmitted by helm.
func applyHelmEnv(s *helmspray.Spray) {
// When called through helm, debug mode is transmitted through the HELM_DEBUG envvar
helmDebug := os.Getenv("HELM_DEBUG")
if helmDebug == "1" || strings.EqualFold(helmDebug, "true") || strings.EqualFold(helmDebug, "on") {
Expand All @@ -180,6 +211,4 @@ func NewRootCmd() *cobra.Command {
} else {
s.Namespace = "default"
}

return cmd
}
158 changes: 158 additions & 0 deletions cmd/root_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
package cmd

import (
"bytes"
"os"
"testing"
)

func runCmd(args []string) error {
cmd := NewRootCmd()
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
cmd.SetArgs(args)
return cmd.Execute()
}

func TestRootCmd_NoArgs(t *testing.T) {
err := runCmd([]string{})
if err == nil || err.Error() != "this command needs at least 1 argument: chart name" {
t.Errorf("unexpected error: %v", err)
}
}

func TestRootCmd_TooManyArgs(t *testing.T) {
err := runCmd([]string{"chart1", "chart2"})
if err == nil || err.Error() != "this command accepts only 1 argument: chart name" {
t.Errorf("unexpected error: %v", err)
}
}

func TestRootCmd_VersionWithTgz(t *testing.T) {
err := runCmd([]string{"--version", "1.0.0", "mychart.tgz"})
if err == nil || err.Error() != "cannot use --version together with chart archive" {
t.Errorf("unexpected error: %v", err)
}
}

func TestRootCmd_VersionWithExistingDirectory(t *testing.T) {
dir, _ := os.MkdirTemp("", "spray-cmd-test-")
defer os.RemoveAll(dir)
err := runCmd([]string{"--version", "1.0.0", dir})
if err == nil || err.Error() != "cannot use --version together with chart directory" {
t.Errorf("unexpected error: %v", err)
}
}

func TestRootCmd_VersionWithHTTPURL(t *testing.T) {
err := runCmd([]string{"--version", "1.0.0", "http://example.com/chart"})
if err == nil || err.Error() != "cannot use --version together with chart HTTP(S) URL" {
t.Errorf("unexpected error: %v", err)
}
}

func TestRootCmd_VersionWithHTTPSURL(t *testing.T) {
err := runCmd([]string{"--version", "1.0.0", "https://example.com/chart"})
if err == nil || err.Error() != "cannot use --version together with chart HTTP(S) URL" {
t.Errorf("unexpected error: %v", err)
}
}

func TestRootCmd_BothPrefixFlags(t *testing.T) {
err := runCmd([]string{"--prefix-releases", "myprefix", "--prefix-releases-with-namespace", "mychart.tgz"})
if err == nil || err.Error() != "cannot use both --prefix-releases and --prefix-releases-with-namespace together" {
t.Errorf("unexpected error: %v", err)
}
}

func TestRootCmd_BothTargetAndExclude(t *testing.T) {
err := runCmd([]string{"--target", "svc1", "--exclude", "svc2", "mychart.tgz"})
if err == nil || err.Error() != "cannot use both --target and --exclude together" {
t.Errorf("unexpected error: %v", err)
}
}

func TestNewRootCmd_HelmDebugEnvVar(t *testing.T) {
os.Setenv("HELM_DEBUG", "1")
defer os.Unsetenv("HELM_DEBUG")
// Just verify NewRootCmd does not panic when HELM_DEBUG is set
cmd := NewRootCmd()
if cmd == nil {
t.Error("expected non-nil command")
}
}

func TestNewRootCmd_HelmNamespaceEnvVar(t *testing.T) {
os.Setenv("HELM_NAMESPACE", "mynamespace")
defer os.Unsetenv("HELM_NAMESPACE")
cmd := NewRootCmd()
if cmd == nil {
t.Error("expected non-nil command")
}
}

func TestNewRootCmd_DefaultNamespace(t *testing.T) {
os.Unsetenv("HELM_NAMESPACE")
cmd := NewRootCmd()
if cmd == nil {
t.Error("expected non-nil command")
}
}

// --- paths that call helm.Fetch or s.Spray() ---
// These cover the remaining branches in RunE. helm.Fetch/Spray will fail
// (invalid URL / chart not found), but the lines are still executed.

func TestRootCmd_HTTPURLFetch(t *testing.T) {
// http:// prefix → fetch from URL path, no --version (covers log without version + Fetch error)
// Use 127.0.0.1:1 to get an immediate connection-refused rather than a DNS timeout.
err := runCmd([]string{"http://127.0.0.1:1/chart"})
if err == nil {
t.Error("expected error fetching from unreachable URL")
}
}

func TestRootCmd_HTTPURLFetchWithVersion(t *testing.T) {
// http:// prefix + version → covers the log-with-version branch inside the URL block.
// Note: the "cannot use --version with HTTP URL" guard only fires for http/https.
// We use oci:// here to bypass that guard while still hitting the URL-fetch path.
err := runCmd([]string{"--version", "1.0.0", "oci://127.0.0.1:1/chart"})
if err == nil {
t.Error("expected error fetching oci chart")
}
}

func TestRootCmd_OCIURLFetch(t *testing.T) {
// oci:// prefix → same URL-fetch branch, no --version
err := runCmd([]string{"oci://127.0.0.1:1/chart"})
if err == nil {
t.Error("expected error fetching oci chart")
}
}

func TestRootCmd_NonExistentChartFetch(t *testing.T) {
// Chart name does not exist locally → fetch from repo, no version
err := runCmd([]string{"nonexistent-spray-chart-xyz-abc-000"})
if err == nil {
t.Error("expected error for non-existent chart")
}
}

func TestRootCmd_NonExistentChartFetchWithVersion(t *testing.T) {
// Chart name does not exist locally + --version → covers log-with-version branch
err := runCmd([]string{"--version", "1.0.0", "nonexistent-spray-chart-xyz-abc-000"})
if err == nil {
t.Error("expected error for non-existent versioned chart")
}
}

func TestRootCmd_ExistingLocalChart(t *testing.T) {
// Chart path exists locally → covers the "else" log branch + s.Spray() call.
// loader.Load will fail on an empty dir (not a valid chart), but line 143/146 are covered.
dir, _ := os.MkdirTemp("", "spray-chart-test-")
defer os.RemoveAll(dir)
err := runCmd([]string{dir})
if err == nil {
t.Error("expected error loading empty directory as chart")
}
}
Loading