From 5597082792e507f4079f5df09e15194338fec5f1 Mon Sep 17 00:00:00 2001 From: sh0rez Date: Sat, 4 Apr 2020 00:56:44 +0200 Subject: [PATCH 1/9] feat(prune): find old resources --- cmd/tk/main.go | 1 + cmd/tk/workflow.go | 17 +++++ pkg/kubernetes/apply.go | 100 ++++++++++++++++++++++------ pkg/kubernetes/client/client.go | 2 +- pkg/kubernetes/client/get.go | 21 +++--- pkg/kubernetes/client/resources.go | 7 +- pkg/kubernetes/diff.go | 4 +- pkg/kubernetes/kubernetes.go | 16 ++--- pkg/kubernetes/kubernetes_test.go | 4 +- pkg/kubernetes/manifest/manifest.go | 26 +++++--- pkg/kubernetes/reconcile.go | 10 ++- pkg/spec/v1alpha1/config.go | 7 ++ pkg/tanka/parse.go | 4 +- pkg/tanka/status.go | 2 +- pkg/tanka/workflow.go | 42 +++++++++++- 15 files changed, 207 insertions(+), 56 deletions(-) diff --git a/cmd/tk/main.go b/cmd/tk/main.go index e3f7fae22..c3c6d49e8 100644 --- a/cmd/tk/main.go +++ b/cmd/tk/main.go @@ -39,6 +39,7 @@ func main() { applyCmd(), showCmd(), diffCmd(), + pruneCmd(), ) rootCmd.AddCommand( diff --git a/cmd/tk/workflow.go b/cmd/tk/workflow.go index f38d3b058..410b17b5d 100644 --- a/cmd/tk/workflow.go +++ b/cmd/tk/workflow.go @@ -63,6 +63,23 @@ func applyCmd() *cli.Command { return cmd } +func pruneCmd() *cli.Command { + cmd := &cli.Command{ + Use: "prune ", + Short: "delete resources removed from Jsonnet", + Args: workflowArgs, + } + + getExtCode := extCodeParser(cmd.Flags()) + cmd.Run = func(cmd *cli.Command, args []string) error { + return tanka.Prune(args[0], + tanka.WithExtCode(getExtCode()), + ) + } + + return cmd +} + func diffCmd() *cli.Command { cmd := &cli.Command{ Use: "diff ", diff --git a/pkg/kubernetes/apply.go b/pkg/kubernetes/apply.go index c6cd35d01..50f0f3722 100644 --- a/pkg/kubernetes/apply.go +++ b/pkg/kubernetes/apply.go @@ -2,12 +2,10 @@ package kubernetes import ( "fmt" - - "github.com/fatih/color" + "strings" "github.com/grafana/tanka/pkg/kubernetes/client" "github.com/grafana/tanka/pkg/kubernetes/manifest" - "github.com/grafana/tanka/pkg/term" ) // ApplyOpts allow set additional parameters for the apply operation @@ -15,22 +13,86 @@ type ApplyOpts client.ApplyOpts // Apply receives a state object generated using `Reconcile()` and may apply it to the target system func (k *Kubernetes) Apply(state manifest.List, opts ApplyOpts) error { - alert := color.New(color.FgRed, color.Bold).SprintFunc() - - cluster := k.ctl.Info().Kubeconfig.Cluster - context := k.ctl.Info().Kubeconfig.Context - if !opts.AutoApprove { - if err := term.Confirm( - fmt.Sprintf(`Applying to namespace '%s' of cluster '%s' at '%s' using context '%s'.`, - alert(k.Spec.Namespace), - alert(cluster.Name), - alert(cluster.Cluster.Server), - alert(context.Name), - ), - "yes", - ); err != nil { - return err + return k.ctl.Apply(state, client.ApplyOpts(opts)) +} + +func (k *Kubernetes) Prune(state manifest.List) error { + orphaned, err := k.listOrphaned(state) + if err != nil { + return err + } + + for _, m := range orphaned { + fmt.Println(m.Metadata().Namespace(), m.APIVersion(), m.Kind(), m.Metadata().Name()) + } + return nil +} + +func (k *Kubernetes) uids(state manifest.List) (map[string]bool, error) { + uids := make(map[string]bool) + + for _, local := range state { + ns := local.Metadata().Namespace() + if ns == "" { + ns = k.Env.Spec.Namespace + } + + live, err := k.ctl.Get(ns, local.Kind(), local.Metadata().Name()) + if err != nil { + return nil, err } + uids[live.Metadata().UID()] = true } - return k.ctl.Apply(state, client.ApplyOpts(opts)) + + return uids, nil +} + +// listOrphaned returns previously created resources that are missing from the +// local state. It uses UIDs to safely identify objects. +func (k *Kubernetes) listOrphaned(state manifest.List) (manifest.List, error) { + apiResources, err := k.ctl.Resources() + if err != nil { + return nil, err + } + + uids, err := k.uids(state) + if err != nil { + return nil, err + } + + var orphaned manifest.List + for _, r := range apiResources { + if !strings.Contains(r.Verbs, "list") { + continue + } + + matched, err := k.ctl.GetByLabels("", r.FQN(), map[string]string{ + LabelEnvironment: k.Env.Metadata.NameLabel(), + }) + if err != nil { + return nil, err + } + + // filter unknown using uids + for _, m := range matched { + if uids[m.Metadata().UID()] { + continue + } + + // ComponentStatus resource is broken in Kubernetes versions + // below 1.17, it will be returned even if the label does not + // match. Ignoring it here is fine, as it is an internal object + // type. + if m.APIVersion() == "v1" && m.Kind() == "ComponentStatus" { + continue + } + + orphaned = append(orphaned, m) + + // recorded. skip from now on + uids[m.Metadata().UID()] = true + } + } + + return orphaned, nil } diff --git a/pkg/kubernetes/client/client.go b/pkg/kubernetes/client/client.go index c4d7bdc0f..937ea23a3 100644 --- a/pkg/kubernetes/client/client.go +++ b/pkg/kubernetes/client/client.go @@ -8,7 +8,7 @@ import ( type Client interface { // Get the specified object(s) from the cluster Get(namespace, kind, name string) (manifest.Manifest, error) - GetByLabels(namespace string, labels map[string]interface{}) (manifest.List, error) + GetByLabels(namespace, kind string, labels map[string]string) (manifest.List, error) // Apply the configuration to the cluster. `data` must contain a plaintext // format that is `kubectl-apply(1)` compatible diff --git a/pkg/kubernetes/client/get.go b/pkg/kubernetes/client/get.go index e5062f2e2..d5e089bf1 100644 --- a/pkg/kubernetes/client/get.go +++ b/pkg/kubernetes/client/get.go @@ -11,17 +11,17 @@ import ( // Get retrieves a single Kubernetes object from the cluster func (k Kubectl) Get(namespace, kind, name string) (manifest.Manifest, error) { - return k.get(namespace, []string{kind, name}) + return k.get(namespace, kind, []string{name}) } // GetByLabels retrieves all objects matched by the given labels from the cluster -func (k Kubectl) GetByLabels(namespace string, labels map[string]interface{}) (manifest.List, error) { +func (k Kubectl) GetByLabels(namespace, kind string, labels map[string]string) (manifest.List, error) { lArgs := make([]string, 0, len(labels)) for k, v := range labels { lArgs = append(lArgs, fmt.Sprintf("-l=%s=%s", k, v)) } - list, err := k.get(namespace, lArgs) + list, err := k.get(namespace, kind, lArgs) if err != nil { return nil, err } @@ -39,11 +39,16 @@ func (k Kubectl) GetByLabels(namespace string, labels map[string]interface{}) (m return ms, nil } -func (k Kubectl) get(namespace string, sel []string) (manifest.Manifest, error) { - argv := append([]string{ - "-o", "json", - "-n", namespace, - }, sel...) +func (k Kubectl) get(namespace, kind string, sel []string) (manifest.Manifest, error) { + argv := []string{"-o", "json"} + if namespace == "" { + argv = append(argv, "--all-namespaces") + } else { + argv = append(argv, "-n", namespace) + } + argv = append(argv, kind) + argv = append(argv, sel...) + cmd := k.ctl("get", argv...) var sout, serr bytes.Buffer diff --git a/pkg/kubernetes/client/resources.go b/pkg/kubernetes/client/resources.go index 6a30c1a0d..d7ec4f62f 100644 --- a/pkg/kubernetes/client/resources.go +++ b/pkg/kubernetes/client/resources.go @@ -34,11 +34,16 @@ type Resource struct { Name string `json:"NAME"` Namespaced bool `json:"NAMESPACED,string"` Shortnames string `json:"SHORTNAMES"` + Verbs string `json:"VERBS"` +} + +func (r Resource) FQN() string { + return strings.TrimPrefix(r.Kind+"."+r.APIGroup, ".") } // Resources returns all API resources known to the server func (k Kubectl) Resources() (Resources, error) { - cmd := k.ctl("api-resources", "--cached") + cmd := k.ctl("api-resources", "--cached", "--output=wide") var out bytes.Buffer cmd.Stdout = &out cmd.Stderr = os.Stderr diff --git a/pkg/kubernetes/diff.go b/pkg/kubernetes/diff.go index f10ab67b6..61bb400d3 100644 --- a/pkg/kubernetes/diff.go +++ b/pkg/kubernetes/diff.go @@ -29,7 +29,7 @@ func (k *Kubernetes) Diff(state manifest.List, opts DiffOpts) (*string, error) { // would cause an error // // live: all other resources - live, soon := separate(state, k.Spec.Namespace, separateOpts{ + live, soon := separate(state, k.Env.Spec.Namespace, separateOpts{ namespaces: namespaces, resources: resources, }) @@ -119,7 +119,7 @@ func (e ErrorDiffStrategyUnknown) Error() string { } func (k *Kubernetes) differ(override string) (Differ, error) { - strategy := k.Spec.DiffStrategy + strategy := k.Env.Spec.DiffStrategy if override != "" { strategy = override } diff --git a/pkg/kubernetes/kubernetes.go b/pkg/kubernetes/kubernetes.go index ad0c01c7f..3c725c794 100644 --- a/pkg/kubernetes/kubernetes.go +++ b/pkg/kubernetes/kubernetes.go @@ -12,7 +12,7 @@ import ( // Kubernetes exposes methods to work with the Kubernetes orchestrator type Kubernetes struct { - Spec v1alpha1.Spec + Env v1alpha1.Config // Client (kubectl) ctl client.Client @@ -26,25 +26,25 @@ type Kubernetes struct { type Differ func(manifest.List) (*string, error) // New creates a new Kubernetes with an initialized client -func New(s v1alpha1.Spec) (*Kubernetes, error) { +func New(env v1alpha1.Config) (*Kubernetes, error) { // setup client - ctl, err := client.New(s.APIServer, s.Namespace) + ctl, err := client.New(env.Spec.APIServer, env.Spec.Namespace) if err != nil { return nil, err } // setup diffing - if s.DiffStrategy == "" { - s.DiffStrategy = "native" + if env.Spec.DiffStrategy == "" { + env.Spec.DiffStrategy = "native" if ctl.Info().ServerVersion.LessThan(semver.MustParse("1.13.0")) { - s.DiffStrategy = "subset" + env.Spec.DiffStrategy = "subset" } } k := Kubernetes{ - Spec: s, - ctl: ctl, + Env: env, + ctl: ctl, differs: map[string]Differ{ "native": ctl.DiffServerSide, "subset": SubsetDiffer(ctl), diff --git a/pkg/kubernetes/kubernetes_test.go b/pkg/kubernetes/kubernetes_test.go index a8641317a..12889d609 100644 --- a/pkg/kubernetes/kubernetes_test.go +++ b/pkg/kubernetes/kubernetes_test.go @@ -65,7 +65,7 @@ func TestReconcile(t *testing.T) { t.Run(c.name, func(t *testing.T) { config := v1alpha1.New() config.Spec = c.spec - got, err := Reconcile(c.deep.(map[string]interface{}), config.Spec, c.targets) + got, err := Reconcile(c.deep.(map[string]interface{}), *config, c.targets) require.Equal(t, c.err, err) assert.ElementsMatch(t, c.flat, got) @@ -76,7 +76,7 @@ func TestReconcile(t *testing.T) { func TestReconcileOrder(t *testing.T) { got := make([]manifest.List, 10) for i := 0; i < 10; i++ { - r, err := Reconcile(testDataDeep().Deep.(map[string]interface{}), v1alpha1.New().Spec, nil) + r, err := Reconcile(testDataDeep().Deep.(map[string]interface{}), *v1alpha1.New(), nil) require.NoError(t, err) got[i] = r } diff --git a/pkg/kubernetes/manifest/manifest.go b/pkg/kubernetes/manifest/manifest.go index 641944020..b333853b9 100644 --- a/pkg/kubernetes/manifest/manifest.go +++ b/pkg/kubernetes/manifest/manifest.go @@ -132,30 +132,40 @@ func (m Metadata) Namespace() string { return m["namespace"].(string) } +func (m Metadata) UID() string { + uid, ok := m["uid"].(string) + if !ok { + return "" + } + return uid +} + // HasLabels returns whether the manifest has labels func (m Metadata) HasLabels() bool { - return m2o(m).Get("labels").IsMSI() + _, ok := m["labels"].(map[string]string) + return ok } // Labels of the manifest -func (m Metadata) Labels() map[string]interface{} { +func (m Metadata) Labels() map[string]string { if !m.HasLabels() { - return make(map[string]interface{}) + m["labels"] = make(map[string]string) } - return m["labels"].(map[string]interface{}) + return m["labels"].(map[string]string) } // HasAnnotations returns whether the manifest has annotations func (m Metadata) HasAnnotations() bool { - return m2o(m).Get("annotations").IsMSI() + _, ok := m["annotations"].(map[string]string) + return ok } // Annotations of the manifest -func (m Metadata) Annotations() map[string]interface{} { +func (m Metadata) Annotations() map[string]string { if !m.HasAnnotations() { - return make(map[string]interface{}) + m["annotations"] = make(map[string]string) } - return m["annotations"].(map[string]interface{}) + return m["annotations"].(map[string]string) } // List of individual Manifests diff --git a/pkg/kubernetes/reconcile.go b/pkg/kubernetes/reconcile.go index 3d23b38a9..ac49b0bd8 100644 --- a/pkg/kubernetes/reconcile.go +++ b/pkg/kubernetes/reconcile.go @@ -52,6 +52,11 @@ var kindOrder = []string{ "APIService", } +const ( + MetadataPrefix = "tanka.dev" + LabelEnvironment = MetadataPrefix + "/environment" +) + // Reconcile extracts kubernetes Manifests from raw evaluated jsonnet /, // provided the manifests match the given regular expressions. It finds each manifest by // recursively walking the jsonnet structure. @@ -59,7 +64,7 @@ var kindOrder = []string{ // In addition, we sort the manifests to ensure the order is consistent in each // show/diff/apply cycle. This isn't necessary, but it does help users by producing // consistent diffs. -func Reconcile(raw map[string]interface{}, spec v1alpha1.Spec, targets []*regexp.Regexp) (state manifest.List, err error) { +func Reconcile(raw map[string]interface{}, cfg v1alpha1.Config, targets []*regexp.Regexp) (state manifest.List, err error) { extracted, err := extract(raw) if err != nil { return nil, errors.Wrap(err, "flattening manifests") @@ -67,6 +72,9 @@ func Reconcile(raw map[string]interface{}, spec v1alpha1.Spec, targets []*regexp out := make(manifest.List, 0, len(extracted)) for _, m := range extracted { + // inject tanka.dev/environment label + m.Metadata().Labels()[LabelEnvironment] = cfg.Metadata.NameLabel() + out = append(out, m) } diff --git a/pkg/spec/v1alpha1/config.go b/pkg/spec/v1alpha1/config.go index ba5270039..a7f66c691 100644 --- a/pkg/spec/v1alpha1/config.go +++ b/pkg/spec/v1alpha1/config.go @@ -1,5 +1,7 @@ package v1alpha1 +import "strings" + // New creates a new Config object with internal values already set func New() *Config { c := Config{} @@ -31,9 +33,14 @@ type Metadata struct { Labels map[string]string `json:"labels,omitempty"` } +func (m Metadata) NameLabel() string { + return strings.Replace(m.Name, "/", ".", -1) +} + // Spec defines Kubernetes properties type Spec struct { APIServer string `json:"apiServer"` Namespace string `json:"namespace"` DiffStrategy string `json:"diffStrategy,omitempty"` + Prune bool `json:"prune,omitempty"` } diff --git a/pkg/tanka/parse.go b/pkg/tanka/parse.go index d141f1404..95e7920e0 100644 --- a/pkg/tanka/parse.go +++ b/pkg/tanka/parse.go @@ -23,7 +23,7 @@ type ParseResult struct { } func (p *ParseResult) newKube() (*kubernetes.Kubernetes, error) { - kube, err := kubernetes.New(p.Env.Spec) + kube, err := kubernetes.New(*p.Env) if err != nil { return nil, errors.Wrap(err, "connecting to Kubernetes") } @@ -38,7 +38,7 @@ func parse(dir string, opts *options) (*ParseResult, error) { return nil, err } - rec, err := kubernetes.Reconcile(raw, env.Spec, opts.targets) + rec, err := kubernetes.Reconcile(raw, *env, opts.targets) if err != nil { return nil, errors.Wrap(err, "reconciling") diff --git a/pkg/tanka/status.go b/pkg/tanka/status.go index 89bb30556..a56635dc1 100644 --- a/pkg/tanka/status.go +++ b/pkg/tanka/status.go @@ -28,7 +28,7 @@ func Status(baseDir string, mods ...Modifier) (*Info, error) { return nil, err } - r.Env.Spec.DiffStrategy = kube.Spec.DiffStrategy + r.Env.Spec.DiffStrategy = kube.Env.Spec.DiffStrategy return &Info{ Env: r.Env, diff --git a/pkg/tanka/workflow.go b/pkg/tanka/workflow.go index 7eda0a598..6f73f7044 100644 --- a/pkg/tanka/workflow.go +++ b/pkg/tanka/workflow.go @@ -3,7 +3,9 @@ package tanka import ( "fmt" + "github.com/fatih/color" "github.com/grafana/tanka/pkg/kubernetes" + "github.com/grafana/tanka/pkg/kubernetes/client" "github.com/grafana/tanka/pkg/kubernetes/manifest" "github.com/grafana/tanka/pkg/term" ) @@ -11,9 +13,6 @@ import ( // Apply parses the environment at the given directory (a `baseDir`) and applies // the evaluated jsonnet to the Kubernetes cluster defined in the environments // `spec.json`. -// NOTE: This function prints on screen in default configuration. -// Use the `WithWarnWriter` modifier to change that. The `WithApply*` modifiers -// may be used to further influence the behavior. func Apply(baseDir string, mods ...Modifier) error { opts := parseModifiers(mods) @@ -27,6 +26,7 @@ func Apply(baseDir string, mods ...Modifier) error { } defer kube.Close() + // show diff diff, err := kube.Diff(p.Resources, kubernetes.DiffOpts{}) switch { case err != nil: @@ -40,9 +40,29 @@ func Apply(baseDir string, mods ...Modifier) error { b := term.Colordiff(*diff) fmt.Print(b.String()) + // prompt for confirmation + if err := applyPrompt(p.Env.Spec.Namespace, kube.Info()); err != nil { + return err + } + return kube.Apply(p.Resources, opts.apply) } +// applyPrompt asks the user for confirmation before apply +func applyPrompt(namespace string, info client.Info) error { + alert := color.New(color.FgRed, color.Bold).SprintFunc() + + return term.Confirm( + fmt.Sprintf(`Applying to namespace '%s' of cluster '%s' at '%s' using context '%s'.`, + alert(namespace), + alert(info.Kubeconfig.Cluster.Name), + alert(info.Kubeconfig.Cluster.Cluster.Server), + alert(info.Kubeconfig.Context.Name), + ), + "yes", + ) +} + // Diff parses the environment at the given directory (a `baseDir`) and returns // the differences from the live cluster state in `diff(1)` format. If the // `WithDiffSummarize` modifier is used, a histogram created using `diffstat(1)` @@ -65,6 +85,22 @@ func Diff(baseDir string, mods ...Modifier) (*string, error) { return kube.Diff(p.Resources, opts.diff) } +func Prune(baseDir string, mods ...Modifier) error { + opts := parseModifiers(mods) + + p, err := parse(baseDir, opts) + if err != nil { + return err + } + kube, err := p.newKube() + if err != nil { + return err + } + defer kube.Close() + + return kube.Prune(p.Resources) +} + // Show parses the environment at the given directory (a `baseDir`) and returns // the list of Kubernetes objects. // Tip: use the `String()` function on the returned list to get the familiar yaml stream From 347c0578c22a820bfe27282f6adaa51d4715fe55 Mon Sep 17 00:00:00 2001 From: sh0rez Date: Sat, 4 Apr 2020 22:58:23 +0200 Subject: [PATCH 2/9] fix: respect labels in unit test --- pkg/kubernetes/kubernetes_test.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/pkg/kubernetes/kubernetes_test.go b/pkg/kubernetes/kubernetes_test.go index 12889d609..43b45286f 100644 --- a/pkg/kubernetes/kubernetes_test.go +++ b/pkg/kubernetes/kubernetes_test.go @@ -64,10 +64,17 @@ func TestReconcile(t *testing.T) { for _, c := range tests { t.Run(c.name, func(t *testing.T) { config := v1alpha1.New() + config.Metadata.Name = "testdata" config.Spec = c.spec - got, err := Reconcile(c.deep.(map[string]interface{}), *config, c.targets) + for i, m := range c.flat { + m.Metadata().Labels()[LabelEnvironment] = config.Metadata.NameLabel() + c.flat[i] = m + } + + got, err := Reconcile(c.deep.(map[string]interface{}), *config, c.targets) require.Equal(t, c.err, err) + assert.ElementsMatch(t, c.flat, got) }) } From e8ba244ceb9782c1aa5dff5be0dec69a0dbbb389 Mon Sep 17 00:00:00 2001 From: sh0rez Date: Mon, 6 Apr 2020 19:53:36 +0200 Subject: [PATCH 3/9] feat(prune): faster querying Queries the required data a lot faster, by using kubectl more efficiently (one call vs a call for each kind). Can be possibly improved even further by running in parallel. --- pkg/kubernetes/apply.go | 83 +++++++++++++++++------------- pkg/kubernetes/client/client.go | 2 +- pkg/kubernetes/client/delete.go | 19 ++----- pkg/kubernetes/client/get.go | 52 ++++++++++++++----- pkg/kubernetes/client/resources.go | 2 +- pkg/kubernetes/delete.go | 18 +++++++ pkg/tanka/workflow.go | 7 +++ 7 files changed, 115 insertions(+), 68 deletions(-) create mode 100644 pkg/kubernetes/delete.go diff --git a/pkg/kubernetes/apply.go b/pkg/kubernetes/apply.go index 50f0f3722..4e0dad678 100644 --- a/pkg/kubernetes/apply.go +++ b/pkg/kubernetes/apply.go @@ -3,6 +3,7 @@ package kubernetes import ( "fmt" "strings" + "time" "github.com/grafana/tanka/pkg/kubernetes/client" "github.com/grafana/tanka/pkg/kubernetes/manifest" @@ -17,7 +18,7 @@ func (k *Kubernetes) Apply(state manifest.List, opts ApplyOpts) error { } func (k *Kubernetes) Prune(state manifest.List) error { - orphaned, err := k.listOrphaned(state) + orphaned, err := k.Orphaned(state) if err != nil { return err } @@ -31,67 +32,77 @@ func (k *Kubernetes) Prune(state manifest.List) error { func (k *Kubernetes) uids(state manifest.List) (map[string]bool, error) { uids := make(map[string]bool) - for _, local := range state { - ns := local.Metadata().Namespace() - if ns == "" { - ns = k.Env.Spec.Namespace - } + live, err := k.ctl.GetByState(state) + if err != nil { + return nil, err + } - live, err := k.ctl.Get(ns, local.Kind(), local.Metadata().Name()) - if err != nil { - return nil, err - } - uids[live.Metadata().UID()] = true + for _, m := range live { + uids[m.Metadata().UID()] = true } return uids, nil } -// listOrphaned returns previously created resources that are missing from the +// Orphaned returns previously created resources that are missing from the // local state. It uses UIDs to safely identify objects. -func (k *Kubernetes) listOrphaned(state manifest.List) (manifest.List, error) { +func (k *Kubernetes) Orphaned(state manifest.List) (manifest.List, error) { apiResources, err := k.ctl.Resources() if err != nil { return nil, err } + start := time.Now() + fmt.Print("fetching UID's .. ") uids, err := k.uids(state) if err != nil { return nil, err } + fmt.Println("done", time.Since(start)) var orphaned manifest.List + + // join all kinds that support LIST into a comma separated string for + // kubectl + kinds := "" for _, r := range apiResources { if !strings.Contains(r.Verbs, "list") { continue } - matched, err := k.ctl.GetByLabels("", r.FQN(), map[string]string{ - LabelEnvironment: k.Env.Metadata.NameLabel(), - }) - if err != nil { - return nil, err - } - - // filter unknown using uids - for _, m := range matched { - if uids[m.Metadata().UID()] { - continue - } - - // ComponentStatus resource is broken in Kubernetes versions - // below 1.17, it will be returned even if the label does not - // match. Ignoring it here is fine, as it is an internal object - // type. - if m.APIVersion() == "v1" && m.Kind() == "ComponentStatus" { - continue - } + kinds += "," + r.FQN() + } + kinds = strings.TrimPrefix(kinds, ",") + + start = time.Now() + fmt.Print("fetching previously created resources .. ") + // get all resources matching our label + matched, err := k.ctl.GetByLabels("", kinds, map[string]string{ + LabelEnvironment: k.Env.Metadata.NameLabel(), + }) + if err != nil { + return nil, err + } + fmt.Println("done", time.Since(start)) - orphaned = append(orphaned, m) + // filter unknown + for _, m := range matched { + // ignore known ones + if uids[m.Metadata().UID()] { + continue + } - // recorded. skip from now on - uids[m.Metadata().UID()] = true + // ComponentStatus resource is broken in Kubernetes versions + // below 1.17, it will be returned even if the label does not + // match. Ignoring it here is fine, as it is an internal object + // type. + if m.APIVersion() == "v1" && m.Kind() == "ComponentStatus" { + continue } + + // record and skip from now on + orphaned = append(orphaned, m) + uids[m.Metadata().UID()] = true } return orphaned, nil diff --git a/pkg/kubernetes/client/client.go b/pkg/kubernetes/client/client.go index 937ea23a3..4bc048d94 100644 --- a/pkg/kubernetes/client/client.go +++ b/pkg/kubernetes/client/client.go @@ -9,6 +9,7 @@ type Client interface { // Get the specified object(s) from the cluster Get(namespace, kind, name string) (manifest.Manifest, error) GetByLabels(namespace, kind string, labels map[string]string) (manifest.List, error) + GetByState(data manifest.List) (manifest.List, error) // Apply the configuration to the cluster. `data` must contain a plaintext // format that is `kubectl-apply(1)` compatible @@ -20,7 +21,6 @@ type Client interface { // Delete the specified object(s) from the cluster Delete(namespace, kind, name string, opts DeleteOpts) error - DeleteByLabels(namespace string, labels map[string]interface{}, opts DeleteOpts) error // Namespaces the cluster currently has Namespaces() (map[string]bool, error) diff --git a/pkg/kubernetes/client/delete.go b/pkg/kubernetes/client/delete.go index 4b3a5c0d6..55a2593c2 100644 --- a/pkg/kubernetes/client/delete.go +++ b/pkg/kubernetes/client/delete.go @@ -1,27 +1,14 @@ package client import ( - "fmt" "os" ) -// Delete removes the specified object from the cluster func (k Kubectl) Delete(namespace, kind, name string, opts DeleteOpts) error { - return k.delete(namespace, []string{kind, name}, opts) -} - -// DeleteByLabels removes all objects matched by the given labels from the cluster -func (k Kubectl) DeleteByLabels(namespace string, labels map[string]interface{}, opts DeleteOpts) error { - lArgs := make([]string, 0, len(labels)) - for k, v := range labels { - lArgs = append(lArgs, fmt.Sprintf("-l=%s=%s", k, v)) + argv := []string{ + "-n", namespace, + kind, name, } - - return k.delete(namespace, lArgs, opts) -} - -func (k Kubectl) delete(namespace string, sel []string, opts DeleteOpts) error { - argv := append([]string{"-n", namespace}, sel...) if opts.Force { argv = append(argv, "--force") } diff --git a/pkg/kubernetes/client/get.go b/pkg/kubernetes/client/get.go index d5e089bf1..d6fa4d4d7 100644 --- a/pkg/kubernetes/client/get.go +++ b/pkg/kubernetes/client/get.go @@ -11,7 +11,7 @@ import ( // Get retrieves a single Kubernetes object from the cluster func (k Kubectl) Get(namespace, kind, name string) (manifest.Manifest, error) { - return k.get(namespace, kind, []string{name}) + return k.get(namespace, kind, []string{name}, "") } // GetByLabels retrieves all objects matched by the given labels from the cluster @@ -21,32 +21,39 @@ func (k Kubectl) GetByLabels(namespace, kind string, labels map[string]string) ( lArgs = append(lArgs, fmt.Sprintf("-l=%s=%s", k, v)) } - list, err := k.get(namespace, kind, lArgs) + list, err := k.get(namespace, kind, lArgs, "") if err != nil { return nil, err } - if list.Kind() != "List" { - return nil, fmt.Errorf("expected kind `List` but got `%s` instead", list.Kind()) - } + return unwrapList(list) +} - items := list["items"].([]interface{}) - ms := make(manifest.List, 0, len(items)) - for _, i := range items { - ms = append(ms, manifest.Manifest(i.(map[string]interface{}))) +// GetByState returns the full object, including runtime fields for each +// resource in the state +func (k Kubectl) GetByState(data manifest.List) (manifest.List, error) { + list, err := k.get("", "", []string{"-f", "-"}, data.String()) + if err != nil { + return nil, err } - return ms, nil + return unwrapList(list) } -func (k Kubectl) get(namespace, kind string, sel []string) (manifest.Manifest, error) { +func (k Kubectl) get(namespace, kind string, sel []string, stdin string) (manifest.Manifest, error) { + // build flags argv := []string{"-o", "json"} - if namespace == "" { + switch { // set namespace, unless reading from stdin + case stdin != "": + break + case namespace == "": argv = append(argv, "--all-namespaces") - } else { + default: argv = append(argv, "-n", namespace) } - argv = append(argv, kind) + if kind != "" { + argv = append(argv, kind) + } argv = append(argv, sel...) cmd := k.ctl("get", argv...) @@ -54,6 +61,9 @@ func (k Kubectl) get(namespace, kind string, sel []string) (manifest.Manifest, e var sout, serr bytes.Buffer cmd.Stdout = &sout cmd.Stderr = &serr + if stdin != "" { + cmd.Stdin = strings.NewReader(stdin) + } if err := cmd.Run(); err != nil { if strings.HasPrefix(serr.String(), "Error from server (NotFound)") { @@ -74,3 +84,17 @@ func (k Kubectl) get(namespace, kind string, sel []string) (manifest.Manifest, e return m, nil } + +func unwrapList(list manifest.Manifest) (manifest.List, error) { + if list.Kind() != "List" { + return nil, fmt.Errorf("expected kind `List` but got `%s` instead", list.Kind()) + } + + items := list["items"].([]interface{}) + ms := make(manifest.List, 0, len(items)) + for _, i := range items { + ms = append(ms, manifest.Manifest(i.(map[string]interface{}))) + } + + return ms, nil +} diff --git a/pkg/kubernetes/client/resources.go b/pkg/kubernetes/client/resources.go index d7ec4f62f..96a68092f 100644 --- a/pkg/kubernetes/client/resources.go +++ b/pkg/kubernetes/client/resources.go @@ -38,7 +38,7 @@ type Resource struct { } func (r Resource) FQN() string { - return strings.TrimPrefix(r.Kind+"."+r.APIGroup, ".") + return strings.TrimSuffix(r.Kind+"."+r.APIGroup, ".") } // Resources returns all API resources known to the server diff --git a/pkg/kubernetes/delete.go b/pkg/kubernetes/delete.go new file mode 100644 index 000000000..4330eb831 --- /dev/null +++ b/pkg/kubernetes/delete.go @@ -0,0 +1,18 @@ +package kubernetes + +import ( + "github.com/grafana/tanka/pkg/kubernetes/client" + "github.com/grafana/tanka/pkg/kubernetes/manifest" +) + +type DeleteOpts client.DeleteOpts + +func (k *Kubernetes) Delete(state manifest.List, opts DeleteOpts) error { + for _, m := range state { + if err := k.ctl.Delete(m.Metadata().Namespace(), m.Kind(), m.Metadata().Name(), client.DeleteOpts(opts)); err != nil { + return err + } + } + + return nil +} diff --git a/pkg/tanka/workflow.go b/pkg/tanka/workflow.go index 6f73f7044..670d19656 100644 --- a/pkg/tanka/workflow.go +++ b/pkg/tanka/workflow.go @@ -88,6 +88,7 @@ func Diff(baseDir string, mods ...Modifier) (*string, error) { func Prune(baseDir string, mods ...Modifier) error { opts := parseModifiers(mods) + // parse jsonnet, init k8s client p, err := parse(baseDir, opts) if err != nil { return err @@ -98,6 +99,12 @@ func Prune(baseDir string, mods ...Modifier) error { } defer kube.Close() + // find orphaned resources + // orphaned, err := kube.Orphaned(p.Resources) + // if err != nil { + // return err + // } + return kube.Prune(p.Resources) } From 80bdfc8b650cb68d889e06431c82c7bb1dd55e2c Mon Sep 17 00:00:00 2001 From: sh0rez Date: Mon, 6 Apr 2020 21:52:11 +0200 Subject: [PATCH 4/9] refactor(kubernetes): client.get() --- pkg/kubernetes/client/get.go | 70 +++++++++++++++++++++++------------- pkg/tanka/workflow.go | 1 + 2 files changed, 46 insertions(+), 25 deletions(-) diff --git a/pkg/kubernetes/client/get.go b/pkg/kubernetes/client/get.go index d6fa4d4d7..44adb2182 100644 --- a/pkg/kubernetes/client/get.go +++ b/pkg/kubernetes/client/get.go @@ -3,6 +3,7 @@ package client import ( "bytes" "encoding/json" + "errors" "fmt" "strings" @@ -11,17 +12,22 @@ import ( // Get retrieves a single Kubernetes object from the cluster func (k Kubectl) Get(namespace, kind, name string) (manifest.Manifest, error) { - return k.get(namespace, kind, []string{name}, "") + return k.get(namespace, kind, []string{name}, getOpts{}) } -// GetByLabels retrieves all objects matched by the given labels from the cluster +// GetByLabels retrieves all objects matched by the given labels from the cluster. +// Set namespace to empty string for --all-namespaces func (k Kubectl) GetByLabels(namespace, kind string, labels map[string]string) (manifest.List, error) { lArgs := make([]string, 0, len(labels)) for k, v := range labels { lArgs = append(lArgs, fmt.Sprintf("-l=%s=%s", k, v)) } - list, err := k.get(namespace, kind, lArgs, "") + var opts getOpts + if namespace == "" { + opts.allNamespaces = true + } + list, err := k.get(namespace, kind, lArgs, opts) if err != nil { return nil, err } @@ -32,7 +38,9 @@ func (k Kubectl) GetByLabels(namespace, kind string, labels map[string]string) ( // GetByState returns the full object, including runtime fields for each // resource in the state func (k Kubectl) GetByState(data manifest.List) (manifest.List, error) { - list, err := k.get("", "", []string{"-f", "-"}, data.String()) + list, err := k.get("", "", []string{"-f", "-"}, getOpts{ + stdin: data.String(), + }) if err != nil { return nil, err } @@ -40,43 +48,44 @@ func (k Kubectl) GetByState(data manifest.List) (manifest.List, error) { return unwrapList(list) } -func (k Kubectl) get(namespace, kind string, sel []string, stdin string) (manifest.Manifest, error) { - // build flags - argv := []string{"-o", "json"} - switch { // set namespace, unless reading from stdin - case stdin != "": - break - case namespace == "": +type getOpts struct { + allNamespaces bool + stdin string +} + +func (k Kubectl) get(namespace, kind string, selector []string, opts getOpts) (manifest.Manifest, error) { + // build cli flags and args + argv := []string{ + "-o", "json", + } + + if opts.allNamespaces { argv = append(argv, "--all-namespaces") - default: + } else if namespace != "" { argv = append(argv, "-n", namespace) } + if kind != "" { argv = append(argv, kind) } - argv = append(argv, sel...) - cmd := k.ctl("get", argv...) + argv = append(argv, selector...) + // setup command environment + cmd := k.ctl("get", argv...) var sout, serr bytes.Buffer cmd.Stdout = &sout cmd.Stderr = &serr - if stdin != "" { - cmd.Stdin = strings.NewReader(stdin) + if opts.stdin != "" { + cmd.Stdin = strings.NewReader(opts.stdin) } + // run command if err := cmd.Run(); err != nil { - if strings.HasPrefix(serr.String(), "Error from server (NotFound)") { - return nil, ErrorNotFound{serr.String()} - } - if strings.HasPrefix(serr.String(), "error: the server doesn't have a resource type") { - return nil, ErrorUnknownResource{serr.String()} - } - - fmt.Print(serr.String()) - return nil, err + return nil, parseGetErr(err, serr.String()) } + // parse result var m manifest.Manifest if err := json.Unmarshal(sout.Bytes(), &m); err != nil { return nil, err @@ -85,6 +94,17 @@ func (k Kubectl) get(namespace, kind string, sel []string, stdin string) (manife return m, nil } +func parseGetErr(err error, stderr string) error { + if strings.HasPrefix(stderr, "Error from server (NotFound)") { + return ErrorNotFound{stderr} + } + if strings.HasPrefix(stderr, "error: the server doesn't have a resource type") { + return ErrorUnknownResource{stderr} + } + + return errors.New(strings.TrimPrefix(fmt.Sprintf("%s\n%s", stderr, err), "\n")) +} + func unwrapList(list manifest.Manifest) (manifest.List, error) { if list.Kind() != "List" { return nil, fmt.Errorf("expected kind `List` but got `%s` instead", list.Kind()) diff --git a/pkg/tanka/workflow.go b/pkg/tanka/workflow.go index 670d19656..8aded9d5d 100644 --- a/pkg/tanka/workflow.go +++ b/pkg/tanka/workflow.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/fatih/color" + "github.com/grafana/tanka/pkg/kubernetes" "github.com/grafana/tanka/pkg/kubernetes/client" "github.com/grafana/tanka/pkg/kubernetes/manifest" From b0491a5ad02b668b1a21b0c4acd9ab60d77821e9 Mon Sep 17 00:00:00 2001 From: sh0rez Date: Mon, 6 Apr 2020 22:38:59 +0200 Subject: [PATCH 5/9] feat: functional prune command Adds a `tk prune` command, that does what it promises. Prune is functionally complete now. --- cmd/tk/workflow.go | 5 ++++ pkg/kubernetes/apply.go | 44 ++++++++++++------------------ pkg/spec/v1alpha1/config.go | 1 - pkg/tanka/prune.go | 54 +++++++++++++++++++++++++++++++++++++ pkg/tanka/workflow.go | 34 +++++------------------ 5 files changed, 82 insertions(+), 56 deletions(-) create mode 100644 pkg/tanka/prune.go diff --git a/cmd/tk/workflow.go b/cmd/tk/workflow.go index 410b17b5d..b23a66fd0 100644 --- a/cmd/tk/workflow.go +++ b/cmd/tk/workflow.go @@ -71,9 +71,14 @@ func pruneCmd() *cli.Command { } getExtCode := extCodeParser(cmd.Flags()) + autoApprove := cmd.Flags().Bool("dangerous-auto-approve", false, "skip interactive approval. Only for automation!") + force := cmd.Flags().Bool("force", false, "force deleting (kubectl delete --force)") + cmd.Run = func(cmd *cli.Command, args []string) error { return tanka.Prune(args[0], tanka.WithExtCode(getExtCode()), + tanka.WithApplyAutoApprove(*autoApprove), + tanka.WithApplyForce(*force), ) } diff --git a/pkg/kubernetes/apply.go b/pkg/kubernetes/apply.go index 4e0dad678..465141c21 100644 --- a/pkg/kubernetes/apply.go +++ b/pkg/kubernetes/apply.go @@ -17,33 +17,6 @@ func (k *Kubernetes) Apply(state manifest.List, opts ApplyOpts) error { return k.ctl.Apply(state, client.ApplyOpts(opts)) } -func (k *Kubernetes) Prune(state manifest.List) error { - orphaned, err := k.Orphaned(state) - if err != nil { - return err - } - - for _, m := range orphaned { - fmt.Println(m.Metadata().Namespace(), m.APIVersion(), m.Kind(), m.Metadata().Name()) - } - return nil -} - -func (k *Kubernetes) uids(state manifest.List) (map[string]bool, error) { - uids := make(map[string]bool) - - live, err := k.ctl.GetByState(state) - if err != nil { - return nil, err - } - - for _, m := range live { - uids[m.Metadata().UID()] = true - } - - return uids, nil -} - // Orphaned returns previously created resources that are missing from the // local state. It uses UIDs to safely identify objects. func (k *Kubernetes) Orphaned(state manifest.List) (manifest.List, error) { @@ -98,6 +71,8 @@ func (k *Kubernetes) Orphaned(state manifest.List) (manifest.List, error) { // type. if m.APIVersion() == "v1" && m.Kind() == "ComponentStatus" { continue + } else if m.APIVersion() == "v1" && m.Kind() == "Endpoints" { + continue } // record and skip from now on @@ -107,3 +82,18 @@ func (k *Kubernetes) Orphaned(state manifest.List) (manifest.List, error) { return orphaned, nil } + +func (k *Kubernetes) uids(state manifest.List) (map[string]bool, error) { + uids := make(map[string]bool) + + live, err := k.ctl.GetByState(state) + if err != nil { + return nil, err + } + + for _, m := range live { + uids[m.Metadata().UID()] = true + } + + return uids, nil +} diff --git a/pkg/spec/v1alpha1/config.go b/pkg/spec/v1alpha1/config.go index a7f66c691..52557c393 100644 --- a/pkg/spec/v1alpha1/config.go +++ b/pkg/spec/v1alpha1/config.go @@ -42,5 +42,4 @@ type Spec struct { APIServer string `json:"apiServer"` Namespace string `json:"namespace"` DiffStrategy string `json:"diffStrategy,omitempty"` - Prune bool `json:"prune,omitempty"` } diff --git a/pkg/tanka/prune.go b/pkg/tanka/prune.go new file mode 100644 index 000000000..f95d16f55 --- /dev/null +++ b/pkg/tanka/prune.go @@ -0,0 +1,54 @@ +package tanka + +import ( + "fmt" + + "github.com/grafana/tanka/pkg/kubernetes" + "github.com/grafana/tanka/pkg/term" +) + +// Prune deletes all resources from the cluster, that are no longer present in +// Jsonnet. It uses the `tanka.dev/environment` label to identify those. +func Prune(baseDir string, mods ...Modifier) error { + opts := parseModifiers(mods) + + // parse jsonnet, init k8s client + p, err := parse(baseDir, opts) + if err != nil { + return err + } + kube, err := p.newKube() + if err != nil { + return err + } + defer kube.Close() + + // find orphaned resources + orphaned, err := kube.Orphaned(p.Resources) + if err != nil { + return err + } + + if len(orphaned) == 0 { + fmt.Println("Nothing found to prune.") + return nil + } + + // print diff + diff, err := kubernetes.StaticDiffer(false)(orphaned) + if err != nil { + // static diff can't fail normally, so unlike in apply, this is fatal + // here + return err + } + fmt.Print(term.Colordiff(*diff).String()) + + // prompt for confirm + if opts.apply.AutoApprove { + } else if err := confirmPrompt("Pruning from", p.Env.Spec.Namespace, kube.Info()); err != nil { + return err + } + + // delete resources + return kube.Delete(orphaned, kubernetes.DeleteOpts(opts.apply)) +} diff --git a/pkg/tanka/workflow.go b/pkg/tanka/workflow.go index 8aded9d5d..6907b1b58 100644 --- a/pkg/tanka/workflow.go +++ b/pkg/tanka/workflow.go @@ -28,7 +28,7 @@ func Apply(baseDir string, mods ...Modifier) error { defer kube.Close() // show diff - diff, err := kube.Diff(p.Resources, kubernetes.DiffOpts{}) + diff, err := kube.Diff(p.Resources, kubernetes.DiffOpts{Strategy: opts.diff.Strategy}) switch { case err != nil: // This is not fatal, the diff is not strictly required @@ -42,19 +42,20 @@ func Apply(baseDir string, mods ...Modifier) error { fmt.Print(b.String()) // prompt for confirmation - if err := applyPrompt(p.Env.Spec.Namespace, kube.Info()); err != nil { + if opts.apply.AutoApprove { + } else if err := confirmPrompt("Applying to", p.Env.Spec.Namespace, kube.Info()); err != nil { return err } return kube.Apply(p.Resources, opts.apply) } -// applyPrompt asks the user for confirmation before apply -func applyPrompt(namespace string, info client.Info) error { +// confirmPrompt asks the user for confirmation before apply +func confirmPrompt(action, namespace string, info client.Info) error { alert := color.New(color.FgRed, color.Bold).SprintFunc() return term.Confirm( - fmt.Sprintf(`Applying to namespace '%s' of cluster '%s' at '%s' using context '%s'.`, + fmt.Sprintf(`%s namespace '%s' of cluster '%s' at '%s' using context '%s'.`, action, alert(namespace), alert(info.Kubeconfig.Cluster.Name), alert(info.Kubeconfig.Cluster.Cluster.Server), @@ -86,29 +87,6 @@ func Diff(baseDir string, mods ...Modifier) (*string, error) { return kube.Diff(p.Resources, opts.diff) } -func Prune(baseDir string, mods ...Modifier) error { - opts := parseModifiers(mods) - - // parse jsonnet, init k8s client - p, err := parse(baseDir, opts) - if err != nil { - return err - } - kube, err := p.newKube() - if err != nil { - return err - } - defer kube.Close() - - // find orphaned resources - // orphaned, err := kube.Orphaned(p.Resources) - // if err != nil { - // return err - // } - - return kube.Prune(p.Resources) -} - // Show parses the environment at the given directory (a `baseDir`) and returns // the list of Kubernetes objects. // Tip: use the `String()` function on the returned list to get the familiar yaml stream From 46087c17dbc23d8834a11c71fbe56c9e4a19784b Mon Sep 17 00:00:00 2001 From: sh0rez Date: Tue, 7 Apr 2020 22:46:49 +0200 Subject: [PATCH 6/9] feat(prune): intelligently ignore objects --- pkg/kubernetes/apply.go | 12 ++++---- pkg/kubernetes/manifest/manifest.go | 45 +++++++++++++++++------------ 2 files changed, 31 insertions(+), 26 deletions(-) diff --git a/pkg/kubernetes/apply.go b/pkg/kubernetes/apply.go index 465141c21..18ce8dc6f 100644 --- a/pkg/kubernetes/apply.go +++ b/pkg/kubernetes/apply.go @@ -17,6 +17,9 @@ func (k *Kubernetes) Apply(state manifest.List, opts ApplyOpts) error { return k.ctl.Apply(state, client.ApplyOpts(opts)) } +// AnnoationLastApplied is the last-applied-configuration annotation used by kubectl +const AnnotationLastApplied = "kubectl.kubernetes.io/last-applied-configuration" + // Orphaned returns previously created resources that are missing from the // local state. It uses UIDs to safely identify objects. func (k *Kubernetes) Orphaned(state manifest.List) (manifest.List, error) { @@ -65,13 +68,8 @@ func (k *Kubernetes) Orphaned(state manifest.List) (manifest.List, error) { continue } - // ComponentStatus resource is broken in Kubernetes versions - // below 1.17, it will be returned even if the label does not - // match. Ignoring it here is fine, as it is an internal object - // type. - if m.APIVersion() == "v1" && m.Kind() == "ComponentStatus" { - continue - } else if m.APIVersion() == "v1" && m.Kind() == "Endpoints" { + // skip objects not created explicitely + if _, ok := m.Metadata().Annotations()[AnnotationLastApplied]; !ok { continue } diff --git a/pkg/kubernetes/manifest/manifest.go b/pkg/kubernetes/manifest/manifest.go index b333853b9..ad34110dc 100644 --- a/pkg/kubernetes/manifest/manifest.go +++ b/pkg/kubernetes/manifest/manifest.go @@ -140,32 +140,39 @@ func (m Metadata) UID() string { return uid } -// HasLabels returns whether the manifest has labels -func (m Metadata) HasLabels() bool { - _, ok := m["labels"].(map[string]string) - return ok -} - // Labels of the manifest func (m Metadata) Labels() map[string]string { - if !m.HasLabels() { - m["labels"] = make(map[string]string) - } - return m["labels"].(map[string]string) -} - -// HasAnnotations returns whether the manifest has annotations -func (m Metadata) HasAnnotations() bool { - _, ok := m["annotations"].(map[string]string) - return ok + return safeStringMap(m, "labels") } // Annotations of the manifest func (m Metadata) Annotations() map[string]string { - if !m.HasAnnotations() { - m["annotations"] = make(map[string]string) + return safeStringMap(m, "annotations") +} + +// safeStringMap safely returns a string map: +// - returns if map[string]string +// - converts if map[string]interface{} +// - zeroes if anything else +func safeStringMap(m map[string]interface{}, key string) map[string]string { + switch t := m[key].(type) { + case map[string]string: + return t + case map[string]interface{}: + mss := make(map[string]string) + for k, v := range t { + s, ok := v.(string) + if !ok { + continue + } + mss[k] = s + } + m[key] = mss + return m[key].(map[string]string) + default: + m[key] = make(map[string]string) + return m[key].(map[string]string) } - return m["annotations"].(map[string]string) } // List of individual Manifests From b08b5297431582feb1e35425f6b119fa82d2a246 Mon Sep 17 00:00:00 2001 From: sh0rez Date: Wed, 8 Apr 2020 22:20:37 +0200 Subject: [PATCH 7/9] feat(prune): feature flag --- pkg/kubernetes/apply.go | 6 ++++++ pkg/kubernetes/reconcile.go | 5 ++++- pkg/spec/v1alpha1/config.go | 1 + 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/pkg/kubernetes/apply.go b/pkg/kubernetes/apply.go index 18ce8dc6f..49ad99174 100644 --- a/pkg/kubernetes/apply.go +++ b/pkg/kubernetes/apply.go @@ -23,6 +23,12 @@ const AnnotationLastApplied = "kubectl.kubernetes.io/last-applied-configuration" // Orphaned returns previously created resources that are missing from the // local state. It uses UIDs to safely identify objects. func (k *Kubernetes) Orphaned(state manifest.List) (manifest.List, error) { + if !k.Env.Spec.InjectLabels { + return nil, fmt.Errorf(`spec.injectLabels is set to false in your spec.json. Tanka needs to add +a label to your resources to reliably detect which were removed from Jsonnet. +See https://tanka.dev/garbage-collection for more details.`) + } + apiResources, err := k.ctl.Resources() if err != nil { return nil, err diff --git a/pkg/kubernetes/reconcile.go b/pkg/kubernetes/reconcile.go index ac49b0bd8..62f658b0d 100644 --- a/pkg/kubernetes/reconcile.go +++ b/pkg/kubernetes/reconcile.go @@ -72,8 +72,11 @@ func Reconcile(raw map[string]interface{}, cfg v1alpha1.Config, targets []*regex out := make(manifest.List, 0, len(extracted)) for _, m := range extracted { + // inject tanka.dev/environment label - m.Metadata().Labels()[LabelEnvironment] = cfg.Metadata.NameLabel() + if cfg.Spec.InjectLabels { + m.Metadata().Labels()[LabelEnvironment] = cfg.Metadata.NameLabel() + } out = append(out, m) } diff --git a/pkg/spec/v1alpha1/config.go b/pkg/spec/v1alpha1/config.go index 52557c393..2e29220d0 100644 --- a/pkg/spec/v1alpha1/config.go +++ b/pkg/spec/v1alpha1/config.go @@ -42,4 +42,5 @@ type Spec struct { APIServer string `json:"apiServer"` Namespace string `json:"namespace"` DiffStrategy string `json:"diffStrategy,omitempty"` + InjectLabels bool `json:"injectLabels,omitempty"` } From e3e5a9551308a0b83c8eaefe1c602c53c731b14a Mon Sep 17 00:00:00 2001 From: sh0rez Date: Wed, 8 Apr 2020 22:21:13 +0200 Subject: [PATCH 8/9] doc: Garbage collection + Config reference --- docs/docs/config.md | 64 +++++++++++++++++++++++++++++++++ docs/docs/garbage-collection.md | 32 +++++++++++++++++ docs/doczrc.js | 2 ++ 3 files changed, 98 insertions(+) create mode 100644 docs/docs/config.md create mode 100644 docs/docs/garbage-collection.md diff --git a/docs/docs/config.md b/docs/docs/config.md new file mode 100644 index 000000000..c5a177d6b --- /dev/null +++ b/docs/docs/config.md @@ -0,0 +1,64 @@ +--- +name: "Configuration Reference" +route: "/config" +--- + +# Configuration Reference + +Tanka's behavior can be customized per Environment using a file called `spec.json` + +## File format + +```json +{ + // Config format revision. Currently only "v1alpha1" + "apiVersion": "v1alpha1", + // Always "Environment". Reserved for future use + "kind": "Environment", + + // Descriptive fields + "metadata": { + // Name of the Environment. Automatically set to the relative + // path from the project root + "name": "", + + // Arbitrary key:value string pairs. Not parsed by Tanka + "labels": { "": "" } + }, + + // Properties influencing Tanka's behavior + "spec": { + // The Kubernetes cluster to use. + // Must be the full URL, e.g. https://cluster.fqdn:6443 + "apiServer": "", + + // Default namespace for objects that don't explicitely specify one + "namespace": "" | default = "default", + + // diffStrategy to use. Automatically chosen by default based on + // the availability of "kubectl diff". + // - native: uses "kubectl diff". Recommended + // - subset: fallback for k8s versions below 1.13.0 + "diffStrategy": "[native, subset]" | default = "auto", + + // Whether to add a "tanka.dev/environment" label to each created resource. + // Required for garbage collection ("tk prune"). + "injectLabels": | default = false + } +} +``` + +## Jsonnet access + +It is possible to access above data from Jsonnet: + +```jsonnet +local tk = import "tk.libsonnet"; + +{ + // The cluster IP + cluster: tk.env.spec.apiServer, + // The labels of your Environment + labels: tk.env.metadata.labels, +} +``` diff --git a/docs/docs/garbage-collection.md b/docs/docs/garbage-collection.md new file mode 100644 index 000000000..59e76c734 --- /dev/null +++ b/docs/docs/garbage-collection.md @@ -0,0 +1,32 @@ +--- +name: "Garbage collection" +route: "/garbage-collection" +--- + +# Garbage collection + +Tanka can automatically delete resources from your cluster once you remove them +from Jsonnet. + +> **Note:** This feature is **experimental**. Please report problems at https://github.com/grafana/tanka/issues. + +To accomplish this, it appends the `tanka.dev/environment: ` label to each created +resource. This is used to identify those which are missing from the local state in the +future. + +Because the label causes a `diff` for every single object in your cluster and +not everybody wants this, it needs to be explicitly enabled. To do so, add the +following field to your `spec.json`: + +```diff +{ + "spec": { ++ "injectLabels": true, + } +} +``` + +Once added, run a `tk apply`, make sure the label is actually added and confirm +by typing `yes`. + +From now on, you can use `tk prune` to remove old resources from your cluster. diff --git a/docs/doczrc.js b/docs/doczrc.js index 130c32b3a..bef467550 100644 --- a/docs/doczrc.js +++ b/docs/doczrc.js @@ -47,10 +47,12 @@ export default { // additional features "Output filtering", "Exporting as YAML", + "Garbage collection", "Command-line completion", "Diff strategies", // reference + "Configuration Reference", "Directory structure", "Environment variables", From c06a1fa24f80943a409ae2c53e5ec043bb36470a Mon Sep 17 00:00:00 2001 From: sh0rez Date: Wed, 8 Apr 2020 22:31:27 +0200 Subject: [PATCH 9/9] test: injectLabels --- pkg/kubernetes/kubernetes_test.go | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/pkg/kubernetes/kubernetes_test.go b/pkg/kubernetes/kubernetes_test.go index 43b45286f..1c0ba4cb4 100644 --- a/pkg/kubernetes/kubernetes_test.go +++ b/pkg/kubernetes/kubernetes_test.go @@ -28,6 +28,14 @@ func TestReconcile(t *testing.T) { deep: testDataRegular().Deep, flat: mapToList(testDataRegular().Flat), }, + { + name: "injectLabels", + deep: testDataRegular().Deep, + flat: mapToList(testDataRegular().Flat), + spec: v1alpha1.Spec{ + InjectLabels: true, + }, + }, { name: "targets", deep: testDataDeep().Deep, @@ -67,9 +75,11 @@ func TestReconcile(t *testing.T) { config.Metadata.Name = "testdata" config.Spec = c.spec - for i, m := range c.flat { - m.Metadata().Labels()[LabelEnvironment] = config.Metadata.NameLabel() - c.flat[i] = m + if config.Spec.InjectLabels { + for i, m := range c.flat { + m.Metadata().Labels()[LabelEnvironment] = config.Metadata.NameLabel() + c.flat[i] = m + } } got, err := Reconcile(c.deep.(map[string]interface{}), *config, c.targets)