diff --git a/examples/README.md b/examples/README.md index f5874481..850252c2 100644 --- a/examples/README.md +++ b/examples/README.md @@ -35,6 +35,7 @@ Examples of working configurations for various use cases are available [here](.. - [Top-level Configuration Map](#top-level-configuration-map) - [Network](#network) - [Storage](#storage) + - [Fast restart](#fast-restart) - [Authentication](#authentication) - [TLS Mutual Authentication](#tls-mutual-authentication) - [Passphrase Authentication](#passphrase-authentication) @@ -167,6 +168,33 @@ their own repository paths, dedupe and garbage collection settings with: }, ``` +### Fast restart + +On large registries (for example a 1TB+ S3 backend with many repos), the +startup walk that reconciles metaDB with the current storage can dominate +restart time. Setting `fastRestart` lets zot skip that walk when the same +binary is restarted with the same storage config. After a successful walk, +zot stamps metaDB with the running binary's identity plus a fingerprint of +the storage config, so that the next startup, if the stamp matches, may skip +the walk. Any binary upgrade or storage configuration changes (for example, +`dedupe`/`rootDirectory`/`subPaths`) invalidates the stamp and forces a full +reparse. + +Fast restart is off by default. The trade-off when enabling it is that +out-of-band changes to Zot's storage will not be detected and may cause +inconsistencies between the metaDB and storage. To enable it: + +```json + "storage": { + "rootDirectory": "/var/lib/registry", + "fastRestart": true + } +``` + +`fastRestart` is a top-level storage setting; it is not honored under `subPaths`. + +You can also force a full reparse with the `--force-reparse` flag to `zot serve`. + ## Retention You can define tag retention rules that govern how many tags of a given repository to retain, or for how long to retain certain tags. diff --git a/pkg/api/config/config.go b/pkg/api/config/config.go index a0269ed2..3c4fd3ae 100644 --- a/pkg/api/config/config.go +++ b/pkg/api/config/config.go @@ -1,6 +1,8 @@ package config import ( + "crypto/sha256" + "encoding/hex" "encoding/json" "maps" "os" @@ -498,6 +500,14 @@ type GlobalStorageConfig struct { StorageConfig `mapstructure:",squash"` SubPaths map[string]StorageConfig + + // FastRestart lets the controller skip the startup storage walk when neither + // the Zot binary nor the storage config has changed since the last run. This + // avoids re-reading all metadata from storage on every restart, at the cost + // of not detecting out-of-band changes to storage; any storage-config change + // forces a full reparse. It is a top-level storage setting only and is not + // honored under subPaths. Defaults to false. + FastRestart *bool `mapstructure:",omitempty"` } type AccessControlConfig struct { @@ -1270,6 +1280,61 @@ func (c *Config) GetRealm() string { return c.HTTP.Realm } +// IsFastRestartEnabled reports whether the controller may skip the startup +// storage walk when the metaDB fast-restart stamp matches the current binary +// and storage config. Defaults to false when unset. +func (c *Config) IsFastRestartEnabled() bool { + if c == nil { + return false + } + + c.mu.RLock() + defer c.mu.RUnlock() + + if c.Storage.FastRestart == nil { + return false + } + + return *c.Storage.FastRestart +} + +// StorageFingerprint returns a stable SHA-256 of the storage config that influences the +// storage->metaDB walk. It is combined with this binary's identity (see meta.FastRestartStamp) +// into the fast-restart stamp: when it changes, the metaDB may no longer match storage and a full +// reparse is forced. FastRestart and the runtime-only GCMaxSchedulerDelay are excluded so +// toggling them never spuriously invalidates the stamp. +func (c *Config) StorageFingerprint() string { + if c == nil { + return "" + } + + c.mu.RLock() + defer c.mu.RUnlock() + + var norm GlobalStorageConfig + if err := DeepCopy(c.Storage, &norm); err != nil { + return "" + } + + norm.FastRestart = nil + norm.GCMaxSchedulerDelay = 0 + + for name, subPath := range norm.SubPaths { + subPath.GCMaxSchedulerDelay = 0 + norm.SubPaths[name] = subPath + } + + // encoding/json sorts map keys, so the serialization is deterministic across restarts. + blob, err := json.Marshal(norm) + if err != nil { + return "" + } + + sum := sha256.Sum256(blob) + + return hex.EncodeToString(sum[:]) +} + // GetCompat returns a copy of the compatibility config. func (c *Config) GetCompat() []compat.MediaCompatibility { if c == nil { diff --git a/pkg/api/config/config_test.go b/pkg/api/config/config_test.go index 0afc8767..6427113b 100644 --- a/pkg/api/config/config_test.go +++ b/pkg/api/config/config_test.go @@ -13,6 +13,75 @@ import ( syncconf "zotregistry.dev/zot/v2/pkg/extensions/config/sync" ) +func TestStorageFingerprint(t *testing.T) { + newConf := func() *config.Config { + conf := config.New() + conf.Storage.RootDirectory = "/var/lib/registry" + + return conf + } + + Convey("StorageFingerprint", t, func() { + Convey("nil config yields an empty fingerprint", func() { + var nilConf *config.Config + + So(nilConf.StorageFingerprint(), ShouldEqual, "") + }) + + Convey("identical storage config yields an identical, non-empty fingerprint", func() { + fingerprint := newConf().StorageFingerprint() + + So(fingerprint, ShouldNotEqual, "") + So(newConf().StorageFingerprint(), ShouldEqual, fingerprint) + }) + + Convey("changing a storage field changes the fingerprint", func() { + base := newConf().StorageFingerprint() + + dedupe := newConf() + dedupe.Storage.Dedupe = !dedupe.Storage.Dedupe + So(dedupe.StorageFingerprint(), ShouldNotEqual, base) + + rootDir := newConf() + rootDir.Storage.RootDirectory = "/different" + So(rootDir.StorageFingerprint(), ShouldNotEqual, base) + + driver := newConf() + driver.Storage.StorageDriver = map[string]any{"name": "s3"} + So(driver.StorageFingerprint(), ShouldNotEqual, base) + + subPaths := newConf() + subPaths.Storage.SubPaths = map[string]config.StorageConfig{"/a": {RootDirectory: "/data/a"}} + So(subPaths.StorageFingerprint(), ShouldNotEqual, base) + }) + + Convey("changing a non-storage field keeps the fingerprint", func() { + base := newConf().StorageFingerprint() + + port := newConf() + port.HTTP.Port = "9999" + So(port.StorageFingerprint(), ShouldEqual, base) + + logLevel := newConf() + logLevel.Log = &config.LogConfig{Level: "debug"} + So(logLevel.StorageFingerprint(), ShouldEqual, base) + }) + + Convey("FastRestart and GCMaxSchedulerDelay are excluded from the fingerprint", func() { + base := newConf().StorageFingerprint() + + enabled := true + fastRestart := newConf() + fastRestart.Storage.FastRestart = &enabled + So(fastRestart.StorageFingerprint(), ShouldEqual, base) + + schedDelay := newConf() + schedDelay.Storage.GCMaxSchedulerDelay = 5 * time.Minute + So(schedDelay.StorageFingerprint(), ShouldEqual, base) + }) + }) +} + func TestConfig(t *testing.T) { Convey("Test config utils", t, func() { firstStorageConfig := config.StorageConfig{ @@ -605,6 +674,24 @@ func TestConfig(t *testing.T) { So(conf.IsRetentionEnabled(), ShouldBeFalse) }) + Convey("Test IsFastRestartEnabled()", t, func() { + var nilConf *config.Config = nil + + So(nilConf.IsFastRestartEnabled(), ShouldBeFalse) + + // Default config leaves FastRestart unset + conf := config.New() + So(conf.IsFastRestartEnabled(), ShouldBeFalse) + + disabled := false + conf.Storage.FastRestart = &disabled + So(conf.IsFastRestartEnabled(), ShouldBeFalse) + + enabled := true + conf.Storage.FastRestart = &enabled + So(conf.IsFastRestartEnabled(), ShouldBeTrue) + }) + Convey("Test IsEventRecorderEnabled()", t, func() { conf := config.New() extensionsConfig := conf.CopyExtensionsConfig() diff --git a/pkg/api/controller.go b/pkg/api/controller.go index 749433d2..e7b68ff0 100644 --- a/pkg/api/controller.go +++ b/pkg/api/controller.go @@ -28,6 +28,7 @@ import ( log "zotregistry.dev/zot/v2/pkg/log" meta "zotregistry.dev/zot/v2/pkg/meta" mTypes "zotregistry.dev/zot/v2/pkg/meta/types" + version "zotregistry.dev/zot/v2/pkg/meta/version" scheduler "zotregistry.dev/zot/v2/pkg/scheduler" storage "zotregistry.dev/zot/v2/pkg/storage" gc "zotregistry.dev/zot/v2/pkg/storage/gc" @@ -442,7 +443,8 @@ func (c *Controller) InitMetaDB() error { return err } - err = meta.ParseStorage(driver, c.StoreController, c.Log) //nolint: contextcheck + err = meta.MaybeParseStorage(driver, c.StoreController, c.Config.IsFastRestartEnabled(), + meta.FastRestartStamp(version.CurrentBinaryVersion(), c.Config.StorageFingerprint()), c.Log) if err != nil { return err } diff --git a/pkg/cli/server/root.go b/pkg/cli/server/root.go index 61f4e4e2..7287bd79 100644 --- a/pkg/cli/server/root.go +++ b/pkg/cli/server/root.go @@ -47,6 +47,8 @@ func metadataConfig(md *mapstructure.Metadata) viper.DecoderConfigOption { } func newServeCmd(conf *config.Config) *cobra.Command { + var forceReparse bool + // "serve" serveCmd := &cobra.Command{ Use: "serve ", @@ -63,6 +65,11 @@ func newServeCmd(conf *config.Config) *cobra.Command { } } + if forceReparse { + disable := false + conf.Storage.FastRestart = &disable + } + ctlr := api.NewController(conf) ldapCredentials := "" @@ -96,6 +103,9 @@ func newServeCmd(conf *config.Config) *cobra.Command { }, } + serveCmd.Flags().BoolVar(&forceReparse, "force-reparse", false, + "force a full storage->metaDB reparse on startup, ignoring the fast-restart stamp") + return serveCmd } diff --git a/pkg/meta/boltdb/boltdb.go b/pkg/meta/boltdb/boltdb.go index 7448f918..b2b0a2d3 100644 --- a/pkg/meta/boltdb/boltdb.go +++ b/pkg/meta/boltdb/boltdb.go @@ -1698,6 +1698,34 @@ func (bdw *BoltDB) PatchDB() error { return nil } +func (bdw *BoltDB) GetFastRestartStamp() (string, error) { + var stamp string + + err := bdw.DB.View(func(tx *bbolt.Tx) error { + buck := tx.Bucket([]byte(VersionBucket)) + if buck == nil { + return nil + } + + stamp = string(buck.Get([]byte(mTypes.FastRestartStampKey))) + + return nil + }) + + return stamp, err +} + +func (bdw *BoltDB) SetFastRestartStamp(stamp string) error { + return bdw.DB.Update(func(tx *bbolt.Tx) error { + buck, err := tx.CreateBucketIfNotExists([]byte(VersionBucket)) + if err != nil { + return err + } + + return buck.Put([]byte(mTypes.FastRestartStampKey), []byte(stamp)) + }) +} + func getUserStars(ctx context.Context, transaction *bbolt.Tx) []string { userAc, err := reqCtx.UserAcFromContext(ctx) if err != nil { @@ -2155,6 +2183,12 @@ func (bdw *BoltDB) ResetDB() error { return err } + if versionBuck := transaction.Bucket([]byte(VersionBucket)); versionBuck != nil { + if err := versionBuck.Delete([]byte(mTypes.FastRestartStampKey)); err != nil { + return err + } + } + return nil }) diff --git a/pkg/meta/boltdb/boltdb_test.go b/pkg/meta/boltdb/boltdb_test.go index 327a00a5..65da9d88 100644 --- a/pkg/meta/boltdb/boltdb_test.go +++ b/pkg/meta/boltdb/boltdb_test.go @@ -1235,3 +1235,48 @@ func TestBoltDBCountRepos(t *testing.T) { }) }) } + +func TestBoltDBFastRestartStamp(t *testing.T) { + Convey("FastRestartStamp", t, func() { + tmpDir := t.TempDir() + boltDBParams := boltdb.DBParameters{RootDir: tmpDir} + boltDriver, err := boltdb.GetBoltDriver(boltDBParams) + So(err, ShouldBeNil) + + boltdbWrapper, err := boltdb.New(boltDriver, log.NewTestLogger()) + So(err, ShouldBeNil) + So(boltdbWrapper, ShouldNotBeNil) + + Convey("returns empty before set", func() { + v, err := boltdbWrapper.GetFastRestartStamp() + So(err, ShouldBeNil) + So(v, ShouldEqual, "") + }) + + Convey("round-trips a value", func() { + So(boltdbWrapper.SetFastRestartStamp("v2.3.4"), ShouldBeNil) + + v, err := boltdbWrapper.GetFastRestartStamp() + So(err, ShouldBeNil) + So(v, ShouldEqual, "v2.3.4") + }) + + Convey("overwrites a previous value", func() { + So(boltdbWrapper.SetFastRestartStamp("v1"), ShouldBeNil) + So(boltdbWrapper.SetFastRestartStamp("v2"), ShouldBeNil) + + v, err := boltdbWrapper.GetFastRestartStamp() + So(err, ShouldBeNil) + So(v, ShouldEqual, "v2") + }) + + Convey("ResetDB clears the stamp", func() { + So(boltdbWrapper.SetFastRestartStamp("v1"), ShouldBeNil) + So(boltdbWrapper.ResetDB(), ShouldBeNil) + + v, err := boltdbWrapper.GetFastRestartStamp() + So(err, ShouldBeNil) + So(v, ShouldEqual, "") + }) + }) +} diff --git a/pkg/meta/dynamodb/dynamodb.go b/pkg/meta/dynamodb/dynamodb.go index 7151a4ae..c9a44e5c 100644 --- a/pkg/meta/dynamodb/dynamodb.go +++ b/pkg/meta/dynamodb/dynamodb.go @@ -331,7 +331,7 @@ func (dwr *DynamoDB) setProtoRepoMeta(repo string, repoMeta *proto_go.RepoMeta) return err } - _, err = dwr.Client.UpdateItem(context.TODO(), &dynamodb.UpdateItemInput{ + _, err = dwr.Client.UpdateItem(context.Background(), &dynamodb.UpdateItemInput{ ExpressionAttributeNames: map[string]string{ "#RM": "RepoMeta", }, @@ -625,7 +625,7 @@ func (dwr *DynamoDB) setRepoBlobsInfo(repo string, repoBlobs *proto_go.RepoBlobs return err } - _, err = dwr.Client.UpdateItem(context.TODO(), &dynamodb.UpdateItemInput{ + _, err = dwr.Client.UpdateItem(context.Background(), &dynamodb.UpdateItemInput{ ExpressionAttributeNames: map[string]string{ "#RBI": "RepoBlobsInfo", "#RLU": "RepoLastUpdated", @@ -1518,7 +1518,7 @@ func (dwr *DynamoDB) RemoveRepoReference(repo, reference string, manifestDigest ) error { ctx := context.Background() - protoRepoMeta, err := dwr.getProtoRepoMeta(context.Background(), repo) + protoRepoMeta, err := dwr.getProtoRepoMeta(ctx, repo) if err != nil { if errors.Is(err, zerr.ErrRepoMetaNotFound) { return nil @@ -1527,7 +1527,7 @@ func (dwr *DynamoDB) RemoveRepoReference(repo, reference string, manifestDigest return err } - protoImageMeta, err := dwr.GetProtoImageMeta(context.TODO(), manifestDigest) + protoImageMeta, err := dwr.GetProtoImageMeta(ctx, manifestDigest) if err != nil { if errors.Is(err, zerr.ErrImageMetaNotFound) { return nil @@ -2228,6 +2228,56 @@ func (dwr *DynamoDB) PatchDB() error { return nil } +func (dwr *DynamoDB) GetFastRestartStamp() (string, error) { + resp, err := dwr.Client.GetItem(context.Background(), &dynamodb.GetItemInput{ + TableName: aws.String(dwr.VersionTablename), + Key: map[string]types.AttributeValue{ + "TableKey": &types.AttributeValueMemberS{Value: mTypes.FastRestartStampKey}, + }, + }) + if err != nil { + return "", err + } + + if resp.Item == nil { + return "", nil + } + + var stamp string + + // In aws-sdk-go-v2, a missing attribute arrives as a nil AttributeValue, + // which Unmarshal treats as null, setting the attribute to its zero + // value ("") and returning nil rather than an error + if err := attributevalue.Unmarshal(resp.Item["Version"], &stamp); err != nil { + return "", err + } + + return stamp, nil +} + +func (dwr *DynamoDB) SetFastRestartStamp(stamp string) error { + mdAttributeValue, err := attributevalue.Marshal(stamp) + if err != nil { + return err + } + + _, err = dwr.Client.UpdateItem(context.Background(), &dynamodb.UpdateItemInput{ + ExpressionAttributeNames: map[string]string{ + "#V": "Version", + }, + ExpressionAttributeValues: map[string]types.AttributeValue{ + ":Version": mdAttributeValue, + }, + Key: map[string]types.AttributeValue{ + "TableKey": &types.AttributeValueMemberS{Value: mTypes.FastRestartStampKey}, + }, + TableName: aws.String(dwr.VersionTablename), + UpdateExpression: aws.String("SET #V = :Version"), + }) + + return err +} + func (dwr *DynamoDB) ResetDB() error { err := dwr.ResetTable(dwr.APIKeyTablename) if err != nil { @@ -2254,6 +2304,16 @@ func (dwr *DynamoDB) ResetDB() error { return err } + _, err = dwr.Client.DeleteItem(context.Background(), &dynamodb.DeleteItemInput{ + TableName: aws.String(dwr.VersionTablename), + Key: map[string]types.AttributeValue{ + "TableKey": &types.AttributeValueMemberS{Value: mTypes.FastRestartStampKey}, + }, + }) + if err != nil { + return err + } + return nil } @@ -2402,7 +2462,7 @@ func (dwr *DynamoDB) createVersionTable() error { return err } - _, err = dwr.Client.UpdateItem(context.TODO(), &dynamodb.UpdateItemInput{ + _, err = dwr.Client.UpdateItem(context.Background(), &dynamodb.UpdateItemInput{ ExpressionAttributeNames: map[string]string{ "#V": "Version", }, @@ -2432,7 +2492,7 @@ func (dwr *DynamoDB) createVersionTable() error { } func (dwr *DynamoDB) getDBVersion() (string, error) { - resp, err := dwr.Client.GetItem(context.TODO(), &dynamodb.GetItemInput{ + resp, err := dwr.Client.GetItem(context.Background(), &dynamodb.GetItemInput{ TableName: aws.String(dwr.VersionTablename), Key: map[string]types.AttributeValue{ "TableKey": &types.AttributeValueMemberS{Value: version.DBVersionKey}, diff --git a/pkg/meta/dynamodb/dynamodb_test.go b/pkg/meta/dynamodb/dynamodb_test.go index ac4d3467..7ecf29cd 100644 --- a/pkg/meta/dynamodb/dynamodb_test.go +++ b/pkg/meta/dynamodb/dynamodb_test.go @@ -1656,3 +1656,77 @@ func TestDynamoDBCountRepos(t *testing.T) { }) }) } + +func TestDynamoDBFastRestartStamp(t *testing.T) { + tskip.SkipDynamo(t) + + const region = "us-east-2" + + endpoint := os.Getenv("DYNAMODBMOCK_ENDPOINT") + + uuid, err := guuid.NewV4() + if err != nil { + panic(err) + } + + repoMetaTablename := "RepoMetadataTable" + uuid.String() + versionTablename := "Version" + uuid.String() + imageMetaTablename := "ImageMeta" + uuid.String() + repoBlobsTablename := "RepoBlobs" + uuid.String() + userDataTablename := "UserDataTable" + uuid.String() + apiKeyTablename := "ApiKeyTable" + uuid.String() + + log := log.NewTestLogger() + + Convey("FastRestartStamp", t, func() { + params := mdynamodb.DBDriverParameters{ + Endpoint: endpoint, + Region: region, + RepoMetaTablename: repoMetaTablename, + ImageMetaTablename: imageMetaTablename, + RepoBlobsInfoTablename: repoBlobsTablename, + VersionTablename: versionTablename, + APIKeyTablename: apiKeyTablename, + UserDataTablename: userDataTablename, + } + client, err := mdynamodb.GetDynamoClient(params) + So(err, ShouldBeNil) + + dynamoWrapper, err := mdynamodb.New(client, params, log) + So(err, ShouldBeNil) + + So(dynamoWrapper.ResetTable(dynamoWrapper.VersionTablename), ShouldBeNil) + + Convey("returns empty before set", func() { + v, err := dynamoWrapper.GetFastRestartStamp() + So(err, ShouldBeNil) + So(v, ShouldEqual, "") + }) + + Convey("round-trips a value", func() { + So(dynamoWrapper.SetFastRestartStamp("v2.3.4"), ShouldBeNil) + + v, err := dynamoWrapper.GetFastRestartStamp() + So(err, ShouldBeNil) + So(v, ShouldEqual, "v2.3.4") + }) + + Convey("overwrites a previous value", func() { + So(dynamoWrapper.SetFastRestartStamp("v1"), ShouldBeNil) + So(dynamoWrapper.SetFastRestartStamp("v2"), ShouldBeNil) + + v, err := dynamoWrapper.GetFastRestartStamp() + So(err, ShouldBeNil) + So(v, ShouldEqual, "v2") + }) + + Convey("ResetDB clears the stamp", func() { + So(dynamoWrapper.SetFastRestartStamp("v1"), ShouldBeNil) + So(dynamoWrapper.ResetDB(), ShouldBeNil) + + v, err := dynamoWrapper.GetFastRestartStamp() + So(err, ShouldBeNil) + So(v, ShouldEqual, "") + }) + }) +} diff --git a/pkg/meta/maybe_parse_test.go b/pkg/meta/maybe_parse_test.go new file mode 100644 index 00000000..5e3faa0e --- /dev/null +++ b/pkg/meta/maybe_parse_test.go @@ -0,0 +1,207 @@ +package meta_test + +import ( + "errors" + "testing" + "time" + + godigest "github.com/opencontainers/go-digest" + ispec "github.com/opencontainers/image-spec/specs-go/v1" + . "github.com/smartystreets/goconvey/convey" + + zerr "zotregistry.dev/zot/v2/errors" + "zotregistry.dev/zot/v2/pkg/log" + "zotregistry.dev/zot/v2/pkg/meta" + "zotregistry.dev/zot/v2/pkg/storage" + "zotregistry.dev/zot/v2/pkg/test/mocks" +) + +func TestMaybeParseStorageGate(t *testing.T) { + logger := log.NewTestLogger() + emptyStore := storage.StoreController{DefaultStore: mocks.MockedImageStore{ + GetRepositoriesFn: func() ([]string, error) { return nil, nil }, + }} + + Convey("fastRestart=false always runs the full parse and stamps", t, func() { + var stamped string + + mock := mocks.MetaDBMock{ + GetAllRepoNamesFn: func() ([]string, error) { return nil, nil }, + GetFastRestartStampFn: func() (string, error) { + t.Fatal("GetFastRestartStamp must not be called when fastRestart=false") + + return "", nil + }, + SetFastRestartStampFn: func(v string) error { + stamped = v + + return nil + }, + } + + err := meta.MaybeParseStorage(mock, emptyStore, false, "v1", logger) + So(err, ShouldBeNil) + So(stamped, ShouldEqual, "v1") + }) + + Convey("fastRestart=true with matching stamp skips the parse", t, func() { + mock := mocks.MetaDBMock{ + GetAllRepoNamesFn: func() ([]string, error) { + t.Fatal("full parse must not run when stamp matches") + + return nil, nil + }, + GetFastRestartStampFn: func() (string, error) { return "v1", nil }, + SetFastRestartStampFn: func(v string) error { + t.Fatal("must not re-stamp when stamp already matches") + + return nil + }, + } + + err := meta.MaybeParseStorage(mock, emptyStore, true, "v1", logger) + So(err, ShouldBeNil) + }) + + Convey("fastRestart=true with mismatched stamp runs full parse and re-stamps", t, func() { + var ( + parsed bool + stamped string + ) + + mock := mocks.MetaDBMock{ + GetAllRepoNamesFn: func() ([]string, error) { + parsed = true + + return nil, nil + }, + GetFastRestartStampFn: func() (string, error) { return "v1", nil }, + SetFastRestartStampFn: func(v string) error { + stamped = v + + return nil + }, + } + + err := meta.MaybeParseStorage(mock, emptyStore, true, "v2", logger) + So(err, ShouldBeNil) + So(parsed, ShouldBeTrue) + So(stamped, ShouldEqual, "v2") + }) + + Convey("fastRestart=true with empty stamp runs full parse and stamps", t, func() { + var stamped string + + mock := mocks.MetaDBMock{ + GetAllRepoNamesFn: func() ([]string, error) { return nil, nil }, + GetFastRestartStampFn: func() (string, error) { return "", nil }, + SetFastRestartStampFn: func(v string) error { + stamped = v + + return nil + }, + } + + err := meta.MaybeParseStorage(mock, emptyStore, true, "v1", logger) + So(err, ShouldBeNil) + So(stamped, ShouldEqual, "v1") + }) + + Convey("fastRestart=true falls back to full parse when GetFastRestartStamp errors", t, func() { + var stamped string + + mock := mocks.MetaDBMock{ + GetAllRepoNamesFn: func() ([]string, error) { return nil, nil }, + GetFastRestartStampFn: func() (string, error) { return "", errors.New("redis down") }, //nolint: err113 + SetFastRestartStampFn: func(v string) error { + stamped = v + + return nil + }, + } + + err := meta.MaybeParseStorage(mock, emptyStore, true, "v1", logger) + So(err, ShouldBeNil) + So(stamped, ShouldEqual, "v1") + }) + + Convey("fastRestart=true with empty binary identity always parses and never stamps", t, func() { + var ( + parsed bool + stampInvoked bool + ) + + mock := mocks.MetaDBMock{ + GetAllRepoNamesFn: func() ([]string, error) { + parsed = true + + return nil, nil + }, + GetFastRestartStampFn: func() (string, error) { return "", nil }, + SetFastRestartStampFn: func(v string) error { + stampInvoked = true + + return nil + }, + } + + err := meta.MaybeParseStorage(mock, emptyStore, true, "", logger) + So(err, ShouldBeNil) + So(parsed, ShouldBeTrue) + So(stampInvoked, ShouldBeFalse) + }) + + Convey("a repo that fails to parse is not stamped", t, func() { + // StatIndex fails for the only repo, so it is skipped (failedRepos > 0). + store := storage.StoreController{DefaultStore: mocks.MockedImageStore{ + GetRepositoriesFn: func() ([]string, error) { return []string{repo}, nil }, + StatIndexFn: func(string) (bool, int64, time.Time, error) { + return false, 0, time.Time{}, errMetaTestInjected + }, + }} + + mock := mocks.MetaDBMock{ + GetAllRepoNamesFn: func() ([]string, error) { return nil, nil }, + SetFastRestartStampFn: func(string) error { + t.Fatal("must not stamp when a repo failed to parse") + + return nil + }, + } + + err := meta.MaybeParseStorage(mock, store, false, "v1", logger) + So(err, ShouldBeNil) + }) + + Convey("a repo with a missing manifest blob is not stamped", t, func() { + // The repo parses, but its only manifest blob is missing, so the repo is + // only partially parsed (partialRepos > 0). + store := storage.StoreController{DefaultStore: mocks.MockedImageStore{ + GetRepositoriesFn: func() ([]string, error) { return []string{repo}, nil }, + GetIndexContentFn: func(string) ([]byte, error) { + return getIndexBlob(ispec.Index{ + Manifests: []ispec.Descriptor{{ + MediaType: ispec.MediaTypeImageManifest, + Digest: godigest.FromString("missing"), + Annotations: map[string]string{ispec.AnnotationRefName: "tag1"}, + }}, + }), nil + }, + GetBlobContentFn: func(string, godigest.Digest) ([]byte, error) { + return nil, zerr.ErrBlobNotFound + }, + }} + + mock := mocks.MetaDBMock{ + GetAllRepoNamesFn: func() ([]string, error) { return nil, nil }, + SetFastRestartStampFn: func(string) error { + t.Fatal("must not stamp when a repo was only partially parsed") + + return nil + }, + } + + err := meta.MaybeParseStorage(mock, store, false, "v1", logger) + So(err, ShouldBeNil) + }) +} diff --git a/pkg/meta/parse.go b/pkg/meta/parse.go index a358bcda..7f912039 100644 --- a/pkg/meta/parse.go +++ b/pkg/meta/parse.go @@ -24,14 +24,36 @@ const ( NotationType = "notation" ) +// parseStats tracks per-repo outcomes of a storage walk. +type parseStats struct { + failedRepos int // skipped on a StatIndex or ParseRepo error + partialRepos int // parsed, but a manifest blob was missing +} + +// complete reports whether the walk fully populated the metaDB. +func (s parseStats) complete() bool { + return s.failedRepos == 0 && s.partialRepos == 0 +} + // ParseStorage will sync all repos found in the rootdirectory of the oci layout that zot was deployed on with the // ParseStorage database. func ParseStorage(metaDB mTypes.MetaDB, storeController stypes.StoreController, log log.Logger) error { + _, err := parseStorage(metaDB, storeController, log) + + return err +} + +// parseStorage runs the storage walk, returning per-repo outcomes in parseStats. +// Per-repo failures are logged and skipped. Only enumeration or deletion errors +// abort the walk and return a non-nil error. +func parseStorage(metaDB mTypes.MetaDB, storeController stypes.StoreController, log log.Logger) (parseStats, error) { log.Info().Str("component", "metadb").Msg("parsing storage and initializing") + var stats parseStats + allStorageRepos, err := getAllRepos(storeController, log) if err != nil { - return err + return parseStats{}, err } allMetaDBRepos, err := metaDB.GetAllRepoNames() @@ -40,7 +62,7 @@ func ParseStorage(metaDB mTypes.MetaDB, storeController stypes.StoreController, log.Error().Err(err).Str("component", "metadb").Str("rootDir", rootDir). Msg("failed to get all repo names present under rootDir") - return err + return parseStats{}, err } for _, repo := range getReposToBeDeleted(allStorageRepos, allMetaDBRepos) { @@ -49,7 +71,7 @@ func ParseStorage(metaDB mTypes.MetaDB, storeController stypes.StoreController, log.Error().Err(err).Str("rootDir", storeController.GetImageStore(repo).RootDir()).Str("component", "metadb"). Str("repo", repo).Msg("failed to delete repo meta") - return err + return parseStats{}, err } } @@ -64,6 +86,8 @@ func ParseStorage(metaDB mTypes.MetaDB, storeController stypes.StoreController, log.Error().Err(err).Str("rootDir", imgStore.RootDir()). Str("repo", repo).Msg("failed to sync repo") + stats.failedRepos++ + continue } @@ -75,16 +99,93 @@ func ParseStorage(metaDB mTypes.MetaDB, storeController stypes.StoreController, continue } - err = ParseRepo(repo, metaDB, storeController, log) + partial, err := parseRepo(repo, metaDB, storeController, log) if err != nil { log.Error().Err(err).Str("repo", repo).Str("rootDir", imgStore.RootDir()).Msg("failed to sync repo") + stats.failedRepos++ + continue } + + if partial { + stats.partialRepos++ + } } log.Info().Str("component", "metadb").Msg("successfully initialized") + return stats, nil +} + +// FastRestartStamp combines this binary's identity (binaryVersion, from version.CurrentBinaryVersion) +// with a fingerprint of the storage config into the stamp used to gate a fast restart. +func FastRestartStamp(binaryVersion, storageFingerprint string) string { + if binaryVersion == "" || storageFingerprint == "" { + return "" + } + + return binaryVersion + "|" + storageFingerprint +} + +// MaybeParseStorage conditionally runs ParseStorage based on a fast-restart stamp stored in metaDB. +// When fastRestart is true and the metaDB carries a stamp matching this binary and storage config, +// the full walk is skipped under the assumption that metaDB is consistent with storage from the +// previous run. +func MaybeParseStorage(metaDB mTypes.MetaDB, storeController stypes.StoreController, + fastRestart bool, fastRestartStamp string, log log.Logger, +) error { + if fastRestart { + if fastRestartStamp == "" { + log.Info().Str("component", "metadb"). + Msg("fast-restart enabled but no stamp is available; falling back to full parse") + } else { + storedStamp, err := metaDB.GetFastRestartStamp() + switch { + case err != nil: + log.Warn().Err(err).Str("component", "metadb"). + Msg("failed to read fast-restart stamp, falling back to full parse") + case storedStamp == fastRestartStamp: + log.Info().Str("component", "metadb").Str("fastRestartStamp", storedStamp). + Msg("metaDB fast-restart stamp matches, skipping full storage parse") + + return nil + case storedStamp == "": + log.Info().Str("component", "metadb"). + Msg("metaDB has no fast-restart stamp, running full parse") + default: + log.Info().Str("component", "metadb"). + Str("storedStamp", storedStamp).Str("currentStamp", fastRestartStamp). + Msg("metaDB fast-restart stamp differs, running full parse") + } + } + } + + stats, err := parseStorage(metaDB, storeController, log) + if err != nil { + return err + } + + if fastRestartStamp == "" { + // go run/go test builds have no stamp, so always reparse. + return nil + } + + // Leave the stamp untouched on an incomplete walk so the next restart + // reparses and can recover. + if !stats.complete() { + log.Warn().Str("component", "metadb"). + Int("failedRepos", stats.failedRepos).Int("partialRepos", stats.partialRepos). + Msg("storage parse incomplete; skipping fast-restart stamp so the next restart reparses") + + return nil + } + + if err := metaDB.SetFastRestartStamp(fastRestartStamp); err != nil { + log.Warn().Err(err).Str("component", "metadb"). + Msg("failed to write fast-restart stamp; next restart will reparse") + } + return nil } @@ -109,6 +210,16 @@ func getReposToBeDeleted(allStorageRepos []string, allMetaDBRepos []string) []st // ParseRepo reads the contents of a repo and syncs all images and signatures found. func ParseRepo(repo string, metaDB mTypes.MetaDB, storeController stypes.StoreController, log log.Logger) error { + _, err := parseRepo(repo, metaDB, storeController, log) + + return err +} + +// parseRepo syncs all images and signatures in a repo. It returns partial=true +// when a manifest was skipped because its blob is missing, so the caller knows +// the metaDB is incomplete even though no error was returned. +func parseRepo(repo string, metaDB mTypes.MetaDB, storeController stypes.StoreController, log log.Logger, +) (bool, error) { imageStore := storeController.GetImageStore(repo) var lockLatency time.Time @@ -120,7 +231,7 @@ func ParseRepo(repo string, metaDB mTypes.MetaDB, storeController stypes.StoreCo if err != nil { log.Error().Err(err).Str("repository", repo).Msg("failed to read index.json for repo") - return err + return false, err } var indexContent ispec.Index @@ -129,7 +240,7 @@ func ParseRepo(repo string, metaDB mTypes.MetaDB, storeController stypes.StoreCo if err != nil { log.Error().Err(err).Str("repository", repo).Msg("failed to unmarshal index.json for repo") - return err + return false, err } // Collect tags that exist in storage to preserve them @@ -146,9 +257,11 @@ func ParseRepo(repo string, metaDB mTypes.MetaDB, storeController stypes.StoreCo if err != nil && !errors.Is(err, zerr.ErrRepoMetaNotFound) { log.Error().Err(err).Str("repository", repo).Msg("failed to reset tag field in RepoMetadata for repo") - return err + return false, err } + partial := false + for _, manifest := range indexContent.Manifests { tag := manifest.Annotations[ispec.AnnotationRefName] @@ -164,13 +277,15 @@ func ParseRepo(repo string, metaDB mTypes.MetaDB, storeController stypes.StoreCo log.Warn().Err(err).Str("repository", repo).Str("digest", manifest.Digest.String()). Msg("skipping missing manifest blob, continuing repo parse") + partial = true + continue } log.Error().Err(err).Str("repository", repo).Str("digest", manifest.Digest.String()). Msg("failed to get blob for image") - return err + return false, err } reference := tag @@ -185,11 +300,11 @@ func ParseRepo(repo string, metaDB mTypes.MetaDB, storeController stypes.StoreCo log.Error().Err(err).Str("repository", repo).Str("tag", tag). Msg("failed to set metadata for image") - return err + return false, err } } - return nil + return partial, nil } func getAllRepos(storeController stypes.StoreController, log log.Logger) ([]string, error) { diff --git a/pkg/meta/parse_internal_test.go b/pkg/meta/parse_internal_test.go new file mode 100644 index 00000000..7567527f --- /dev/null +++ b/pkg/meta/parse_internal_test.go @@ -0,0 +1,141 @@ +package meta + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + godigest "github.com/opencontainers/go-digest" + ispec "github.com/opencontainers/image-spec/specs-go/v1" + . "github.com/smartystreets/goconvey/convey" + + zerr "zotregistry.dev/zot/v2/errors" + "zotregistry.dev/zot/v2/pkg/log" + mTypes "zotregistry.dev/zot/v2/pkg/meta/types" + "zotregistry.dev/zot/v2/pkg/storage" + testimage "zotregistry.dev/zot/v2/pkg/test/image-utils" + "zotregistry.dev/zot/v2/pkg/test/mocks" +) + +var errParseInternal = errors.New("parse internal test error") + +func indexBlobFor(digest godigest.Digest, tag string) []byte { + blob, err := json.Marshal(ispec.Index{ + MediaType: ispec.MediaTypeImageIndex, + Manifests: []ispec.Descriptor{{ + MediaType: ispec.MediaTypeImageManifest, + Digest: digest, + Annotations: map[string]string{ispec.AnnotationRefName: tag}, + }}, + }) + if err != nil { + panic("image index should always be marshable") + } + + return blob +} + +func TestParseStatsComplete(t *testing.T) { + Convey("parseStats.complete is true only with no failed or partial repos", t, func() { + So(parseStats{}.complete(), ShouldBeTrue) + So(parseStats{failedRepos: 1}.complete(), ShouldBeFalse) + So(parseStats{partialRepos: 1}.complete(), ShouldBeFalse) + So(parseStats{failedRepos: 2, partialRepos: 3}.complete(), ShouldBeFalse) + }) +} + +func TestFastRestartStamp(t *testing.T) { + Convey("FastRestartStamp joins the binary version and storage fingerprint", t, func() { + So(FastRestartStamp("v2.3.4+abc123", "deadbeef"), ShouldEqual, "v2.3.4+abc123|deadbeef") + }) + + Convey("FastRestartStamp returns empty when the binary version is empty", t, func() { + So(FastRestartStamp("", "deadbeef"), ShouldEqual, "") + }) + + Convey("FastRestartStamp returns empty when the storage fingerprint is empty", t, func() { + So(FastRestartStamp("v2.3.4+abc123", ""), ShouldEqual, "") + }) +} + +func TestParseStorageStats(t *testing.T) { + logger := log.NewTestLogger() + + // A valid image whose manifest + config blobs parse cleanly through ParseRepo. + validImage := testimage.CreateRandomImage() + + manifestBlob, err := json.Marshal(validImage.Manifest) + if err != nil { + t.Fatalf("marshal manifest: %v", err) + } + + configBlob, err := json.Marshal(validImage.Config) + if err != nil { + t.Fatalf("marshal config: %v", err) + } + + goodDigest := godigest.FromString("good-manifest") + missingDigest := godigest.FromString("missing-manifest") + + // blobFor maps the descriptor digests our mocked index references back to the + // valid image blobs, anything else is reported as a missing blob. + blobFor := func(_ string, digest godigest.Digest) ([]byte, error) { + switch digest { + case goodDigest: + return manifestBlob, nil + case validImage.ConfigDescriptor.Digest: + return configBlob, nil + default: + return nil, zerr.ErrBlobNotFound + } + } + + metaDB := mocks.MetaDBMock{ + SetRepoReferenceFn: func(context.Context, string, string, mTypes.ImageMeta) error { return nil }, + } + + Convey("a fully-parsed repo yields a complete parseStats", t, func() { + store := storage.StoreController{DefaultStore: mocks.MockedImageStore{ + GetRepositoriesFn: func() ([]string, error) { return []string{"goodrepo"}, nil }, + GetIndexContentFn: func(string) ([]byte, error) { return indexBlobFor(goodDigest, "gtag"), nil }, + GetBlobContentFn: blobFor, + }} + + stats, err := parseStorage(metaDB, store, logger) + So(err, ShouldBeNil) + So(stats.failedRepos, ShouldEqual, 0) + So(stats.partialRepos, ShouldEqual, 0) + So(stats.complete(), ShouldBeTrue) + }) + + Convey("failed and partial repos are counted independently", t, func() { + store := storage.StoreController{DefaultStore: mocks.MockedImageStore{ + GetRepositoriesFn: func() ([]string, error) { + return []string{"failrepo", "partialrepo", "goodrepo"}, nil + }, + StatIndexFn: func(repo string) (bool, int64, time.Time, error) { + if repo == "failrepo" { + return false, 0, time.Time{}, errParseInternal + } + + return true, 0, time.Time{}, nil + }, + GetIndexContentFn: func(repo string) ([]byte, error) { + if repo == "partialrepo" { + return indexBlobFor(missingDigest, "ptag"), nil + } + + return indexBlobFor(goodDigest, "gtag"), nil + }, + GetBlobContentFn: blobFor, + }} + + stats, err := parseStorage(metaDB, store, logger) + So(err, ShouldBeNil) + So(stats.failedRepos, ShouldEqual, 1) + So(stats.partialRepos, ShouldEqual, 1) + So(stats.complete(), ShouldBeFalse) + }) +} diff --git a/pkg/meta/redis/redis.go b/pkg/meta/redis/redis.go index 289480e8..6509d424 100644 --- a/pkg/meta/redis/redis.go +++ b/pkg/meta/redis/redis.go @@ -42,20 +42,21 @@ const ( ) type RedisDB struct { - Client redis.UniversalClient - imgTrustStore mTypes.ImageTrustStore - Patches []func(client redis.UniversalClient) error - Version string - Log log.Logger - RS *redsync.Redsync - ImageMetaKey string - RepoMetaKey string - RepoBlobsKey string - RepoLastUpdatedKey string - UserDataKey string - VersionKey string - UserAPIKeysKey string - LocksKey string + Client redis.UniversalClient + imgTrustStore mTypes.ImageTrustStore + Patches []func(client redis.UniversalClient) error + Version string + Log log.Logger + RS *redsync.Redsync + ImageMetaKey string + RepoMetaKey string + RepoBlobsKey string + RepoLastUpdatedKey string + UserDataKey string + VersionKey string + FastRestartStampKey string + UserAPIKeysKey string + LocksKey string } type DBDriverParameters struct { @@ -64,19 +65,20 @@ type DBDriverParameters struct { func New(client redis.UniversalClient, params DBDriverParameters, log log.Logger) (*RedisDB, error) { redisWrapper := RedisDB{ - Client: client, - Log: log, - Patches: version.GetRedisDBPatches(), - Version: version.CurrentVersion, - imgTrustStore: nil, - ImageMetaKey: join(params.KeyPrefix, ImageMetaBucket), - RepoMetaKey: join(params.KeyPrefix, RepoMetaBucket), - RepoBlobsKey: join(params.KeyPrefix, RepoBlobsBucket), - RepoLastUpdatedKey: join(params.KeyPrefix, RepoLastUpdatedBucket), - UserDataKey: join(params.KeyPrefix, UserDataBucket), - VersionKey: join(params.KeyPrefix, VersionBucket), - UserAPIKeysKey: join(params.KeyPrefix, UserAPIKeysBucket), - LocksKey: join(params.KeyPrefix, LocksBucket), + Client: client, + Log: log, + Patches: version.GetRedisDBPatches(), + Version: version.CurrentVersion, + imgTrustStore: nil, + ImageMetaKey: join(params.KeyPrefix, ImageMetaBucket), + RepoMetaKey: join(params.KeyPrefix, RepoMetaBucket), + RepoBlobsKey: join(params.KeyPrefix, RepoBlobsBucket), + RepoLastUpdatedKey: join(params.KeyPrefix, RepoLastUpdatedBucket), + UserDataKey: join(params.KeyPrefix, UserDataBucket), + VersionKey: join(params.KeyPrefix, VersionBucket), + FastRestartStampKey: join(params.KeyPrefix, mTypes.FastRestartStampKey), + UserAPIKeysKey: join(params.KeyPrefix, UserAPIKeysBucket), + LocksKey: join(params.KeyPrefix, LocksBucket), } if err := client.Ping(context.Background()).Err(); err != nil { @@ -2165,6 +2167,12 @@ func (rc *RedisDB) ResetDB() error { return fmt.Errorf("failed to delete version bucket: %w", err) } + if err := txrp.Del(ctx, rc.FastRestartStampKey).Err(); err != nil { + rc.Log.Error().Err(err).Str("del", rc.FastRestartStampKey).Msg("failed to delete fast-restart stamp key") + + return fmt.Errorf("failed to delete fast-restart stamp key: %w", err) + } + return nil }) @@ -2218,6 +2226,35 @@ func (rc *RedisDB) PatchDB() error { return err } +func (rc *RedisDB) GetFastRestartStamp() (string, error) { + ctx := context.Background() + + stamp, err := rc.Client.Get(ctx, rc.FastRestartStampKey).Result() + if err != nil { + if errors.Is(err, redis.Nil) { + return "", nil + } + + rc.Log.Error().Err(err).Str("get", rc.FastRestartStampKey).Msg("failed to get fast-restart stamp") + + return "", err + } + + return stamp, nil +} + +func (rc *RedisDB) SetFastRestartStamp(stamp string) error { + ctx := context.Background() + + if err := rc.Client.Set(ctx, rc.FastRestartStampKey, stamp, 0).Err(); err != nil { + rc.Log.Error().Err(err).Str("set", rc.FastRestartStampKey).Msg("failed to set fast-restart stamp") + + return err + } + + return nil +} + func (rc *RedisDB) ImageTrustStore() mTypes.ImageTrustStore { return rc.imgTrustStore } diff --git a/pkg/meta/redis/redis_internal_test.go b/pkg/meta/redis/redis_internal_test.go index 831dc2b2..818c79d8 100644 --- a/pkg/meta/redis/redis_internal_test.go +++ b/pkg/meta/redis/redis_internal_test.go @@ -33,6 +33,7 @@ func Test(t *testing.T) { So(metaDB.UserDataKey, ShouldEqual, "zot:UserData") So(metaDB.UserAPIKeysKey, ShouldEqual, "zot:UserAPIKeys") So(metaDB.VersionKey, ShouldEqual, "zot:Version") + So(metaDB.FastRestartStampKey, ShouldEqual, "zot:FastRestartStamp") So(metaDB.LocksKey, ShouldEqual, "zot:Locks") So(metaDB.getUserLockKey("user1"), ShouldEqual, "zot:Locks:User:user1") @@ -51,6 +52,7 @@ func Test(t *testing.T) { So(metaDB.UserDataKey, ShouldEqual, "someprefix:UserData") So(metaDB.UserAPIKeysKey, ShouldEqual, "someprefix:UserAPIKeys") So(metaDB.VersionKey, ShouldEqual, "someprefix:Version") + So(metaDB.FastRestartStampKey, ShouldEqual, "someprefix:FastRestartStamp") So(metaDB.LocksKey, ShouldEqual, "someprefix:Locks") So(metaDB.getUserLockKey("user1"), ShouldEqual, "someprefix:Locks:User:user1") diff --git a/pkg/meta/redis/redis_test.go b/pkg/meta/redis/redis_test.go index a7364422..fcc297b2 100644 --- a/pkg/meta/redis/redis_test.go +++ b/pkg/meta/redis/redis_test.go @@ -45,6 +45,7 @@ func TestRedisMocked(t *testing.T) { So(log, ShouldNotBeNil) client, mock := redismock.NewClientMock() + defer client.Close() defer DumpKeys(t, client) // Troubleshoot test failures mock.ExpectPing().SetVal("PONG") @@ -236,6 +237,7 @@ func TestRedisRepoMeta(t *testing.T) { So(err, ShouldBeNil) client := goredis.NewClient(opts) + defer client.Close() defer DumpKeys(t, client) // Troubleshoot test failures params := redis.DBDriverParameters{KeyPrefix: "zot"} @@ -412,6 +414,7 @@ func TestRedisUnreachable(t *testing.T) { connOpts, err := goredis.ParseURL("redis://" + miniRedis.Addr()) So(err, ShouldBeNil) workingClient := goredis.NewClient(connOpts) + defer workingClient.Close() params := redis.DBDriverParameters{KeyPrefix: "zot"} @@ -422,6 +425,7 @@ func TestRedisUnreachable(t *testing.T) { connOpts, err = goredis.ParseURL("redis://127.0.0.1:" + test.GetFreePort()) So(err, ShouldBeNil) brokenClient := goredis.NewClient(connOpts) + defer brokenClient.Close() // Replace connection with the unreachable server metaDB.Client = brokenClient @@ -586,6 +590,7 @@ func TestWrapperErrors(t *testing.T) { So(err, ShouldBeNil) client := goredis.NewClient(opts) + defer client.Close() params := redis.DBDriverParameters{KeyPrefix: keyPrefix} metaDB, err := redis.New(client, params, log) @@ -1773,3 +1778,57 @@ func DumpKeys(t *testing.T, client goredis.UniversalClient) { } } } + +func TestRedisFastRestartStamp(t *testing.T) { + miniRedis := miniredis.RunT(t) + + Convey("FastRestartStamp", t, func() { + log := log.NewTestLogger() + So(log, ShouldNotBeNil) + + opts, err := goredis.ParseURL("redis://" + miniRedis.Addr()) + So(err, ShouldBeNil) + + client := goredis.NewClient(opts) + defer client.Close() + defer DumpKeys(t, client) // Troubleshoot test failures + + params := redis.DBDriverParameters{KeyPrefix: keyPrefix} + + metaDB, err := redis.New(client, params, log) + So(err, ShouldBeNil) + So(metaDB, ShouldNotBeNil) + + Convey("returns empty before set", func() { + v, err := metaDB.GetFastRestartStamp() + So(err, ShouldBeNil) + So(v, ShouldEqual, "") + }) + + Convey("round-trips a value", func() { + So(metaDB.SetFastRestartStamp("v2.3.4"), ShouldBeNil) + + v, err := metaDB.GetFastRestartStamp() + So(err, ShouldBeNil) + So(v, ShouldEqual, "v2.3.4") + }) + + Convey("overwrites a previous value", func() { + So(metaDB.SetFastRestartStamp("v1"), ShouldBeNil) + So(metaDB.SetFastRestartStamp("v2"), ShouldBeNil) + + v, err := metaDB.GetFastRestartStamp() + So(err, ShouldBeNil) + So(v, ShouldEqual, "v2") + }) + + Convey("ResetDB clears the stamp", func() { + So(metaDB.SetFastRestartStamp("v1"), ShouldBeNil) + So(metaDB.ResetDB(), ShouldBeNil) + + v, err := metaDB.GetFastRestartStamp() + So(err, ShouldBeNil) + So(v, ShouldEqual, "") + }) + }) +} diff --git a/pkg/meta/types/types.go b/pkg/meta/types/types.go index f0704ba4..82a59e88 100644 --- a/pkg/meta/types/types.go +++ b/pkg/meta/types/types.go @@ -17,6 +17,11 @@ const ( Removed ) +// FastRestartStampKey is the metaDB key/attribute name under which each backend +// stores the fast-restart stamp of the last successful ParseStorage run. +// Defined here so all backends share a single source of truth. +const FastRestartStampKey = "FastRestartStamp" + type ( // FilterFunc is a filter function. // Currently imageMeta applied for indexes is applied for each manifest individually so imageMeta.manifests @@ -158,6 +163,16 @@ type MetaDB interface { //nolint:interfacebloat PatchDB() error + // GetFastRestartStamp returns the fast-restart stamp (binary identity + + // storage-config fingerprint) of the last successful ParseStorage run. + // Returns "" when unset (new DB, or DB last written by a binary that + // predates this stamp). + GetFastRestartStamp() (string, error) + + // SetFastRestartStamp records the given fast-restart stamp in the metaDB. + // Called by MaybeParseStorage only after a successful walk + per-repo parse. + SetFastRestartStamp(fastRestartStamp string) error + ImageTrustStore() ImageTrustStore SetImageTrustStore(imgTrustStore ImageTrustStore) diff --git a/pkg/meta/version/binary.go b/pkg/meta/version/binary.go new file mode 100644 index 00000000..0b9734a5 --- /dev/null +++ b/pkg/meta/version/binary.go @@ -0,0 +1,28 @@ +package version + +import "zotregistry.dev/zot/v2/pkg/buildinfo" + +// CurrentBinaryVersion returns this binary's identity used to stamp the +// metaDB after a successful storage parse. For released builds it combines the +// release tag and commit ("+"). For local development builds +// without a release tag it is "dev-". Builds without either ldflag +// (typically `go run` and `go test`) return "" which always forces a full parse. +func CurrentBinaryVersion() string { + return binaryVersion(buildinfo.ReleaseTag, buildinfo.Commit) +} + +// binaryVersion is the core of CurrentBinaryVersion, split out so the +// release-tag/commit resolution can be tested directly without mutating the +// process-global buildinfo values. +func binaryVersion(releaseTag, commit string) string { + switch { + case releaseTag != "" && commit != "": + return releaseTag + "+" + commit + case releaseTag != "": + return releaseTag + case commit != "": + return "dev-" + commit + default: + return "" + } +} diff --git a/pkg/meta/version/binary_internal_test.go b/pkg/meta/version/binary_internal_test.go new file mode 100644 index 00000000..e51335c0 --- /dev/null +++ b/pkg/meta/version/binary_internal_test.go @@ -0,0 +1,30 @@ +package version + +import ( + "testing" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestBinaryVersion(t *testing.T) { + Convey("binaryVersion combines release tag and commit", t, func() { + So(binaryVersion("v2.3.4", "abc123"), ShouldEqual, "v2.3.4+abc123") + }) + + Convey("binaryVersion distinguishes a retagged release", t, func() { + // Same tag re-pointed at a different commit must yield a different stamp + So(binaryVersion("v2.3.4", "abc123"), ShouldNotEqual, binaryVersion("v2.3.4", "def456")) + }) + + Convey("binaryVersion falls back to the bare tag when commit is unset", t, func() { + So(binaryVersion("v2.3.4", ""), ShouldEqual, "v2.3.4") + }) + + Convey("binaryVersion falls back to dev- without a release tag", t, func() { + So(binaryVersion("", "abc123"), ShouldEqual, "dev-abc123") + }) + + Convey("binaryVersion returns empty when neither is set", t, func() { + So(binaryVersion("", ""), ShouldEqual, "") + }) +} diff --git a/pkg/test/mocks/repo_db_mock.go b/pkg/test/mocks/repo_db_mock.go index 7901eed9..d3f41d12 100644 --- a/pkg/test/mocks/repo_db_mock.go +++ b/pkg/test/mocks/repo_db_mock.go @@ -46,6 +46,10 @@ type MetaDBMock struct { PatchDBFn func() error + GetFastRestartStampFn func() (string, error) + + SetFastRestartStampFn func(stamp string) error + ImageTrustStoreFn func() mTypes.ImageTrustStore SetImageTrustStoreFn func(mTypes.ImageTrustStore) @@ -171,6 +175,22 @@ func (sdm MetaDBMock) PatchDB() error { return nil } +func (sdm MetaDBMock) GetFastRestartStamp() (string, error) { + if sdm.GetFastRestartStampFn != nil { + return sdm.GetFastRestartStampFn() + } + + return "", nil +} + +func (sdm MetaDBMock) SetFastRestartStamp(stamp string) error { + if sdm.SetFastRestartStampFn != nil { + return sdm.SetFastRestartStampFn(stamp) + } + + return nil +} + func (sdm MetaDBMock) GetStarredRepos(ctx context.Context) ([]string, error) { if sdm.GetStarredReposFn != nil { return sdm.GetStarredReposFn(ctx)