Skip to content

feat: clean stale provisioner files #9545

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

Merged
merged 27 commits into from
Sep 11, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
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
6 changes: 5 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -570,7 +570,7 @@ coderd/apidoc/swagger.json: $(shell find ./scripts/apidocgen $(FIND_EXCLUSIONS)
./scripts/apidocgen/generate.sh
pnpm run format:write:only ./docs/api ./docs/manifest.json ./coderd/apidoc/swagger.json

update-golden-files: cli/testdata/.gen-golden helm/coder/tests/testdata/.gen-golden helm/provisioner/tests/testdata/.gen-golden scripts/ci-report/testdata/.gen-golden enterprise/cli/testdata/.gen-golden coderd/.gen-golden
update-golden-files: cli/testdata/.gen-golden helm/coder/tests/testdata/.gen-golden helm/provisioner/tests/testdata/.gen-golden scripts/ci-report/testdata/.gen-golden enterprise/cli/testdata/.gen-golden coderd/.gen-golden provisioner/terraform/testdata/.gen-golden
.PHONY: update-golden-files

cli/testdata/.gen-golden: $(wildcard cli/testdata/*.golden) $(wildcard cli/*.tpl) $(GO_SRC_FILES) $(wildcard cli/*_test.go)
Expand All @@ -593,6 +593,10 @@ coderd/.gen-golden: $(wildcard coderd/testdata/*/*.golden) $(GO_SRC_FILES) $(wil
go test ./coderd -run="Test.*Golden$$" -update
touch "$@"

provisioner/terraform/testdata/.gen-golden: $(wildcard provisioner/terraform/testdata/*/*.golden) $(GO_SRC_FILES) $(wildcard provisioner/terraform/*_test.go)
go test ./provisioner/terraform -run="Test.*Golden$$" -update
touch "$@"

scripts/ci-report/testdata/.gen-golden: $(wildcard scripts/ci-report/testdata/*) $(wildcard scripts/ci-report/*.go)
go test ./scripts/ci-report -run=TestOutputMatchesGoldenFile -update
touch "$@"
Expand Down
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,8 @@ require (
tailscale.com v1.46.1
)

require github.com/djherbis/times v1.5.0
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's super annoying that this isn't exposed in the standard library.
I'm slightly concerned that this module doesn't appear to be very active, but it has no dependencies, so maybe that's OK?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did the self-evaluation of the module, and I agree with you. Fortunately, this is just a basic syscall, so in theory, we could "port" it to coder.


require (
cloud.google.com/go/compute v1.23.0 // indirect
cloud.google.com/go/logging v1.8.1 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,8 @@ github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WA
github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48 h1:fRzb/w+pyskVMQ+UbP35JkH8yB7MYb4q/qhBarqZE6g=
github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
github.com/dhui/dktest v0.3.16 h1:i6gq2YQEtcrjKbeJpBkWjE8MmLZPYllcjOFbTZuPDnw=
github.com/djherbis/times v1.5.0 h1:79myA211VwPhFTqUk8xehWrsEO+zcIZj0zT8mXPVARU=
github.com/djherbis/times v1.5.0/go.mod h1:5q7FDLvbNg1L/KaBmPcWlVR9NmoKo3+ucqUA3ijQhA0=
github.com/dlclark/regexp2 v1.7.0 h1:7lJfhqlPssTb1WQx4yvTHN0uElPEv52sbaECrAQxjAo=
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/docker/cli v23.0.5+incompatible h1:ufWmAOuD3Vmr7JP2G5K3cyuNC4YZWiAsuDEvFVVDafE=
Expand Down
153 changes: 153 additions & 0 deletions provisioner/terraform/cleanup.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
package terraform

import (
"context"
"os"
"path/filepath"
"strings"
"time"

"github.com/djherbis/times"
"github.com/spf13/afero"
"golang.org/x/xerrors"

"cdr.dev/slog"
)

// CleanStaleTerraformPlugins browses the Terraform cache directory
// and remove stale plugins that haven't been used for a while.
// Additionally, it sweeps empty, old directory trees.
//
// Sample cachePath:
//
// /Users/john.doe/Library/Caches/coder/provisioner-1/tf
// /tmp/coder/provisioner-0/tf
func CleanStaleTerraformPlugins(ctx context.Context, cachePath string, fs afero.Fs, now time.Time, logger slog.Logger) error {
cachePath, err := filepath.Abs(cachePath) // sanity check in case the path is e.g. ../../../cache
if err != nil {
return xerrors.Errorf("unable to determine absolute path %q: %w", cachePath, err)
}

// Firstly, check if the cache path exists.
_, err = fs.Stat(cachePath)
if os.IsNotExist(err) {
return nil
} else if err != nil {
return xerrors.Errorf("unable to stat cache path %q: %w", cachePath, err)
}

logger.Info(ctx, "clean stale Terraform plugins", slog.F("cache_path", cachePath))

// Filter directory trees matching pattern: <repositoryURL>/<company>/<plugin>/<version>/<distribution>
filterFunc := func(path string, info os.FileInfo) bool {
if !info.IsDir() {
return false
}

relativePath, err := filepath.Rel(cachePath, path)
if err != nil {
logger.Error(ctx, "unable to evaluate a relative path", slog.F("base", cachePath), slog.F("target", path), slog.Error(err))
return false
}

parts := strings.Split(relativePath, string(filepath.Separator))
return len(parts) == 5
}

// Review cached Terraform plugins
var pluginPaths []string
err = afero.Walk(fs, cachePath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}

if !filterFunc(path, info) {
return nil
}

logger.Debug(ctx, "plugin directory discovered", slog.F("path", path))
pluginPaths = append(pluginPaths, path)
return nil
})
if err != nil {
return xerrors.Errorf("unable to walk through cache directory %q: %w", cachePath, err)
}

// Identify stale plugins
var stalePlugins []string
for _, pluginPath := range pluginPaths {
accessTime, err := latestAccessTime(fs, pluginPath)
if err != nil {
return xerrors.Errorf("unable to evaluate latest access time for directory %q: %w", pluginPath, err)
}

if accessTime.Add(staleTerraformPluginRetention).Before(now) {
logger.Info(ctx, "plugin directory is stale and will be removed", slog.F("plugin_path", pluginPath))
stalePlugins = append(stalePlugins, pluginPath)
} else {
logger.Debug(ctx, "plugin directory is not stale", slog.F("plugin_path", pluginPath))
}
}

// Remove stale plugins
for _, stalePluginPath := range stalePlugins {
// Remove the plugin directory
err = fs.RemoveAll(stalePluginPath)
if err != nil {
return xerrors.Errorf("unable to remove stale plugin %q: %w", stalePluginPath, err)
}

// Compact the plugin structure by removing empty directories.
wd := stalePluginPath
level := 5 // <repositoryURL>/<company>/<plugin>/<version>/<distribution>
for {
level--
if level == 0 {
break // do not compact further
}

wd = filepath.Dir(wd)

files, err := afero.ReadDir(fs, wd)
if err != nil {
return xerrors.Errorf("unable to read directory content %q: %w", wd, err)
}

if len(files) > 0 {
break // there are still other plugins
}

logger.Debug(ctx, "remove empty directory", slog.F("path", wd))
err = fs.Remove(wd)
if err != nil {
return xerrors.Errorf("unable to remove directory %q: %w", wd, err)
}
}
}
return nil
}

// latestAccessTime walks recursively through the directory content, and locates
// the last accessed file.
func latestAccessTime(fs afero.Fs, pluginPath string) (time.Time, error) {
var latest time.Time
err := afero.Walk(fs, pluginPath, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}

accessTime := info.ModTime() // fallback to modTime if accessTime is not available (afero)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just be aware that modtime is not guaranteed (see: https://www.kernel.org/doc/html/latest/filesystems/api-summary.html?highlight=nocmtime#c.file_update_time)

Howver, I would assume it to at least be the time the file was created.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that is correct. I was thinking about running a self-test on the cache directory, but on the other hand I wouldn't like to overcomplicate things.

if info.Sys() != nil {
timeSpec := times.Get(info)
accessTime = timeSpec.AccessTime()
}
if latest.Before(accessTime) {
latest = accessTime
}
return nil
})
if err != nil {
return time.Time{}, xerrors.Errorf("unable to walk the plugin path %q: %w", pluginPath, err)
}
return latest, nil
}
186 changes: 186 additions & 0 deletions provisioner/terraform/cleanup_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
//go:build linux || darwin

package terraform_test

import (
"bytes"
"context"
"flag"
"os"
"path/filepath"
"strings"
"testing"
"time"

"github.com/google/go-cmp/cmp"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"cdr.dev/slog"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/v2/provisioner/terraform"
"github.com/coder/coder/v2/testutil"
)

const cachePath = "/tmp/coder/provisioner-0/tf"

// updateGoldenFiles is a flag that can be set to update golden files.
var updateGoldenFiles = flag.Bool("update", false, "Update golden files")

var (
coderPluginPath = filepath.Join("registry.terraform.io", "coder", "coder", "0.11.1", "darwin_arm64")
dockerPluginPath = filepath.Join("registry.terraform.io", "kreuzwerker", "docker", "2.25.0", "darwin_arm64")
)

func TestPluginCache_Golden(t *testing.T) {
t.Parallel()

prepare := func() (afero.Fs, time.Time, slog.Logger) {
fs := afero.NewMemMapFs()
now := time.Date(2023, time.June, 3, 4, 5, 6, 0, time.UTC)
logger := slogtest.Make(t, nil).
Leveled(slog.LevelDebug).
Named("cleanup-test")
return fs, now, logger
}

t.Run("all plugins are stale", func(t *testing.T) {
t.Parallel()

ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()

fs, now, logger := prepare()

// given
// This plugin is older than 30 days.
addPluginFile(t, fs, coderPluginPath, "terraform-provider-coder_v0.11.1", now.Add(-63*24*time.Hour))
addPluginFile(t, fs, coderPluginPath, "LICENSE", now.Add(-33*24*time.Hour))
addPluginFile(t, fs, coderPluginPath, "README.md", now.Add(-31*24*time.Hour))
addPluginFolder(t, fs, coderPluginPath, "new_folder", now.Add(-31*24*time.Hour))
addPluginFile(t, fs, coderPluginPath, filepath.Join("new_folder", "foobar.tf"), now.Add(-43*24*time.Hour))

// This plugin is older than 30 days.
addPluginFile(t, fs, dockerPluginPath, "terraform-provider-docker_v2.25.0", now.Add(-31*24*time.Hour))
addPluginFile(t, fs, dockerPluginPath, "LICENSE", now.Add(-32*24*time.Hour))
addPluginFile(t, fs, dockerPluginPath, "README.md", now.Add(-33*24*time.Hour))

// when
terraform.CleanStaleTerraformPlugins(ctx, cachePath, fs, now, logger)

// then
diffFileSystem(t, fs)
})

t.Run("one plugin is stale", func(t *testing.T) {
t.Parallel()

ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()

fs, now, logger := prepare()

// given
addPluginFile(t, fs, coderPluginPath, "terraform-provider-coder_v0.11.1", now.Add(-2*time.Hour))
addPluginFile(t, fs, coderPluginPath, "LICENSE", now.Add(-3*time.Hour))
addPluginFile(t, fs, coderPluginPath, "README.md", now.Add(-4*time.Hour))
addPluginFolder(t, fs, coderPluginPath, "new_folder", now.Add(-5*time.Hour))
addPluginFile(t, fs, coderPluginPath, filepath.Join("new_folder", "foobar.tf"), now.Add(-4*time.Hour))

// This plugin is older than 30 days.
addPluginFile(t, fs, dockerPluginPath, "terraform-provider-docker_v2.25.0", now.Add(-31*24*time.Hour))
addPluginFile(t, fs, dockerPluginPath, "LICENSE", now.Add(-32*24*time.Hour))
addPluginFile(t, fs, dockerPluginPath, "README.md", now.Add(-33*24*time.Hour))

// when
terraform.CleanStaleTerraformPlugins(ctx, cachePath, fs, now, logger)

// then
diffFileSystem(t, fs)
})

t.Run("one plugin file is touched", func(t *testing.T) {
t.Parallel()

ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()

fs, now, logger := prepare()

// given
addPluginFile(t, fs, coderPluginPath, "terraform-provider-coder_v0.11.1", now.Add(-63*24*time.Hour))
addPluginFile(t, fs, coderPluginPath, "LICENSE", now.Add(-33*24*time.Hour))
addPluginFile(t, fs, coderPluginPath, "README.md", now.Add(-31*24*time.Hour))
addPluginFolder(t, fs, coderPluginPath, "new_folder", now.Add(-4*time.Hour)) // touched
addPluginFile(t, fs, coderPluginPath, filepath.Join("new_folder", "foobar.tf"), now.Add(-43*24*time.Hour))

addPluginFile(t, fs, dockerPluginPath, "terraform-provider-docker_v2.25.0", now.Add(-31*24*time.Hour))
addPluginFile(t, fs, dockerPluginPath, "LICENSE", now.Add(-2*time.Hour))
addPluginFile(t, fs, dockerPluginPath, "README.md", now.Add(-33*24*time.Hour))

// when
terraform.CleanStaleTerraformPlugins(ctx, cachePath, fs, now, logger)

// then
diffFileSystem(t, fs)
})
}

func addPluginFile(t *testing.T, fs afero.Fs, pluginPath string, resourcePath string, accessTime time.Time) {
err := fs.MkdirAll(filepath.Join(cachePath, pluginPath), 0o755)
require.NoError(t, err, "can't create test folder for plugin file")

err = fs.Chtimes(filepath.Join(cachePath, pluginPath), accessTime, accessTime)
require.NoError(t, err, "can't set times")

err = afero.WriteFile(fs, filepath.Join(cachePath, pluginPath, resourcePath), []byte("foo"), 0o644)
require.NoError(t, err, "can't create test file")

err = fs.Chtimes(filepath.Join(cachePath, pluginPath, resourcePath), accessTime, accessTime)
require.NoError(t, err, "can't set times")
}

func addPluginFolder(t *testing.T, fs afero.Fs, pluginPath string, folderPath string, accessTime time.Time) {
err := fs.MkdirAll(filepath.Join(cachePath, pluginPath, folderPath), 0o755)
require.NoError(t, err, "can't create plugin folder")

err = fs.Chtimes(filepath.Join(cachePath, pluginPath, folderPath), accessTime, accessTime)
require.NoError(t, err, "can't set times")
}

func diffFileSystem(t *testing.T, fs afero.Fs) {
actual := dumpFileSystem(t, fs)

partialName := strings.Join(strings.Split(t.Name(), "/")[1:], "_")
goldenFile := filepath.Join("testdata", "cleanup-stale-plugins", partialName+".txt.golden")
if *updateGoldenFiles {
err := os.MkdirAll(filepath.Dir(goldenFile), 0o755)
require.NoError(t, err, "want no error creating golden file directory")

err = os.WriteFile(goldenFile, actual, 0o600)
require.NoError(t, err, "want no error creating golden file")
return
}

want, err := os.ReadFile(goldenFile)
require.NoError(t, err, "open golden file, run \"make update-golden-files\" and commit the changes")
assert.Empty(t, cmp.Diff(want, actual), "golden file mismatch (-want +got): %s, run \"make update-golden-files\", verify and commit the changes", goldenFile)
}

func dumpFileSystem(t *testing.T, fs afero.Fs) []byte {
var buffer bytes.Buffer
err := afero.Walk(fs, "/", func(path string, info os.FileInfo, err error) error {
_, _ = buffer.WriteString(path)
_ = buffer.WriteByte(' ')
if info.IsDir() {
_ = buffer.WriteByte('d')
} else {
_ = buffer.WriteByte('f')
}
_ = buffer.WriteByte('\n')
return nil
})
require.NoError(t, err, "can't dump the file system")
return buffer.Bytes()
}
Loading