diff --git a/.github/workflows/test-ci.yml b/.github/workflows/test-ci.yml new file mode 100644 index 0000000000..f526200807 --- /dev/null +++ b/.github/workflows/test-ci.yml @@ -0,0 +1,20 @@ +name: Test CI Trigger + +on: + pull_request: + push: + branches: + - feature/add-utility-functions + - main + +permissions: + contents: read + +jobs: + test-trigger: + name: Test if CI runs + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Test step + run: echo "CI is working!" diff --git a/pkg/commands/commands.go b/pkg/commands/commands.go index da92ac21a6..c5357fac0c 100644 --- a/pkg/commands/commands.go +++ b/pkg/commands/commands.go @@ -15,7 +15,9 @@ package commands import ( + "fmt" "os/exec" + "strings" "github.com/spf13/cobra" ) @@ -41,3 +43,58 @@ func isKubectlAvailable() bool { } return true } + +// getKubectlVersion returns the kubectl version string +func getKubectlVersion() (string, error) { + cmd := exec.Command("kubectl", "version", "--client=true", "--short=true") + output, err := cmd.Output() + if err != nil { + return "", fmt.Errorf("failed to get kubectl version: %w", err) + } + return strings.TrimSpace(string(output)), nil +} + +// validateKubeCommands checks if all required tools for Kubernetes commands are available +func validateKubeCommands() error { + if !isKubectlAvailable() { + return fmt.Errorf("kubectl is required but not found in PATH") + } + + version, err := getKubectlVersion() + if err != nil { + return fmt.Errorf("failed to validate kubectl: %w", err) + } + + if version == "" { + return fmt.Errorf("kubectl version could not be determined") + } + + return nil +} + +// isCommandAvailable checks if a given command is available in PATH +func isCommandAvailable(cmd string) bool { + if _, err := exec.LookPath(cmd); err != nil { + return false + } + return true +} + +// getRequiredCommands returns a list of commands required for ko's full functionality +func getRequiredCommands() []string { + return []string{"kubectl", "docker"} +} + +// validatePrerequisites checks if all prerequisites for ko are met +func validatePrerequisites() []string { + var missing []string + required := getRequiredCommands() + + for _, cmd := range required { + if !isCommandAvailable(cmd) { + missing = append(missing, cmd) + } + } + + return missing +} diff --git a/pkg/commands/commands_test.go b/pkg/commands/commands_test.go new file mode 100644 index 0000000000..f2c245f656 --- /dev/null +++ b/pkg/commands/commands_test.go @@ -0,0 +1,186 @@ +package commands + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestIsCommandAvailable_NotFound(t *testing.T) { + assert.False(t, isCommandAvailable("this-command-should-not-exist-xyz-123")) +} + +func TestIsCommandAvailable_FoundWithTempExecutable(t *testing.T) { + dir := t.TempDir() + makeFakeExecutable(t, dir, "kubectl", "echo ok", 0) + + origPath := os.Getenv("PATH") + t.Setenv("PATH", dir+string(os.PathListSeparator)+origPath) + + assert.True(t, isCommandAvailable("kubectl")) +} + +func TestIsKubectlAvailable(t *testing.T) { + t.Run("false when not in PATH", func(t *testing.T) { + // Remove PATH so nothing can be found + t.Setenv("PATH", "") + assert.False(t, isKubectlAvailable()) + }) + + t.Run("true when in PATH", func(t *testing.T) { + dir := t.TempDir() + makeFakeExecutable(t, dir, "kubectl", "echo ok", 0) + t.Setenv("PATH", dir) + assert.True(t, isKubectlAvailable()) + }) +} + +func TestGetKubectlVersion_Success(t *testing.T) { + dir := t.TempDir() + expected := "v1.28.3" + makeFakeExecutable(t, dir, "kubectl", echoCommand(expected), 0) + t.Setenv("PATH", dir) + + got, err := getKubectlVersion() + require.NoError(t, err) + assert.Equal(t, expected, got) +} + +func TestGetKubectlVersion_FailureFromExitCode(t *testing.T) { + dir := t.TempDir() + makeFakeExecutable(t, dir, "kubectl", echoCommand("ignored output"), 1) + t.Setenv("PATH", dir) + + got, err := getKubectlVersion() + assert.Error(t, err) + assert.Empty(t, got) + assert.Contains(t, err.Error(), "failed to get kubectl version") +} + +func TestValidateKubeCommands(t *testing.T) { + t.Run("missing kubectl", func(t *testing.T) { + t.Setenv("PATH", "") + err := validateKubeCommands() + require.Error(t, err) + assert.Contains(t, err.Error(), "kubectl is required but not found in PATH") + }) + + t.Run("kubectl present returns non-empty version", func(t *testing.T) { + dir := t.TempDir() + makeFakeExecutable(t, dir, "kubectl", echoCommand("Client Version: v1.29.0"), 0) + t.Setenv("PATH", dir) + + err := validateKubeCommands() + assert.NoError(t, err) + }) + + t.Run("kubectl version empty string", func(t *testing.T) { + dir := t.TempDir() + // Print empty line + makeFakeExecutable(t, dir, "kubectl", echoCommand(""), 0) + t.Setenv("PATH", dir) + + err := validateKubeCommands() + require.Error(t, err) + assert.Contains(t, err.Error(), "kubectl version could not be determined") + }) +} + +func TestGetRequiredCommands(t *testing.T) { + want := []string{"kubectl", "docker"} + got := getRequiredCommands() + assert.Equal(t, want, got) +} + +func TestValidatePrerequisites_AllMissing(t *testing.T) { + t.Setenv("PATH", "") + missing := validatePrerequisites() + // Order should be as defined in getRequiredCommands + assert.Equal(t, []string{"kubectl", "docker"}, missing) +} + +func TestAddKubeCommands_NoPanic(t *testing.T) { + root := &cobra.Command{Use: "root"} + assert.NotPanics(t, func() { + AddKubeCommands(root) + }) +} + +/************ helpers ************/ + +// makeFakeExecutable creates a fake executable named `name` in `dir` with the provided +// shell/batch body and exit code. +// On Unix it writes a sh script; on Windows it writes a .bat script. +func makeFakeExecutable(t *testing.T, dir, name, body string, exitCode int) string { + t.Helper() + + if runtime.GOOS == "windows" { + // Write a .bat file + path := filepath.Join(dir, name+".bat") + content := "@echo off\r\n" + if body != "" { + content += body + "\r\n" + } + // Ensure proper exit code + content += "exit /b " + itoa(exitCode) + "\r\n" + require.NoError(t, os.WriteFile(path, []byte(content), 0o755)) + return path + } + + // Unix-like + path := filepath.Join(dir, name) + content := "#!/bin/sh\n" + if body != "" { + content += body + "\n" + } + content += "exit " + itoa(exitCode) + "\n" + require.NoError(t, os.WriteFile(path, []byte(content), 0o755)) + require.NoError(t, os.Chmod(path, 0o755)) + return path +} + +// echoCommand returns a platform-appropriate echo command to print the given message. +func echoCommand(msg string) string { + if runtime.GOOS == "windows" { + // In batch, echo without quotes prints as-is + if msg == "" { + // echo. prints a blank line + return "echo." + } + // Escape any special characters minimally + escaped := strings.ReplaceAll(msg, "%", "%%") + return "echo " + escaped + } + // Unix shell: use printf to avoid adding extra newline unless desired + // But we want a newline so getKubectlVersion TrimSpace trims it + return "echo \"" + escapeDoubleQuotes(msg) + "\"" +} + +func escapeDoubleQuotes(s string) string { + return strings.ReplaceAll(s, "\"", "\\\"") +} + +func itoa(n int) string { + if n == 0 { + return "0" + } + sign := "" + if n < 0 { + sign = "-" + n = -n + } + var b [20]byte + i := len(b) + for n > 0 { + i-- + b[i] = byte('0' + n%10) + n /= 10 + } + return sign + string(b[i:]) +} diff --git a/pkg/commands/version.go b/pkg/commands/version.go index 630ed1ad2b..093b1c1048 100644 --- a/pkg/commands/version.go +++ b/pkg/commands/version.go @@ -16,7 +16,9 @@ package commands import ( "fmt" + "regexp" "runtime/debug" + "strings" "github.com/spf13/cobra" ) @@ -50,3 +52,41 @@ func version() string { } return Version } + +// formatVersion formats the version string for display +func formatVersion(v string) string { + if v == "" { + return "unknown" + } + // Clean up version strings that might have build info + if strings.Contains(v, "+") { + parts := strings.Split(v, "+") + return parts[0] + } + return v +} + +// isValidVersion checks if the version string matches semantic versioning format +func isValidVersion(v string) bool { + if v == "" || v == "unknown" { + return false + } + // Basic semver regex pattern + semverPattern := regexp.MustCompile(`^v?\d+\.\d+\.\d+(-[\w\.\-]+)?(\+[\w\.\-]+)?$`) + return semverPattern.MatchString(v) +} + +// getVersionInfo returns formatted version information +func getVersionInfo() string { + v := version() + formatted := formatVersion(v) + + var status string + if isValidVersion(v) { + status = "valid" + } else { + status = "non-standard" + } + + return fmt.Sprintf("ko version %s (%s)", formatted, status) +} diff --git a/pkg/commands/version_test.go b/pkg/commands/version_test.go new file mode 100644 index 0000000000..8d8f0a85a6 --- /dev/null +++ b/pkg/commands/version_test.go @@ -0,0 +1,232 @@ +package commands + +import ( + "io" + "os" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + origStdout := os.Stdout + r, w, err := os.Pipe() + require.NoError(t, err) + + os.Stdout = w + defer func() { os.Stdout = origStdout }() + + fn() + + require.NoError(t, w.Close()) + out, err := io.ReadAll(r) + require.NoError(t, err) + require.NoError(t, r.Close()) + return string(out) +} + +func TestIsValidVersion_Table(t *testing.T) { + tests := []struct { + name string + in string + ok bool + }{ + {"v-prefix valid", "v1.2.3", true}, + {"no prefix valid", "1.2.3", true}, + {"missing patch", "1.2", false}, + {"empty", "", false}, + {"unknown literal", "unknown", false}, + {"prerelease", "v1.2.3-beta.1", true}, + {"build metadata", "v1.2.3+build.1", true}, + {"prerelease and build", "v1.2.3-beta.1+build.1", true}, + {"nonsense", "not-a-version", false}, + {"dangling prerelease dash", "v1.2.3-", false}, + {"dangling build plus", "v1.2.3+", false}, + {"leading zeros allowed", "v01.02.03", true}, + {"missing dash before rc", "v1.2.3rc1", false}, + {"underscore in prerelease", "v1.2.3-rc_1", true}, + {"dot-ending prerelease", "v1.2.3-rc.", true}, + {"multiple build plus segments invalid", "v1.2.3+build+extra", false}, + {"devel", "devel", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isValidVersion(tt.in) + assert.Equal(t, tt.ok, got, "input: %q", tt.in) + }) + } +} + +func TestFormatVersion_Table(t *testing.T) { + tests := []struct { + name string + in string + out string + }{ + {"empty -> unknown", "", "unknown"}, + {"plain semver", "v1.2.3", "v1.2.3"}, + {"plain no prefix", "1.2.3", "1.2.3"}, + {"with build metadata", "v1.2.3+exp.sha.5114f85", "v1.2.3"}, + {"prerelease with build metadata", "1.2.3-beta.1+build.5", "1.2.3-beta.1"}, + {"non-semver passthrough", "devel", "devel"}, + {"multiple plus signs", "custom+abc+def", "custom"}, + {"trailing plus", "abc+", "abc"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := formatVersion(tt.in) + assert.Equal(t, tt.out, got) + }) + } +} + +func TestFormatVersion_DropsOnlyFirstPlus(t *testing.T) { + in := "x+y+z" + out := formatVersion(in) + assert.Equal(t, "x", out) +} + +func TestIsValidVersion_VPrefixOptional(t *testing.T) { + assert.True(t, isValidVersion("v1.0.0")) + assert.True(t, isValidVersion("1.0.0")) +} + +func TestIsValidVersion_InvalidExtraPlusOrDangling(t *testing.T) { + assert.False(t, isValidVersion("v1.0.0+")) + assert.False(t, isValidVersion("v1.0.0-")) + assert.False(t, isValidVersion("v1.0.0+build+extra")) +} + +func TestVersion_UsesGlobalWhenSet(t *testing.T) { + orig := Version + defer func() { Version = orig }() + + Version = "test-version-123" + got := version() + assert.Equal(t, "test-version-123", got) + assert.Equal(t, "test-version-123", Version) +} + +func TestVersion_ReadsBuildInfoWhenEmpty(t *testing.T) { + orig := Version + defer func() { Version = orig }() + + Version = "" + got1 := version() + // It should set the global Version to whatever build info provides (possibly "(devel)") + assert.Equal(t, Version, got1) + + // Subsequent calls should return the same value since Version is now set + got2 := version() + assert.Equal(t, got1, got2) +} + +func TestGetVersionInfo_WithValidSemver(t *testing.T) { + orig := Version + defer func() { Version = orig }() + + Version = "v1.2.3" + got := getVersionInfo() + assert.Equal(t, "ko version v1.2.3 (valid)", got) +} + +func TestGetVersionInfo_WithBuildMetadata(t *testing.T) { + orig := Version + defer func() { Version = orig }() + + Version = "v1.2.3+meta.build-1" + got := getVersionInfo() + assert.Equal(t, "ko version v1.2.3 (valid)", got) +} + +func TestGetVersionInfo_WithPrereleaseAndBuild(t *testing.T) { + orig := Version + defer func() { Version = orig }() + + Version = "1.2.3-beta.2+build.5" + got := getVersionInfo() + assert.Equal(t, "ko version 1.2.3-beta.2 (valid)", got) +} + +func TestGetVersionInfo_NonStandard_Devel(t *testing.T) { + orig := Version + defer func() { Version = orig }() + + Version = "devel" + got := getVersionInfo() + assert.Equal(t, "ko version devel (non-standard)", got) +} + +func TestGetVersionInfo_UnknownLiteral(t *testing.T) { + orig := Version + defer func() { Version = orig }() + + Version = "unknown" + got := getVersionInfo() + assert.Equal(t, "ko version unknown (non-standard)", got) +} + +func TestAddVersion_AddsSubcommand(t *testing.T) { + root := &cobra.Command{Use: "root"} + addVersion(root) + + var found *cobra.Command + for _, c := range root.Commands() { + if c.Name() == "version" { + found = c + break + } + } + require.NotNil(t, found, "version subcommand should be registered") + assert.Equal(t, "version", found.Use) + assert.Equal(t, "Print ko version.", found.Short) +} + +func TestVersionCommand_PrintsGlobalVersion(t *testing.T) { + orig := Version + defer func() { Version = orig }() + + Version = "v9.9.9" + + root := &cobra.Command{Use: "root"} + addVersion(root) + root.SetArgs([]string{"version"}) + + out := captureStdout(t, func() { + err := root.Execute() + require.NoError(t, err) + }) + + assert.Equal(t, "v9.9.9\n", out) +} + +func TestVersionCommand_PrintsBuildInfoOrMessageWhenEmpty(t *testing.T) { + orig := Version + defer func() { Version = orig }() + + // Force reading from build info + Version = "" + + root := &cobra.Command{Use: "root"} + addVersion(root) + root.SetArgs([]string{"version"}) + + out := captureStdout(t, func() { + err := root.Execute() + require.NoError(t, err) + }) + + out = strings.TrimSpace(out) + // Typically will be something like "(devel)" when running tests, but fallback message is also acceptable. + if out == "could not determine build information" { + assert.Equal(t, "could not determine build information", out) + } else { + assert.NotEmpty(t, out) + } +}