mirror of
https://github.com/project-zot/zot.git
synced 2026-06-15 11:37:56 +08:00
da426850e7
* chore: Update golangci-lint Signed-off-by: Lars Francke <git@lars-francke.de> * chore: fix all golangci-lint issues - Remove deprecated `// +build` tags - Fix godoclint, modernize, wsl_v5, govet, lll, gci, noctx issues - Update linter configuration - Modernize code to use Go 1.22+ features (for range N, slices.Contains, etc.) - Update make check lint the privileged tests Signed-off-by: Andrei Aaron <andreifdaaron@gmail.com> --------- Signed-off-by: Lars Francke <git@lars-francke.de> Signed-off-by: Andrei Aaron <andreifdaaron@gmail.com> Co-authored-by: Lars Francke <git@lars-francke.de>
1745 lines
61 KiB
Go
1745 lines
61 KiB
Go
package server_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/alicebob/miniredis/v2"
|
|
goredis "github.com/redis/go-redis/v9"
|
|
. "github.com/smartystreets/goconvey/convey"
|
|
|
|
zerr "zotregistry.dev/zot/v2/errors"
|
|
"zotregistry.dev/zot/v2/pkg/api"
|
|
"zotregistry.dev/zot/v2/pkg/api/config"
|
|
cli "zotregistry.dev/zot/v2/pkg/cli/server"
|
|
"zotregistry.dev/zot/v2/pkg/extensions/monitoring"
|
|
zlog "zotregistry.dev/zot/v2/pkg/log"
|
|
"zotregistry.dev/zot/v2/pkg/meta"
|
|
"zotregistry.dev/zot/v2/pkg/meta/boltdb"
|
|
"zotregistry.dev/zot/v2/pkg/meta/redis"
|
|
"zotregistry.dev/zot/v2/pkg/storage"
|
|
"zotregistry.dev/zot/v2/pkg/storage/local"
|
|
storageTypes "zotregistry.dev/zot/v2/pkg/storage/types"
|
|
. "zotregistry.dev/zot/v2/pkg/test/common"
|
|
. "zotregistry.dev/zot/v2/pkg/test/image-utils"
|
|
)
|
|
|
|
const (
|
|
decisionKeep = "keep"
|
|
decisionDelete = "delete"
|
|
retentionTestRepo = "retention-test-repo"
|
|
retentionTestRepoSubpath = "a/retention-test-repo"
|
|
testGCDelay = "1ms"
|
|
)
|
|
|
|
func TestRetentionCheckNegative(t *testing.T) {
|
|
oldArgs := os.Args
|
|
|
|
defer func() { os.Args = oldArgs }()
|
|
|
|
Convey("Test verify-feature retention no args", t, func(c C) {
|
|
os.Args = []string{"cli_test", "verify-feature", "retention"}
|
|
err := cli.NewServerRootCmd().Execute()
|
|
So(err, ShouldNotBeNil)
|
|
})
|
|
|
|
Convey("non-existent config", t, func(c C) {
|
|
os.Args = []string{"cli_test", "verify-feature", "retention", path.Join(os.TempDir(), "/x.yaml")}
|
|
err := cli.NewServerRootCmd().Execute()
|
|
So(err, ShouldNotBeNil)
|
|
})
|
|
|
|
Convey("unknown config", t, func(c C) {
|
|
os.Args = []string{"cli_test", "verify-feature", "retention", path.Join(os.TempDir(), "/x")}
|
|
err := cli.NewServerRootCmd().Execute()
|
|
So(err, ShouldNotBeNil)
|
|
})
|
|
|
|
Convey("bad config", t, func(c C) {
|
|
testDir := t.TempDir()
|
|
configFile := path.Join(testDir, "zot-config.json")
|
|
|
|
content := []byte(`{"log":{}}`)
|
|
err := os.WriteFile(configFile, content, 0o600)
|
|
So(err, ShouldBeNil)
|
|
|
|
os.Args = []string{"cli_test", "verify-feature", "retention", "-t", "30s", configFile}
|
|
err = cli.NewServerRootCmd().Execute()
|
|
So(err, ShouldNotBeNil)
|
|
})
|
|
|
|
Convey("config with GC disabled", t, func(c C) {
|
|
testDir := t.TempDir()
|
|
configFile := path.Join(testDir, "zot-config.json")
|
|
logFile := path.Join(testDir, "retention-check.log")
|
|
port := GetFreePort()
|
|
|
|
content := fmt.Appendf([]byte{}, `{
|
|
"distSpecVersion": "1.1.1",
|
|
"storage": {
|
|
"rootDirectory": "%s",
|
|
"gc": false
|
|
},
|
|
"http": {
|
|
"address": "127.0.0.1",
|
|
"port": "%s"
|
|
}
|
|
}`, testDir, port)
|
|
err := os.WriteFile(configFile, content, 0o600)
|
|
So(err, ShouldBeNil)
|
|
|
|
os.Args = []string{"cli_test", "verify-feature", "retention", "-l", logFile, "-t", "30s", configFile}
|
|
err = cli.NewServerRootCmd().Execute()
|
|
|
|
// Verify the specific error
|
|
So(err, ShouldNotBeNil)
|
|
So(err.Error(), ShouldEqual,
|
|
fmt.Sprintf("%s: %s", zerr.ErrBadConfig.Error(), "verify-feature retention requires GC to be enabled"))
|
|
|
|
// Verify error message is logged to the log file
|
|
logContent, err := os.ReadFile(logFile)
|
|
So(err, ShouldBeNil)
|
|
logStr := string(logContent)
|
|
So(logStr, ShouldContainSubstring,
|
|
"failed to run verify-feature retention, garbage collection is disabled in config")
|
|
})
|
|
|
|
Convey("server is running", t, func(c C) {
|
|
port := GetFreePort()
|
|
config := config.New()
|
|
config.HTTP.Port = port
|
|
controller := api.NewController(config)
|
|
|
|
testDir := t.TempDir()
|
|
storageDir := path.Join(testDir, "storage")
|
|
configFile := path.Join(testDir, "zot-config.json")
|
|
logFile := path.Join(testDir, "retention-check.log")
|
|
|
|
controller.Config.Storage.RootDirectory = storageDir
|
|
controller.Config.Storage.GC = true
|
|
ctrlManager := NewControllerManager(controller)
|
|
ctrlManager.StartAndWait(port)
|
|
|
|
defer ctrlManager.StopServer()
|
|
|
|
content := fmt.Appendf([]byte{}, `{
|
|
"storage": {
|
|
"rootDirectory": "%s",
|
|
"gc": true,
|
|
"retention": {
|
|
"delay": "1ms",
|
|
"policies": [
|
|
{
|
|
"repositories": ["**"],
|
|
"keepTags": [
|
|
{
|
|
"patterns": [".*"],
|
|
"mostRecentlyPulledCount": 5
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
},
|
|
"http": {
|
|
"port": %s
|
|
},
|
|
"log": {
|
|
"level": "debug"
|
|
}
|
|
}
|
|
`, storageDir, port)
|
|
err := os.WriteFile(configFile, content, 0o600)
|
|
So(err, ShouldBeNil)
|
|
|
|
os.Args = []string{"cli_test", "verify-feature", "retention", "-l", logFile, "-t", "30s", configFile}
|
|
err = cli.NewServerRootCmd().Execute()
|
|
So(err, ShouldNotBeNil)
|
|
So(err, ShouldEqual, zerr.ErrServerIsRunning)
|
|
|
|
// Verify warning messages are logged to the log file
|
|
logContent, err := os.ReadFile(logFile)
|
|
So(err, ShouldBeNil)
|
|
So(string(logContent), ShouldContainSubstring,
|
|
"local storage detected - the zot server must be stopped to access the storage database")
|
|
So(string(logContent), ShouldContainSubstring,
|
|
"server is running, in order to perform the verify-feature retention command the server should be shut down")
|
|
})
|
|
|
|
Convey("invalid log-file flag", t, func(c C) {
|
|
testCases := []struct {
|
|
name string
|
|
logFile string
|
|
}{
|
|
{"invalid log file path (parent directory doesn't exist)", "/invalid/directory/logfile.log"},
|
|
{"invalid log file path (null bytes)", "logfile\x00.log"},
|
|
}
|
|
|
|
for _, testCase := range testCases {
|
|
Convey(testCase.name, func() {
|
|
testDir := t.TempDir()
|
|
configFile := path.Join(testDir, "zot-config.json")
|
|
port := GetFreePort()
|
|
|
|
content := fmt.Appendf([]byte{}, `{
|
|
"distSpecVersion": "1.1.1",
|
|
"storage": {
|
|
"rootDirectory": "%s",
|
|
"gc": true
|
|
},
|
|
"http": {
|
|
"address": "127.0.0.1",
|
|
"port": "%s"
|
|
}
|
|
}`, testDir, port)
|
|
err := os.WriteFile(configFile, content, 0o600)
|
|
So(err, ShouldBeNil)
|
|
|
|
os.Args = []string{"cli_test", "verify-feature", "retention", "-l", testCase.logFile, "-t", "30s", configFile}
|
|
// This panics during logger initialization due to invalid log file location
|
|
So(func() {
|
|
_ = cli.NewServerRootCmd().Execute()
|
|
}, ShouldPanic)
|
|
})
|
|
}
|
|
})
|
|
|
|
Convey("invalid duration flags", t, func(c C) {
|
|
testCases := []struct {
|
|
name string
|
|
flag string
|
|
flagValue string
|
|
}{
|
|
{"invalid gc-interval flag", "-i", "invalid-duration"},
|
|
{"invalid timeout flag", "-t", "invalid-duration"},
|
|
}
|
|
|
|
for _, testCase := range testCases {
|
|
Convey(testCase.name, func() {
|
|
testDir := t.TempDir()
|
|
configFile := path.Join(testDir, "zot-config.json")
|
|
logFile := path.Join(testDir, "retention-check.log")
|
|
port := GetFreePort()
|
|
|
|
content := fmt.Appendf([]byte{}, `{
|
|
"distSpecVersion": "1.1.1",
|
|
"storage": {
|
|
"rootDirectory": "%s",
|
|
"gc": true
|
|
},
|
|
"http": {
|
|
"address": "127.0.0.1",
|
|
"port": "%s"
|
|
}
|
|
}`, testDir, port)
|
|
err := os.WriteFile(configFile, content, 0o600)
|
|
So(err, ShouldBeNil)
|
|
|
|
args := []string{
|
|
"cli_test", "verify-feature", "retention", "-l", logFile,
|
|
testCase.flag, testCase.flagValue,
|
|
}
|
|
|
|
if testCase.flag == "-i" {
|
|
args = append(args, "-t", "30s")
|
|
}
|
|
|
|
args = append(args, configFile)
|
|
os.Args = args
|
|
|
|
err = cli.NewServerRootCmd().Execute()
|
|
// Flag parsing should fail before reaching RunE
|
|
So(err, ShouldNotBeNil)
|
|
So(err.Error(), ShouldContainSubstring, "invalid duration")
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestRetentionCheckWithRetentionEnabledAndRedisDriver(t *testing.T) {
|
|
oldArgs := os.Args
|
|
|
|
defer func() { os.Args = oldArgs }()
|
|
|
|
Convey("server is running with Redis driver", t, func(c C) {
|
|
miniRedis := miniredis.RunT(t)
|
|
port := GetFreePort()
|
|
testDir := t.TempDir()
|
|
storageDir := path.Join(testDir, "storage")
|
|
configFile := path.Join(testDir, "zot-config.json")
|
|
logFile := path.Join(testDir, "retention-check.log")
|
|
|
|
content := fmt.Appendf([]byte{}, `{
|
|
"distSpecVersion": "1.1.1",
|
|
"storage": {
|
|
"rootDirectory": "%s",
|
|
"gc": true,
|
|
"remoteCache": true,
|
|
"gcDelay": %q,
|
|
"gcInterval": "1m",
|
|
"cacheDriver": {
|
|
"name": "redis",
|
|
"url": "redis://%s"
|
|
},
|
|
"retention": {
|
|
"delay": "1ms",
|
|
"policies": [
|
|
{
|
|
"repositories": ["**"],
|
|
"keepTags": [
|
|
{
|
|
"patterns": [".*"],
|
|
"mostRecentlyPulledCount": 2
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
},
|
|
"http": {
|
|
"address": "127.0.0.1",
|
|
"port": "%s"
|
|
},
|
|
"log": {
|
|
"level": "debug"
|
|
}
|
|
}
|
|
`, storageDir, testGCDelay, miniRedis.Addr(), port)
|
|
err := os.WriteFile(configFile, content, 0o600)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Create complex image setup before running verify-feature retention
|
|
conf := config.New()
|
|
err = cli.LoadConfiguration(conf, configFile)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Initialize storage and metaDB using the same approach as gc tests
|
|
metricsServer := monitoring.NewMetricsServer(false, zlog.NewLogger("info", ""))
|
|
// Create ImageStore directly (like gc tests)
|
|
imgStore := local.NewImageStore(storageDir, false, false, zlog.NewLogger("info", ""), metricsServer,
|
|
nil, nil, nil, nil)
|
|
// Initialize metaDB with Redis
|
|
redisClient := goredis.NewClient(&goredis.Options{
|
|
Addr: miniRedis.Addr(),
|
|
})
|
|
params := redis.DBDriverParameters{KeyPrefix: "zot"}
|
|
metaDB, err := redis.New(redisClient, params, zlog.NewLogger("info", ""))
|
|
So(err, ShouldBeNil)
|
|
// Create store controller
|
|
storeController := storage.StoreController{}
|
|
storeController.DefaultStore = imgStore
|
|
err = meta.ParseStorage(metaDB, storeController, zlog.NewLogger("info", ""))
|
|
So(err, ShouldBeNil)
|
|
|
|
// Create test repositories with different image types for retention testing
|
|
// Repository 1: Multiple tagged images (some old, some recent)
|
|
repo1 := retentionTestRepo
|
|
|
|
// Old image (should be deleted by retention - keeping only 2 most recent)
|
|
oldImage := CreateRandomImage()
|
|
err = WriteImageToFileSystem(oldImage, repo1, "old-tag", storeController)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Recent images (should be kept)
|
|
recentImage1 := CreateRandomImage()
|
|
err = WriteImageToFileSystem(recentImage1, repo1, "recent-tag-1", storeController)
|
|
So(err, ShouldBeNil)
|
|
|
|
recentImage2 := CreateRandomImage()
|
|
err = WriteImageToFileSystem(recentImage2, repo1, "recent-tag-2", storeController)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Multiarch image
|
|
multiarchImage := CreateRandomMultiarch()
|
|
err = WriteMultiArchImageToFileSystem(multiarchImage, repo1, "multiarch-tag", storeController)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Untagged image (should be cleaned up by GC)
|
|
untaggedImage := CreateRandomImage()
|
|
err = WriteImageToFileSystem(untaggedImage, repo1, untaggedImage.DigestStr(), storeController)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Repository 2: Referrers
|
|
repo2 := "referrer-test-repo"
|
|
|
|
// Base image
|
|
baseImage := CreateRandomImage()
|
|
err = WriteImageToFileSystem(baseImage, repo2, "base-tag", storeController)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Referrer pointing to base image
|
|
referrer := CreateRandomImageWith().Subject(baseImage.DescriptorRef()).Build()
|
|
err = WriteImageToFileSystem(referrer, repo2, referrer.DigestStr(), storeController)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Referrer pointing to non-existent subject (should be deleted)
|
|
nonExistentSubject := CreateRandomImage() // Create but don't write to storage
|
|
referrerWithInvalidSubject := CreateRandomImageWith().Subject(nonExistentSubject.DescriptorRef()).Build()
|
|
err = WriteImageToFileSystem(referrerWithInvalidSubject, repo2,
|
|
referrerWithInvalidSubject.DigestStr(), storeController)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Re-parse storage after creating images to update metadata
|
|
err = meta.ParseStorage(metaDB, storeController, zlog.NewLogger("info", ""))
|
|
So(err, ShouldBeNil)
|
|
|
|
// Update metadata with timestamps for retention testing
|
|
// Set old timestamps for images that should be deleted
|
|
repoMeta1, err := metaDB.GetRepoMeta(context.Background(), repo1)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Old images (should be deleted by retention - keeping only 2 most recent)
|
|
oldImageStats := repoMeta1.Statistics[oldImage.DigestStr()]
|
|
oldImageStats.PushTimestamp = time.Now().Add(-10 * 24 * time.Hour)
|
|
oldImageStats.LastPullTimestamp = time.Now().Add(-10 * 24 * time.Hour)
|
|
repoMeta1.Statistics[oldImage.DigestStr()] = oldImageStats
|
|
|
|
// Recent images (should be kept)
|
|
recentImage1Stats := repoMeta1.Statistics[recentImage1.DigestStr()]
|
|
recentImage1Stats.PushTimestamp = time.Now().Add(-1 * 24 * time.Hour)
|
|
recentImage1Stats.LastPullTimestamp = time.Now().Add(-1 * 24 * time.Hour)
|
|
repoMeta1.Statistics[recentImage1.DigestStr()] = recentImage1Stats
|
|
|
|
recentImage2Stats := repoMeta1.Statistics[recentImage2.DigestStr()]
|
|
recentImage2Stats.PushTimestamp = time.Now().Add(-2 * 24 * time.Hour)
|
|
recentImage2Stats.LastPullTimestamp = time.Now().Add(-2 * 24 * time.Hour)
|
|
repoMeta1.Statistics[recentImage2.DigestStr()] = recentImage2Stats
|
|
|
|
multiarchStats := repoMeta1.Statistics[multiarchImage.DigestStr()]
|
|
multiarchStats.PushTimestamp = time.Now().Add(-3 * 24 * time.Hour)
|
|
multiarchStats.LastPullTimestamp = time.Now().Add(-3 * 24 * time.Hour)
|
|
repoMeta1.Statistics[multiarchImage.DigestStr()] = multiarchStats
|
|
|
|
err = metaDB.SetRepoMeta(repo1, repoMeta1)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Set timestamps for referrer repo
|
|
repoMeta2, err := metaDB.GetRepoMeta(context.Background(), repo2)
|
|
So(err, ShouldBeNil)
|
|
|
|
baseImageStats := repoMeta2.Statistics[baseImage.DigestStr()]
|
|
baseImageStats.PushTimestamp = time.Now().Add(-5 * 24 * time.Hour)
|
|
baseImageStats.LastPullTimestamp = time.Now().Add(-5 * 24 * time.Hour)
|
|
repoMeta2.Statistics[baseImage.DigestStr()] = baseImageStats
|
|
|
|
referrerStats := repoMeta2.Statistics[referrer.DigestStr()]
|
|
referrerStats.PushTimestamp = time.Now().Add(-4 * 24 * time.Hour)
|
|
referrerStats.LastPullTimestamp = time.Now().Add(-4 * 24 * time.Hour)
|
|
repoMeta2.Statistics[referrer.DigestStr()] = referrerStats
|
|
|
|
err = metaDB.SetRepoMeta(repo2, repoMeta2)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Close metaDB to release database lock before running verify-feature retention
|
|
err = metaDB.Close()
|
|
So(err, ShouldBeNil)
|
|
|
|
gcDelay, _ := time.ParseDuration(testGCDelay)
|
|
time.Sleep(gcDelay + 50*time.Millisecond) // wait for GC delay to pass
|
|
|
|
// Start a controller using the same config to test running verify-feature retention while server is running
|
|
controller := api.NewController(conf)
|
|
ctrlManager := NewControllerManager(controller)
|
|
ctrlManager.StartAndWait(port)
|
|
|
|
defer ctrlManager.StopServer()
|
|
|
|
os.Args = []string{"cli_test", "verify-feature", "retention", "-l", logFile, "-t", "1s", configFile}
|
|
err = cli.NewServerRootCmd().Execute()
|
|
So(err, ShouldBeNil)
|
|
|
|
// Verify success messages are logged to the log file
|
|
logContent, err := os.ReadFile(logFile)
|
|
So(err, ShouldBeNil)
|
|
logStr := string(logContent)
|
|
|
|
// Dump log content to stdout on test failure
|
|
defer func() {
|
|
if t.Failed() {
|
|
t.Logf("Retention check log content:\n%s", logStr)
|
|
}
|
|
}()
|
|
|
|
// Verify basic verify-feature retention and GC messages
|
|
So(logStr, ShouldContainSubstring, "configuration settings (after applying overrides)")
|
|
// Verify GC configuration values are present in the log
|
|
So(logStr, ShouldContainSubstring, "\"GCInterval\":60000000000") // 1m = 60s in nanoseconds
|
|
So(logStr, ShouldContainSubstring, "\"GCDelay\":1000000") // 1ms in nanoseconds
|
|
So(logStr, ShouldContainSubstring, "\"GCMaxSchedulerDelay\":5000000") // 5ms
|
|
So(logStr, ShouldContainSubstring,
|
|
"garbage collection and retention tasks will be submitted to the scheduler")
|
|
So(logStr, ShouldContainSubstring, "waiting for garbage collection tasks to complete...")
|
|
So(logStr, ShouldContainSubstring, "executing gc of orphaned blobs")
|
|
So(logStr, ShouldContainSubstring, "garbage collected blobs")
|
|
So(logStr, ShouldContainSubstring, "gc successfully completed")
|
|
So(logStr, ShouldContainSubstring, "retention check completed successfully")
|
|
|
|
// No need to build expectedResults - we only need counts for concurrent scenario
|
|
|
|
// In concurrent scenarios (controller + verify-feature retention running together),
|
|
// we just verify that the command completes successfully. The actual retention
|
|
// policy validation is tested in the non-concurrent test cases.
|
|
actualDecisions := parseRetentionDecisions([]byte(logStr))
|
|
|
|
// Count KEEP decisions to verify tag retention policies work
|
|
keepCount := 0
|
|
|
|
for _, decision := range actualDecisions {
|
|
if decision.Decision == decisionKeep {
|
|
keepCount++
|
|
}
|
|
}
|
|
|
|
// Validate KEEP decisions exactly (base-tag, recent-tag-1, recent-tag-2)
|
|
So(keepCount, ShouldEqual, 3)
|
|
})
|
|
}
|
|
|
|
func TestRetentionCheckWithRetentionEnabled(t *testing.T) {
|
|
oldArgs := os.Args
|
|
|
|
defer func() { os.Args = oldArgs }()
|
|
|
|
Convey("valid config with retention enabled", t, func(c C) {
|
|
port := GetFreePort()
|
|
testDir := t.TempDir()
|
|
storageDir := path.Join(testDir, "storage")
|
|
configFile := path.Join(testDir, "zot-config.json")
|
|
logFile := path.Join(testDir, "retention-check.log")
|
|
|
|
content := fmt.Appendf([]byte{}, `{
|
|
"distSpecVersion": "1.1.1",
|
|
"storage": {
|
|
"rootDirectory": "%s",
|
|
"gc": true,
|
|
"gcDelay": %q,
|
|
"gcInterval": "1m",
|
|
"retention": {
|
|
"delay": "1ms",
|
|
"policies": [
|
|
{
|
|
"repositories": ["**"],
|
|
"keepTags": [
|
|
{
|
|
"patterns": [".*"],
|
|
"mostRecentlyPulledCount": 2
|
|
}
|
|
]
|
|
}
|
|
]
|
|
}
|
|
},
|
|
"http": {
|
|
"address": "127.0.0.1",
|
|
"port": "%s"
|
|
},
|
|
"log": {
|
|
"level": "debug"
|
|
}
|
|
}
|
|
`, storageDir, testGCDelay, port)
|
|
err := os.WriteFile(configFile, content, 0o600)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Create complex image setup before running verify-feature retention
|
|
conf := config.New()
|
|
err = cli.LoadConfiguration(conf, configFile)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Initialize storage and metaDB using the same approach as gc tests
|
|
metricsServer := monitoring.NewMetricsServer(false, zlog.NewLogger("info", ""))
|
|
// Create ImageStore directly (like gc tests)
|
|
imgStore := local.NewImageStore(storageDir, false, false, zlog.NewLogger("info", ""), metricsServer,
|
|
nil, nil, nil, nil)
|
|
// Initialize metaDB directly (like gc tests)
|
|
params := boltdb.DBParameters{
|
|
RootDir: storageDir,
|
|
}
|
|
boltDriver, err := boltdb.GetBoltDriver(params)
|
|
So(err, ShouldBeNil)
|
|
metaDB, err := boltdb.New(boltDriver, zlog.NewLogger("info", ""))
|
|
So(err, ShouldBeNil)
|
|
// Create store controller
|
|
storeController := storage.StoreController{}
|
|
storeController.DefaultStore = imgStore
|
|
err = meta.ParseStorage(metaDB, storeController, zlog.NewLogger("info", ""))
|
|
So(err, ShouldBeNil)
|
|
|
|
// Create test repositories with different image types for retention testing
|
|
// Repository 1: Multiple tagged images (some old, some recent)
|
|
repo1 := retentionTestRepo
|
|
|
|
// Old images (should be deleted by retention - keeping only 2 most recent)
|
|
oldImage1 := CreateRandomImage()
|
|
err = WriteImageToFileSystem(oldImage1, repo1, "old-tag-1", storeController)
|
|
So(err, ShouldBeNil)
|
|
|
|
oldImage2 := CreateRandomImage()
|
|
err = WriteImageToFileSystem(oldImage2, repo1, "old-tag-2", storeController)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Recent images (should be kept)
|
|
recentImage1 := CreateRandomImage()
|
|
err = WriteImageToFileSystem(recentImage1, repo1, "recent-tag-1", storeController)
|
|
So(err, ShouldBeNil)
|
|
|
|
recentImage2 := CreateRandomImage()
|
|
err = WriteImageToFileSystem(recentImage2, repo1, "recent-tag-2", storeController)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Multiarch image
|
|
multiarchImage := CreateRandomMultiarch()
|
|
err = WriteMultiArchImageToFileSystem(multiarchImage, repo1, "multiarch-tag", storeController)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Untagged images (should be cleaned up by GC)
|
|
untaggedImage1 := CreateRandomImage()
|
|
err = WriteImageToFileSystem(untaggedImage1, repo1, untaggedImage1.DigestStr(), storeController)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Repository 2: Referrers and referrers of referrers
|
|
repo2 := "referrer-test-repo"
|
|
|
|
// Base image
|
|
baseImage := CreateRandomImage()
|
|
err = WriteImageToFileSystem(baseImage, repo2, "base-tag", storeController)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Referrer pointing to base image
|
|
referrer1 := CreateRandomImageWith().Subject(baseImage.DescriptorRef()).Build()
|
|
err = WriteImageToFileSystem(referrer1, repo2, referrer1.DigestStr(), storeController)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Referrer pointing to referrer
|
|
referrerOfReferrer := CreateRandomImageWith().Subject(referrer1.DescriptorRef()).Build()
|
|
err = WriteImageToFileSystem(referrerOfReferrer, repo2, referrerOfReferrer.DigestStr(), storeController)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Referrer pointing to non-existent subject (should be deleted)
|
|
nonExistentSubject := CreateRandomImage() // Create but don't write to storage
|
|
referrerWithInvalidSubject := CreateRandomImageWith().Subject(nonExistentSubject.DescriptorRef()).Build()
|
|
err = WriteImageToFileSystem(referrerWithInvalidSubject, repo2,
|
|
referrerWithInvalidSubject.DigestStr(), storeController)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Re-parse storage after creating images to update metadata
|
|
err = meta.ParseStorage(metaDB, storeController, zlog.NewLogger("info", ""))
|
|
So(err, ShouldBeNil)
|
|
|
|
// Update metadata with timestamps for retention testing
|
|
// Set old timestamps for images that should be deleted
|
|
repoMeta1, err := metaDB.GetRepoMeta(context.Background(), repo1)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Old images (should be deleted by retention - keeping only 2 most recent)
|
|
oldImage1Stats := repoMeta1.Statistics[oldImage1.DigestStr()]
|
|
oldImage1Stats.PushTimestamp = time.Now().Add(-10 * 24 * time.Hour)
|
|
oldImage1Stats.LastPullTimestamp = time.Now().Add(-10 * 24 * time.Hour)
|
|
repoMeta1.Statistics[oldImage1.DigestStr()] = oldImage1Stats
|
|
|
|
oldImage2Stats := repoMeta1.Statistics[oldImage2.DigestStr()]
|
|
oldImage2Stats.PushTimestamp = time.Now().Add(-11 * 24 * time.Hour)
|
|
oldImage2Stats.LastPullTimestamp = time.Now().Add(-11 * 24 * time.Hour)
|
|
repoMeta1.Statistics[oldImage2.DigestStr()] = oldImage2Stats
|
|
|
|
// Recent images (should be kept)
|
|
recentImage1Stats := repoMeta1.Statistics[recentImage1.DigestStr()]
|
|
recentImage1Stats.PushTimestamp = time.Now().Add(-1 * 24 * time.Hour)
|
|
recentImage1Stats.LastPullTimestamp = time.Now().Add(-1 * 24 * time.Hour)
|
|
repoMeta1.Statistics[recentImage1.DigestStr()] = recentImage1Stats
|
|
|
|
recentImage2Stats := repoMeta1.Statistics[recentImage2.DigestStr()]
|
|
recentImage2Stats.PushTimestamp = time.Now().Add(-2 * 24 * time.Hour)
|
|
recentImage2Stats.LastPullTimestamp = time.Now().Add(-2 * 24 * time.Hour)
|
|
repoMeta1.Statistics[recentImage2.DigestStr()] = recentImage2Stats
|
|
|
|
multiarchStats := repoMeta1.Statistics[multiarchImage.DigestStr()]
|
|
multiarchStats.PushTimestamp = time.Now().Add(-3 * 24 * time.Hour)
|
|
multiarchStats.LastPullTimestamp = time.Now().Add(-3 * 24 * time.Hour)
|
|
repoMeta1.Statistics[multiarchImage.DigestStr()] = multiarchStats
|
|
|
|
err = metaDB.SetRepoMeta(repo1, repoMeta1)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Set timestamps for referrer repo
|
|
repoMeta2, err := metaDB.GetRepoMeta(context.Background(), repo2)
|
|
So(err, ShouldBeNil)
|
|
|
|
baseImageStats := repoMeta2.Statistics[baseImage.DigestStr()]
|
|
baseImageStats.PushTimestamp = time.Now().Add(-5 * 24 * time.Hour)
|
|
baseImageStats.LastPullTimestamp = time.Now().Add(-5 * 24 * time.Hour)
|
|
repoMeta2.Statistics[baseImage.DigestStr()] = baseImageStats
|
|
|
|
referrer1Stats := repoMeta2.Statistics[referrer1.DigestStr()]
|
|
referrer1Stats.PushTimestamp = time.Now().Add(-4 * 24 * time.Hour)
|
|
referrer1Stats.LastPullTimestamp = time.Now().Add(-4 * 24 * time.Hour)
|
|
repoMeta2.Statistics[referrer1.DigestStr()] = referrer1Stats
|
|
|
|
referrerOfReferrerStats := repoMeta2.Statistics[referrerOfReferrer.DigestStr()]
|
|
referrerOfReferrerStats.PushTimestamp = time.Now().Add(-3 * 24 * time.Hour)
|
|
referrerOfReferrerStats.LastPullTimestamp = time.Now().Add(-3 * 24 * time.Hour)
|
|
repoMeta2.Statistics[referrerOfReferrer.DigestStr()] = referrerOfReferrerStats
|
|
|
|
err = metaDB.SetRepoMeta(repo2, repoMeta2)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Close metaDB to release database lock before running verify-feature retention
|
|
err = metaDB.Close()
|
|
So(err, ShouldBeNil)
|
|
|
|
gcDelay, _ := time.ParseDuration(testGCDelay)
|
|
time.Sleep(gcDelay + 50*time.Millisecond) // wait for GC delay to pass
|
|
|
|
os.Args = []string{"cli_test", "verify-feature", "retention", "-l", logFile, "-t", "1s", configFile}
|
|
err = cli.NewServerRootCmd().Execute()
|
|
So(err, ShouldBeNil)
|
|
|
|
// Verify success messages are logged to the log file
|
|
logContent, err := os.ReadFile(logFile)
|
|
So(err, ShouldBeNil)
|
|
logStr := string(logContent)
|
|
|
|
// Dump log content to stdout on test failure
|
|
defer func() {
|
|
if t.Failed() {
|
|
t.Logf("Retention check log content:\n%s", logStr)
|
|
}
|
|
}()
|
|
|
|
// Verify basic verify-feature retention and GC messages
|
|
So(logStr, ShouldContainSubstring,
|
|
"local storage detected - the zot server must be stopped to access the storage database")
|
|
So(logStr, ShouldContainSubstring, "configuration settings (after applying overrides)")
|
|
// Verify GC configuration values are present in the log
|
|
So(logStr, ShouldContainSubstring, "\"GCInterval\":60000000000") // 1m = 60s in nanoseconds
|
|
So(logStr, ShouldContainSubstring, "\"GCDelay\":1000000") // 1ms in nanoseconds
|
|
So(logStr, ShouldContainSubstring, "\"GCMaxSchedulerDelay\":5000000") // 5ms
|
|
So(logStr, ShouldContainSubstring,
|
|
"garbage collection and retention tasks will be submitted to the scheduler")
|
|
So(logStr, ShouldContainSubstring, "waiting for garbage collection tasks to complete...")
|
|
So(logStr, ShouldContainSubstring, "executing gc of orphaned blobs")
|
|
So(logStr, ShouldContainSubstring, "garbage collected blobs")
|
|
So(logStr, ShouldContainSubstring, "gc successfully completed")
|
|
So(logStr, ShouldContainSubstring, "retention check completed successfully")
|
|
|
|
// Validate specific retention decisions by parsing log entries
|
|
expectedResults := []ExpectedRetentionResult{
|
|
{
|
|
Tag: "base-tag", Repository: "referrer-test-repo", Decision: decisionKeep,
|
|
Reason: "retained by mostRecentlyPulledCount",
|
|
},
|
|
{
|
|
Tag: "recent-tag-1", Repository: repo1, Decision: decisionKeep,
|
|
Reason: "retained by mostRecentlyPulledCount",
|
|
},
|
|
{
|
|
Tag: "recent-tag-2", Repository: repo1, Decision: decisionKeep,
|
|
Reason: "retained by mostRecentlyPulledCount",
|
|
},
|
|
{
|
|
Tag: "old-tag-1", Repository: repo1, Decision: decisionDelete,
|
|
Reason: "didn't meet any tag retention rule",
|
|
},
|
|
{
|
|
Tag: "old-tag-2", Repository: repo1, Decision: decisionDelete,
|
|
Reason: "didn't meet any tag retention rule",
|
|
},
|
|
{
|
|
Tag: "multiarch-tag", Repository: repo1, Decision: decisionDelete,
|
|
Reason: "didn't meet any tag retention rule",
|
|
},
|
|
// Untagged manifest deletions - original untagged image + deleted tagged images
|
|
// (old-tag-1, old-tag-2, multiarch-tag) plus single-image manifests from the multiarch image
|
|
// (which become untagged when the multiarch-tag is deleted)
|
|
{
|
|
Tag: "", Repository: repo1, Decision: decisionDelete,
|
|
Reason: "deleteUntagged", Digest: untaggedImage1.DigestStr(), IsUntagged: true,
|
|
},
|
|
{
|
|
Tag: "", Repository: repo1, Decision: decisionDelete,
|
|
Reason: "deleteUntagged", Digest: oldImage1.DigestStr(), IsUntagged: true,
|
|
},
|
|
{
|
|
Tag: "", Repository: repo1, Decision: decisionDelete,
|
|
Reason: "deleteUntagged", Digest: oldImage2.DigestStr(), IsUntagged: true,
|
|
},
|
|
{
|
|
Tag: "", Repository: repo1, Decision: decisionDelete,
|
|
Reason: "deleteUntagged", Digest: multiarchImage.DigestStr(), IsUntagged: true,
|
|
},
|
|
// Single-image manifests from multiarch image (they become untagged when multiarch-tag is deleted)
|
|
{
|
|
Tag: "", Repository: repo1, Decision: decisionDelete,
|
|
Reason: "deleteUntagged", Digest: multiarchImage.Images[0].DigestStr(), IsUntagged: true,
|
|
},
|
|
{
|
|
Tag: "", Repository: repo1, Decision: decisionDelete,
|
|
Reason: "deleteUntagged", Digest: multiarchImage.Images[1].DigestStr(), IsUntagged: true,
|
|
},
|
|
{
|
|
Tag: "", Repository: repo1, Decision: decisionDelete,
|
|
Reason: "deleteUntagged", Digest: multiarchImage.Images[2].DigestStr(), IsUntagged: true,
|
|
},
|
|
}
|
|
|
|
validateRetentionDecisions(t, logContent, expectedResults)
|
|
})
|
|
}
|
|
|
|
func TestRetentionCheckWithDeleteReferrers(t *testing.T) {
|
|
oldArgs := os.Args
|
|
|
|
defer func() { os.Args = oldArgs }()
|
|
|
|
Convey("valid config with deleteReferrers enabled", t, func(c C) {
|
|
port := GetFreePort()
|
|
testDir := t.TempDir()
|
|
storageDir := path.Join(testDir, "storage")
|
|
configFile := path.Join(testDir, "zot-config.json")
|
|
logFile := path.Join(testDir, "retention-check.log")
|
|
|
|
content := fmt.Appendf([]byte{}, `{
|
|
"distSpecVersion": "1.1.1",
|
|
"storage": {
|
|
"rootDirectory": "%s",
|
|
"gc": true,
|
|
"gcDelay": %q,
|
|
"gcInterval": "1m",
|
|
"retention": {
|
|
"delay": "1ms",
|
|
"policies": [
|
|
{
|
|
"repositories": ["**"],
|
|
"keepTags": [
|
|
{
|
|
"patterns": [".*"],
|
|
"mostRecentlyPulledCount": 1
|
|
}
|
|
],
|
|
"deleteReferrers": true
|
|
}
|
|
]
|
|
}
|
|
},
|
|
"http": {
|
|
"address": "127.0.0.1",
|
|
"port": "%s"
|
|
},
|
|
"log": {
|
|
"level": "debug"
|
|
}
|
|
}
|
|
`, storageDir, testGCDelay, port)
|
|
err := os.WriteFile(configFile, content, 0o600)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Create image setup before running verify-feature retention
|
|
conf := config.New()
|
|
err = cli.LoadConfiguration(conf, configFile)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Initialize storage and metaDB
|
|
metricsServer := monitoring.NewMetricsServer(false, zlog.NewLogger("info", ""))
|
|
imgStore := local.NewImageStore(storageDir, false, false, zlog.NewLogger("info", ""), metricsServer,
|
|
nil, nil, nil, nil)
|
|
params := boltdb.DBParameters{
|
|
RootDir: storageDir,
|
|
}
|
|
boltDriver, err := boltdb.GetBoltDriver(params)
|
|
So(err, ShouldBeNil)
|
|
metaDB, err := boltdb.New(boltDriver, zlog.NewLogger("info", ""))
|
|
So(err, ShouldBeNil)
|
|
storeController := storage.StoreController{}
|
|
storeController.DefaultStore = imgStore
|
|
err = meta.ParseStorage(metaDB, storeController, zlog.NewLogger("info", ""))
|
|
So(err, ShouldBeNil)
|
|
|
|
// Repository with images and referrers
|
|
repo := retentionTestRepo
|
|
|
|
// Old image (should be deleted by retention - keeping only 1 most recent)
|
|
oldImage := CreateRandomImage()
|
|
err = WriteImageToFileSystem(oldImage, repo, "old-tag", storeController)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Recent image (should be kept)
|
|
recentImage := CreateRandomImage()
|
|
err = WriteImageToFileSystem(recentImage, repo, "recent-tag", storeController)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Referrer pointing to old image (should be deleted when old image is deleted)
|
|
referrerToOldImage := CreateRandomImageWith().Subject(oldImage.DescriptorRef()).Build()
|
|
err = WriteImageToFileSystem(referrerToOldImage, repo, referrerToOldImage.DigestStr(), storeController)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Referrer pointing to recent image (should be kept)
|
|
referrerToRecentImage := CreateRandomImageWith().Subject(recentImage.DescriptorRef()).Build()
|
|
err = WriteImageToFileSystem(referrerToRecentImage, repo, referrerToRecentImage.DigestStr(), storeController)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Re-parse storage after creating images to update metadata
|
|
err = meta.ParseStorage(metaDB, storeController, zlog.NewLogger("info", ""))
|
|
So(err, ShouldBeNil)
|
|
|
|
// Update metadata with timestamps for retention testing
|
|
repoMeta, err := metaDB.GetRepoMeta(context.Background(), repo)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Old image (should be deleted by retention)
|
|
oldImageStats := repoMeta.Statistics[oldImage.DigestStr()]
|
|
oldImageStats.PushTimestamp = time.Now().Add(-10 * 24 * time.Hour)
|
|
oldImageStats.LastPullTimestamp = time.Now().Add(-10 * 24 * time.Hour)
|
|
repoMeta.Statistics[oldImage.DigestStr()] = oldImageStats
|
|
|
|
// Recent image (should be kept)
|
|
recentImageStats := repoMeta.Statistics[recentImage.DigestStr()]
|
|
recentImageStats.PushTimestamp = time.Now().Add(-1 * 24 * time.Hour)
|
|
recentImageStats.LastPullTimestamp = time.Now().Add(-1 * 24 * time.Hour)
|
|
repoMeta.Statistics[recentImage.DigestStr()] = recentImageStats
|
|
|
|
err = metaDB.SetRepoMeta(repo, repoMeta)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Close metaDB to release database lock before running verify-feature retention
|
|
err = metaDB.Close()
|
|
So(err, ShouldBeNil)
|
|
|
|
gcDelay, _ := time.ParseDuration(testGCDelay)
|
|
time.Sleep(gcDelay + 50*time.Millisecond) // wait for GC delay to pass
|
|
|
|
os.Args = []string{"cli_test", "verify-feature", "retention", "-l", logFile, "-t", "1s", configFile}
|
|
err = cli.NewServerRootCmd().Execute()
|
|
So(err, ShouldBeNil)
|
|
|
|
// Verify success messages are logged to the log file
|
|
logContent, err := os.ReadFile(logFile)
|
|
So(err, ShouldBeNil)
|
|
logStr := string(logContent)
|
|
|
|
// Dump log content to stdout on test failure
|
|
defer func() {
|
|
if t.Failed() {
|
|
t.Logf("Retention check log content:\n%s", logStr)
|
|
}
|
|
}()
|
|
|
|
// Verify basic verify-feature retention and GC messages
|
|
So(logStr, ShouldContainSubstring,
|
|
"local storage detected - the zot server must be stopped to access the storage database")
|
|
So(logStr, ShouldContainSubstring, "configuration settings (after applying overrides)")
|
|
// Verify GC configuration values are present in the log
|
|
So(logStr, ShouldContainSubstring, "\"GCInterval\":60000000000") // 1m = 60s in nanoseconds
|
|
So(logStr, ShouldContainSubstring, "\"GCDelay\":1000000") // 1ms in nanoseconds
|
|
So(logStr, ShouldContainSubstring, "\"GCMaxSchedulerDelay\":5000000") // 5ms
|
|
So(logStr, ShouldContainSubstring,
|
|
"garbage collection and retention tasks will be submitted to the scheduler")
|
|
So(logStr, ShouldContainSubstring, "waiting for garbage collection tasks to complete...")
|
|
So(logStr, ShouldContainSubstring, "executing gc of orphaned blobs")
|
|
So(logStr, ShouldContainSubstring, "garbage collected blobs")
|
|
So(logStr, ShouldContainSubstring, "gc successfully completed")
|
|
So(logStr, ShouldContainSubstring, "retention check completed successfully")
|
|
|
|
// Validate specific retention decisions by parsing log entries
|
|
expectedResults := []ExpectedRetentionResult{
|
|
// Tagged images
|
|
{
|
|
Tag: "recent-tag", Repository: repo, Decision: decisionKeep,
|
|
Reason: "retained by mostRecentlyPulledCount",
|
|
},
|
|
{
|
|
Tag: "old-tag", Repository: repo, Decision: decisionDelete,
|
|
Reason: "didn't meet any tag retention rule",
|
|
},
|
|
// Untagged manifest deletions (old-tag image becomes untagged)
|
|
{
|
|
Tag: "", Repository: repo, Decision: decisionDelete,
|
|
Reason: "deleteUntagged", Digest: oldImage.DigestStr(), IsUntagged: true,
|
|
},
|
|
// Referrer deletions - with deleteReferrers=true, only referrer to deleted subject is deleted
|
|
{
|
|
Tag: "", Repository: repo, Decision: decisionDelete,
|
|
Reason: "deleteReferrers", Digest: referrerToOldImage.DigestStr(), IsReferrer: true, Subject: oldImage.DigestStr(),
|
|
},
|
|
// Note: referrerToRecentImage is kept because its subject (recentImage) is retained
|
|
}
|
|
|
|
validateRetentionDecisions(t, logContent, expectedResults)
|
|
})
|
|
}
|
|
|
|
func TestRetentionCheckWithRetentionDisabled(t *testing.T) {
|
|
oldArgs := os.Args
|
|
|
|
defer func() { os.Args = oldArgs }()
|
|
|
|
Convey("valid config with retention disabled", t, func(c C) {
|
|
port := GetFreePort()
|
|
testDir := t.TempDir()
|
|
storageDir := path.Join(testDir, "storage")
|
|
configFile := path.Join(testDir, "zot-config.json")
|
|
logFile := path.Join(testDir, "retention-check.log")
|
|
|
|
content := fmt.Appendf([]byte{}, `{
|
|
"distSpecVersion": "1.1.1",
|
|
"storage": {
|
|
"rootDirectory": "%s",
|
|
"gc": true,
|
|
"gcDelay": %q,
|
|
"gcInterval": "1m"
|
|
},
|
|
"http": {
|
|
"address": "127.0.0.1",
|
|
"port": "%s"
|
|
},
|
|
"log": {
|
|
"level": "debug"
|
|
}
|
|
}
|
|
`, storageDir, testGCDelay, port)
|
|
err := os.WriteFile(configFile, content, 0o600)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Create image setup for GC testing (no retention, no MetaDB needed)
|
|
conf := config.New()
|
|
err = cli.LoadConfiguration(conf, configFile)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Initialize storage only (no MetaDB needed when retention is disabled)
|
|
metricsServer := monitoring.NewMetricsServer(false, zlog.NewLogger("info", ""))
|
|
imgStore := local.NewImageStore(storageDir, false, false, zlog.NewLogger("info", ""), metricsServer,
|
|
nil, nil, nil, nil)
|
|
storeController := storage.StoreController{}
|
|
storeController.DefaultStore = imgStore
|
|
|
|
// Create test repositories with various image types for GC testing
|
|
// Repository 1: Tagged and untagged images
|
|
repo1 := "gc-test-repo"
|
|
|
|
// Tagged image (should be kept)
|
|
taggedImage := CreateRandomImage()
|
|
err = WriteImageToFileSystem(taggedImage, repo1, "tagged-1", storeController)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Untagged image (should be cleaned up by GC)
|
|
untaggedImage1 := CreateRandomImage()
|
|
err = WriteImageToFileSystem(untaggedImage1, repo1, untaggedImage1.DigestStr(), storeController)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Repository 2: Multiarch images
|
|
repo2 := "multiarch-test-repo"
|
|
|
|
// Tagged multiarch (should be kept)
|
|
multiarchImage := CreateRandomMultiarch()
|
|
err = WriteMultiArchImageToFileSystem(multiarchImage, repo2, "multiarch-tag-1", storeController)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Untagged multiarch (should be cleaned up)
|
|
untaggedMultiarch := CreateRandomMultiarch()
|
|
err = WriteMultiArchImageToFileSystem(untaggedMultiarch, repo2, untaggedMultiarch.DigestStr(), storeController)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Repository 3: Referrers
|
|
repo3 := "referrer-gc-repo"
|
|
|
|
// Base image
|
|
baseImage := CreateRandomImage()
|
|
err = WriteImageToFileSystem(baseImage, repo3, "base-tag", storeController)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Referrer pointing to base image (should be kept)
|
|
referrer1 := CreateRandomImageWith().Subject(baseImage.DescriptorRef()).Build()
|
|
err = WriteImageToFileSystem(referrer1, repo3, referrer1.DigestStr(), storeController)
|
|
So(err, ShouldBeNil)
|
|
|
|
gcDelay, _ := time.ParseDuration(testGCDelay)
|
|
time.Sleep(gcDelay + 50*time.Millisecond) // wait for GC delay to pass
|
|
|
|
os.Args = []string{"cli_test", "verify-feature", "retention", "-l", logFile, "-t", "1s", configFile}
|
|
err = cli.NewServerRootCmd().Execute()
|
|
So(err, ShouldBeNil)
|
|
|
|
// Verify warning and success messages are logged to the log file
|
|
logContent, err := os.ReadFile(logFile)
|
|
So(err, ShouldBeNil)
|
|
logStr := string(logContent)
|
|
|
|
// Dump log content to stdout on test failure
|
|
defer func() {
|
|
if t.Failed() {
|
|
t.Logf("Retention check log content:\n%s", logStr)
|
|
}
|
|
}()
|
|
|
|
// Verify basic verify-feature retention messages
|
|
So(logStr, ShouldContainSubstring,
|
|
"no retention policies are configured - garbage collection will run with default settings")
|
|
So(logStr, ShouldContainSubstring, "configuration settings (after applying overrides)")
|
|
// Verify GC configuration values are present in the log
|
|
So(logStr, ShouldContainSubstring, "\"GCInterval\":60000000000") // 1m = 60s in nanoseconds
|
|
So(logStr, ShouldContainSubstring, "\"GCDelay\":1000000") // 1ms in nanoseconds
|
|
So(logStr, ShouldContainSubstring, "\"GCMaxSchedulerDelay\":5000000") // 5ms
|
|
So(logStr, ShouldContainSubstring,
|
|
"garbage collection and retention tasks will be submitted to the scheduler")
|
|
So(logStr, ShouldContainSubstring, "waiting for garbage collection tasks to complete...")
|
|
So(logStr, ShouldContainSubstring, "executing gc of orphaned blobs")
|
|
So(logStr, ShouldContainSubstring, "garbage collected blobs")
|
|
So(logStr, ShouldContainSubstring, "gc successfully completed")
|
|
So(logStr, ShouldContainSubstring, "retention check completed successfully")
|
|
|
|
// Validate retention decisions - untagged manifests should be cleaned up by default
|
|
expectedResults := []ExpectedRetentionResult{
|
|
// gc-test-repo: 1 untagged manifest deleted
|
|
{
|
|
Tag: "", Repository: "gc-test-repo", Decision: decisionDelete,
|
|
Reason: "deleteUntagged", Digest: untaggedImage1.DigestStr(), IsUntagged: true,
|
|
},
|
|
|
|
// multiarch-test-repo: 4 untagged manifests deleted (multiarch index + 3 single-image manifests)
|
|
{
|
|
Tag: "", Repository: "multiarch-test-repo", Decision: decisionDelete,
|
|
Reason: "deleteUntagged", Digest: untaggedMultiarch.DigestStr(), IsUntagged: true,
|
|
},
|
|
{
|
|
Tag: "", Repository: "multiarch-test-repo", Decision: decisionDelete,
|
|
Reason: "deleteUntagged", Digest: untaggedMultiarch.Images[0].DigestStr(), IsUntagged: true,
|
|
},
|
|
{
|
|
Tag: "", Repository: "multiarch-test-repo", Decision: decisionDelete,
|
|
Reason: "deleteUntagged", Digest: untaggedMultiarch.Images[1].DigestStr(), IsUntagged: true,
|
|
},
|
|
{
|
|
Tag: "", Repository: "multiarch-test-repo", Decision: decisionDelete,
|
|
Reason: "deleteUntagged", Digest: untaggedMultiarch.Images[2].DigestStr(), IsUntagged: true,
|
|
},
|
|
}
|
|
|
|
validateRetentionDecisions(t, logContent, expectedResults)
|
|
|
|
// Verify that tagged images are NOT logged for deletion (they should be kept)
|
|
// Check that no tagged images appear in deletion logs
|
|
So(logStr, ShouldNotContainSubstring, "\"tag\":\"tagged-1\"")
|
|
So(logStr, ShouldNotContainSubstring, "\"tag\":\"multiarch-tag-1\"")
|
|
So(logStr, ShouldNotContainSubstring, "\"tag\":\"base-tag\"")
|
|
})
|
|
}
|
|
|
|
func TestRetentionCheckWithSubpaths(t *testing.T) {
|
|
oldArgs := os.Args
|
|
|
|
defer func() { os.Args = oldArgs }()
|
|
|
|
Convey("config with subpaths", t, func(c C) {
|
|
port := GetFreePort()
|
|
testDir := t.TempDir()
|
|
storageDir := path.Join(testDir, "storage")
|
|
configFile := path.Join(testDir, "zot-config.json")
|
|
logFile := path.Join(testDir, "retention-check.log")
|
|
|
|
content := fmt.Appendf([]byte{}, `{
|
|
"distSpecVersion": "1.1.1",
|
|
"storage": {
|
|
"rootDirectory": "%s",
|
|
"gc": true,
|
|
"gcDelay": %q,
|
|
"gcInterval": "1m",
|
|
"retention": {
|
|
"delay": "1ms",
|
|
"policies": [
|
|
{
|
|
"repositories": ["**"],
|
|
"keepTags": [
|
|
{
|
|
"patterns": [".*"],
|
|
"mostRecentlyPulledCount": 2
|
|
}
|
|
],
|
|
"deleteReferrers": true
|
|
}
|
|
]
|
|
},
|
|
"subPaths": {
|
|
"/a": {
|
|
"rootDirectory": "%s/a",
|
|
"gc": true,
|
|
"gcDelay": %q,
|
|
"gcInterval": "1m",
|
|
"retention": {
|
|
"delay": "1ms",
|
|
"policies": [
|
|
{
|
|
"repositories": ["**"],
|
|
"keepTags": [
|
|
{
|
|
"patterns": [".*"],
|
|
"mostRecentlyPulledCount": 2
|
|
}
|
|
],
|
|
"deleteReferrers": true
|
|
}
|
|
]
|
|
}
|
|
}
|
|
}
|
|
},
|
|
"http": {
|
|
"address": "127.0.0.1",
|
|
"port": "%s"
|
|
},
|
|
"log": {
|
|
"level": "debug"
|
|
}
|
|
}
|
|
`, storageDir, testGCDelay, storageDir, testGCDelay, port)
|
|
err := os.WriteFile(configFile, content, 0o600)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Create image setup before running verify-feature retention
|
|
conf := config.New()
|
|
err = cli.LoadConfiguration(conf, configFile)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Initialize storage and metaDB
|
|
metricsServer := monitoring.NewMetricsServer(false, zlog.NewLogger("info", ""))
|
|
imgStore := local.NewImageStore(storageDir, false, false, zlog.NewLogger("info", ""), metricsServer,
|
|
nil, nil, nil, nil)
|
|
subpathStore := local.NewImageStore(path.Join(storageDir, "a"), false, false,
|
|
zlog.NewLogger("info", ""), metricsServer, nil, nil, nil, nil)
|
|
params := boltdb.DBParameters{
|
|
RootDir: storageDir,
|
|
}
|
|
boltDriver, err := boltdb.GetBoltDriver(params)
|
|
So(err, ShouldBeNil)
|
|
metaDB, err := boltdb.New(boltDriver, zlog.NewLogger("info", ""))
|
|
So(err, ShouldBeNil)
|
|
storeController := storage.StoreController{}
|
|
storeController.DefaultStore = imgStore
|
|
storeController.SubStore = map[string]storageTypes.ImageStore{
|
|
"/a": subpathStore,
|
|
}
|
|
err = meta.ParseStorage(metaDB, storeController, zlog.NewLogger("info", ""))
|
|
So(err, ShouldBeNil)
|
|
|
|
// Create simplified image setup for retention testing
|
|
repo1 := retentionTestRepo
|
|
|
|
// Old image (should be deleted by retention - keeping only 1 most recent)
|
|
oldImage := CreateRandomImage()
|
|
err = WriteImageToFileSystem(oldImage, repo1, "old-tag", storeController)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Recent image (should be kept)
|
|
recentImage := CreateRandomImage()
|
|
err = WriteImageToFileSystem(recentImage, repo1, "recent-tag", storeController)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Multiarch image (should be deleted by retention)
|
|
multiarchImage := CreateRandomMultiarch()
|
|
err = WriteMultiArchImageToFileSystem(multiarchImage, repo1, "multiarch-tag", storeController)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Untagged image (should be cleaned up by GC)
|
|
untaggedImage := CreateRandomImage()
|
|
err = WriteImageToFileSystem(untaggedImage, repo1, untaggedImage.DigestStr(), storeController)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Referrer pointing to oldImage (subject will be deleted, so referrer should be deleted)
|
|
referrerToOldImage := CreateRandomImageWith().Subject(oldImage.DescriptorRef()).Build()
|
|
err = WriteImageToFileSystem(referrerToOldImage, repo1, referrerToOldImage.DigestStr(), storeController)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Images in subpath /a/retention-test-repo
|
|
repo2 := retentionTestRepoSubpath
|
|
|
|
subpathOldImage := CreateRandomImage()
|
|
err = WriteImageToFileSystem(subpathOldImage, repo2, "old-tag", storeController)
|
|
So(err, ShouldBeNil)
|
|
|
|
subpathRecentImage := CreateRandomImage()
|
|
err = WriteImageToFileSystem(subpathRecentImage, repo2, "recent-tag", storeController)
|
|
So(err, ShouldBeNil)
|
|
|
|
subpathMultiarchImage := CreateRandomMultiarch()
|
|
err = WriteMultiArchImageToFileSystem(subpathMultiarchImage, repo2, "multiarch-tag", storeController)
|
|
So(err, ShouldBeNil)
|
|
|
|
subpathUntaggedImage := CreateRandomImage()
|
|
err = WriteImageToFileSystem(subpathUntaggedImage, repo2, subpathUntaggedImage.DigestStr(), storeController)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Referrer pointing to subpathOldImage (subject will be deleted, so referrer should be deleted)
|
|
subpathReferrerToOldImage := CreateRandomImageWith().Subject(subpathOldImage.DescriptorRef()).Build()
|
|
err = WriteImageToFileSystem(subpathReferrerToOldImage, repo2, subpathReferrerToOldImage.DigestStr(), storeController)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Re-parse storage after creating images to update metadata
|
|
err = meta.ParseStorage(metaDB, storeController, zlog.NewLogger("info", ""))
|
|
So(err, ShouldBeNil)
|
|
|
|
// Update metadata with timestamps for retention testing
|
|
repoMeta1, err := metaDB.GetRepoMeta(context.Background(), repo1)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Old image (should be deleted by retention)
|
|
oldImageStats := repoMeta1.Statistics[oldImage.DigestStr()]
|
|
oldImageStats.PushTimestamp = time.Now().Add(-10 * 24 * time.Hour)
|
|
oldImageStats.LastPullTimestamp = time.Now().Add(-10 * 24 * time.Hour)
|
|
repoMeta1.Statistics[oldImage.DigestStr()] = oldImageStats
|
|
|
|
// Recent image (should be kept)
|
|
recentImageStats := repoMeta1.Statistics[recentImage.DigestStr()]
|
|
recentImageStats.PushTimestamp = time.Now().Add(-1 * 24 * time.Hour)
|
|
recentImageStats.LastPullTimestamp = time.Now().Add(-1 * 24 * time.Hour)
|
|
repoMeta1.Statistics[recentImage.DigestStr()] = recentImageStats
|
|
|
|
// Multiarch image (should be deleted by retention)
|
|
multiarchStats := repoMeta1.Statistics[multiarchImage.DigestStr()]
|
|
multiarchStats.PushTimestamp = time.Now().Add(-3 * 24 * time.Hour)
|
|
multiarchStats.LastPullTimestamp = time.Now().Add(-3 * 24 * time.Hour)
|
|
repoMeta1.Statistics[multiarchImage.DigestStr()] = multiarchStats
|
|
|
|
err = metaDB.SetRepoMeta(repo1, repoMeta1)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Update metadata for subpath repository
|
|
repoMeta3, err := metaDB.GetRepoMeta(context.Background(), repo2)
|
|
So(err, ShouldBeNil)
|
|
|
|
subpathOldImageStats := repoMeta3.Statistics[subpathOldImage.DigestStr()]
|
|
subpathOldImageStats.PushTimestamp = time.Now().Add(-10 * 24 * time.Hour)
|
|
subpathOldImageStats.LastPullTimestamp = time.Now().Add(-10 * 24 * time.Hour)
|
|
repoMeta3.Statistics[subpathOldImage.DigestStr()] = subpathOldImageStats
|
|
|
|
subpathRecentImageStats := repoMeta3.Statistics[subpathRecentImage.DigestStr()]
|
|
subpathRecentImageStats.PushTimestamp = time.Now().Add(-1 * 24 * time.Hour)
|
|
subpathRecentImageStats.LastPullTimestamp = time.Now().Add(-1 * 24 * time.Hour)
|
|
repoMeta3.Statistics[subpathRecentImage.DigestStr()] = subpathRecentImageStats
|
|
|
|
subpathMultiarchStats := repoMeta3.Statistics[subpathMultiarchImage.DigestStr()]
|
|
subpathMultiarchStats.PushTimestamp = time.Now().Add(-3 * 24 * time.Hour)
|
|
subpathMultiarchStats.LastPullTimestamp = time.Now().Add(-3 * 24 * time.Hour)
|
|
repoMeta3.Statistics[subpathMultiarchImage.DigestStr()] = subpathMultiarchStats
|
|
|
|
err = metaDB.SetRepoMeta(repo2, repoMeta3)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Close metaDB to release database lock before running verify-feature retention
|
|
err = metaDB.Close()
|
|
So(err, ShouldBeNil)
|
|
|
|
gcDelay, _ := time.ParseDuration(testGCDelay)
|
|
time.Sleep(gcDelay + 50*time.Millisecond) // wait for GC delay to pass
|
|
|
|
os.Args = []string{"cli_test", "verify-feature", "retention", "-l", logFile, "-t", "1s", configFile}
|
|
err = cli.NewServerRootCmd().Execute()
|
|
So(err, ShouldBeNil)
|
|
|
|
// Verify log file was created and contains expected messages
|
|
logContent, err := os.ReadFile(logFile)
|
|
So(err, ShouldBeNil)
|
|
logStr := string(logContent)
|
|
|
|
// Dump log content to stdout on test failure
|
|
defer func() {
|
|
if t.Failed() {
|
|
t.Logf("Retention check log content:\n%s", logStr)
|
|
}
|
|
}()
|
|
|
|
// Verify basic verify-feature retention and GC messages
|
|
So(logStr, ShouldContainSubstring,
|
|
"local storage detected - the zot server must be stopped to access the storage database")
|
|
So(logStr, ShouldContainSubstring, "configuration settings (after applying overrides)")
|
|
// Verify GC configuration values are present in the log
|
|
So(logStr, ShouldContainSubstring, "\"GCInterval\":60000000000") // 1m = 60s in nanoseconds
|
|
So(logStr, ShouldContainSubstring, "\"GCDelay\":1000000") // 1ms in nanoseconds
|
|
So(logStr, ShouldContainSubstring, "\"GCMaxSchedulerDelay\":5000000") // 5ms
|
|
So(logStr, ShouldContainSubstring,
|
|
"garbage collection and retention tasks will be submitted to the scheduler")
|
|
So(logStr, ShouldContainSubstring, "waiting for garbage collection tasks to complete...")
|
|
So(logStr, ShouldContainSubstring, "executing gc of orphaned blobs")
|
|
So(logStr, ShouldContainSubstring, "garbage collected blobs")
|
|
So(logStr, ShouldContainSubstring, "gc successfully completed")
|
|
So(logStr, ShouldContainSubstring, "retention check completed successfully")
|
|
|
|
// Validate specific retention decisions by parsing log entries
|
|
expectedResults := []ExpectedRetentionResult{
|
|
// Default path repositories
|
|
{
|
|
Tag: "recent-tag", Repository: repo1, Decision: decisionKeep,
|
|
Reason: "retained by mostRecentlyPulledCount",
|
|
},
|
|
{
|
|
Tag: "multiarch-tag", Repository: repo1, Decision: decisionKeep,
|
|
Reason: "retained by mostRecentlyPulledCount",
|
|
},
|
|
{
|
|
Tag: "old-tag", Repository: repo1, Decision: decisionDelete,
|
|
Reason: "didn't meet any tag retention rule",
|
|
},
|
|
// Untagged manifest deletions (only untaggedImage and oldImage, multiarch is kept)
|
|
{
|
|
Tag: "", Repository: repo1, Decision: decisionDelete,
|
|
Reason: "deleteUntagged", Digest: untaggedImage.DigestStr(), IsUntagged: true,
|
|
},
|
|
{
|
|
Tag: "", Repository: repo1, Decision: decisionDelete,
|
|
Reason: "deleteUntagged", Digest: oldImage.DigestStr(), IsUntagged: true,
|
|
},
|
|
// Referrer deletion (subject oldImage is deleted)
|
|
{
|
|
Tag: "", Repository: repo1, Decision: decisionDelete,
|
|
Reason: "deleteReferrers", Digest: referrerToOldImage.DigestStr(), IsReferrer: true, Subject: oldImage.DigestStr(),
|
|
},
|
|
// Subpath repositories
|
|
{
|
|
Tag: "recent-tag", Repository: repo2, Decision: decisionKeep,
|
|
Reason: "retained by mostRecentlyPulledCount",
|
|
},
|
|
{
|
|
Tag: "multiarch-tag", Repository: repo2, Decision: decisionKeep,
|
|
Reason: "retained by mostRecentlyPulledCount",
|
|
},
|
|
{
|
|
Tag: "old-tag", Repository: repo2, Decision: decisionDelete,
|
|
Reason: "didn't meet any tag retention rule",
|
|
},
|
|
// Untagged manifest deletions in subpath
|
|
{
|
|
Tag: "", Repository: repo2, Decision: decisionDelete,
|
|
Reason: "deleteUntagged", Digest: subpathUntaggedImage.DigestStr(), IsUntagged: true,
|
|
},
|
|
{
|
|
Tag: "", Repository: repo2, Decision: decisionDelete,
|
|
Reason: "deleteUntagged", Digest: subpathOldImage.DigestStr(), IsUntagged: true,
|
|
},
|
|
// Referrer deletion in subpath (subject subpathOldImage is deleted)
|
|
{
|
|
Tag: "", Repository: repo2, Decision: decisionDelete,
|
|
Reason: "deleteReferrers", Digest: subpathReferrerToOldImage.DigestStr(),
|
|
IsReferrer: true, Subject: subpathOldImage.DigestStr(),
|
|
},
|
|
}
|
|
|
|
validateRetentionDecisions(t, logContent, expectedResults)
|
|
})
|
|
}
|
|
|
|
func TestRetentionCheckWithGCIntervalOverride(t *testing.T) {
|
|
oldArgs := os.Args
|
|
|
|
defer func() { os.Args = oldArgs }()
|
|
|
|
Convey("config with gc-interval override", t, func(c C) {
|
|
testDir := t.TempDir()
|
|
storageDir := path.Join(testDir, "storage")
|
|
configFile := path.Join(testDir, "zot-config.json")
|
|
logFile := path.Join(testDir, "retention-check.log")
|
|
port := GetFreePort()
|
|
|
|
content := fmt.Appendf([]byte{}, `{
|
|
"distSpecVersion": "1.1.1",
|
|
"storage": {
|
|
"rootDirectory": "%s",
|
|
"gc": true,
|
|
"gcDelay": %q,
|
|
"gcInterval": "1m",
|
|
"subPaths": {
|
|
"/a": {
|
|
"rootDirectory": "%s/a",
|
|
"gc": true,
|
|
"gcDelay": %q,
|
|
"gcInterval": "1m"
|
|
}
|
|
}
|
|
},
|
|
"http": {
|
|
"address": "127.0.0.1",
|
|
"port": "%s"
|
|
},
|
|
"log": {
|
|
"level": "debug"
|
|
}
|
|
}
|
|
`, storageDir, testGCDelay, storageDir, testGCDelay, port)
|
|
err := os.WriteFile(configFile, content, 0o600)
|
|
So(err, ShouldBeNil)
|
|
|
|
gcDelay, _ := time.ParseDuration(testGCDelay)
|
|
time.Sleep(gcDelay + 50*time.Millisecond) // wait for GC delay to pass
|
|
|
|
// Override GC interval from 1m to 30s using -i flag
|
|
os.Args = []string{"cli_test", "verify-feature", "retention", "-l", logFile, "-i", "30s", "-t", "5ms", configFile}
|
|
err = cli.NewServerRootCmd().Execute()
|
|
So(err, ShouldBeNil)
|
|
|
|
// Verify log file was created and contains expected messages
|
|
logContent, err := os.ReadFile(logFile)
|
|
So(err, ShouldBeNil)
|
|
logStr := string(logContent)
|
|
|
|
// Verify the local storage warning message is logged
|
|
So(logStr, ShouldContainSubstring,
|
|
"local storage detected - the zot server must be stopped to access the storage database")
|
|
|
|
// Parse the configuration log line as JSON
|
|
lines := strings.Split(logStr, "\n")
|
|
|
|
var configLogLine string
|
|
|
|
for _, line := range lines {
|
|
if strings.Contains(line, "configuration settings (after applying overrides)") {
|
|
configLogLine = line
|
|
|
|
break
|
|
}
|
|
}
|
|
|
|
So(configLogLine, ShouldNotBeEmpty)
|
|
|
|
// Parse the JSON log line
|
|
//nolint:tagliatelle // JSON field names match Go struct names
|
|
type ConfigParams struct {
|
|
Storage struct {
|
|
GCInterval int64 `json:"GCInterval"`
|
|
GCDelay int64 `json:"GCDelay"`
|
|
GCMaxSchedulerDelay int64 `json:"GCMaxSchedulerDelay"`
|
|
SubPaths map[string]any `json:"SubPaths"`
|
|
} `json:"Storage"`
|
|
}
|
|
|
|
type ConfigLog struct {
|
|
Params ConfigParams `json:"params"`
|
|
}
|
|
|
|
var configLog ConfigLog
|
|
err = json.Unmarshal([]byte(configLogLine), &configLog)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Verify default storage configuration
|
|
So(configLog.Params.Storage.GCInterval, ShouldEqual, 30000000000) // 30s in nanoseconds
|
|
So(configLog.Params.Storage.GCDelay, ShouldEqual, 1000000) // 1ms in nanoseconds
|
|
So(configLog.Params.Storage.GCMaxSchedulerDelay, ShouldEqual, 5000000) // 5ms
|
|
|
|
// Verify subpaths configuration
|
|
So(configLog.Params.Storage.SubPaths, ShouldNotBeNil)
|
|
subpathA, exists := configLog.Params.Storage.SubPaths["/a"]
|
|
So(exists, ShouldBeTrue)
|
|
|
|
// Parse subpath configuration
|
|
subpathJSON, err := json.Marshal(subpathA)
|
|
So(err, ShouldBeNil)
|
|
|
|
//nolint:tagliatelle // JSON field names match Go struct names
|
|
type SubPathConfig struct {
|
|
GCInterval int64 `json:"GCInterval"`
|
|
GCDelay int64 `json:"GCDelay"`
|
|
GCMaxSchedulerDelay int64 `json:"GCMaxSchedulerDelay"`
|
|
}
|
|
|
|
var subpathConfig SubPathConfig
|
|
|
|
err = json.Unmarshal(subpathJSON, &subpathConfig)
|
|
So(err, ShouldBeNil)
|
|
|
|
// Verify subpath GC interval was also overridden
|
|
So(subpathConfig.GCInterval, ShouldEqual, 30000000000) // 30s in nanoseconds
|
|
So(subpathConfig.GCDelay, ShouldEqual, 1000000) // 1ms in nanoseconds
|
|
So(subpathConfig.GCMaxSchedulerDelay, ShouldEqual, 5000000) // 5ms
|
|
|
|
// Verify other expected log messages
|
|
So(logStr, ShouldContainSubstring,
|
|
"no retention policies are configured - garbage collection will run with default settings")
|
|
So(logStr, ShouldContainSubstring,
|
|
"garbage collection and retention tasks will be submitted to the scheduler")
|
|
So(logStr, ShouldContainSubstring, "waiting for garbage collection tasks to complete...")
|
|
So(logStr, ShouldContainSubstring, "retention check completed successfully")
|
|
})
|
|
}
|
|
|
|
// ExpectedRetentionResult represents the expected outcome for a specific tag, untagged image, or referrer.
|
|
type ExpectedRetentionResult struct {
|
|
Tag string
|
|
Repository string
|
|
Decision string
|
|
Reason string
|
|
Digest string // For untagged images and referrers, this will be the digest
|
|
IsUntagged bool // Flag to indicate if this is an untagged image
|
|
IsReferrer bool // Flag to indicate if this is a referrer
|
|
Subject string // For referrers, this is the subject digest
|
|
}
|
|
|
|
// RetentionDecision represents a parsed retention decision from logs.
|
|
type RetentionDecision struct {
|
|
Message string `json:"message"`
|
|
Repository string `json:"repository"`
|
|
Tag string `json:"tag"`
|
|
Decision string `json:"decision"`
|
|
Reason string `json:"reason"`
|
|
Reference string `json:"reference"` // For untagged images and referrers, this contains the digest
|
|
Subject string `json:"subject"` // For referrers, this contains the subject digest
|
|
}
|
|
|
|
func parseRetentionDecisions(logContent []byte) []RetentionDecision {
|
|
lines := strings.Split(string(logContent), "\n")
|
|
|
|
var actualDecisions []RetentionDecision
|
|
|
|
for _, line := range lines {
|
|
// Parse retention policy decisions
|
|
if strings.Contains(line, "applied policy") && strings.Contains(line, "decision") {
|
|
var decision RetentionDecision
|
|
|
|
if err := json.Unmarshal([]byte(line), &decision); err == nil {
|
|
actualDecisions = append(actualDecisions, decision)
|
|
}
|
|
}
|
|
// Parse untagged manifest cleanup
|
|
if strings.Contains(line, "removed untagged manifest") {
|
|
var decision RetentionDecision
|
|
|
|
if err := json.Unmarshal([]byte(line), &decision); err == nil {
|
|
// For untagged manifests, the digest is in the "reference" field
|
|
decision.Tag = "" // Untagged images have no tag
|
|
actualDecisions = append(actualDecisions, decision)
|
|
}
|
|
}
|
|
// Parse referrer cleanup
|
|
if strings.Contains(line, "removed manifest without reference") {
|
|
var decision RetentionDecision
|
|
|
|
if err := json.Unmarshal([]byte(line), &decision); err == nil {
|
|
// For referrers, the digest is in the "reference" field, subject in "subject" field
|
|
decision.Tag = "" // Referrers have no tag
|
|
actualDecisions = append(actualDecisions, decision)
|
|
}
|
|
}
|
|
}
|
|
|
|
return actualDecisions
|
|
}
|
|
|
|
func getExpectedKey(expected ExpectedRetentionResult) string {
|
|
switch {
|
|
case expected.IsUntagged:
|
|
return expected.Repository + ":untagged:" + expected.Digest
|
|
case expected.IsReferrer:
|
|
return expected.Repository + ":referrer:" + expected.Digest
|
|
default:
|
|
return expected.Repository + ":tag:" + expected.Tag
|
|
}
|
|
}
|
|
|
|
func getActualKey(actual RetentionDecision) string {
|
|
switch {
|
|
case actual.Tag == "" && actual.Reference != "" && actual.Subject != "":
|
|
// This is a referrer
|
|
return actual.Repository + ":referrer:" + actual.Reference
|
|
case actual.Tag == "" && actual.Reference != "":
|
|
// This is an untagged image
|
|
return actual.Repository + ":untagged:" + actual.Reference
|
|
default:
|
|
// This is a tagged image
|
|
return actual.Repository + ":tag:" + actual.Tag
|
|
}
|
|
}
|
|
|
|
func validateRetentionDecisions(t *testing.T, logContent []byte, expectedResults []ExpectedRetentionResult) {
|
|
t.Helper()
|
|
|
|
actualDecisions := parseRetentionDecisions(logContent)
|
|
|
|
logRetentionDecisions(t, actualDecisions)
|
|
logExpectedResults(t, expectedResults)
|
|
|
|
// Validate that we have the expected number of decisions
|
|
So(len(actualDecisions), ShouldEqual, len(expectedResults))
|
|
|
|
// Create maps for easy lookup
|
|
expectedMap := make(map[string]ExpectedRetentionResult)
|
|
|
|
for _, expected := range expectedResults {
|
|
expectedMap[getExpectedKey(expected)] = expected
|
|
}
|
|
|
|
actualMap := make(map[string]RetentionDecision)
|
|
|
|
for _, actual := range actualDecisions {
|
|
actualMap[getActualKey(actual)] = actual
|
|
}
|
|
|
|
// Validate each expected result
|
|
for _, expected := range expectedResults {
|
|
key := getExpectedKey(expected)
|
|
actual, exists := actualMap[key]
|
|
|
|
So(exists, ShouldBeTrue)
|
|
So(actual.Decision, ShouldEqual, expected.Decision)
|
|
So(actual.Reason, ShouldContainSubstring, expected.Reason)
|
|
|
|
// For referrers, also validate the subject
|
|
if expected.IsReferrer {
|
|
So(actual.Subject, ShouldEqual, expected.Subject)
|
|
}
|
|
}
|
|
|
|
// Validate that we don't have unexpected decisions
|
|
for _, actual := range actualDecisions {
|
|
key := getActualKey(actual)
|
|
_, exists := expectedMap[key]
|
|
So(exists, ShouldBeTrue)
|
|
}
|
|
}
|
|
|
|
func logRetentionDecisions(t *testing.T, actualDecisions []RetentionDecision) {
|
|
t.Helper()
|
|
|
|
keepTags := make([]string, 0)
|
|
deleteTags := make([]string, 0)
|
|
|
|
for _, decision := range actualDecisions {
|
|
switch decision.Decision {
|
|
case decisionKeep:
|
|
keepTags = append(keepTags, decision.Tag)
|
|
case decisionDelete:
|
|
deleteTags = append(deleteTags, decision.Tag)
|
|
}
|
|
}
|
|
|
|
t.Logf("KEEP decisions (%d): %v", len(keepTags), keepTags)
|
|
t.Logf("DELETE decisions (%d): %v", len(deleteTags), deleteTags)
|
|
}
|
|
|
|
func logExpectedResults(t *testing.T, expectedResults []ExpectedRetentionResult) {
|
|
t.Helper()
|
|
|
|
keepTags := make([]string, 0)
|
|
deleteTags := make([]string, 0)
|
|
|
|
for _, expected := range expectedResults {
|
|
switch expected.Decision {
|
|
case decisionKeep:
|
|
if expected.Tag != "" {
|
|
keepTags = append(keepTags, expected.Tag)
|
|
}
|
|
case decisionDelete:
|
|
switch {
|
|
case expected.Tag != "":
|
|
deleteTags = append(deleteTags, expected.Tag)
|
|
case expected.IsUntagged:
|
|
deleteTags = append(deleteTags, "untagged:"+expected.Digest[:12])
|
|
case expected.IsReferrer:
|
|
deleteTags = append(deleteTags, "referrer:"+expected.Digest[:12])
|
|
}
|
|
}
|
|
}
|
|
|
|
t.Logf("EXPECTED KEEP decisions (%d): %v", len(keepTags), keepTags)
|
|
t.Logf("EXPECTED DELETE decisions (%d): %v", len(deleteTags), deleteTags)
|
|
}
|