Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

tk export: Expand merging capabilities with --merge-strategy flag #760

Merged
merged 7 commits into from
Sep 26, 2022
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
Add --delete-previous option to tk export
Documented in `exporting.md`. This option allows us to delete the previously exported manifests for an environment.

This is useful when exporting a single environment and merging the result with an existing GitOps repository, rather than re-exporting all environments.

I also added benchmark and a test for the export code.
  • Loading branch information
julienduchesne committed Sep 21, 2022
commit 628c729008adfb7bc90b44585b7434be0da6f2fc
8 changes: 5 additions & 3 deletions cmd/tk/export.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ func exportCmd() *cli.Command {
cachePath := cmd.Flags().StringP("cache-path", "c", "", "Local file path where cached evaluations should be stored")
cacheEnvs := cmd.Flags().StringArrayP("cache-envs", "e", nil, "Regexes which define which environment should be cached (if caching is enabled)")
ballastBytes := cmd.Flags().Int("mem-ballast-size-bytes", 0, "Size of memory ballast to allocate. This may improve performance for large environments.")
deletePrevious := cmd.Flags().Bool("delete-previous", false, "If set, before exporting, delete files previously exported by the targeted envs, leaving untargeted envs intact. To be used with --merge.")

vars := workflowFlags(cmd.Flags())
getJsonnetOpts := jsonnetFlags(cmd.Flags())
Expand All @@ -57,9 +58,10 @@ func exportCmd() *cli.Command {
}

opts := tanka.ExportEnvOpts{
Format: *format,
Extension: *extension,
Merge: *merge,
Format: *format,
Extension: *extension,
Merge: *merge,
DeletePrevious: *deletePrevious,
Opts: tanka.Opts{
JsonnetOpts: getJsonnetOpts(),
Filters: filters,
Expand Down
18 changes: 16 additions & 2 deletions docs/docs/exporting.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,15 +99,29 @@ $ tk export exportDir environments/ --recursive
$ tk export exportDir environments/ -r -l team=infra
```

## Using a memory ballast
## Performance features

When exporting a large amount of environments, jsonnet evaluation can become a bottleneck. To speed up the process, Tanka provides a few optional features.

### Partial export (in a GitOps context)

Given multiple environments, one may want to only export the environments that were modified since the last export. This is enabled by passing both the `--merge` and `--delete-previous` flags.

When these flags are passed, Tanka will:

1. Delete the manifests that were previously exported by the environments that are being exported. This is done by looking at the `manifest.json` file that is generated by Tanka when exporting. The related entries are also removed from the `manifest.json` file.
2. Generate the manifests for the targeted environments into the output directory.
3. Add in the new manifests entries into the `manifest.json` file and re-export it.

### Using a memory ballast

_Read [this blog post](https://blog.twitch.tv/en/2019/04/10/go-memory-ballast-how-i-learnt-to-stop-worrying-and-love-the-heap/) for more information about memory ballasts._

For large environments that load lots of data into memory on evaluation, a memory ballast can dramatically improve performance. This feature is exposed through the `--mem-ballast-size-bytes` flag on the export command.

Anecdotally (Grafana Labs), environments that took around a minute to load were able to load in around 45 secs with a ballast of 5GB (`--mem-ballast-size-bytes=5368709120`). Decreasing the ballast size resulted in negative impact on performance, and increasing it more did not result in any noticeable impact.

## Caching
### Caching

Tanka can also cache the results of the export. This is useful if you often export the same files and want to avoid recomputing them. The cache key is calculated from the main file and all of its transitive imports, so any change to any file possibly used in an environment will invalidate the cache.

Expand Down
94 changes: 81 additions & 13 deletions pkg/tanka/export.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,15 @@ import (
"encoding/json"
"fmt"
"io"
"io/fs"
"log"
"os"
"path/filepath"
"strings"
"text/template"

"github.com/Masterminds/sprig/v3"
"github.com/pkg/errors"
"k8s.io/apimachinery/pkg/labels"

"github.com/grafana/tanka/pkg/kubernetes/manifest"
Expand Down Expand Up @@ -41,6 +44,8 @@ type ExportEnvOpts struct {
Selector labels.Selector
// optional: number of environments to process in parallel
Parallelism int
// optional: If set, target an existing export directory, delete files previously exported by the targeted envs and re-export targeted envs.
DeletePrevious bool
}

func ExportEnvironments(envs []*v1alpha1.Environment, to string, opts *ExportEnvOpts) error {
Expand All @@ -56,6 +61,13 @@ func ExportEnvironments(envs []*v1alpha1.Environment, to string, opts *ExportEnv
return fmt.Errorf("output dir `%s` not empty. Pass --merge to ignore this", to)
}

// delete files previously exported by the targeted envs.
if opts.DeletePrevious {
if err := deletePreviouslyExportedManifests(to, envs); err != nil {
return fmt.Errorf("deleting previously exported manifests: %w", err)
}
}

// get all environments for paths
loadedEnvs, err := parallelLoadEnvironments(envs, parallelOpts{
Opts: opts.Opts,
Expand Down Expand Up @@ -122,19 +134,7 @@ func ExportEnvironments(envs []*v1alpha1.Environment, to string, opts *ExportEnv
}
}

// Write manifest file
if len(fileToEnv) != 0 {
data, err := json.MarshalIndent(fileToEnv, "", " ")
if err != nil {
return err
}
path := filepath.Join(to, manifestFile)
if err := writeExportFile(path, data); err != nil {
return err
}
}

return nil
return exportManifestFile(to, fileToEnv, nil)
}

func fileExists(name string) (bool, error) {
Expand Down Expand Up @@ -164,6 +164,74 @@ func dirEmpty(dir string) (bool, error) {
return false, err
}

func deletePreviouslyExportedManifests(path string, envs []*v1alpha1.Environment) error {
fileToEnvMap := make(map[string]string)

manifestFilePath := filepath.Join(path, manifestFile)
manifestContent, err := os.ReadFile(manifestFilePath)
if err != nil && errors.Is(err, fs.ErrNotExist) {
julienduchesne marked this conversation as resolved.
Show resolved Hide resolved
log.Printf("Warning: No manifest file found at %s, skipping deletion of previously exported manifests\n", manifestFilePath)
return nil
} else if err != nil {
return err
}

if err := json.Unmarshal(manifestContent, &fileToEnvMap); err != nil {
return err
}

envNames := make(map[string]struct{})
for _, env := range envs {
envNames[env.Metadata.Namespace] = struct{}{}
}

var deletedManifestKeys []string
for exportedManifest, manifestEnv := range fileToEnvMap {
if _, ok := envNames[manifestEnv]; ok {
deletedManifestKeys = append(deletedManifestKeys, exportedManifest)
if err := os.Remove(filepath.Join(path, exportedManifest)); err != nil {
return err
}
}
}

return exportManifestFile(path, nil, deletedManifestKeys)
}

// exportManifestFile writes a manifest file that maps the exported files to their environment.
// If the file already exists, the new entries will be merged with the existing ones.
func exportManifestFile(path string, newFileToEnvMap map[string]string, deletedKeys []string) error {
if len(newFileToEnvMap) == 0 && len(deletedKeys) == 0 {
return nil
}

manifestFilePath := filepath.Join(path, manifestFile)
manifestContent, err := os.ReadFile(manifestFilePath)
currentFileToEnvMap := make(map[string]string)
if err != nil && !errors.Is(err, fs.ErrNotExist) {
return fmt.Errorf("reading existing manifest file: %w", err)
} else if err == nil {
if err := json.Unmarshal(manifestContent, &currentFileToEnvMap); err != nil {
return fmt.Errorf("unmarshalling existing manifest file: %w", err)
}
}
julienduchesne marked this conversation as resolved.
Show resolved Hide resolved

for k, v := range newFileToEnvMap {
currentFileToEnvMap[k] = v
}
for _, k := range deletedKeys {
delete(currentFileToEnvMap, k)
}

// Write manifest file
data, err := json.MarshalIndent(currentFileToEnvMap, "", " ")
if err != nil {
return fmt.Errorf("marshalling manifest file: %w", err)
}

return writeExportFile(manifestFilePath, data)
}

func writeExportFile(path string, data []byte) error {
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
return fmt.Errorf("creating filepath '%s': %s", filepath.Dir(path), err)
Expand Down
144 changes: 143 additions & 1 deletion pkg/tanka/export_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,18 @@
package tanka

import "testing"
import (
"fmt"
"io/ioutil"
"log"
"os"
"path/filepath"
"testing"

"github.com/grafana/tanka/pkg/jsonnet"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"k8s.io/apimachinery/pkg/labels"
)

func Test_replaceTmplText(t *testing.T) {
type args struct {
Expand Down Expand Up @@ -31,3 +43,133 @@ func Test_replaceTmplText(t *testing.T) {
})
}
}

func TestExportEnvironments(t *testing.T) {
tempDir := t.TempDir()
require.NoError(t, os.Chdir("testdata"))
defer os.Chdir("..")

// Find envs
envs, err := FindEnvs("test-export-envs", FindOpts{Selector: labels.Everything()})
require.NoError(t, err)

// Export all envs
opts := &ExportEnvOpts{
Format: "{{.metadata.namespace}}/{{.metadata.name}}",
Extension: "yaml",
}
opts.Opts.ExtCode = jsonnet.InjectedCode{
"deploymentName": "'initial-deployment'",
"serviceName": "'initial-service'",
}
require.NoError(t, ExportEnvironments(envs, tempDir, opts))
checkFiles(t, tempDir, []string{
filepath.Join(tempDir, "inline-namespace1", "my-configmap.yaml"),
filepath.Join(tempDir, "inline-namespace1", "my-deployment.yaml"),
filepath.Join(tempDir, "inline-namespace1", "my-service.yaml"),
filepath.Join(tempDir, "inline-namespace2", "my-deployment.yaml"),
filepath.Join(tempDir, "inline-namespace2", "my-service.yaml"),
filepath.Join(tempDir, "static", "initial-deployment.yaml"),
filepath.Join(tempDir, "static", "initial-service.yaml"),
filepath.Join(tempDir, "manifest.json"),
})
manifestContent, err := os.ReadFile(filepath.Join(tempDir, "manifest.json"))
require.NoError(t, err)
assert.Equal(t, string(manifestContent), `{
"inline-namespace1/my-configmap.yaml": "test-export-envs/inline-envs/main.jsonnet",
"inline-namespace1/my-deployment.yaml": "test-export-envs/inline-envs/main.jsonnet",
"inline-namespace1/my-service.yaml": "test-export-envs/inline-envs/main.jsonnet",
"inline-namespace2/my-deployment.yaml": "test-export-envs/inline-envs/main.jsonnet",
"inline-namespace2/my-service.yaml": "test-export-envs/inline-envs/main.jsonnet",
"static/initial-deployment.yaml": "test-export-envs/static-env/main.jsonnet",
"static/initial-service.yaml": "test-export-envs/static-env/main.jsonnet"
}`)

// Try to re-export
assert.EqualError(t, ExportEnvironments(envs, tempDir, opts), fmt.Sprintf("Output dir `%s` not empty. Pass --merge to ignore this", tempDir))

// Try to re-export with the --merge flag. Will still fail because Tanka will not overwrite manifests silently
opts.Merge = true
assert.ErrorContains(t, ExportEnvironments(envs, tempDir, opts), "already exists. Aborting")

// Re-export only one env with --delete-previous flag
opts.Opts.ExtCode = jsonnet.InjectedCode{
"deploymentName": "'updated-deployment'",
"serviceName": "'updated-service'",
}
opts.DeletePrevious = true
staticEnv, err := FindEnvs("test-export-envs", FindOpts{Selector: labels.SelectorFromSet(labels.Set{"type": "static"})})
require.NoError(t, err)
require.NoError(t, ExportEnvironments(staticEnv, tempDir, opts))
checkFiles(t, tempDir, []string{
filepath.Join(tempDir, "inline-namespace1", "my-configmap.yaml"),
filepath.Join(tempDir, "inline-namespace1", "my-deployment.yaml"),
filepath.Join(tempDir, "inline-namespace1", "my-service.yaml"),
filepath.Join(tempDir, "inline-namespace2", "my-deployment.yaml"),
filepath.Join(tempDir, "inline-namespace2", "my-service.yaml"),
filepath.Join(tempDir, "static", "updated-deployment.yaml"),
filepath.Join(tempDir, "static", "updated-service.yaml"),
filepath.Join(tempDir, "manifest.json"),
})
manifestContent, err = os.ReadFile(filepath.Join(tempDir, "manifest.json"))
require.NoError(t, err)
assert.Equal(t, string(manifestContent), `{
"inline-namespace1/my-configmap.yaml": "test-export-envs/inline-envs/main.jsonnet",
"inline-namespace1/my-deployment.yaml": "test-export-envs/inline-envs/main.jsonnet",
"inline-namespace1/my-service.yaml": "test-export-envs/inline-envs/main.jsonnet",
"inline-namespace2/my-deployment.yaml": "test-export-envs/inline-envs/main.jsonnet",
"inline-namespace2/my-service.yaml": "test-export-envs/inline-envs/main.jsonnet",
"static/updated-deployment.yaml": "test-export-envs/static-env/main.jsonnet",
"static/updated-service.yaml": "test-export-envs/static-env/main.jsonnet"
}`)
}

func BenchmarkExportEnvironmentsWithDeletePrevious(b *testing.B) {
log.SetOutput(ioutil.Discard)
tempDir := b.TempDir()
require.NoError(b, os.Chdir("testdata"))
defer os.Chdir("..")

// Find envs
envs, err := FindEnvs("test-export-envs", FindOpts{Selector: labels.Everything()})
require.NoError(b, err)

// Export all envs
opts := &ExportEnvOpts{
Format: "{{.metadata.namespace}}/{{.metadata.name}}",
Extension: "yaml",
Merge: true,
DeletePrevious: true,
}
opts.Opts.ExtCode = jsonnet.InjectedCode{
"deploymentName": "'initial-deployment'",
"serviceName": "'initial-service'",
}
// Export a first time so that the benchmark loops are identical
require.NoError(b, ExportEnvironments(envs, tempDir, opts))

// On every loop, delete manifests from previous envs + reexport all envs
b.ResetTimer()
for i := 0; i < b.N; i++ {
require.NoError(b, ExportEnvironments(envs, tempDir, opts), "failed on iteration %d", i)
}
}

func checkFiles(t testing.TB, dir string, files []string) {
t.Helper()

var existingFiles []string
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
existingFiles = append(existingFiles, path)
return nil
})
require.NoError(t, err)

assert.ElementsMatch(t, files, existingFiles)
}
53 changes: 53 additions & 0 deletions pkg/tanka/testdata/test-export-envs/inline-envs/main.jsonnet
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
[
{
apiVersion: 'tanka.dev/v1alpha1',
kind: 'Environment',
metadata: {
name: env.namespace,
labels: {
type: 'inline',
},
},
spec: {
apiServer: 'https://localhost',
namespace: env.namespace,
},
data:
{
deployment: {
apiVersion: 'apps/v1',
kind: 'Deployment',
metadata: {
name: 'my-deployment',
},
},
service: {
apiVersion: 'v1',
kind: 'Service',
metadata: {
name: 'my-service',
},
},
} +
(if env.hasConfigMap then {
configMap: {
apiVersion: 'v1',
kind: 'ConfigMap',
metadata: {
name: 'my-configmap',
},
},
} else {}),
}

for env in [
{
namespace: 'inline-namespace1',
hasConfigMap: true,
},
{
namespace: 'inline-namespace2',
hasConfigMap: false,
},
]
]
Loading