-
Notifications
You must be signed in to change notification settings - Fork 888
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
Changes from all commits
277d7c2
afb0982
1624453
4bd9a56
d395d02
e2c71f8
19741fc
2af24c2
85a74cd
20bd67c
905388b
cd0c134
7c8ca9f
574f084
7aada74
eabdacb
1b49d80
44e0517
18c5a1d
2fdd756
b273cbf
6c45602
b35f7a7
1cd5fc2
662c32b
4e6a09c
ed2a8fd
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
} |
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() | ||
} |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.