feat: clean stale provisioner files (#9545)

This commit is contained in:
Marcin Tojek 2023-09-11 09:37:14 +02:00 committed by GitHub
parent d055f93706
commit 67fe3ae8d6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 594 additions and 9 deletions

View File

@ -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 "$@"

2
go.mod
View File

@ -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

2
go.sum
View File

@ -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=

View File

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

View File

@ -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()
}

View File

@ -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)

View File

@ -0,0 +1,5 @@
/ d
/tmp d
/tmp/coder d
/tmp/coder/provisioner-0 d
/tmp/coder/provisioner-0/tf d

View File

@ -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

View File

@ -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

View File

@ -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

53
provisionersdk/cleanup.go Normal file
View File

@ -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
}

View File

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

View File

@ -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
}