diff --git a/README.md b/README.md index 27e63e4..26b1697 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ This plugin provides an interface between [Drone](https://drone.io/) and [Helm 3 * Lint your charts * Deploy your service * Delete your service +* Publish it to a Helm Registry (OCI-based registries) The plugin is inpsired by [drone-helm](https://github.com/ipedrazas/drone-helm), which fills the same role for Helm 2. It provides a comparable feature-set and the configuration settings are backward-compatible. @@ -58,6 +59,26 @@ steps: from_secret: kubernetes_token ``` +### Publish + +```yaml +steps: + - name: publish + image: pelotech/drone-helm3 + settings: + mode: publish + chart: ./ + environment: + REGISTRY_URL: + from_secret: registry_url + REGISTRY_LOGIN_USER_ID: + from_secret: registry_login_user_id + REGISTRY_LOGIN_PASSWORD: + from_secret: registry_login_password + REGISTRY_REPO_NAME: + CHART_VERSION: +``` + ## Upgrading from drone-helm drone-helm3 is largely backward-compatible with drone-helm. There are some known differences: diff --git a/build/drone-helm b/build/drone-helm index 793b9a8..fffcde2 100755 Binary files a/build/drone-helm and b/build/drone-helm differ diff --git a/docs/parameter_reference.md b/docs/parameter_reference.md index ad28ae6..d304f1d 100644 --- a/docs/parameter_reference.md +++ b/docs/parameter_reference.md @@ -66,6 +66,20 @@ Uninstallations are triggered when the `mode` setting is "uninstall" or "delete. | skip_tls_verify | boolean | | | Connect to the Kubernetes cluster without checking for a valid TLS certificate. Not recommended in production. | | chart | string | | | Required when the global `update_dependencies` parameter is true. No effect otherwise. | +## Publish + +Publish are triggered when the `mode` setting is "publish." + +| Param name | Type | Required | Alias | Purpose | +|------------------------|----------|----------|------------------------|---------| +| registry_url | string | yes | | Registry url to where the chart be published| +| registry_login_user_id | string | yes | | Login ID for the Helm registry | +| registry_login_password| string | yes | | Login Password for the Helm registry | +| registry_repo_name | string | yes | | Repository name in the Helm registry | +| chart_version | string | yes | | Version of the chart | +| chart | string | | | Local path of the chart | + + ### Where to put settings Any setting can go in either the `settings` or `environment` section. If a setting exists in _both_ sections, the version in `environment` will override the version in `settings`. diff --git a/internal/env/config.go b/internal/env/config.go index dd5c352..8753b82 100644 --- a/internal/env/config.go +++ b/internal/env/config.go @@ -21,35 +21,39 @@ var ( // not have the `PLUGIN_` prefix. type Config struct { // Configuration for drone-helm itself - Command string `envconfig:"mode"` // Helm command to run - DroneEvent string `envconfig:"drone_build_event"` // Drone event that invoked this plugin. - UpdateDependencies bool `split_words:"true"` // [Deprecated] Call `helm dependency update` before the main command (deprecated, use dependencies_action: update instead) - DependenciesAction string `split_words:"true"` // Call `helm dependency build` or `helm dependency update` before the main command - AddRepos []string `split_words:"true"` // Call `helm repo add` before the main command - RepoCertificate string `envconfig:"repo_certificate"` // The Helm chart repository's self-signed certificate (must be base64-encoded) - RepoCACertificate string `envconfig:"repo_ca_certificate"` // The Helm chart repository CA's self-signed certificate (must be base64-encoded) - Debug bool `` // Generate debug output and pass --debug to all helm commands - Values string `` // Argument to pass to --set in applicable helm commands - StringValues string `split_words:"true"` // Argument to pass to --set-string in applicable helm commands - ValuesFiles []string `split_words:"true"` // Arguments to pass to --values in applicable helm commands - Namespace string `` // Kubernetes namespace for all helm commands - KubeToken string `split_words:"true"` // Kubernetes authentication token to put in .kube/config - SkipTLSVerify bool `envconfig:"skip_tls_verify"` // Put insecure-skip-tls-verify in .kube/config - Certificate string `envconfig:"kube_certificate"` // The Kubernetes cluster CA's self-signed certificate (must be base64-encoded) - APIServer string `envconfig:"kube_api_server"` // The Kubernetes cluster's API endpoint - ServiceAccount string `envconfig:"kube_service_account"` // Account to use for connecting to the Kubernetes cluster - ChartVersion string `split_words:"true"` // Specific chart version to use in `helm upgrade` - DryRun bool `split_words:"true"` // Pass --dry-run to applicable helm commands - Wait bool `envconfig:"wait_for_upgrade"` // Pass --wait to applicable helm commands - ReuseValues bool `split_words:"true"` // Pass --reuse-values to `helm upgrade` - KeepHistory bool `split_words:"true"` // Pass --keep-history to `helm uninstall` - Timeout string `` // Argument to pass to --timeout in applicable helm commands - Chart string `` // Chart argument to use in applicable helm commands - Release string `` // Release argument to use in applicable helm commands - Force bool `envconfig:"force_upgrade"` // Pass --force to applicable helm commands - AtomicUpgrade bool `split_words:"true"` // Pass --atomic to `helm upgrade` - CleanupOnFail bool `envconfig:"cleanup_failed_upgrade"` // Pass --cleanup-on-fail to `helm upgrade` - LintStrictly bool `split_words:"true"` // Pass --strict to `helm lint` + Command string `envconfig:"mode"` // Helm command to run + DroneEvent string `envconfig:"drone_build_event"` // Drone event that invoked this plugin. + UpdateDependencies bool `split_words:"true"` // [Deprecated] Call `helm dependency update` before the main command (deprecated, use dependencies_action: update instead) + DependenciesAction string `split_words:"true"` // Call `helm dependency build` or `helm dependency update` before the main command + AddRepos []string `split_words:"true"` // Call `helm repo add` before the main command + RepoCertificate string `envconfig:"repo_certificate"` // The Helm chart repository's self-signed certificate (must be base64-encoded) + RepoCACertificate string `envconfig:"repo_ca_certificate"` // The Helm chart repository CA's self-signed certificate (must be base64-encoded) + Debug bool `` // Generate debug output and pass --debug to all helm commands + Values string `` // Argument to pass to --set in applicable helm commands + StringValues string `split_words:"true"` // Argument to pass to --set-string in applicable helm commands + ValuesFiles []string `split_words:"true"` // Arguments to pass to --values in applicable helm commands + Namespace string `` // Kubernetes namespace for all helm commands + KubeToken string `split_words:"true"` // Kubernetes authentication token to put in .kube/config + SkipTLSVerify bool `envconfig:"skip_tls_verify"` // Put insecure-skip-tls-verify in .kube/config + Certificate string `envconfig:"kube_certificate"` // The Kubernetes cluster CA's self-signed certificate (must be base64-encoded) + APIServer string `envconfig:"kube_api_server"` // The Kubernetes cluster's API endpoint + ServiceAccount string `envconfig:"kube_service_account"` // Account to use for connecting to the Kubernetes cluster + ChartVersion string `split_words:"true"` // Specific chart version to use in `helm upgrade` + DryRun bool `split_words:"true"` // Pass --dry-run to applicable helm commands + Wait bool `envconfig:"wait_for_upgrade"` // Pass --wait to applicable helm commands + ReuseValues bool `split_words:"true"` // Pass --reuse-values to `helm upgrade` + KeepHistory bool `split_words:"true"` // Pass --keep-history to `helm uninstall` + Timeout string `` // Argument to pass to --timeout in applicable helm commands + Chart string `` // Chart argument to use in applicable helm commands + Release string `` // Release argument to use in applicable helm commands + Force bool `envconfig:"force_upgrade"` // Pass --force to applicable helm commands + AtomicUpgrade bool `split_words:"true"` // Pass --atomic to `helm upgrade` + CleanupOnFail bool `envconfig:"cleanup_failed_upgrade"` // Pass --cleanup-on-fail to `helm upgrade` + LintStrictly bool `split_words:"true"` // Pass --strict to `helm lint` + RegistryURL string `envconfig:"registry_url"` // Helm Registry URL + RegistryLoginUserID string `envconfig:"registry_login_user_id"` // Helm Registry login user ID + RegistryLoginPassword string `envconfig:"registry_login_password"` // Helm Registry login password + RegistryRepoName string `envconfig:"registry_repo_name"` // Helm Registry repository name Stdout io.Writer `ignored:"true"` Stderr io.Writer `ignored:"true"` @@ -67,14 +71,18 @@ func NewConfig(stdout, stderr io.Writer) (*Config, error) { } cfg := Config{ - Command: aliases.Command, - AddRepos: aliases.AddRepos, - APIServer: aliases.APIServer, - ServiceAccount: aliases.ServiceAccount, - Wait: aliases.Wait, - Force: aliases.Force, - KubeToken: aliases.KubeToken, - Certificate: aliases.Certificate, + Command: aliases.Command, + AddRepos: aliases.AddRepos, + APIServer: aliases.APIServer, + ServiceAccount: aliases.ServiceAccount, + Wait: aliases.Wait, + Force: aliases.Force, + KubeToken: aliases.KubeToken, + Certificate: aliases.Certificate, + RegistryURL: aliases.RegistryURL, + RegistryLoginUserID: aliases.RegistryLoginUserID, + RegistryLoginPassword: aliases.RegistryLoginPassword, + RegistryRepoName: aliases.RegistryRepoName, Stdout: stdout, Stderr: stderr, @@ -145,12 +153,16 @@ func (cfg *Config) deprecationWarn() { } type settingAliases struct { - Command string `envconfig:"helm_command"` - AddRepos []string `envconfig:"helm_repos"` - APIServer string `envconfig:"api_server"` - ServiceAccount string `split_words:"true"` - Wait bool `` - Force bool `` - KubeToken string `envconfig:"kubernetes_token"` - Certificate string `envconfig:"kubernetes_certificate"` + Command string `envconfig:"helm_command"` + AddRepos []string `envconfig:"helm_repos"` + APIServer string `envconfig:"api_server"` + ServiceAccount string `split_words:"true"` + Wait bool `` + Force bool `` + KubeToken string `envconfig:"kubernetes_token"` + Certificate string `envconfig:"kubernetes_certificate"` + RegistryURL string `envconfig:"registry_url"` + RegistryLoginUserID string `envconfig:"registry_login_user_id"` + RegistryLoginPassword string `envconfig:"registry_login_password"` + RegistryRepoName string `envconfig:"registry_repo_name"` } diff --git a/internal/helm/plan.go b/internal/helm/plan.go index 4f62162..afb17d6 100644 --- a/internal/helm/plan.go +++ b/internal/helm/plan.go @@ -3,9 +3,10 @@ package helm import ( "errors" "fmt" + "os" + "github.com/pelotech/drone-helm3/internal/env" "github.com/pelotech/drone-helm3/internal/run" - "os" ) const ( @@ -61,6 +62,8 @@ func determineSteps(cfg env.Config) *func(env.Config) []Step { return &uninstall case "lint": return &lint + case "publish": + return &publish case "help": return &help default: @@ -136,3 +139,16 @@ var lint = func(cfg env.Config) []Step { var help = func(cfg env.Config) []Step { return []Step{run.NewHelp(cfg)} } + +var publish = func(cfg env.Config) []Step { + fmt.Println("inside publish") + var steps []Step + + os.Setenv("HELM_EXPERIMENTAL_OCI", "1") + + steps = append(steps, run.NewRegistry("login", cfg)) + steps = append(steps, run.NewChart("save", cfg)) + steps = append(steps, run.NewChart("push", cfg)) + steps = append(steps, run.NewRegistry("logout", cfg)) + return steps +} diff --git a/internal/run/chart.go b/internal/run/chart.go new file mode 100644 index 0000000..2efbba8 --- /dev/null +++ b/internal/run/chart.go @@ -0,0 +1,63 @@ +package run + +import ( + "fmt" + + "github.com/pelotech/drone-helm3/internal/env" +) + +// Chart is an execution step that calls `helm chart` when executed. +type Chart struct { + *config + chartPath string + registryURL string + registryRepoName string + chartVersion string + subCommand string + cmd cmd +} + +// NewChart creates a Chart using fields from the given Config. No validation is performed at this time. +func NewChart(subCommand string, cfg env.Config) *Chart { + return &Chart{ + config: newConfig(cfg), + chartPath: cfg.Chart, + registryURL: cfg.RegistryURL, + registryRepoName: cfg.RegistryRepoName, + chartVersion: cfg.ChartVersion, + subCommand: subCommand, + } +} + +// Execute executes the `helm chart` command. +func (reg *Chart) Execute() error { + return reg.cmd.Run() +} + +// Prepare gets the Chart ready to execute. +func (reg *Chart) Prepare() error { + args := []string{} + + args = append(args, "chart") + + if reg.subCommand == "save" { + args = append(args, "save") + args = append(args, reg.chartPath) + cmd := fmt.Sprintf("%s/%s:%s", reg.registryURL, reg.registryRepoName, reg.chartVersion) + args = append(args, cmd) + } else if reg.subCommand == "push" { + args = append(args, "push") + cmd := fmt.Sprintf("%s/%s:%s", reg.registryURL, reg.registryRepoName, reg.chartVersion) + args = append(args, cmd) + } + + reg.cmd = command(helmBin, args...) + reg.cmd.Stdout(reg.stdout) + reg.cmd.Stderr(reg.stderr) + + if reg.debug { + fmt.Fprintf(reg.stderr, "Generated command: '%s'\n", reg.cmd.String()) + } + + return nil +} diff --git a/internal/run/chart_test.go b/internal/run/chart_test.go new file mode 100644 index 0000000..2aa6963 --- /dev/null +++ b/internal/run/chart_test.go @@ -0,0 +1,124 @@ +package run + +import ( + "testing" + + "github.com/golang/mock/gomock" + "github.com/pelotech/drone-helm3/internal/env" + "github.com/stretchr/testify/suite" +) + +type ChartTestSuite struct { + suite.Suite + ctrl *gomock.Controller + mockCmd *Mockcmd + actualArgs []string + originalCommand func(string, ...string) cmd +} + +func (suite *ChartTestSuite) BeforeTest(_, _ string) { + suite.ctrl = gomock.NewController(suite.T()) + suite.mockCmd = NewMockcmd(suite.ctrl) + + suite.originalCommand = command + command = func(path string, args ...string) cmd { + suite.actualArgs = args + return suite.mockCmd + } +} + +func (suite *ChartTestSuite) AfterTest(_, _ string) { + command = suite.originalCommand +} + +func TestChartTestSuite(t *testing.T) { + suite.Run(t, new(ChartTestSuite)) +} + +func (suite *ChartTestSuite) TestNewChart() { + cfg := env.Config{ + Chart: "./", + RegistryRepoName: "repo_name", + RegistryURL: "registry_url", + ChartVersion: "0.0.1", + } + cs := NewChart("save", cfg) + cp := NewChart("push", cfg) + + suite.Equal("./", cs.chartPath) + suite.Equal("repo_name", cs.registryRepoName) + suite.Equal("registry_url", cs.registryURL) + suite.Equal("repo_name", cs.registryRepoName) + suite.Equal("save", cs.subCommand) + suite.Equal("push", cp.subCommand) + suite.NotNil(cs.config) +} + +func (suite *ChartTestSuite) TestPrepareAndExecuteSave() { + defer suite.ctrl.Finish() + + cfg := env.Config{ + Chart: "./", + RegistryRepoName: "repo_name", + RegistryURL: "registry_url", + ChartVersion: "0.0.1", + } + + c := NewChart("save", cfg) + + actual := []string{} + command = func(path string, args ...string) cmd { + suite.Equal(helmBin, path) + actual = args + + return suite.mockCmd + } + + suite.mockCmd.EXPECT(). + Stdout(gomock.Any()) + suite.mockCmd.EXPECT(). + Stderr(gomock.Any()) + suite.mockCmd.EXPECT(). + Run(). + Times(1) + + suite.NoError(c.Prepare()) + expected := []string{"chart", "save", "./", "registry_url/repo_name:0.0.1"} + suite.Equal(expected, actual) + + c.Execute() +} + +func (suite *ChartTestSuite) TestPrepareAndExecutePush() { + defer suite.ctrl.Finish() + + cfg := env.Config{ + RegistryRepoName: "repo_name", + RegistryURL: "registry_url", + ChartVersion: "0.0.1", + } + + c := NewChart("push", cfg) + + actual := []string{} + command = func(path string, args ...string) cmd { + suite.Equal(helmBin, path) + actual = args + + return suite.mockCmd + } + + suite.mockCmd.EXPECT(). + Stdout(gomock.Any()) + suite.mockCmd.EXPECT(). + Stderr(gomock.Any()) + suite.mockCmd.EXPECT(). + Run(). + Times(1) + + suite.NoError(c.Prepare()) + expected := []string{"chart", "push", "registry_url/repo_name:0.0.1"} + suite.Equal(expected, actual) + + c.Execute() +} diff --git a/internal/run/registry.go b/internal/run/registry.go new file mode 100644 index 0000000..cf46a58 --- /dev/null +++ b/internal/run/registry.go @@ -0,0 +1,60 @@ +package run + +import ( + "fmt" + + "github.com/pelotech/drone-helm3/internal/env" +) + +// Registry is an execution step that calls `helm registry` when executed. +type Registry struct { + *config + userID string + password string + registryURL string + subCommand string + cmd cmd +} + +// NewRegistry creates a Registry using fields from the given Config. No validation is performed at this time. +func NewRegistry(subCommand string, cfg env.Config) *Registry { + return &Registry{ + config: newConfig(cfg), + userID: cfg.RegistryLoginUserID, + password: cfg.RegistryLoginPassword, + registryURL: cfg.RegistryURL, + subCommand: subCommand, + } +} + +// Execute executes the `helm registry` command. +func (reg *Registry) Execute() error { + return reg.cmd.Run() +} + +// Prepare gets the Registry ready to execute. +func (reg *Registry) Prepare() error { + args := []string{} + + args = append(args, "registry") + + if reg.subCommand == "login" { + args = append(args, "login") + args = append(args, "-u", reg.userID) + args = append(args, "-p", reg.password) + args = append(args, reg.registryURL) + } else if reg.subCommand == "logout" { + args = append(args, "logout") + args = append(args, reg.registryURL) + } + + reg.cmd = command(helmBin, args...) + reg.cmd.Stdout(reg.stdout) + reg.cmd.Stderr(reg.stderr) + + if reg.debug { + fmt.Fprintf(reg.stderr, "Generated command: '%s'\n", reg.cmd.String()) + } + + return nil +} diff --git a/internal/run/registry_test.go b/internal/run/registry_test.go new file mode 100644 index 0000000..209b120 --- /dev/null +++ b/internal/run/registry_test.go @@ -0,0 +1,119 @@ +package run + +import ( + "testing" + + "github.com/golang/mock/gomock" + "github.com/pelotech/drone-helm3/internal/env" + "github.com/stretchr/testify/suite" +) + +type RegistryTestSuite struct { + suite.Suite + ctrl *gomock.Controller + mockCmd *Mockcmd + actualArgs []string + originalCommand func(string, ...string) cmd +} + +func (suite *RegistryTestSuite) BeforeTest(_, _ string) { + suite.ctrl = gomock.NewController(suite.T()) + suite.mockCmd = NewMockcmd(suite.ctrl) + + suite.originalCommand = command + command = func(path string, args ...string) cmd { + suite.actualArgs = args + return suite.mockCmd + } +} + +func (suite *RegistryTestSuite) AfterTest(_, _ string) { + command = suite.originalCommand +} + +func TestRegistryTestSuite(t *testing.T) { + suite.Run(t, new(RegistryTestSuite)) +} + +func (suite *RegistryTestSuite) TestNewRegistry() { + cfg := env.Config{ + RegistryLoginUserID: "johndoe", + RegistryLoginPassword: "super_secret_password", + RegistryURL: "registry_url", + } + r := NewRegistry("login", cfg) + ro := NewRegistry("logout", cfg) + + suite.Equal("johndoe", r.userID) + suite.Equal("super_secret_password", r.password) + suite.Equal("registry_url", r.registryURL) + suite.Equal("login", r.subCommand) + suite.Equal("logout", ro.subCommand) + suite.NotNil(r.config) +} + +func (suite *RegistryTestSuite) TestPrepareAndExecuteLogin() { + defer suite.ctrl.Finish() + + cfg := env.Config{ + RegistryLoginUserID: "johndoe", + RegistryLoginPassword: "super_secret_password", + RegistryURL: "registry_url", + } + + u := NewRegistry("login", cfg) + + actual := []string{} + command = func(path string, args ...string) cmd { + suite.Equal(helmBin, path) + actual = args + + return suite.mockCmd + } + + suite.mockCmd.EXPECT(). + Stdout(gomock.Any()) + suite.mockCmd.EXPECT(). + Stderr(gomock.Any()) + suite.mockCmd.EXPECT(). + Run(). + Times(1) + + suite.NoError(u.Prepare()) + expected := []string{"registry", "login", "-u", "johndoe", "-p", "super_secret_password", "registry_url"} + suite.Equal(expected, actual) + + u.Execute() +} + +func (suite *RegistryTestSuite) TestPrepareAndExecuteLogout() { + defer suite.ctrl.Finish() + + cfg := env.Config{ + RegistryURL: "registry_url", + } + + u := NewRegistry("logout", cfg) + + actual := []string{} + command = func(path string, args ...string) cmd { + suite.Equal(helmBin, path) + actual = args + + return suite.mockCmd + } + + suite.mockCmd.EXPECT(). + Stdout(gomock.Any()) + suite.mockCmd.EXPECT(). + Stderr(gomock.Any()) + suite.mockCmd.EXPECT(). + Run(). + Times(1) + + suite.NoError(u.Prepare()) + expected := []string{"registry", "logout", "registry_url"} + suite.Equal(expected, actual) + + u.Execute() +}