Unverified Commit a767df24 authored by Bryan Liles's avatar Bryan Liles Committed by GitHub
Browse files

Merge pull request #401 from bryanl/apply-action

Reworks apply action
parents c9662ea0 6fb0b2b2
......@@ -669,6 +669,6 @@
[solve-meta]
analyzer-name = "dep"
analyzer-version = 1
inputs-digest = "6f35693a1aeecc410f7fffeb3258dd2974cf7e4cb5a0fb0bc13ad3bc9ca48c3a"
inputs-digest = "59b2dca47d9322c572df60faae156de3dd3a26f748d3f87323a7c2613f012ebc"
solver-name = "gps-cdcl"
solver-version = 1
// Copyright 2018 The ksonnet authors
//
//
// 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
//
// 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.
// See the License for the specific language governing permissions and
// limitations under the License.
package actions
import (
"fmt"
"github.com/ksonnet/ksonnet/client"
"github.com/ksonnet/ksonnet/metadata/app"
"k8s.io/apimachinery/pkg/apis/meta/v1/unstructured"
)
const (
OptionAPIObjects = "api-objects"
OptionApp = "app"
OptionClientConfig = "client-config"
OptionComponentNames = "component-names"
OptionCreate = "create"
OptionDryRun = "dry-run"
OptionEnvName = "env-name"
OptionGcTag = "gc-tag"
OptionSkipGc = "skip-gc"
)
type missingOptionError struct {
name string
}
func newMissingOptionError(name string) *missingOptionError {
return &missingOptionError{
name: name,
}
}
func (e *missingOptionError) Error() string {
return fmt.Sprintf("missing required %s option", e.name)
}
type invalidOptionError struct {
name string
}
func newInvalidOptionError(name string) *invalidOptionError {
return &invalidOptionError{
name: name,
}
}
func (e *invalidOptionError) Error() string {
return fmt.Sprintf("invalid type for option %s", e.name)
}
type optionLoader struct {
err error
m map[string]interface{}
}
func newOptionLoader(m map[string]interface{}) *optionLoader {
return &optionLoader{
m: m,
}
}
func (o *optionLoader) loadBool(name string) bool {
i := o.load(name)
if i == nil {
return false
}
a, ok := i.(bool)
if !ok {
o.err = newInvalidOptionError(name)
return false
}
return a
}
func (o *optionLoader) loadString(name string) string {
i := o.load(name)
if i == nil {
return ""
}
a, ok := i.(string)
if !ok {
o.err = newInvalidOptionError(name)
return ""
}
return a
}
func (o *optionLoader) loadStringSlice(name string) []string {
i := o.load(name)
if i == nil {
return nil
}
a, ok := i.([]string)
if !ok {
o.err = newInvalidOptionError(name)
return nil
}
return a
}
func (o *optionLoader) loadAPIObjects() []*unstructured.Unstructured {
i := o.load(OptionAPIObjects)
if i == nil {
return nil
}
a, ok := i.([]*unstructured.Unstructured)
if !ok {
o.err = newInvalidOptionError(OptionAPIObjects)
return nil
}
return a
}
func (o *optionLoader) loadClientConfig() *client.Config {
i := o.load(OptionClientConfig)
if i == nil {
return nil
}
a, ok := i.(*client.Config)
if !ok {
o.err = newInvalidOptionError(OptionClientConfig)
return nil
}
return a
}
func (o *optionLoader) loadApp() app.App {
i := o.load(OptionApp)
if i == nil {
return nil
}
a, ok := i.(app.App)
if !ok {
o.err = newInvalidOptionError(OptionApp)
return nil
}
return a
}
func (ol *optionLoader) load(key string) interface{} {
if ol.err != nil {
return nil
}
i, ok := ol.m[key]
if !ok {
ol.err = newMissingOptionError(key)
}
return i
}
// Copyright 2018 The ksonnet authors
//
//
// 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
//
// 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.
// See the License for the specific language governing permissions and
// limitations under the License.
package actions
import (
"github.com/ksonnet/ksonnet/client"
"github.com/ksonnet/ksonnet/metadata/app"
"github.com/ksonnet/ksonnet/pkg/cluster"
)
type runApplyFn func(cluster.ApplyConfig, ...cluster.ApplyOpts) error
// RunApply runs `apply`.
func RunApply(m map[string]interface{}) error {
a, err := newApply(m)
if err != nil {
return err
}
return a.run()
}
type applyOpt func(*Apply)
// Apply collects options for applying objects to a cluster.
type Apply struct {
app app.App
clientConfig *client.Config
componentNames []string
create bool
dryRun bool
envName string
gcTag string
skipGc bool
runApplyFn runApplyFn
}
// RunApply runs `apply`
func newApply(m map[string]interface{}, opts ...applyOpt) (*Apply, error) {
ol := newOptionLoader(m)
a := &Apply{
app: ol.loadApp(),
clientConfig: ol.loadClientConfig(),
componentNames: ol.loadStringSlice(OptionComponentNames),
create: ol.loadBool(OptionCreate),
dryRun: ol.loadBool(OptionDryRun),
envName: ol.loadString(OptionEnvName),
gcTag: ol.loadString(OptionGcTag),
skipGc: ol.loadBool(OptionSkipGc),
runApplyFn: cluster.RunApply,
}
if ol.err != nil {
return nil, ol.err
}
for _, opt := range opts {
opt(a)
}
return a, nil
}
func (a *Apply) run() error {
config := cluster.ApplyConfig{
App: a.app,
ClientConfig: a.clientConfig,
ComponentNames: a.componentNames,
Create: a.create,
DryRun: a.dryRun,
EnvName: a.envName,
GcTag: a.gcTag,
SkipGc: a.skipGc,
}
return a.runApplyFn(config)
}
// Copyright 2018 The ksonnet authors
//
//
// 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
//
// 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.
// See the License for the specific language governing permissions and
// limitations under the License.
package actions
import (
"testing"
"github.com/ksonnet/ksonnet/client"
amocks "github.com/ksonnet/ksonnet/metadata/app/mocks"
"github.com/ksonnet/ksonnet/pkg/cluster"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestApply(t *testing.T) {
withApp(t, func(appMock *amocks.App) {
in := map[string]interface{}{
OptionApp: appMock,
OptionClientConfig: &client.Config{},
OptionComponentNames: []string{},
OptionCreate: true,
OptionDryRun: true,
OptionEnvName: "default",
OptionGcTag: "gc-tag",
OptionSkipGc: true,
}
expected := cluster.ApplyConfig{
App: appMock,
ClientConfig: &client.Config{},
ComponentNames: []string{},
Create: true,
DryRun: true,
EnvName: "default",
GcTag: "gc-tag",
SkipGc: true,
}
runApplyOpt := func(a *Apply) {
a.runApplyFn = func(config cluster.ApplyConfig, opts ...cluster.ApplyOpts) error {
assert.Equal(t, expected, config)
return nil
}
}
a, err := newApply(in, runApplyOpt)
require.NoError(t, err)
err = a.run()
require.NoError(t, err)
})
}
func TestApply_invalid_input(t *testing.T) {
withApp(t, func(appMock *amocks.App) {
in := map[string]interface{}{
OptionClientConfig: "invalid",
}
_, err := newApply(in)
require.Error(t, err)
})
}
......@@ -15,15 +15,36 @@
package cmd
import "github.com/ksonnet/ksonnet/actions"
import (
"github.com/ksonnet/ksonnet/actions"
"github.com/pkg/errors"
)
type initName int
const (
actionInit initName = iota
actionApply initName = iota
actionInit
actionValidate
)
type actionFn func(map[string]interface{}) error
var (
actionFns = map[initName]actionFn{
actionApply: actions.RunApply,
}
)
func runAction(name initName, args map[string]interface{}) error {
fn, ok := actionFns[name]
if !ok {
return errors.Errorf("invalid action")
}
return fn(args)
}
var (
actionMap = map[initName]interface{}{
actionInit: actions.RunInit,
......
......@@ -17,12 +17,14 @@ package cmd
import (
"fmt"
"os"
"github.com/ksonnet/ksonnet/actions"
"github.com/spf13/viper"
"github.com/spf13/cobra"
"github.com/ksonnet/ksonnet/client"
"github.com/ksonnet/ksonnet/pkg/kubecfg"
)
var (
......@@ -30,10 +32,6 @@ var (
)
const (
flagCreate = "create"
flagSkipGc = "skip-gc"
flagGcTag = "gc-tag"
flagDryRun = "dry-run"
// AnnotationGcTag annotation that triggers
// garbage collection. Objects with value equal to
......@@ -53,17 +51,35 @@ const (
applyShortDesc = "Apply local Kubernetes manifests (components) to remote clusters"
)
const (
vApplyComponent = "apply-components"
vApplyCreate = "apply-create"
vApplyGcTag = "apply-gc-tag"
vApplyDryRun = "apply-dry-run"
vApplySkipGc = "apply-skip-gc"
)
func init() {
RootCmd.AddCommand(applyCmd)
addEnvCmdFlags(applyCmd)
applyClientConfig = client.NewDefaultClientConfig()
applyClientConfig.BindClientGoFlags(applyCmd)
bindJsonnetFlags(applyCmd)
applyCmd.PersistentFlags().Bool(flagCreate, true, "Option to create resources if they do not already exist on the cluster")
applyCmd.PersistentFlags().Bool(flagSkipGc, false, "Option to skip garbage collection, even with --"+flagGcTag+" specified")
applyCmd.PersistentFlags().String(flagGcTag, "", "A tag that's (1) added to all updated objects (2) used to garbage collect existing objects that are no longer in the manifest")
applyCmd.PersistentFlags().Bool(flagDryRun, false, "Option to preview the list of operations without changing the cluster state")
applyCmd.Flags().StringSliceP(flagComponent, shortComponent, nil, "Name of a specific component (multiple -c flags accepted, allows YAML, JSON, and Jsonnet)")
viper.BindPFlag(vApplyComponent, applyCmd.Flags().Lookup(flagComponent))
applyCmd.Flags().Bool(flagCreate, true, "Option to create resources if they do not already exist on the cluster")
viper.BindPFlag(vApplyCreate, applyCmd.Flags().Lookup(flagCreate))
applyCmd.Flags().Bool(flagSkipGc, false, "Option to skip garbage collection, even with --"+flagGcTag+" specified")
viper.BindPFlag(vApplySkipGc, applyCmd.Flags().Lookup(flagSkipGc))
applyCmd.Flags().String(flagGcTag, "", "A tag that's (1) added to all updated objects (2) used to garbage collect existing objects that are no longer in the manifest")
viper.BindPFlag(vApplyGcTag, applyCmd.Flags().Lookup(flagGcTag))
applyCmd.Flags().Bool(flagDryRun, false, "Option to preview the list of operations without changing the cluster state")
viper.BindPFlag(vApplyDryRun, applyCmd.Flags().Lookup(flagDryRun))
}
var applyCmd = &cobra.Command{
......@@ -73,58 +89,19 @@ var applyCmd = &cobra.Command{
if len(args) != 1 {
return fmt.Errorf("'apply' requires an environment name; use `env list` to see available environments\n\n%s", cmd.UsageString())
}
env := args[0]
flags := cmd.Flags()
var err error
c := kubecfg.ApplyCmd{App: ka}
c.Create, err = flags.GetBool(flagCreate)
if err != nil {
return err
}
c.GcTag, err = flags.GetString(flagGcTag)
if err != nil {
return err
}
c.SkipGc, err = flags.GetBool(flagSkipGc)
if err != nil {
return err
}
c.DryRun, err = flags.GetBool(flagDryRun)
if err != nil {
return err
}
c.ClientConfig = applyClientConfig
c.Env = env
componentNames, err := flags.GetStringSlice(flagComponent)
if err != nil {
return err
}
cwd, err := os.Getwd()
if err != nil {
return err
}
te := newCmdObjExpander(cmdObjExpanderConfig{
cmd: cmd,
env: env,
components: componentNames,
cwd: cwd,
})
objs, err := te.Expand()
if err != nil {
return err
m := map[string]interface{}{
actions.OptionApp: ka,
actions.OptionClientConfig: applyClientConfig,
actions.OptionComponentNames: viper.GetStringSlice(vApplyComponent),
actions.OptionCreate: viper.GetBool(vApplyCreate),
actions.OptionDryRun: viper.GetBool(vApplyDryRun),
actions.OptionEnvName: args[0],
actions.OptionGcTag: viper.GetString(vApplyGcTag),
actions.OptionSkipGc: viper.GetBool(vApplySkipGc),
}
return c.Run(objs, cwd)
return actionFns[actionApply](m)
},
Long: `
The ` + "`apply`" + `command uses local manifest(s) to update (and optionally create)
......
// Copyright 2018 The ksonnet authors
//
//
// 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
//
// 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.
// See the License for the specific language governing permissions and
// limitations under the License.
package cmd
import (
"testing"
"github.com/ksonnet/ksonnet/actions"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_ApplyCmd(t *testing.T) {
var got map[string]interface{}
override := func(m map[string]interface{}) error {
got = m
return nil
}
cases := []struct {
name string
args []string
action initName
isErr bool
overrideFn actionFn
expected map[string]interface{}
}{
{
name: "with no options",
args: []string{"apply", "default"},
action: actionApply,
overrideFn: override,
expected: map[string]interface{}{
actions.OptionApp: nil,
actions.OptionEnvName: "default",
actions.OptionGcTag: "",
actions.OptionSkipGc: false,
actions.OptionComponentNames: make([]string, 0),
actions.OptionCreate: true,
actions.OptionDryRun: false,
actions.OptionClientConfig: applyClientConfig,
},
},
{
name: "with no env",
args: []string{"apply"},
action: actionApply,
isErr: true,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
withCmd2(tc.action, tc.overrideFn, func() {
err := runCmd(tc.args...)
if tc.isErr {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tc.expected, got)
})
})
}
}
......@@ -19,10 +19,14 @@ const (
// For use in the commands (e.g., diff, apply, delete) that require either an
// environment or the -f flag.
flagComponent = "component"
flagCreate = "create"