Skip to content

Commit

Permalink
feat: Prune old resources (#251)
Browse files Browse the repository at this point in the history
* feat(prune): find old resources

* fix: respect labels in unit test

* 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.

* refactor(kubernetes): client.get()

* feat: functional prune command

Adds a `tk prune` command, that does what it promises. Prune is
functionally complete now.

* feat(prune): intelligently ignore objects

* feat(prune): feature flag

* doc: Garbage collection + Config reference

* test: injectLabels
  • Loading branch information
sh0rez authored Apr 20, 2020
1 parent 740ad7b commit 7c78c8f
Show file tree
Hide file tree
Showing 21 changed files with 476 additions and 101 deletions.
1 change: 1 addition & 0 deletions cmd/tk/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ func main() {
applyCmd(),
showCmd(),
diffCmd(),
pruneCmd(),
)

rootCmd.AddCommand(
Expand Down
22 changes: 22 additions & 0 deletions cmd/tk/workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,28 @@ func applyCmd() *cli.Command {
return cmd
}

func pruneCmd() *cli.Command {
cmd := &cli.Command{
Use: "prune <path>",
Short: "delete resources removed from Jsonnet",
Args: workflowArgs,
}

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),
)
}

return cmd
}

func diffCmd() *cli.Command {
cmd := &cli.Command{
Use: "diff <path>",
Expand Down
64 changes: 64 additions & 0 deletions docs/docs/config.md
Original file line number Diff line number Diff line change
@@ -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": "<string>",

// Arbitrary key:value string pairs. Not parsed by Tanka
"labels": { "<string>": "<string>" }
},

// Properties influencing Tanka's behavior
"spec": {
// The Kubernetes cluster to use.
// Must be the full URL, e.g. https://cluster.fqdn:6443
"apiServer": "<url>",

// Default namespace for objects that don't explicitely specify one
"namespace": "<string>" | 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": <boolean> | 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,
}
```
32 changes: 32 additions & 0 deletions docs/docs/garbage-collection.md
Original file line number Diff line number Diff line change
@@ -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: <name>` 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.
2 changes: 2 additions & 0 deletions docs/doczrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",

Expand Down
105 changes: 86 additions & 19 deletions pkg/kubernetes/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,35 +2,102 @@ package kubernetes

import (
"fmt"

"github.com/fatih/color"
"strings"
"time"

"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
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))
}

// 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) {
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
}

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
}

kinds += "," + r.FQN()
}
return k.ctl.Apply(state, client.ApplyOpts(opts))
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))

// filter unknown
for _, m := range matched {
// ignore known ones
if uids[m.Metadata().UID()] {
continue
}

// skip objects not created explicitely
if _, ok := m.Metadata().Annotations()[AnnotationLastApplied]; !ok {
continue
}

// record and skip from now on
orphaned = append(orphaned, m)
uids[m.Metadata().UID()] = true
}

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
}
4 changes: 2 additions & 2 deletions pkg/kubernetes/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ 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)
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
Expand All @@ -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)
Expand Down
19 changes: 3 additions & 16 deletions pkg/kubernetes/client/delete.go
Original file line number Diff line number Diff line change
@@ -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")
}
Expand Down
Loading

0 comments on commit 7c78c8f

Please sign in to comment.