diff --git a/Makefile b/Makefile index 943334c780f5a..67e506851d583 100644 --- a/Makefile +++ b/Makefile @@ -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) @@ -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 "$@" diff --git a/go.mod b/go.mod index eca074932e2fe..06eecae33b744 100644 --- a/go.mod +++ b/go.mod @@ -196,6 +196,8 @@ require ( tailscale.com v1.46.1 ) +require github.com/djherbis/times v1.5.0 + require ( cloud.google.com/go/compute v1.23.0 // indirect cloud.google.com/go/logging v1.8.1 // indirect diff --git a/go.sum b/go.sum index 125be1f486435..49d8443fe79a6 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/provisioner/terraform/cleanup.go b/provisioner/terraform/cleanup.go new file mode 100644 index 0000000000000..65f876551b6d7 --- /dev/null +++ b/provisioner/terraform/cleanup.go @@ -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: //// + 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 // //// + 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) + 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 +} diff --git a/provisioner/terraform/cleanup_test.go b/provisioner/terraform/cleanup_test.go new file mode 100644 index 0000000000000..42e97305df89b --- /dev/null +++ b/provisioner/terraform/cleanup_test.go @@ -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() +} diff --git a/provisioner/terraform/provision.go b/provisioner/terraform/provision.go index ab832e4408683..f1d5da3bbed9a 100644 --- a/provisioner/terraform/provision.go +++ b/provisioner/terraform/provision.go @@ -7,14 +7,18 @@ import ( "strings" "time" + "github.com/spf13/afero" + "cdr.dev/slog" + "github.com/coder/terraform-provider-coder/provider" "github.com/coder/coder/v2/coderd/tracing" "github.com/coder/coder/v2/provisionersdk" "github.com/coder/coder/v2/provisionersdk/proto" - "github.com/coder/terraform-provider-coder/provider" ) +const staleTerraformPluginRetention = 30 * 24 * time.Hour + func (s *server) setupContexts(parent context.Context, canceledOrComplete <-chan struct{}) ( ctx context.Context, cancel func(), killCtx context.Context, kill func(), ) { @@ -89,8 +93,13 @@ func (s *server) Plan( } } + err := CleanStaleTerraformPlugins(sess.Context(), s.cachePath, afero.NewOsFs(), time.Now(), s.logger) + if err != nil { + return provisionersdk.PlanErrorf("unable to clean stale Terraform plugins: %s", err) + } + s.logger.Debug(ctx, "running initialization") - err := e.init(ctx, killCtx, sess) + err = e.init(ctx, killCtx, sess) if err != nil { s.logger.Debug(ctx, "init failed", slog.Error(err)) return provisionersdk.PlanErrorf("initialize terraform: %s", err) diff --git a/provisioner/terraform/testdata/cleanup-stale-plugins/all_plugins_are_stale.txt.golden b/provisioner/terraform/testdata/cleanup-stale-plugins/all_plugins_are_stale.txt.golden new file mode 100644 index 0000000000000..385a8c0ae7835 --- /dev/null +++ b/provisioner/terraform/testdata/cleanup-stale-plugins/all_plugins_are_stale.txt.golden @@ -0,0 +1,5 @@ +/ d +/tmp d +/tmp/coder d +/tmp/coder/provisioner-0 d +/tmp/coder/provisioner-0/tf d diff --git a/provisioner/terraform/testdata/cleanup-stale-plugins/one_plugin_file_is_touched.txt.golden b/provisioner/terraform/testdata/cleanup-stale-plugins/one_plugin_file_is_touched.txt.golden new file mode 100644 index 0000000000000..399d94a8dfc2d --- /dev/null +++ b/provisioner/terraform/testdata/cleanup-stale-plugins/one_plugin_file_is_touched.txt.golden @@ -0,0 +1,22 @@ +/ d +/tmp d +/tmp/coder d +/tmp/coder/provisioner-0 d +/tmp/coder/provisioner-0/tf d +/tmp/coder/provisioner-0/tf/registry.terraform.io d +/tmp/coder/provisioner-0/tf/registry.terraform.io/coder d +/tmp/coder/provisioner-0/tf/registry.terraform.io/coder/coder d +/tmp/coder/provisioner-0/tf/registry.terraform.io/coder/coder/0.11.1 d +/tmp/coder/provisioner-0/tf/registry.terraform.io/coder/coder/0.11.1/darwin_arm64 d +/tmp/coder/provisioner-0/tf/registry.terraform.io/coder/coder/0.11.1/darwin_arm64/LICENSE f +/tmp/coder/provisioner-0/tf/registry.terraform.io/coder/coder/0.11.1/darwin_arm64/README.md f +/tmp/coder/provisioner-0/tf/registry.terraform.io/coder/coder/0.11.1/darwin_arm64/new_folder d +/tmp/coder/provisioner-0/tf/registry.terraform.io/coder/coder/0.11.1/darwin_arm64/new_folder/foobar.tf f +/tmp/coder/provisioner-0/tf/registry.terraform.io/coder/coder/0.11.1/darwin_arm64/terraform-provider-coder_v0.11.1 f +/tmp/coder/provisioner-0/tf/registry.terraform.io/kreuzwerker d +/tmp/coder/provisioner-0/tf/registry.terraform.io/kreuzwerker/docker d +/tmp/coder/provisioner-0/tf/registry.terraform.io/kreuzwerker/docker/2.25.0 d +/tmp/coder/provisioner-0/tf/registry.terraform.io/kreuzwerker/docker/2.25.0/darwin_arm64 d +/tmp/coder/provisioner-0/tf/registry.terraform.io/kreuzwerker/docker/2.25.0/darwin_arm64/LICENSE f +/tmp/coder/provisioner-0/tf/registry.terraform.io/kreuzwerker/docker/2.25.0/darwin_arm64/README.md f +/tmp/coder/provisioner-0/tf/registry.terraform.io/kreuzwerker/docker/2.25.0/darwin_arm64/terraform-provider-docker_v2.25.0 f diff --git a/provisioner/terraform/testdata/cleanup-stale-plugins/one_plugin_is_stale.txt.golden b/provisioner/terraform/testdata/cleanup-stale-plugins/one_plugin_is_stale.txt.golden new file mode 100644 index 0000000000000..7f6a2cf1b7f1a --- /dev/null +++ b/provisioner/terraform/testdata/cleanup-stale-plugins/one_plugin_is_stale.txt.golden @@ -0,0 +1,15 @@ +/ d +/tmp d +/tmp/coder d +/tmp/coder/provisioner-0 d +/tmp/coder/provisioner-0/tf d +/tmp/coder/provisioner-0/tf/registry.terraform.io d +/tmp/coder/provisioner-0/tf/registry.terraform.io/coder d +/tmp/coder/provisioner-0/tf/registry.terraform.io/coder/coder d +/tmp/coder/provisioner-0/tf/registry.terraform.io/coder/coder/0.11.1 d +/tmp/coder/provisioner-0/tf/registry.terraform.io/coder/coder/0.11.1/darwin_arm64 d +/tmp/coder/provisioner-0/tf/registry.terraform.io/coder/coder/0.11.1/darwin_arm64/LICENSE f +/tmp/coder/provisioner-0/tf/registry.terraform.io/coder/coder/0.11.1/darwin_arm64/README.md f +/tmp/coder/provisioner-0/tf/registry.terraform.io/coder/coder/0.11.1/darwin_arm64/new_folder d +/tmp/coder/provisioner-0/tf/registry.terraform.io/coder/coder/0.11.1/darwin_arm64/new_folder/foobar.tf f +/tmp/coder/provisioner-0/tf/registry.terraform.io/coder/coder/0.11.1/darwin_arm64/terraform-provider-coder_v0.11.1 f diff --git a/provisioner/terraform/testdata/generate.sh b/provisioner/terraform/testdata/generate.sh index 29e26a310c522..4ae1a87fb2504 100755 --- a/provisioner/terraform/testdata/generate.sh +++ b/provisioner/terraform/testdata/generate.sh @@ -13,6 +13,12 @@ for d in */; do continue fi + # This directory is used for a different purpose (quick workaround). + if [[ $name == "cleanup-stale-plugins" ]]; then + popd + continue + fi + terraform init -upgrade terraform plan -out terraform.tfplan terraform show -json ./terraform.tfplan | jq >"$name".tfplan.json diff --git a/provisionersdk/cleanup.go b/provisionersdk/cleanup.go new file mode 100644 index 0000000000000..8f940546cb05c --- /dev/null +++ b/provisionersdk/cleanup.go @@ -0,0 +1,53 @@ +package provisionersdk + +import ( + "context" + "path/filepath" + "time" + + "github.com/djherbis/times" + "github.com/spf13/afero" + "golang.org/x/xerrors" + + "cdr.dev/slog" +) + +// CleanStaleSessions browses the work directory searching for stale session +// directories. Coder provisioner is supposed to remove them once after finishing the provisioning, +// but there is a risk of keeping them in case of a failure. +func CleanStaleSessions(ctx context.Context, workDirectory string, fs afero.Fs, now time.Time, logger slog.Logger) error { + entries, err := afero.ReadDir(fs, workDirectory) + if err != nil { + return xerrors.Errorf("can't read %q directory", workDirectory) + } + + for _, fi := range entries { + dirName := fi.Name() + + if fi.IsDir() && isValidSessionDir(dirName) { + sessionDirPath := filepath.Join(workDirectory, dirName) + + accessTime := fi.ModTime() // fallback to modTime if accessTime is not available (afero) + if fi.Sys() != nil { + timeSpec := times.Get(fi) + accessTime = timeSpec.AccessTime() + } + + if accessTime.Add(staleSessionRetention).After(now) { + continue + } + + logger.Info(ctx, "remove stale session directory", slog.F("session_path", sessionDirPath)) + err = fs.RemoveAll(sessionDirPath) + if err != nil { + return xerrors.Errorf("can't remove %q directory: %w", sessionDirPath, err) + } + } + } + return nil +} + +func isValidSessionDir(dirName string) bool { + match, err := filepath.Match(sessionDirPrefix+"*", dirName) + return err == nil && match +} diff --git a/provisionersdk/cleanup_test.go b/provisionersdk/cleanup_test.go new file mode 100644 index 0000000000000..cf0296cb05927 --- /dev/null +++ b/provisionersdk/cleanup_test.go @@ -0,0 +1,112 @@ +package provisionersdk_test + +import ( + "context" + "path/filepath" + "testing" + "time" + + "github.com/google/uuid" + "github.com/spf13/afero" + "github.com/stretchr/testify/require" + + "cdr.dev/slog" + "cdr.dev/slog/sloggers/slogtest" + + "github.com/coder/coder/v2/provisionersdk" + "github.com/coder/coder/v2/testutil" +) + +const workDirectory = "/tmp/coder/provisioner-34/work" + +func TestStaleSessions(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 sessions are stale", func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + defer cancel() + + fs, now, logger := prepare() + + // given + first := provisionersdk.SessionDir(uuid.NewString()) + addSessionFolder(t, fs, first, now.Add(-7*24*time.Hour)) + second := provisionersdk.SessionDir(uuid.NewString()) + addSessionFolder(t, fs, second, now.Add(-8*24*time.Hour)) + third := provisionersdk.SessionDir(uuid.NewString()) + addSessionFolder(t, fs, third, now.Add(-9*24*time.Hour)) + + // when + provisionersdk.CleanStaleSessions(ctx, workDirectory, fs, now, logger) + + // then + entries, err := afero.ReadDir(fs, workDirectory) + require.NoError(t, err) + require.Empty(t, entries, "all session leftovers should be removed") + }) + + t.Run("one session is stale", func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + defer cancel() + + fs, now, logger := prepare() + + // given + first := provisionersdk.SessionDir(uuid.NewString()) + addSessionFolder(t, fs, first, now.Add(-7*24*time.Hour)) + second := provisionersdk.SessionDir(uuid.NewString()) + addSessionFolder(t, fs, second, now.Add(-6*24*time.Hour)) + + // when + provisionersdk.CleanStaleSessions(ctx, workDirectory, fs, now, logger) + + // then + entries, err := afero.ReadDir(fs, workDirectory) + require.NoError(t, err) + require.Len(t, entries, 1, "one session should be present") + require.Equal(t, second, entries[0].Name(), 1) + }) + + t.Run("no stale sessions", func(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort) + defer cancel() + + fs, now, logger := prepare() + + // given + first := provisionersdk.SessionDir(uuid.NewString()) + addSessionFolder(t, fs, first, now.Add(-6*24*time.Hour)) + second := provisionersdk.SessionDir(uuid.NewString()) + addSessionFolder(t, fs, second, now.Add(-5*24*time.Hour)) + + // when + provisionersdk.CleanStaleSessions(ctx, workDirectory, fs, now, logger) + + // then + entries, err := afero.ReadDir(fs, workDirectory) + require.NoError(t, err) + require.Len(t, entries, 2, "both sessions should be present") + }) +} + +func addSessionFolder(t *testing.T, fs afero.Fs, sessionName string, accessTime time.Time) { + err := fs.MkdirAll(filepath.Join(workDirectory, sessionName), 0o755) + require.NoError(t, err, "can't create session folder") + fs.Chtimes(filepath.Join(workDirectory, sessionName), accessTime, accessTime) + require.NoError(t, err, "can't set times") +} diff --git a/provisionersdk/session.go b/provisionersdk/session.go index dfcd981ce77f5..218807d97e884 100644 --- a/provisionersdk/session.go +++ b/provisionersdk/session.go @@ -12,15 +12,21 @@ import ( "time" "github.com/google/uuid" + "github.com/spf13/afero" "golang.org/x/xerrors" "cdr.dev/slog" + "github.com/coder/coder/v2/provisionersdk/proto" ) -// ReadmeFile is the location we look for to extract documentation from template -// versions. -const ReadmeFile = "README.md" +const ( + // ReadmeFile is the location we look for to extract documentation from template versions. + ReadmeFile = "README.md" + + sessionDirPrefix = "Session" + staleSessionRetention = 7 * 24 * time.Hour +) // protoServer is a wrapper that translates the dRPC protocol into a Session with method calls into the Server. type protoServer struct { @@ -35,9 +41,14 @@ func (p *protoServer) Session(stream proto.DRPCProvisioner_SessionStream) error stream: stream, server: p.server, } - sessDir := fmt.Sprintf("Session%s", sessID) - s.WorkDirectory = filepath.Join(p.opts.WorkDirectory, sessDir) - err := os.MkdirAll(s.WorkDirectory, 0o700) + + err := CleanStaleSessions(s.Context(), p.opts.WorkDirectory, afero.NewOsFs(), time.Now(), s.Logger) + if err != nil { + return xerrors.Errorf("unable to clean stale sessions %q: %w", s.WorkDirectory, err) + } + + s.WorkDirectory = filepath.Join(p.opts.WorkDirectory, SessionDir(sessID)) + err = os.MkdirAll(s.WorkDirectory, 0o700) if err != nil { return xerrors.Errorf("create work directory %q: %w", s.WorkDirectory, err) } @@ -316,3 +327,8 @@ func (r *request[R, C]) do() (C, error) { return c, nil } } + +// SessionDir returns the directory name with mandatory prefix. +func SessionDir(sessID string) string { + return sessionDirPrefix + sessID +}