已合并
docs: update template store semantics #179
docs: update template store semantics #179
已合并
zhaoyifan28创建于 29 天前
18 个文件变更+1047-915
@@ -24,9 +24,10 @@ its identity and storage key are its immutable Boot Index digest.
24| `SourceSandboxID` | `string` | Identifies the Sandbox used to produce the Template. | Optional. |24| `SourceSandboxID` | `string` | Identifies the Sandbox used to produce the Template. | Optional. |
25| `SourceRef` | `string` | Records the registry reference supplied to `pull`. | Optional. |25| `SourceRef` | `string` | Records the registry reference supplied to `pull`. | Optional. |
26| `Labels` | `map[string]string` | Stores caller-defined metadata as key-value pairs. | Optional. |26| `Labels` | `map[string]string` | Stores caller-defined metadata as key-value pairs. | Optional. |
27-| `CreatedAt` | `int64` | Records when the Template record was created. | Unix nanoseconds; assigned by `Create` when zero. |27+| `CreatedAt` | `int64` | Records when the Template record was created. | Unix nanoseconds; derived from the canonical containerd image record. |
28 28 
29-The corresponding containerd image record is also derived from the digest:29+Template metadata is stored as labels on a canonical containerd image record
30+whose name is derived from the digest:
30`localhost/conch/template:<algorithm>-<encoded-digest>`.31`localhost/conch/template:<algorithm>-<encoded-digest>`.
31 32 
32#### Origin33#### Origin
@@ -64,16 +65,18 @@ The corresponding containerd image record is also derived from the digest:
64### Create65### Create
65 66 
66```go67```go
67-Create(ctx context.Context, entry Entry) (Entry, error)68+Create(ctx context.Context, entry Entry, target ocispec.Descriptor) (Entry, error)
68```69```
69 70 
70-Creates a new Template record after validating the `Entry` constraints above.71+Creates a new Template record after validating the `Entry` constraints above
71-A zero `CreatedAt` is set to the current Unix time in nanoseconds.72+and confirming that `target` is a valid Boot Index whose digest and boot mode
73+match the `Entry`.
72 74 
73-The record is inserted atomically using `BootIndexDigest` as the key. An75+The store records the Boot Index child references for containerd GC and inserts
74-existing record is never overwritten; a duplicate digest returns76+the digest-derived canonical image record atomically. An existing record is
75-`ErrAlreadyExists`. On success, `Create` returns the normalized record that was77+never overwritten; a duplicate digest returns `ErrAlreadyExists`. On success,
76-stored.78+`Create` returns the normalized record with `CreatedAt` derived from the image
79+record.
77 80 
78### Get81### Get
79 82 
@@ -105,10 +108,11 @@ Every returned `Entry` is normalized using the same field rules as `Create`.
105Delete(ctx context.Context, bootIndexDigest string) error108Delete(ctx context.Context, bootIndexDigest string) error
106```109```
107 110 
108-Deletes the Template record identified by `bootIndexDigest`. The operation is111+Deletes the digest-derived canonical image record identified by
109-idempotent: deleting an unknown digest succeeds without changing stored data.112+`bootIndexDigest`. The operation is idempotent: deleting an unknown digest
113+succeeds without changing stored data. The delete is conditional on the record
114+still targeting the requested digest.
110 115 
111`Delete` returns an error when the storage operation cannot be completed.116`Delete` returns an error when the storage operation cannot be completed.
112-The runtime service additionally removes the digest-derived canonical117+Containerd GC decides when content that is no longer referenced by an image
113-containerd image record; containerd GC then decides when unreferenced content118+record or another GC root is reclaimed.
114-is reclaimed.
@@ -45,7 +45,7 @@ $ conch template rm sha256:1111...
45Removed template: sha256:1111...45Removed template: sha256:1111...
46```46```
47 47 
48-删除时会移除 Template metadata 和对应的 canonical image record;实际 content 由 containerd GC 在不再被其他记录引用后回收。48+删除时会移除 labels 承载 Template metadata 的 canonical image record;实际 content 由 containerd GC 在不再被其他记录引用后回收。
49 49 
50## 2. Template 分发50## 2. Template 分发
51 51 
@@ -20,8 +20,8 @@ import (
20 "github.com/containerd/plugin/registry"20 "github.com/containerd/plugin/registry"
21 21 
22 containerdclient "github.com/openeuler/Conch/internal/adapters/containerd/client"22 containerdclient "github.com/openeuler/Conch/internal/adapters/containerd/client"
23+ containerdtemplate "github.com/openeuler/Conch/internal/adapters/containerd/template"
23 "github.com/openeuler/Conch/internal/cleanupdiag"24 "github.com/openeuler/Conch/internal/cleanupdiag"
24- "github.com/openeuler/Conch/internal/daemon/state"
25 conchsandbox "github.com/openeuler/Conch/internal/sandbox"25 conchsandbox "github.com/openeuler/Conch/internal/sandbox"
26 conchsnapshot "github.com/openeuler/Conch/internal/snapshot"26 conchsnapshot "github.com/openeuler/Conch/internal/snapshot"
27 conchtemplate "github.com/openeuler/Conch/internal/template"27 conchtemplate "github.com/openeuler/Conch/internal/template"
@@ -36,11 +36,10 @@ const (
36)36)
37 37 
38type Config struct {38type Config struct {
39- RootDir string39+ RootDir string
40- StateDir string40+ StateDir string
41- Snapshot SnapshotConfig41+ Snapshot SnapshotConfig
42- TemplateStore state.Store42+ Sandbox *conchsandbox.Config
43- Sandbox *conchsandbox.Config
44}43}
45 44 
46type SnapshotConfig struct {45type SnapshotConfig struct {
@@ -123,9 +122,6 @@ func Start(ctx context.Context, cfg Config) (*Host, error) {
123 if cfg.StateDir == "" {122 if cfg.StateDir == "" {
124 return nil, errors.New("containerd state dir is required")123 return nil, errors.New("containerd state dir is required")
125 }124 }
126- if cfg.Sandbox != nil && cfg.TemplateStore == nil {
127- return nil, errors.New("template store is required when sandbox manager is enabled")
128- }
129 if err := os.MkdirAll(cfg.RootDir, 0o700); err != nil {125 if err := os.MkdirAll(cfg.RootDir, 0o700); err != nil {
130 return nil, fmt.Errorf("create containerd root dir: %w", err)126 return nil, fmt.Errorf("create containerd root dir: %w", err)
131 }127 }
@@ -185,9 +181,7 @@ func Start(ctx context.Context, cfg Config) (*Host, error) {
185 return fail("snapshot server", err)181 return fail("snapshot server", err)
186 }182 }
187 183 
188- if cfg.TemplateStore != nil {184+ host.templateStore = containerdtemplate.NewStore(inst.client)
189- host.templateStore = conchtemplate.NewStore(cfg.TemplateStore)
190- }
191 185 
192 if cfg.Sandbox != nil {186 if cfg.Sandbox != nil {
193 host.sandboxManager, err = conchsandbox.New(187 host.sandboxManager, err = conchsandbox.New(
@@ -0,0 +1,275 @@
1+package containerdtemplate
2+ 
3+import (
4+ "context"
5+ "fmt"
6+ "strings"
7+ 
8+ "github.com/containerd/containerd/v2/core/content"
9+ "github.com/containerd/containerd/v2/core/images"
10+ "github.com/containerd/containerd/v2/pkg/labels"
11+ "github.com/containerd/errdefs"
12+ "github.com/opencontainers/go-digest"
13+ ocispec "github.com/opencontainers/image-spec/specs-go/v1"
14+ 
15+ containerdclient "github.com/openeuler/Conch/internal/adapters/containerd/client"
16+ conchimage "github.com/openeuler/Conch/internal/image"
17+ conchtemplate "github.com/openeuler/Conch/internal/template"
18+)
19+ 
20+const (
21+ schemaLabel = "io.conch.template.schema"
22+ originLabel = "io.conch.template.origin"
23+ parentLabel = "io.conch.template.parent"
24+ sourceSandboxLabel = "io.conch.template.source-sandbox"
25+ sourceRefLabel = "io.conch.template.source-ref"
26+ userLabelPrefix = "io.conch.template.user."
27+ schemaVersion = "1"
28+)
29+ 
30+type Store struct {
31+ images images.Store
32+ content content.Store
33+}
34+ 
35+func NewStore(client *containerdclient.Client) *Store {
36+ if client == nil || client.Client == nil {
37+ return &Store{}
38+ }
39+ return &Store{images: client.ImageService(), content: client.ContentStore()}
40+}
41+ 
42+func (s *Store) Create(ctx context.Context, entry conchtemplate.Entry, target ocispec.Descriptor) (conchtemplate.Entry, error) {
43+ if err := s.configured(); err != nil {
44+ return conchtemplate.Entry{}, err
45+ }
46+ normalized, err := conchtemplate.NormalizeEntry(entry)
47+ if err != nil {
48+ return conchtemplate.Entry{}, err
49+ }
50+ if target.Digest.String() != normalized.BootIndexDigest {
51+ return conchtemplate.Entry{}, conchtemplate.ErrInvalidArtifact.Wrap(fmt.Errorf(
52+ "boot index target %s does not match Template digest %s", target.Digest, normalized.BootIndexDigest,
53+ ))
54+ }
55+ nsctx := containerdclient.NewNamespaceContext(ctx)
56+ info, err := conchimage.InspectBootIndexContent(nsctx, s.content, target)
57+ if err != nil {
58+ return conchtemplate.Entry{}, conchtemplate.ErrInvalidArtifact.Wrap(err)
59+ }
60+ kind, bootMode := conchimage.ImageKindBootIndexCold, conchtemplate.BootModeCold
61+ if info.Resume {
62+ kind, bootMode = conchimage.ImageKindBootIndexResume, conchtemplate.BootModeResume
63+ }
64+ if normalized.BootMode != bootMode {
65+ return conchtemplate.Entry{}, conchtemplate.ErrInvalidArtifact.Wrap(fmt.Errorf(
66+ "Template boot mode %q does not match Boot Index mode %q", normalized.BootMode, bootMode,
67+ ))
68+ }
69+ name, err := canonicalName(normalized.BootIndexDigest)
70+ if err != nil {
71+ return conchtemplate.Entry{}, err
72+ }
73+ recordLabels, err := encodeLabels(normalized, kind)
74+ if err != nil {
75+ return conchtemplate.Entry{}, err
76+ }
77+ labelChildren := images.SetChildrenLabels(s.content, images.ChildrenHandler(s.content))
78+ if err := images.WalkNotEmpty(nsctx, labelChildren, target); err != nil {
79+ return conchtemplate.Entry{}, fmt.Errorf("label Template content: %w", err)
80+ }
81+ record, err := s.images.Create(nsctx, images.Image{
82+ Name: name, Target: target, Labels: recordLabels,
83+ })
84+ if err != nil {
85+ return conchtemplate.Entry{}, translateError("create Template image record", err)
86+ }
87+ normalized.CreatedAt = record.CreatedAt.UnixNano()
88+ return normalized, nil
89+}
90+ 
91+func (s *Store) Get(ctx context.Context, rawDigest string) (conchtemplate.Entry, error) {
92+ if err := s.configured(); err != nil {
93+ return conchtemplate.Entry{}, err
94+ }
95+ name, err := canonicalName(rawDigest)
96+ if err != nil {
97+ return conchtemplate.Entry{}, err
98+ }
99+ nsctx := containerdclient.NewNamespaceContext(ctx)
100+ record, err := s.images.Get(nsctx, name)
101+ if err != nil {
102+ return conchtemplate.Entry{}, translateError("get Template image record", err)
103+ }
104+ return s.entryFromRecord(nsctx, record)
105+}
106+ 
107+func (s *Store) List(ctx context.Context, filter conchtemplate.Filter) ([]conchtemplate.Entry, error) {
108+ if err := s.configured(); err != nil {
109+ return nil, err
110+ }
111+ if err := validateFilter(filter); err != nil {
112+ return nil, err
113+ }
114+ nsctx := containerdclient.NewNamespaceContext(ctx)
115+ records, err := s.images.List(
116+ nsctx,
117+ `labels."`+schemaLabel+`"==`+schemaVersion,
118+ )
119+ if err != nil {
120+ return nil, translateError("list Template image records", err)
121+ }
122+ out := make([]conchtemplate.Entry, 0, len(records))
123+ for _, record := range records {
124+ if record.Labels[schemaLabel] != schemaVersion {
125+ continue
126+ }
127+ entry, err := s.entryFromRecord(nsctx, record)
128+ if err != nil {
129+ return nil, err
130+ }
131+ if filter.Origin != "" && entry.Origin != filter.Origin {
132+ continue
133+ }
134+ if filter.BootMode != "" && entry.BootMode != filter.BootMode {
135+ continue
136+ }
137+ out = append(out, entry)
138+ }
139+ return out, nil
140+}
141+ 
142+func (s *Store) Delete(ctx context.Context, rawDigest string) error {
143+ if err := s.configured(); err != nil {
144+ return err
145+ }
146+ expected, err := digest.Parse(strings.TrimSpace(rawDigest))
147+ if err != nil {
148+ return conchtemplate.ErrInvalidArgument.Wrap(fmt.Errorf("invalid Template ID %q: %w", rawDigest, err))
149+ }
150+ name, _ := conchimage.CanonicalTemplateRef(expected.String())
151+ nsctx := containerdclient.NewNamespaceContext(ctx)
152+ record, err := s.images.Get(nsctx, name)
153+ if err != nil {
154+ if errdefs.IsNotFound(err) {
155+ return nil
156+ }
157+ return translateError("get Template image record", err)
158+ }
159+ if record.Labels[schemaLabel] != schemaVersion {
160+ return nil
161+ }
162+ if record.Target.Digest != expected {
163+ return conchtemplate.ErrFailedPrecondition.Wrap(fmt.Errorf(
164+ "canonical Template record %s targets %s, want %s", name, record.Target.Digest, expected,
165+ ))
166+ }
167+ if err := s.images.Delete(nsctx, name, images.DeleteTarget(&ocispec.Descriptor{Digest: expected})); err != nil && !errdefs.IsNotFound(err) {
168+ return translateError("delete Template image record", err)
169+ }
170+ return nil
171+}
172+ 
173+func (s *Store) entryFromRecord(ctx context.Context, record images.Image) (conchtemplate.Entry, error) {
174+ if record.Labels[schemaLabel] != schemaVersion {
175+ return conchtemplate.Entry{}, conchtemplate.ErrNotFound.Wrap(fmt.Errorf("image record %s is not a Template", record.Name))
176+ }
177+ wantName, err := canonicalName(record.Target.Digest.String())
178+ if err != nil || record.Name != wantName {
179+ return conchtemplate.Entry{}, conchtemplate.ErrInvalidArtifact.Wrap(fmt.Errorf("invalid canonical Template record %s", record.Name))
180+ }
181+ info, err := conchimage.InspectBootIndexContent(ctx, s.content, record.Target)
182+ if err != nil {
183+ return conchtemplate.Entry{}, conchtemplate.ErrInvalidArtifact.Wrap(err)
184+ }
185+ bootMode, wantKind := conchtemplate.BootModeCold, conchimage.ImageKindBootIndexCold
186+ if info.Resume {
187+ bootMode, wantKind = conchtemplate.BootModeResume, conchimage.ImageKindBootIndexResume
188+ }
189+ if record.Labels[conchimage.ImageKindLabel] != wantKind {
190+ return conchtemplate.Entry{}, conchtemplate.ErrInvalidArtifact.Wrap(fmt.Errorf("Template image kind does not match Boot Index"))
191+ }
192+ entry := conchtemplate.Entry{
193+ Origin: conchtemplate.Origin(record.Labels[originLabel]),
194+ BootMode: bootMode,
195+ BootIndexDigest: record.Target.Digest.String(),
196+ ParentBootIndexDigest: record.Labels[parentLabel],
197+ SourceSandboxID: record.Labels[sourceSandboxLabel],
198+ SourceRef: record.Labels[sourceRefLabel],
199+ CreatedAt: record.CreatedAt.UnixNano(),
200+ }
201+ for key, value := range record.Labels {
202+ if strings.HasPrefix(key, userLabelPrefix) {
203+ if entry.Labels == nil {
204+ entry.Labels = make(map[string]string)
205+ }
206+ entry.Labels[strings.TrimPrefix(key, userLabelPrefix)] = value
207+ }
208+ }
209+ return conchtemplate.NormalizeEntry(entry)
210+}
211+ 
212+func (s *Store) configured() error {
213+ if s == nil || s.images == nil || s.content == nil {
214+ return fmt.Errorf("template store is not configured")
215+ }
216+ return nil
217+}
218+ 
219+func canonicalName(rawDigest string) (string, error) {
220+ parsed, err := digest.Parse(strings.TrimSpace(rawDigest))
221+ if err != nil {
222+ return "", conchtemplate.ErrInvalidArgument.Wrap(fmt.Errorf("invalid Template ID %q: %w", rawDigest, err))
223+ }
224+ return conchimage.CanonicalTemplateRef(parsed.String())
225+}
226+ 
227+func encodeLabels(entry conchtemplate.Entry, kind string) (map[string]string, error) {
228+ out := map[string]string{
229+ conchimage.ImageKindLabel: kind,
230+ schemaLabel: schemaVersion,
231+ originLabel: string(entry.Origin),
232+ }
233+ for key, value := range map[string]string{
234+ parentLabel: entry.ParentBootIndexDigest, sourceSandboxLabel: entry.SourceSandboxID, sourceRefLabel: entry.SourceRef,
235+ } {
236+ if value != "" {
237+ out[key] = value
238+ }
239+ }
240+ for key, value := range entry.Labels {
241+ if strings.TrimSpace(key) == "" {
242+ return nil, conchtemplate.ErrInvalidArgument.Wrap(fmt.Errorf("Template user label key is empty"))
243+ }
244+ out[userLabelPrefix+key] = value
245+ }
246+ for key, value := range out {
247+ if err := labels.Validate(key, value); err != nil {
248+ return nil, conchtemplate.ErrInvalidArgument.Wrap(err)
249+ }
250+ }
251+ return out, nil
252+}
253+ 
254+func validateFilter(filter conchtemplate.Filter) error {
255+ if filter.Origin != "" && filter.Origin != conchtemplate.OriginImage && filter.Origin != conchtemplate.OriginCheckpoint {
256+ return conchtemplate.ErrInvalidArgument.Wrap(fmt.Errorf("unknown template origin %q", filter.Origin))
257+ }
258+ if filter.BootMode != "" && filter.BootMode != conchtemplate.BootModeCold && filter.BootMode != conchtemplate.BootModeResume {
259+ return conchtemplate.ErrInvalidArgument.Wrap(fmt.Errorf("unknown template boot mode %q", filter.BootMode))
260+ }
261+ return nil
262+}
263+ 
264+func translateError(action string, err error) error {
265+ switch {
266+ case errdefs.IsNotFound(err):
267+ return conchtemplate.ErrNotFound.Wrap(err)
268+ case errdefs.IsAlreadyExists(err):
269+ return conchtemplate.ErrAlreadyExists.Wrap(err)
270+ case errdefs.IsInvalidArgument(err):
271+ return conchtemplate.ErrInvalidArgument.Wrap(err)
272+ default:
273+ return fmt.Errorf("%s: %w", action, err)
274+ }
275+}
@@ -0,0 +1,356 @@
1+package containerdtemplate
2+ 
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "errors"
8+ "reflect"
9+ "sort"
10+ "testing"
11+ "time"
12+ 
13+ "github.com/containerd/containerd/v2/core/content"
14+ "github.com/containerd/containerd/v2/core/images"
15+ "github.com/containerd/containerd/v2/plugins/content/local"
16+ "github.com/containerd/errdefs"
17+ "github.com/opencontainers/go-digest"
18+ ispec "github.com/opencontainers/image-spec/specs-go"
19+ ocispec "github.com/opencontainers/image-spec/specs-go/v1"
20+ 
21+ conchimage "github.com/openeuler/Conch/internal/image"
22+ "github.com/openeuler/Conch/internal/image/erofsconvert"
23+ conchtemplate "github.com/openeuler/Conch/internal/template"
24+)
25+ 
26+var _ conchtemplate.Store = (*Store)(nil)
27+ 
28+func TestStoreCRUDUsesCanonicalImageRecord(t *testing.T) {
29+ ctx := context.Background()
30+ contentStore := newTestContentStore(t)
31+ target := buildTestBootIndex(t, ctx, contentStore, false)
32+ imageStore := newMemoryImageStore()
33+ store := &Store{images: imageStore, content: contentStore}
34+ 
35+ entry, err := store.Create(ctx, conchtemplate.Entry{
36+ Origin: conchtemplate.OriginImage,
37+ BootMode: conchtemplate.BootModeCold,
38+ BootIndexDigest: target.Digest.String(),
39+ SourceRef: "registry.example/conch/demo:v1",
40+ Labels: map[string]string{"owner": "team-a"},
41+ }, target)
42+ if err != nil {
43+ t.Fatalf("Create() error = %v", err)
44+ }
45+ 
46+ name, err := conchimage.CanonicalTemplateRef(target.Digest.String())
47+ if err != nil {
48+ t.Fatal(err)
49+ }
50+ record, err := imageStore.Get(ctx, name)
51+ if err != nil {
52+ t.Fatalf("Get canonical image record: %v", err)
53+ }
54+ if !reflect.DeepEqual(record.Target, target) {
55+ t.Fatalf("record target = %#v, want %#v", record.Target, target)
56+ }
57+ if got := record.Labels[schemaLabel]; got != schemaVersion {
58+ t.Fatalf("schema label = %q, want %q", got, schemaVersion)
59+ }
60+ if got := record.Labels[conchimage.ImageKindLabel]; got != conchimage.ImageKindBootIndexCold {
61+ t.Fatalf("image kind = %q", got)
62+ }
63+ if got := record.Labels[userLabelPrefix+"owner"]; got != "team-a" {
64+ t.Fatalf("user owner label = %q", got)
65+ }
66+ if entry.CreatedAt != record.CreatedAt.UnixNano() {
67+ t.Fatalf("entry CreatedAt = %d, want %d", entry.CreatedAt, record.CreatedAt.UnixNano())
68+ }
69+ 
70+ contentInfo, err := contentStore.Info(ctx, target.Digest)
71+ if err != nil {
72+ t.Fatal(err)
73+ }
74+ if len(contentInfo.Labels) == 0 {
75+ t.Fatal("Boot Index content has no containerd GC child labels")
76+ }
77+ 
78+ imageStore.records["registry.example/ordinary:v1"] = images.Image{
79+ Name: "registry.example/ordinary:v1",
80+ Target: target,
81+ Labels: map[string]string{conchimage.ImageKindLabel: conchimage.ImageKindOCIImage},
82+ }
83+ markerless, _ := conchimage.CanonicalTemplateRef(digest.FromString("markerless").String())
84+ imageStore.records[markerless] = images.Image{Name: markerless, Target: target}
85+ 
86+ got, err := store.Get(ctx, target.Digest.String())
87+ if err != nil {
88+ t.Fatalf("Get() error = %v", err)
89+ }
90+ if got.SourceRef != entry.SourceRef || got.Labels["owner"] != "team-a" {
91+ t.Fatalf("Get() = %#v, want source and user labels", got)
92+ }
93+ items, err := store.List(ctx, conchtemplate.Filter{
94+ Origin: conchtemplate.OriginImage,
95+ BootMode: conchtemplate.BootModeCold,
96+ })
97+ if err != nil {
98+ t.Fatalf("List() error = %v", err)
99+ }
100+ if len(items) != 1 || items[0].BootIndexDigest != target.Digest.String() {
101+ t.Fatalf("List() = %#v, want only canonical Template", items)
102+ }
103+ 
104+ if err := store.Delete(ctx, target.Digest.String()); err != nil {
105+ t.Fatalf("Delete() error = %v", err)
106+ }
107+ if err := store.Delete(ctx, target.Digest.String()); err != nil {
108+ t.Fatalf("idempotent Delete() error = %v", err)
109+ }
110+ if _, err := store.Get(ctx, target.Digest.String()); !errors.Is(err, conchtemplate.ErrNotFound) {
111+ t.Fatalf("Get() after Delete error = %v, want ErrNotFound", err)
112+ }
113+}
114+ 
115+func TestStoreCreateDoesNotOverwriteExistingTemplate(t *testing.T) {
116+ ctx := context.Background()
117+ contentStore := newTestContentStore(t)
118+ target := buildTestBootIndex(t, ctx, contentStore, false)
119+ store := &Store{images: newMemoryImageStore(), content: contentStore}
120+ 
121+ first := conchtemplate.Entry{
122+ Origin: conchtemplate.OriginImage,
123+ BootMode: conchtemplate.BootModeCold,
124+ BootIndexDigest: target.Digest.String(),
125+ Labels: map[string]string{"owner": "first"},
126+ }
127+ if _, err := store.Create(ctx, first, target); err != nil {
128+ t.Fatalf("first Create() error = %v", err)
129+ }
130+ first.Labels["owner"] = "second"
131+ if _, err := store.Create(ctx, first, target); !errors.Is(err, conchtemplate.ErrAlreadyExists) {
132+ t.Fatalf("second Create() error = %v, want ErrAlreadyExists", err)
133+ }
134+ got, err := store.Get(ctx, target.Digest.String())
135+ if err != nil {
136+ t.Fatal(err)
137+ }
138+ if got.Labels["owner"] != "first" {
139+ t.Fatalf("existing Template was overwritten: %#v", got.Labels)
140+ }
141+}
142+ 
143+func TestStoreCreateRejectsBootModeMismatch(t *testing.T) {
144+ ctx := context.Background()
145+ contentStore := newTestContentStore(t)
146+ target := buildTestBootIndex(t, ctx, contentStore, false)
147+ store := &Store{images: newMemoryImageStore(), content: contentStore}
148+ 
149+ _, err := store.Create(ctx, conchtemplate.Entry{
150+ Origin: conchtemplate.OriginCheckpoint,
151+ BootMode: conchtemplate.BootModeResume,
152+ BootIndexDigest: target.Digest.String(),
153+ }, target)
154+ if !errors.Is(err, conchtemplate.ErrInvalidArtifact) {
155+ t.Fatalf("Create() error = %v, want ErrInvalidArtifact", err)
156+ }
157+}
158+ 
159+func TestStoreDeleteRejectsMovedCanonicalRecord(t *testing.T) {
160+ ctx := context.Background()
161+ contentStore := newTestContentStore(t)
162+ target := buildTestBootIndex(t, ctx, contentStore, false)
163+ imageStore := newMemoryImageStore()
164+ store := &Store{images: imageStore, content: contentStore}
165+ if _, err := store.Create(ctx, conchtemplate.Entry{
166+ Origin: conchtemplate.OriginImage,
167+ BootMode: conchtemplate.BootModeCold,
168+ BootIndexDigest: target.Digest.String(),
169+ }, target); err != nil {
170+ t.Fatal(err)
171+ }
172+ name, _ := conchimage.CanonicalTemplateRef(target.Digest.String())
173+ record := imageStore.records[name]
174+ record.Target.Digest = digest.FromString("moved-target")
175+ imageStore.records[name] = record
176+ 
177+ if err := store.Delete(ctx, target.Digest.String()); !errors.Is(err, conchtemplate.ErrFailedPrecondition) {
178+ t.Fatalf("Delete() error = %v, want ErrFailedPrecondition", err)
179+ }
180+ if _, err := imageStore.Get(ctx, name); err != nil {
181+ t.Fatalf("moved canonical record was deleted: %v", err)
182+ }
183+}
184+ 
185+type memoryImageStore struct {
186+ records map[string]images.Image
187+ filters []string
188+}
189+ 
190+func newMemoryImageStore() *memoryImageStore {
191+ return &memoryImageStore{records: make(map[string]images.Image)}
192+}
193+ 
194+func (s *memoryImageStore) Get(_ context.Context, name string) (images.Image, error) {
195+ record, ok := s.records[name]
196+ if !ok {
197+ return images.Image{}, errdefs.ErrNotFound
198+ }
199+ return record, nil
200+}
201+ 
202+func (s *memoryImageStore) List(_ context.Context, filters ...string) ([]images.Image, error) {
203+ s.filters = append([]string(nil), filters...)
204+ out := make([]images.Image, 0, len(s.records))
205+ for _, record := range s.records {
206+ out = append(out, record)
207+ }
208+ sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
209+ return out, nil
210+}
211+ 
212+func (s *memoryImageStore) Create(_ context.Context, record images.Image) (images.Image, error) {
213+ if _, ok := s.records[record.Name]; ok {
214+ return images.Image{}, errdefs.ErrAlreadyExists
215+ }
216+ record.CreatedAt = time.Unix(10, 0).UTC()
217+ record.UpdatedAt = record.CreatedAt
218+ s.records[record.Name] = record
219+ return record, nil
220+}
221+ 
222+func (s *memoryImageStore) Update(context.Context, images.Image, ...string) (images.Image, error) {
223+ return images.Image{}, errors.New("unexpected image update")
224+}
225+ 
226+func (s *memoryImageStore) Delete(ctx context.Context, name string, opts ...images.DeleteOpt) error {
227+ record, ok := s.records[name]
228+ if !ok {
229+ return errdefs.ErrNotFound
230+ }
231+ var options images.DeleteOptions
232+ for _, opt := range opts {
233+ if err := opt(ctx, &options); err != nil {
234+ return err
235+ }
236+ }
237+ if options.Target != nil && record.Target.Digest != options.Target.Digest {
238+ return errdefs.ErrNotFound
239+ }
240+ delete(s.records, name)
241+ return nil
242+}
243+ 
244+type memoryLabelStore struct {
245+ labels map[digest.Digest]map[string]string
246+}
247+ 
248+func (s *memoryLabelStore) Get(dgst digest.Digest) (map[string]string, error) {
249+ return copyLabels(s.labels[dgst]), nil
250+}
251+ 
252+func (s *memoryLabelStore) Set(dgst digest.Digest, labels map[string]string) error {
253+ s.labels[dgst] = copyLabels(labels)
254+ return nil
255+}
256+ 
257+func (s *memoryLabelStore) Update(dgst digest.Digest, update map[string]string) (map[string]string, error) {
258+ labels := copyLabels(s.labels[dgst])
259+ if labels == nil {
260+ labels = make(map[string]string)
261+ }
262+ for key, value := range update {
263+ if value == "" {
264+ delete(labels, key)
265+ } else {
266+ labels[key] = value
267+ }
268+ }
269+ s.labels[dgst] = labels
270+ return copyLabels(labels), nil
271+}
272+ 
273+func newTestContentStore(t *testing.T) content.Store {
274+ t.Helper()
275+ store, err := local.NewLabeledStore(t.TempDir(), &memoryLabelStore{
276+ labels: make(map[digest.Digest]map[string]string),
277+ })
278+ if err != nil {
279+ t.Fatal(err)
280+ }
281+ return store
282+}
283+ 
284+func buildTestBootIndex(t *testing.T, ctx context.Context, store content.Store, resume bool) ocispec.Descriptor {
285+ t.Helper()
286+ rootfs := writeTestComponent(t, ctx, store, conchimage.KindRootfs)
287+ sandbox := writeTestComponent(t, ctx, store, conchimage.KindSandbox)
288+ opts := conchimage.BootIndexContentOptions{
289+ RootfsDescriptor: rootfs,
290+ SandboxDescriptor: sandbox,
291+ }
292+ if resume {
293+ opts.MemDescriptor = writeTestComponent(t, ctx, store, conchimage.KindMemSnapshot)
294+ opts.VMMName = "cloud-hypervisor"
295+ opts.MemorySizeMB = 128
296+ }
297+ target, err := conchimage.BuildBootIndexInContent(ctx, store, opts)
298+ if err != nil {
299+ t.Fatalf("BuildBootIndexInContent() error = %v", err)
300+ }
301+ return target
302+}
303+ 
304+func writeTestComponent(t *testing.T, ctx context.Context, store content.Store, kind string) ocispec.Descriptor {
305+ t.Helper()
306+ layer := writeTestBlob(t, ctx, store, []byte("layer-"+kind), erofsconvert.NativeLayerMediaType)
307+ config := writeTestJSON(t, ctx, store, ocispec.Image{
308+ Platform: ocispec.Platform{OS: "linux", Architecture: "amd64"},
309+ RootFS: ocispec.RootFS{Type: "layers", DiffIDs: []digest.Digest{layer.Digest}},
310+ }, ocispec.MediaTypeImageConfig)
311+ manifest := writeTestJSON(t, ctx, store, ocispec.Manifest{
312+ Versioned: ispec.Versioned{SchemaVersion: 2},
313+ MediaType: ocispec.MediaTypeImageManifest,
314+ Config: config,
315+ Layers: []ocispec.Descriptor{layer},
316+ }, ocispec.MediaTypeImageManifest)
317+ manifest.Annotations = map[string]string{"io.conch.kind": kind}
318+ if kind == conchimage.KindMemSnapshot {
319+ manifest.Annotations[conchimage.AnnotationVMM] = "cloud-hypervisor"
320+ manifest.Annotations[conchimage.AnnotationMemorySizeMB] = "128"
321+ }
322+ return manifest
323+}
324+ 
325+func writeTestJSON(t *testing.T, ctx context.Context, store content.Store, value any, mediaType string) ocispec.Descriptor {
326+ t.Helper()
327+ data, err := json.Marshal(value)
328+ if err != nil {
329+ t.Fatal(err)
330+ }
331+ return writeTestBlob(t, ctx, store, data, mediaType)
332+}
333+ 
334+func writeTestBlob(t *testing.T, ctx context.Context, store content.Store, data []byte, mediaType string) ocispec.Descriptor {
335+ t.Helper()
336+ desc := ocispec.Descriptor{
337+ MediaType: mediaType,
338+ Digest: digest.FromBytes(data),
339+ Size: int64(len(data)),
340+ }
341+ if err := content.WriteBlob(ctx, store, "test-"+desc.Digest.String(), bytes.NewReader(data), desc); err != nil {
342+ t.Fatal(err)
343+ }
344+ return desc
345+}
346+ 
347+func copyLabels(in map[string]string) map[string]string {
348+ if in == nil {
349+ return nil
350+ }
351+ out := make(map[string]string, len(in))
352+ for key, value := range in {
353+ out[key] = value
354+ }
355+ return out
356+}
@@ -93,7 +93,6 @@ func New(sandboxOps SandboxOps, client *containerdclient.Client, store state.Sto
93 Sandbox: sandboxOps,93 Sandbox: sandboxOps,
94 Containerd: client,94 Containerd: client,
95 Store: store,95 Store: store,
96- Templates: conchtemplate.NewStore(store),
97 }96 }
98}97}
99 98 
@@ -406,6 +405,9 @@ func (s *Service) CheckpointSandbox(ctx context.Context, opts SandboxCheckpointO
406 if s.Store == nil {405 if s.Store == nil {
407 return SandboxCheckpointResult{}, fmt.Errorf("checkpoint publisher is not configured")406 return SandboxCheckpointResult{}, fmt.Errorf("checkpoint publisher is not configured")
408 }407 }
408+ if s.Templates == nil {
409+ return SandboxCheckpointResult{}, fmt.Errorf("template store is not configured")
410+ }
409 sandboxID := rec.SandboxID411 sandboxID := rec.SandboxID
410 parentID := strings.TrimSpace(rec.CheckpointHeadTemplateID)412 parentID := strings.TrimSpace(rec.CheckpointHeadTemplateID)
411 if parentID == "" {413 if parentID == "" {
@@ -420,7 +422,12 @@ func (s *Service) CheckpointSandbox(ctx context.Context, opts SandboxCheckpointO
420 }422 }
421 defer os.RemoveAll(captured.MemRootPath)423 defer os.RemoveAll(captured.MemRootPath)
422 424 
423- published, err := conchimage.PublishCheckpointBootIndex(ctx, s.Containerd, conchimage.PublishCheckpointBootIndexOptions{425+ publishCtx, done, err := s.Containerd.WithLease(containerdclient.NewNamespaceContext(ctx))
426+ if err != nil {
427+ return SandboxCheckpointResult{}, fmt.Errorf("create checkpoint content lease: %w", err)
428+ }
429+ defer done(publishCtx)
430+ published, err := conchimage.PublishCheckpointBootIndex(publishCtx, s.Containerd, conchimage.PublishCheckpointBootIndexOptions{
424 SourceBootIndexDigest: parentID,431 SourceBootIndexDigest: parentID,
425 MemRoot: captured.MemRootPath,432 MemRoot: captured.MemRootPath,
426 VMMName: captured.VMMName,433 VMMName: captured.VMMName,
@@ -429,13 +436,7 @@ func (s *Service) CheckpointSandbox(ctx context.Context, opts SandboxCheckpointO
429 if err != nil {436 if err != nil {
430 return SandboxCheckpointResult{}, err437 return SandboxCheckpointResult{}, err
431 }438 }
432- keepCanonicalRecord := false439+ info, err := conchimage.InspectBootIndexContent(publishCtx, s.Containerd.ContentStore(), published.Target)
433- defer func() {
434- if !keepCanonicalRecord {
435- s.cleanupCanonicalTemplateRecord(ctx, published.BootIndexDigest)
436- }
437- }()
438- info, err := conchimage.InspectBootIndex(ctx, s.Containerd, published.BootIndexDigest)
439 if err != nil {440 if err != nil {
440 return SandboxCheckpointResult{}, fmt.Errorf("validate published checkpoint boot index: %w", err)441 return SandboxCheckpointResult{}, fmt.Errorf("validate published checkpoint boot index: %w", err)
441 }442 }
@@ -463,20 +464,23 @@ func (s *Service) CheckpointSandbox(ctx context.Context, opts SandboxCheckpointO
463 captured.MemorySizeMB,464 captured.MemorySizeMB,
464 )465 )
465 }466 }
466- if err := s.Store.PublishCheckpoint(ctx, conchtemplate.Entry{467+ entry, err := s.Templates.Create(publishCtx, conchtemplate.Entry{
467 Origin: conchtemplate.OriginCheckpoint,468 Origin: conchtemplate.OriginCheckpoint,
468 BootMode: conchtemplate.BootModeResume,469 BootMode: conchtemplate.BootModeResume,
469 BootIndexDigest: info.BootIndexDigest,470 BootIndexDigest: info.BootIndexDigest,
470 ParentBootIndexDigest: parentID,471 ParentBootIndexDigest: parentID,
471 SourceSandboxID: sandboxID,472 SourceSandboxID: sandboxID,
472 Labels: copyMap(opts.Labels),473 Labels: copyMap(opts.Labels),
473- CreatedAt: time.Now().UnixNano(),474+ }, published.Target)
474- }); err != nil {475+ if err != nil {
476+ return SandboxCheckpointResult{}, err
477+ }
478+ if err := s.Store.AdvanceCheckpointHead(ctx, sandboxID, parentID, entry.BootIndexDigest); err != nil {
479+ s.cleanupTemplateRecord(ctx, entry.BootIndexDigest)
475 return SandboxCheckpointResult{}, err480 return SandboxCheckpointResult{}, err
476 }481 }
477- keepCanonicalRecord = true
478 return SandboxCheckpointResult{482 return SandboxCheckpointResult{
479- TemplateID: info.BootIndexDigest,483+ TemplateID: entry.BootIndexDigest,
480 }, nil484 }, nil
481}485}
482 486 
@@ -494,7 +498,12 @@ func (s *Service) PullTemplate(ctx context.Context, opts TemplatePullOptions) (T
494 if reference == "" {498 if reference == "" {
495 return TemplatePullResult{}, conchtemplate.ErrInvalidArgument.Wrap(fmt.Errorf("template reference is required"))499 return TemplatePullResult{}, conchtemplate.ErrInvalidArgument.Wrap(fmt.Errorf("template reference is required"))
496 }500 }
497- pulled, err := conchimage.PullBootIndex(ctx, s.Containerd, conchimage.RegistryPullOptions{501+ pullCtx, done, err := s.Containerd.WithLease(containerdclient.NewNamespaceContext(ctx))
502+ if err != nil {
503+ return TemplatePullResult{}, fmt.Errorf("create pull content lease: %w", err)
504+ }
505+ defer done(pullCtx)
506+ pulled, err := conchimage.PullBootIndex(pullCtx, s.Containerd, conchimage.RegistryPullOptions{
498 Reference: reference,507 Reference: reference,
499 PlainHTTP: opts.PlainHTTP,508 PlainHTTP: opts.PlainHTTP,
500 Username: opts.Username,509 Username: opts.Username,
@@ -510,18 +519,22 @@ func (s *Service) PullTemplate(ctx context.Context, opts TemplatePullOptions) (T
510 origin = conchtemplate.OriginCheckpoint519 origin = conchtemplate.OriginCheckpoint
511 bootMode = conchtemplate.BootModeResume520 bootMode = conchtemplate.BootModeResume
512 }521 }
513- entry, err := s.Templates.Create(ctx, conchtemplate.Entry{522+ entry, createErr := s.Templates.Create(pullCtx, conchtemplate.Entry{
514 Origin: origin,523 Origin: origin,
515 BootMode: bootMode,524 BootMode: bootMode,
516 BootIndexDigest: info.BootIndexDigest,525 BootIndexDigest: info.BootIndexDigest,
517 SourceRef: reference,526 SourceRef: reference,
518 Labels: opts.Labels,527 Labels: opts.Labels,
519- })528+ }, pulled.Target)
520- if err != nil {529+ cleanupErr := conchimage.RemoveFetchedImageRecord(
521- if !errors.Is(err, conchtemplate.ErrAlreadyExists) {530+ ctx, s.Containerd.ImageService(), pulled.SourceImageName, pulled.Target,
522- s.cleanupCanonicalTemplateRecord(ctx, info.BootIndexDigest)531+ )
523- }532+ if createErr != nil {
524- return TemplatePullResult{}, err533+ return TemplatePullResult{}, errors.Join(createErr, cleanupErr)
534+ }
535+ if cleanupErr != nil {
536+ s.cleanupTemplateRecord(ctx, entry.BootIndexDigest)
537+ return TemplatePullResult{}, fmt.Errorf("remove fetched source image record %s: %w", pulled.SourceImageName, cleanupErr)
525 }538 }
526 return TemplatePullResult{539 return TemplatePullResult{
527 TemplateID: entry.BootIndexDigest,540 TemplateID: entry.BootIndexDigest,
@@ -603,47 +616,15 @@ func (s *Service) CreateTemplate(ctx context.Context, opts TemplateCreateOptions
603 if err != nil {616 if err != nil {
604 return TemplateCreateResult{}, err617 return TemplateCreateResult{}, err
605 }618 }
606- keepCanonicalRecord := false
607- defer func() {
608- if !keepCanonicalRecord {
609- s.cleanupCanonicalTemplateRecord(ctx, result.bootIndexDigest)
610- }
611- }()
612- info, err := conchimage.InspectBootIndex(ctx, s.Containerd, result.bootIndexDigest)
613- if err != nil {
614- return TemplateCreateResult{}, fmt.Errorf("validate published boot index: %w", err)
615- }
616- if info.BootIndexDigest != result.bootIndexDigest {
617- return TemplateCreateResult{}, fmt.Errorf(
618- "validated boot index digest %s does not match published digest %s",
619- info.BootIndexDigest,
620- result.bootIndexDigest,
621- )
622- }
623- bootMode := conchtemplate.BootModeCold
624- if info.Resume {
625- bootMode = conchtemplate.BootModeResume
626- }
627- entry, err := s.Templates.Create(ctx, conchtemplate.Entry{
628- Origin: conchtemplate.OriginImage,
629- BootMode: bootMode,
630- BootIndexDigest: info.BootIndexDigest,
631- SourceRef: source,
632- Labels: opts.Labels,
633- })
634- if err != nil {
635- return TemplateCreateResult{}, err
636- }
637- keepCanonicalRecord = true
638 return TemplateCreateResult{619 return TemplateCreateResult{
639- TemplateID: entry.BootIndexDigest,620+ TemplateID: result.entry.BootIndexDigest,
640 BuildRef: result.buildRef,621 BuildRef: result.buildRef,
641 }, nil622 }, nil
642}623}
643 624 
644type templateBuildResult struct {625type templateBuildResult struct {
645- bootIndexDigest string626+ entry conchtemplate.Entry
646- buildRef string627+ buildRef string
647}628}
648 629 
649func (s *Service) createTemplateFromSource(ctx context.Context, opts TemplateCreateOptions) (templateBuildResult, error) {630func (s *Service) createTemplateFromSource(ctx context.Context, opts TemplateCreateOptions) (templateBuildResult, error) {
@@ -669,6 +650,11 @@ func (s *Service) createTemplateFromSource(ctx context.Context, opts TemplateCre
669 return templateBuildResult{}, fmt.Errorf("resolve pulled rootfs source image %s: %w", opts.Source, err)650 return templateBuildResult{}, fmt.Errorf("resolve pulled rootfs source image %s: %w", opts.Source, err)
670 }651 }
671 }652 }
653+ if conchimage.IsCanonicalTemplateRef(sourceImage.Name()) {
654+ return templateBuildResult{}, conchtemplate.ErrInvalidArgument.Wrap(fmt.Errorf(
655+ "canonical Template image %s cannot be used as a rootfs source", sourceImage.Name(),
656+ ))
657+ }
672 if err := conchimage.SetImageKindLabel(sourceCtx, s.Containerd.ImageService(), sourceImage.Name(), conchimage.ImageKindOCIImage); err != nil {658 if err := conchimage.SetImageKindLabel(sourceCtx, s.Containerd.ImageService(), sourceImage.Name(), conchimage.ImageKindOCIImage); err != nil {
673 return templateBuildResult{}, fmt.Errorf("label rootfs source image: %w", err)659 return templateBuildResult{}, fmt.Errorf("label rootfs source image: %w", err)
674 }660 }
@@ -687,8 +673,24 @@ func (s *Service) createTemplateFromSource(ctx context.Context, opts TemplateCre
687 if err != nil {673 if err != nil {
688 return templateBuildResult{}, conchimage.ErrConversionFailed.Wrap(fmt.Errorf("convert rootfs to EROFS: %w", err))674 return templateBuildResult{}, conchimage.ErrConversionFailed.Wrap(fmt.Errorf("convert rootfs to EROFS: %w", err))
689 }675 }
676+ defer func() {
677+ cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
678+ defer cancel()
679+ if err := conchimage.Remove(cleanupCtx, s.Containerd, runtimeapi.RemoveImageOptions{
680+ ImageName: converted.ImageName,
681+ }); err != nil {
682+ ulog.GetLogger().Warn("failed to remove temporary converted rootfs image",
683+ ulog.F("image", converted.ImageName),
684+ ulog.F("error", err))
685+ }
686+ }()
690 687 
691- published, err := conchimage.PublishBootIndex(ctx, s.Containerd, conchimage.PublishBootIndexOptions{688+ publishCtx, done, err := s.Containerd.WithLease(sourceCtx)
689+ if err != nil {
690+ return templateBuildResult{}, fmt.Errorf("create Template content lease: %w", err)
691+ }
692+ defer done(publishCtx)
693+ published, err := conchimage.PublishBootIndex(publishCtx, s.Containerd, conchimage.PublishBootIndexOptions{
692 RootfsImageName: converted.ImageName,694 RootfsImageName: converted.ImageName,
693 KernelPath: opts.KernelPath,695 KernelPath: opts.KernelPath,
694 InitrdPath: opts.InitrdPath,696 InitrdPath: opts.InitrdPath,
@@ -696,23 +698,20 @@ func (s *Service) createTemplateFromSource(ctx context.Context, opts TemplateCre
696 if err != nil {698 if err != nil {
697 return templateBuildResult{}, fmt.Errorf("publish boot image: %w", err)699 return templateBuildResult{}, fmt.Errorf("publish boot image: %w", err)
698 }700 }
699- 701+ entry, err := s.Templates.Create(publishCtx, conchtemplate.Entry{
700- // The converted image name is only a build-time handle. Once the Boot Index702+ Origin: conchtemplate.OriginImage,
701- // has been published, its digest-derived canonical image record is the GC703+ BootMode: conchtemplate.BootModeCold,
702- // root for the complete descriptor closure.704+ BootIndexDigest: published.BootIndexDigest,
703- cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)705+ SourceRef: opts.Source,
704- defer cancel()706+ Labels: opts.Labels,
705- if err := conchimage.Remove(cleanupCtx, s.Containerd, runtimeapi.RemoveImageOptions{707+ }, published.Target)
706- ImageName: converted.ImageName,708+ if err != nil {
707- }); err != nil {709+ return templateBuildResult{}, err
708- ulog.GetLogger().Warn("failed to remove temporary converted rootfs image",
709- ulog.F("image", converted.ImageName),
710- ulog.F("error", err))
711 }710 }
712 711 
713 return templateBuildResult{712 return templateBuildResult{
714- bootIndexDigest: published.BootIndexDigest,713+ entry: entry,
715- buildRef: published.BuildRef,714+ buildRef: published.BuildRef,
716 }, nil715 }, nil
717}716}
718 717 
@@ -749,19 +748,7 @@ func (s *Service) RemoveTemplate(ctx context.Context, id string) error {
749 if s == nil || s.Templates == nil {748 if s == nil || s.Templates == nil {
750 return fmt.Errorf("template store is not configured")749 return fmt.Errorf("template store is not configured")
751 }750 }
752- if s.Containerd == nil {751+ return s.Templates.Delete(ctx, id)
753- return fmt.Errorf("containerd client is required")
754- }
755- if _, err := s.Templates.Get(ctx, id); err != nil {
756- if !errors.Is(err, conchtemplate.ErrNotFound) {
757- return err
758- }
759- } else if err := s.Templates.Delete(ctx, id); err != nil {
760- return err
761- }
762- cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
763- defer cancel()
764- return conchimage.RemoveCanonicalBootIndexRecord(cleanupCtx, s.Containerd, id)
765}752}
766 753 
767func publicTemplateRecord(entry conchtemplate.Entry) runtimeapi.TemplateRecord {754func publicTemplateRecord(entry conchtemplate.Entry) runtimeapi.TemplateRecord {
@@ -779,10 +766,10 @@ func publicTemplateRecord(entry conchtemplate.Entry) runtimeapi.TemplateRecord {
779 }766 }
780}767}
781 768 
782-func (s *Service) cleanupCanonicalTemplateRecord(ctx context.Context, bootIndexDigest string) {769+func (s *Service) cleanupTemplateRecord(ctx context.Context, bootIndexDigest string) {
783 cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)770 cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
784 defer cancel()771 defer cancel()
785- if err := conchimage.RemoveCanonicalBootIndexRecord(cleanupCtx, s.Containerd, bootIndexDigest); err != nil {772+ if err := s.Templates.Delete(cleanupCtx, bootIndexDigest); err != nil {
786 ulog.GetLogger().Warn("failed to roll back canonical template image record",773 ulog.GetLogger().Warn("failed to roll back canonical template image record",
787 ulog.F("boot_index_digest", bootIndexDigest),774 ulog.F("boot_index_digest", bootIndexDigest),
788 ulog.F("error", err))775 ulog.F("error", err))
@@ -15,6 +15,7 @@ import (
15 15 
16 containerderrdefs "github.com/containerd/errdefs"16 containerderrdefs "github.com/containerd/errdefs"
17 "github.com/opencontainers/go-digest"17 "github.com/opencontainers/go-digest"
18+ ocispec "github.com/opencontainers/image-spec/specs-go/v1"
18 containerdclient "github.com/openeuler/Conch/internal/adapters/containerd/client"19 containerdclient "github.com/openeuler/Conch/internal/adapters/containerd/client"
19 containerdhost "github.com/openeuler/Conch/internal/adapters/containerd/host"20 containerdhost "github.com/openeuler/Conch/internal/adapters/containerd/host"
20 agentprotocol "github.com/openeuler/Conch/internal/agent/protocol"21 agentprotocol "github.com/openeuler/Conch/internal/agent/protocol"
@@ -49,6 +50,14 @@ type serializedDeleteOps struct {
49 calls atomic.Int3250 calls atomic.Int32
50}51}
51 52 
53+type failingCheckpointStore struct {
54+ state.Store
55+}
56+ 
57+func (failingCheckpointStore) AdvanceCheckpointHead(context.Context, string, string, string) error {
58+ return errors.New("checkpoint head changed")
59+}
60+ 
52func (f *serializedDeleteOps) Delete(sandbox.DeleteRequest) error {61func (f *serializedDeleteOps) Delete(sandbox.DeleteRequest) error {
53 if f.calls.Add(1) == 1 {62 if f.calls.Add(1) == 1 {
54 close(f.firstEntered)63 close(f.firstEntered)
@@ -156,7 +165,8 @@ func TestCheckpointSandboxPublishesCaptureAndAtomicallyAdvancesHead(t *testing.T
156 sandboxOps := &fakeSandboxOps{checkpointResults: []sandbox.CheckpointResult{captured}}165 sandboxOps := &fakeSandboxOps{checkpointResults: []sandbox.CheckpointResult{captured}}
157 store := newTestStore(t)166 store := newTestStore(t)
158 svc := New(sandboxOps, host.Client(), store)167 svc := New(sandboxOps, host.Client(), store)
159- seedTemplate(t, ctx, svc.Templates, t0Digest, conchtemplate.BootModeCold)168+ svc.Templates = host.TemplateStore()
169+ seedTemplate(t, ctx, host, t0Digest, conchtemplate.BootModeCold)
160 170 
161 before := state.SandboxRecord{171 before := state.SandboxRecord{
162 SandboxID: "sandbox-a",172 SandboxID: "sandbox-a",
@@ -190,7 +200,7 @@ func TestCheckpointSandboxPublishesCaptureAndAtomicallyAdvancesHead(t *testing.T
190 t.Fatalf("captured memory root still exists after publication: %v", err)200 t.Fatalf("captured memory root still exists after publication: %v", err)
191 }201 }
192 202 
193- t1, err := store.GetTemplate(ctx, result.TemplateID)203+ t1, err := svc.Templates.Get(ctx, result.TemplateID)
194 if err != nil {204 if err != nil {
195 t.Fatalf("GetTemplate(t1) error = %v", err)205 t.Fatalf("GetTemplate(t1) error = %v", err)
196 }206 }
@@ -215,7 +225,7 @@ func TestCheckpointSandboxPublishesCaptureAndAtomicallyAdvancesHead(t *testing.T
215func TestCheckpointSandboxDoesNotPersistBeforeValidationSucceeds(t *testing.T) {225func TestCheckpointSandboxDoesNotPersistBeforeValidationSucceeds(t *testing.T) {
216 ctx := context.Background()226 ctx := context.Background()
217 host := newRuntimeImageHost(t)227 host := newRuntimeImageHost(t)
218- sourceDigest := digest.FromString("checkpoint-source").String()228+ sourceDigest := buildColdBootIndex(t, host, "checkpoint-source")
219 memRoot := t.TempDir()229 memRoot := t.TempDir()
220 sandboxOps := &fakeSandboxOps{checkpointResults: []sandbox.CheckpointResult{{230 sandboxOps := &fakeSandboxOps{checkpointResults: []sandbox.CheckpointResult{{
221 MemRootPath: memRoot,231 MemRootPath: memRoot,
@@ -224,7 +234,13 @@ func TestCheckpointSandboxDoesNotPersistBeforeValidationSucceeds(t *testing.T) {
224 }}}234 }}}
225 store := newTestStore(t)235 store := newTestStore(t)
226 svc := New(sandboxOps, host.Client(), store)236 svc := New(sandboxOps, host.Client(), store)
227- seedTemplate(t, ctx, svc.Templates, sourceDigest, conchtemplate.BootModeCold)237+ svc.Templates = host.TemplateStore()
238+ seedTemplate(t, ctx, host, sourceDigest, conchtemplate.BootModeCold)
239+ if err := host.Client().ContentStore().Delete(
240+ containerdclient.NewNamespaceContext(ctx), digest.Digest(sourceDigest),
241+ ); err != nil {
242+ t.Fatalf("delete source Boot Index content: %v", err)
243+ }
228 before := state.SandboxRecord{244 before := state.SandboxRecord{
229 SandboxID: "sandbox-validation-failure",245 SandboxID: "sandbox-validation-failure",
230 CheckpointHeadTemplateID: sourceDigest,246 CheckpointHeadTemplateID: sourceDigest,
@@ -238,12 +254,18 @@ func TestCheckpointSandboxDoesNotPersistBeforeValidationSucceeds(t *testing.T) {
238 }); err == nil {254 }); err == nil {
239 t.Fatal("CheckpointSandbox() error = nil, want validation failure")255 t.Fatal("CheckpointSandbox() error = nil, want validation failure")
240 }256 }
241- templates, err := store.ListTemplates(ctx)257+ records, err := host.Client().ImageService().List(containerdclient.NewNamespaceContext(ctx))
242 if err != nil {258 if err != nil {
243- t.Fatalf("ListTemplates() error = %v", err)259+ t.Fatalf("List image records: %v", err)
244 }260 }
245- if len(templates) != 1 || templates[0].BootIndexDigest != sourceDigest {261+ canonicalRecords := 0
246- t.Fatalf("templates after failed validation = %#v, want only source template", templates)262+ for _, record := range records {
263+ if conchimage.IsCanonicalTemplateRef(record.Name) {
264+ canonicalRecords++
265+ }
266+ }
267+ if canonicalRecords != 1 {
268+ t.Fatalf("canonical records after failed validation = %d, want source only", canonicalRecords)
247 }269 }
248 after, err := store.GetSandbox(ctx, before.SandboxID)270 after, err := store.GetSandbox(ctx, before.SandboxID)
249 if err != nil {271 if err != nil {
@@ -254,6 +276,43 @@ func TestCheckpointSandboxDoesNotPersistBeforeValidationSucceeds(t *testing.T) {
254 }276 }
255}277}
256 278 
279+func TestCheckpointSandboxRemovesTemplateWhenHeadAdvanceFails(t *testing.T) {
280+ ctx := context.Background()
281+ host := newRuntimeImageHost(t)
282+ sourceDigest := buildColdBootIndex(t, host, "checkpoint-cas-source")
283+ store := newTestStore(t)
284+ if err := store.UpsertSandbox(ctx, state.SandboxRecord{
285+ SandboxID: "sandbox-cas",
286+ CheckpointHeadTemplateID: sourceDigest,
287+ }); err != nil {
288+ t.Fatal(err)
289+ }
290+ seedTemplate(t, ctx, host, sourceDigest, conchtemplate.BootModeCold)
291+ svc := New(&fakeSandboxOps{checkpointResults: []sandbox.CheckpointResult{{
292+ MemRootPath: t.TempDir(),
293+ VMMName: "cloud-hypervisor",
294+ MemorySizeMB: 128,
295+ }}}, host.Client(), failingCheckpointStore{Store: store})
296+ svc.Templates = host.TemplateStore()
297+ 
298+ if _, err := svc.CheckpointSandbox(ctx, SandboxCheckpointOptions{SandboxID: "sandbox-cas"}); err == nil {
299+ t.Fatal("CheckpointSandbox() error = nil, want checkpoint head failure")
300+ }
301+ records, err := host.Client().ImageService().List(containerdclient.NewNamespaceContext(ctx))
302+ if err != nil {
303+ t.Fatal(err)
304+ }
305+ canonical := make([]string, 0, len(records))
306+ for _, record := range records {
307+ if conchimage.IsCanonicalTemplateRef(record.Name) {
308+ canonical = append(canonical, record.Target.Digest.String())
309+ }
310+ }
311+ if len(canonical) != 1 || canonical[0] != sourceDigest {
312+ t.Fatalf("canonical Templates after CAS failure = %v, want source only", canonical)
313+ }
314+}
315+ 
257func TestCheckpointSandboxBuildsConsecutiveTemplateLineage(t *testing.T) {316func TestCheckpointSandboxBuildsConsecutiveTemplateLineage(t *testing.T) {
258 ctx := context.Background()317 ctx := context.Background()
259 host := newRuntimeImageHost(t)318 host := newRuntimeImageHost(t)
@@ -266,7 +325,8 @@ func TestCheckpointSandboxBuildsConsecutiveTemplateLineage(t *testing.T) {
266 }}325 }}
267 store := newTestStore(t)326 store := newTestStore(t)
268 svc := New(sandboxOps, host.Client(), store)327 svc := New(sandboxOps, host.Client(), store)
269- seedTemplate(t, ctx, svc.Templates, t0Digest, conchtemplate.BootModeCold)328+ svc.Templates = host.TemplateStore()
329+ seedTemplate(t, ctx, host, t0Digest, conchtemplate.BootModeCold)
270 if err := store.UpsertSandbox(ctx, state.SandboxRecord{330 if err := store.UpsertSandbox(ctx, state.SandboxRecord{
271 SandboxID: "sandbox-lineage",331 SandboxID: "sandbox-lineage",
272 CheckpointHeadTemplateID: t0Digest,332 CheckpointHeadTemplateID: t0Digest,
@@ -286,11 +346,11 @@ func TestCheckpointSandboxBuildsConsecutiveTemplateLineage(t *testing.T) {
286 t.Fatalf("checkpoint digests = (%q, %q)", t1Result.TemplateID, t2Result.TemplateID)346 t.Fatalf("checkpoint digests = (%q, %q)", t1Result.TemplateID, t2Result.TemplateID)
287 }347 }
288 348 
289- t1, err := store.GetTemplate(ctx, t1Result.TemplateID)349+ t1, err := svc.Templates.Get(ctx, t1Result.TemplateID)
290 if err != nil {350 if err != nil {
291 t.Fatalf("GetTemplate(t1) error = %v", err)351 t.Fatalf("GetTemplate(t1) error = %v", err)
292 }352 }
293- t2, err := store.GetTemplate(ctx, t2Result.TemplateID)353+ t2, err := svc.Templates.Get(ctx, t2Result.TemplateID)
294 if err != nil {354 if err != nil {
295 t.Fatalf("GetTemplate(t2) error = %v", err)355 t.Fatalf("GetTemplate(t2) error = %v", err)
296 }356 }
@@ -941,19 +1001,53 @@ func TestCreateTemplateRequiresContainerdClient(t *testing.T) {
941 }1001 }
942}1002}
943 1003 
1004+func TestCreateTemplateRejectsCanonicalTemplateSource(t *testing.T) {
1005+ ctx := context.Background()
1006+ host := newRuntimeImageHost(t)
1007+ bootIndexDigest := buildColdBootIndex(t, host, "canonical-rootfs-source")
1008+ seedTemplate(t, ctx, host, bootIndexDigest, conchtemplate.BootModeCold)
1009+ buildRef, err := conchimage.CanonicalTemplateRef(bootIndexDigest)
1010+ if err != nil {
1011+ t.Fatal(err)
1012+ }
1013+ 
1014+ svc := New(nil, host.Client(), newTestStore(t))
1015+ svc.Templates = host.TemplateStore()
1016+ _, err = svc.CreateTemplate(ctx, TemplateCreateOptions{
1017+ Source: buildRef,
1018+ KernelPath: "unused-kernel",
1019+ InitrdPath: "unused-initrd",
1020+ })
1021+ if !errors.Is(err, conchtemplate.ErrInvalidArgument) {
1022+ t.Fatalf("CreateTemplate() error = %v, want ErrInvalidArgument", err)
1023+ }
1024+ 
1025+ record, err := host.Client().ImageService().Get(containerdclient.NewNamespaceContext(ctx), buildRef)
1026+ if err != nil {
1027+ t.Fatalf("get canonical Template image: %v", err)
1028+ }
1029+ if got := record.Labels[conchimage.ImageKindLabel]; got != conchimage.ImageKindBootIndexCold {
1030+ t.Fatalf("canonical Template image kind = %q, want %q", got, conchimage.ImageKindBootIndexCold)
1031+ }
1032+ if _, err := svc.Templates.Get(ctx, bootIndexDigest); err != nil {
1033+ t.Fatalf("Get() original Template after rejected create: %v", err)
1034+ }
1035+}
1036+ 
944func TestUnpackTemplateResolvesBootIndexByDigest(t *testing.T) {1037func TestUnpackTemplateResolvesBootIndexByDigest(t *testing.T) {
945 ctx := context.Background()1038 ctx := context.Background()
946 host := newRuntimeImageHost(t)1039 host := newRuntimeImageHost(t)
947 bootIndexDigest := buildColdBootIndex(t, host, "explicit-unpack")1040 bootIndexDigest := buildColdBootIndex(t, host, "explicit-unpack")
948 store := newTestStore(t)1041 store := newTestStore(t)
949 svc := New(nil, host.Client(), store)1042 svc := New(nil, host.Client(), store)
1043+ svc.Templates = host.TemplateStore()
950 1044 
951 if _, err := svc.Templates.Create(ctx, conchtemplate.Entry{1045 if _, err := svc.Templates.Create(ctx, conchtemplate.Entry{
952 Origin: conchtemplate.OriginImage,1046 Origin: conchtemplate.OriginImage,
953 BootMode: conchtemplate.BootModeCold,1047 BootMode: conchtemplate.BootModeCold,
954 BootIndexDigest: bootIndexDigest,1048 BootIndexDigest: bootIndexDigest,
955 SourceRef: "not-the-boot-index:latest",1049 SourceRef: "not-the-boot-index:latest",
956- }); err != nil {1050+ }, bootIndexTarget(t, host, bootIndexDigest)); err != nil {
957 t.Fatalf("create template: %v", err)1051 t.Fatalf("create template: %v", err)
958 }1052 }
959 1053 
@@ -1030,20 +1124,35 @@ func buildColdBootIndex(t *testing.T, host *containerdhost.Host, name string) st
1030func seedTemplate(1124func seedTemplate(
1031 t *testing.T,1125 t *testing.T,
1032 ctx context.Context,1126 ctx context.Context,
1033- templates conchtemplate.Store,1127+ host *containerdhost.Host,
1034 bootIndexDigest string,1128 bootIndexDigest string,
1035 bootMode conchtemplate.BootMode,1129 bootMode conchtemplate.BootMode,
1036) {1130) {
1037 t.Helper()1131 t.Helper()
1038- if _, err := templates.Create(ctx, conchtemplate.Entry{1132+ if _, err := host.TemplateStore().Create(ctx, conchtemplate.Entry{
1039 Origin: conchtemplate.OriginImage,1133 Origin: conchtemplate.OriginImage,
1040 BootMode: bootMode,1134 BootMode: bootMode,
1041 BootIndexDigest: bootIndexDigest,1135 BootIndexDigest: bootIndexDigest,
1042- }); err != nil {1136+ }, bootIndexTarget(t, host, bootIndexDigest)); err != nil {
1043 t.Fatalf("CreateTemplate(%s) error = %v", bootIndexDigest, err)1137 t.Fatalf("CreateTemplate(%s) error = %v", bootIndexDigest, err)
1044 }1138 }
1045}1139}
1046 1140 
1141+func bootIndexTarget(t *testing.T, host *containerdhost.Host, bootIndexDigest string) ocispec.Descriptor {
1142+ t.Helper()
1143+ info, err := host.Client().ContentStore().Info(
1144+ containerdclient.NewNamespaceContext(context.Background()), digest.Digest(bootIndexDigest),
1145+ )
1146+ if err != nil {
1147+ t.Fatalf("resolve Boot Index %s: %v", bootIndexDigest, err)
1148+ }
1149+ return ocispec.Descriptor{
1150+ MediaType: ocispec.MediaTypeImageIndex,
1151+ Digest: digest.Digest(bootIndexDigest),
1152+ Size: info.Size,
1153+ }
1154+}
1155+ 
1047func newTestStore(t *testing.T) *state.BoltStore {1156func newTestStore(t *testing.T) *state.BoltStore {
1048 t.Helper()1157 t.Helper()
1049 store, err := state.OpenBolt(t.TempDir() + "/state.db")1158 store, err := state.OpenBolt(t.TempDir() + "/state.db")
@@ -127,9 +127,8 @@ func New(cfg *config.Config) (*Daemon, error) {
127 }127 }
128 128 
129 host, err := containerdhost.Start(ctx, containerdhost.Config{129 host, err := containerdhost.Start(ctx, containerdhost.Config{
130- RootDir: cfg.ContainerdRootDir(),130+ RootDir: cfg.ContainerdRootDir(),
131- StateDir: cfg.ContainerdStateDir(),131+ StateDir: cfg.ContainerdStateDir(),
132- TemplateStore: store,
133 Snapshot: containerdhost.SnapshotConfig{132 Snapshot: containerdhost.SnapshotConfig{
134 WorkDir: cfg.Server.WorkDir,133 WorkDir: cfg.Server.WorkDir,
135 },134 },
@@ -9,9 +9,11 @@ import (
9 "testing"9 "testing"
10 10 
11 digest "github.com/opencontainers/go-digest"11 digest "github.com/opencontainers/go-digest"
12+ ocispec "github.com/opencontainers/image-spec/specs-go/v1"
12 "github.com/openeuler/Conch/internal/conchruntime"13 "github.com/openeuler/Conch/internal/conchruntime"
13 "github.com/openeuler/Conch/internal/daemon/state"14 "github.com/openeuler/Conch/internal/daemon/state"
14 "github.com/openeuler/Conch/internal/sandbox"15 "github.com/openeuler/Conch/internal/sandbox"
16+ conchtemplate "github.com/openeuler/Conch/internal/template"
15)17)
16 18 
17const (19const (
@@ -204,10 +206,9 @@ func TestHandleInspectMissingTemplateReturnsDomainError(t *testing.T) {
204 }206 }
205 t.Cleanup(func() { _ = store.Close() })207 t.Cleanup(func() { _ = store.Close() })
206 208 
207- server := &Daemon{209+ runtimeService := conchruntime.New(nil, nil, store)
208- router: http.NewServeMux(),210+ runtimeService.Templates = missingTemplateStore{}
209- runtimeService: conchruntime.New(nil, nil, store),211+ server := &Daemon{router: http.NewServeMux(), runtimeService: runtimeService}
210- }
211 server.routes()212 server.routes()
212 recorder := httptest.NewRecorder()213 recorder := httptest.NewRecorder()
213 missingDigest := digest.FromString("missing-template").String()214 missingDigest := digest.FromString("missing-template").String()
@@ -226,3 +227,19 @@ func TestHandleInspectMissingTemplateReturnsDomainError(t *testing.T) {
226 t.Fatalf("response = %#v, error = %v", response, err)227 t.Fatalf("response = %#v, error = %v", response, err)
227 }228 }
228}229}
230+ 
231+type missingTemplateStore struct{}
232+ 
233+func (missingTemplateStore) Create(context.Context, conchtemplate.Entry, ocispec.Descriptor) (conchtemplate.Entry, error) {
234+ return conchtemplate.Entry{}, conchtemplate.ErrNotFound.New()
235+}
236+ 
237+func (missingTemplateStore) Get(context.Context, string) (conchtemplate.Entry, error) {
238+ return conchtemplate.Entry{}, conchtemplate.ErrNotFound.New()
239+}
240+ 
241+func (missingTemplateStore) List(context.Context, conchtemplate.Filter) ([]conchtemplate.Entry, error) {
242+ return nil, nil
243+}
244+ 
245+func (missingTemplateStore) Delete(context.Context, string) error { return nil }
@@ -11,32 +11,18 @@ import (
11 "time"11 "time"
12 12 
13 bolt "go.etcd.io/bbolt"13 bolt "go.etcd.io/bbolt"
14- 
15- conchtemplate "github.com/openeuler/Conch/internal/template"
16)14)
17 15 
18var ErrNotFound = errors.New("state record not found")16var ErrNotFound = errors.New("state record not found")
19 17 
20var buckets = [][]byte{18var buckets = [][]byte{
21 []byte("sandboxes"),19 []byte("sandboxes"),
22- []byte("templates"),
23}20}
24 21 
25type BoltStore struct {22type BoltStore struct {
26 db *bolt.DB23 db *bolt.DB
27}24}
28 25 
29-type templateRecord struct {
30- Origin string `json:"origin"`
31- BootMode string `json:"boot_mode"`
32- BootIndexDigest string `json:"boot_index_digest"`
33- ParentBootIndexDigest string `json:"parent_boot_index_digest,omitempty"`
34- SourceSandboxID string `json:"source_sandbox_id,omitempty"`
35- SourceRef string `json:"source_ref,omitempty"`
36- Labels map[string]string `json:"labels,omitempty"`
37- CreatedAt int64 `json:"created_at"`
38-}
39- 
40func OpenBolt(path string) (*BoltStore, error) {26func OpenBolt(path string) (*BoltStore, error) {
41 if path == "" {27 if path == "" {
42 return nil, fmt.Errorf("state db path is required")28 return nil, fmt.Errorf("state db path is required")
@@ -159,140 +145,31 @@ func (s *BoltStore) DeleteSandbox(ctx context.Context, id string) error {
159 return s.delete(ctx, []byte("sandboxes"), id)145 return s.delete(ctx, []byte("sandboxes"), id)
160}146}
161 147 
162-func (s *BoltStore) CreateTemplate(_ context.Context, entry conchtemplate.Entry) error {148+func (s *BoltStore) AdvanceCheckpointHead(_ context.Context, sandboxID, expectedDigest, nextDigest string) error {
163- rec := templateRecordFromEntry(entry)149+ sandboxID = strings.TrimSpace(sandboxID)
164- data, err := json.Marshal(rec)150+ expectedDigest = strings.TrimSpace(expectedDigest)
165- if err != nil {151+ nextDigest = strings.TrimSpace(nextDigest)
166- return fmt.Errorf("marshal template entry: %w", err)152+ if sandboxID == "" || expectedDigest == "" || nextDigest == "" {
153+ return fmt.Errorf("sandbox id and checkpoint head digests are required")
167 }154 }
168 return s.db.Update(func(tx *bolt.Tx) error {155 return s.db.Update(func(tx *bolt.Tx) error {
169- templates := tx.Bucket([]byte("templates"))
170- if templates.Get([]byte(entry.BootIndexDigest)) != nil {
171- return fmt.Errorf("%w: %s", conchtemplate.ErrAlreadyExists, entry.BootIndexDigest)
172- }
173- return templates.Put([]byte(entry.BootIndexDigest), data)
174- })
175-}
176- 
177-func (s *BoltStore) GetTemplate(ctx context.Context, bootIndexDigest string) (conchtemplate.Entry, error) {
178- var rec templateRecord
179- if err := s.get(ctx, []byte("templates"), bootIndexDigest, &rec); err != nil {
180- if errors.Is(err, ErrNotFound) {
181- return conchtemplate.Entry{}, conchtemplate.ErrNotFound.Wrap(err)
182- }
183- return conchtemplate.Entry{}, err
184- }
185- return templateEntryFromRecord(rec), nil
186-}
187- 
188-func (s *BoltStore) ListTemplates(ctx context.Context) ([]conchtemplate.Entry, error) {
189- var out []conchtemplate.Entry
190- err := s.list(ctx, []byte("templates"), func(data []byte) error {
191- var rec templateRecord
192- if err := json.Unmarshal(data, &rec); err != nil {
193- return err
194- }
195- out = append(out, templateEntryFromRecord(rec))
196- return nil
197- })
198- return out, err
199-}
200- 
201-func (s *BoltStore) DeleteTemplate(ctx context.Context, bootIndexDigest string) error {
202- return s.delete(ctx, []byte("templates"), bootIndexDigest)
203-}
204- 
205-// PublishCheckpoint atomically creates a complete checkpoint Template Entry
206-// and advances the Sandbox checkpoint head. Content publication and validation
207-// happen before this transaction, so a failed transaction can only leave safe
208-// orphaned content.
209-func (s *BoltStore) PublishCheckpoint(_ context.Context, checkpoint conchtemplate.Entry) error {
210- entry := checkpoint
211- bootIndexDigest := entry.BootIndexDigest
212- sandboxID := strings.TrimSpace(entry.SourceSandboxID)
213- if sandboxID == "" {
214- return fmt.Errorf("sandbox id is required")
215- }
216- expectedHeadDigest := strings.TrimSpace(entry.ParentBootIndexDigest)
217- if expectedHeadDigest == "" {
218- return fmt.Errorf("expected checkpoint head boot index digest is required")
219- }
220- if entry.Origin != conchtemplate.OriginCheckpoint {
221- return fmt.Errorf(
222- "checkpoint template origin is %q, want %q",
223- entry.Origin,
224- conchtemplate.OriginCheckpoint,
225- )
226- }
227- if entry.BootMode != conchtemplate.BootModeResume {
228- return fmt.Errorf(
229- "checkpoint template boot mode is %q, want %q",
230- entry.BootMode,
231- conchtemplate.BootModeResume,
232- )
233- }
234- templateData, err := json.Marshal(templateRecordFromEntry(entry))
235- if err != nil {
236- return fmt.Errorf("marshal template entry: %w", err)
237- }
238- 
239- return s.db.Update(func(tx *bolt.Tx) error {
240- templates := tx.Bucket([]byte("templates"))
241 sandboxes := tx.Bucket([]byte("sandboxes"))156 sandboxes := tx.Bucket([]byte("sandboxes"))
242- if templates.Get([]byte(bootIndexDigest)) != nil {157+ data := sandboxes.Get([]byte(sandboxID))
243- return fmt.Errorf("%w: %s", conchtemplate.ErrAlreadyExists, bootIndexDigest)158+ if data == nil {
244- }
245- 
246- var sandboxRecord SandboxRecord
247- if data := sandboxes.Get([]byte(sandboxID)); data == nil {
248 return fmt.Errorf("%w: %s", ErrNotFound, sandboxID)159 return fmt.Errorf("%w: %s", ErrNotFound, sandboxID)
249- } else if err := json.Unmarshal(data, &sandboxRecord); err != nil {160+ }
161+ var record SandboxRecord
162+ if err := json.Unmarshal(data, &record); err != nil {
250 return fmt.Errorf("unmarshal sandbox record %s: %w", sandboxID, err)163 return fmt.Errorf("unmarshal sandbox record %s: %w", sandboxID, err)
251 }164 }
252- currentHeadDigest := strings.TrimSpace(sandboxRecord.CheckpointHeadTemplateID)165+ if record.CheckpointHeadTemplateID != expectedDigest {
253- if currentHeadDigest == "" {166+ return fmt.Errorf("sandbox %s checkpoint head changed from %s to %s", sandboxID, expectedDigest, record.CheckpointHeadTemplateID)
254- return fmt.Errorf("sandbox %s has no checkpoint head Template ID", sandboxID)
255 }167 }
256- if currentHeadDigest != expectedHeadDigest {168+ record.CheckpointHeadTemplateID = nextDigest
257- return fmt.Errorf("sandbox %s checkpoint head boot index digest changed from %s to %s", sandboxID, expectedHeadDigest, currentHeadDigest)169+ data, err := json.Marshal(record)
258- }
259- if sourceID := entry.SourceSandboxID; sourceID != strings.TrimSpace(sandboxRecord.SandboxID) {
260- return fmt.Errorf("template %s source sandbox %s does not match %s", bootIndexDigest, sourceID, sandboxRecord.SandboxID)
261- }
262- sandboxRecord.CheckpointHeadTemplateID = entry.BootIndexDigest
263- sandboxData, err := json.Marshal(sandboxRecord)
264 if err != nil {170 if err != nil {
265 return fmt.Errorf("marshal sandbox record: %w", err)171 return fmt.Errorf("marshal sandbox record: %w", err)
266 }172 }
267- if err := templates.Put([]byte(bootIndexDigest), templateData); err != nil {173+ return sandboxes.Put([]byte(sandboxID), data)
268- return err
269- }
270- return sandboxes.Put([]byte(sandboxID), sandboxData)
271 })174 })
272}175}
273- 
274-func templateRecordFromEntry(entry conchtemplate.Entry) templateRecord {
275- return templateRecord{
276- Origin: string(entry.Origin),
277- BootMode: string(entry.BootMode),
278- BootIndexDigest: entry.BootIndexDigest,
279- ParentBootIndexDigest: entry.ParentBootIndexDigest,
280- SourceSandboxID: entry.SourceSandboxID,
281- SourceRef: entry.SourceRef,
282- Labels: entry.Labels,
283- CreatedAt: entry.CreatedAt,
284- }
285-}
286- 
287-func templateEntryFromRecord(rec templateRecord) conchtemplate.Entry {
288- return conchtemplate.Entry{
289- Origin: conchtemplate.Origin(rec.Origin),
290- BootMode: conchtemplate.BootMode(rec.BootMode),
291- BootIndexDigest: rec.BootIndexDigest,
292- ParentBootIndexDigest: rec.ParentBootIndexDigest,
293- SourceSandboxID: rec.SourceSandboxID,
294- SourceRef: rec.SourceRef,
295- Labels: rec.Labels,
296- CreatedAt: rec.CreatedAt,
297- }
298-}
@@ -2,23 +2,13 @@ package state
2 2 
3import (3import (
4 "context"4 "context"
5- "encoding/json"
6- "errors"
7 "testing"5 "testing"
8 6 
9- "github.com/opencontainers/go-digest"
10 bolt "go.etcd.io/bbolt"7 bolt "go.etcd.io/bbolt"
11- 
12- conchtemplate "github.com/openeuler/Conch/internal/template"
13)8)
14 9 
15func TestBoltStoreSandboxCRUD(t *testing.T) {10func TestBoltStoreSandboxCRUD(t *testing.T) {
16- store, err := OpenBolt(t.TempDir() + "/state.db")11+ store := newBoltStore(t)
17- if err != nil {
18- t.Fatalf("OpenBolt() error = %v", err)
19- }
20- defer store.Close()
21- 
22 ctx := context.Background()12 ctx := context.Background()
23 sandbox := SandboxRecord{13 sandbox := SandboxRecord{
24 SandboxID: "sandbox-1",14 SandboxID: "sandbox-1",
@@ -27,92 +17,69 @@ func TestBoltStoreSandboxCRUD(t *testing.T) {
27 if err := store.UpsertSandbox(ctx, sandbox); err != nil {17 if err := store.UpsertSandbox(ctx, sandbox); err != nil {
28 t.Fatalf("UpsertSandbox() error = %v", err)18 t.Fatalf("UpsertSandbox() error = %v", err)
29 }19 }
30- duplicate := sandbox20+ sandbox.CheckpointHeadTemplateID = "template-replacement"
31- duplicate.CheckpointHeadTemplateID = "template-replacement"21+ if err := store.UpsertSandbox(ctx, sandbox); err != nil {
32- if err := store.UpsertSandbox(ctx, duplicate); err != nil {
33 t.Fatalf("UpsertSandbox(duplicate) error = %v", err)22 t.Fatalf("UpsertSandbox(duplicate) error = %v", err)
34 }23 }
35- gotSandbox, err := store.GetSandbox(ctx, sandbox.SandboxID)24+ got, err := store.GetSandbox(ctx, sandbox.SandboxID)
36- if err != nil {25+ if err != nil || got != sandbox {
37- t.Fatalf("GetSandbox() error = %v", err)26+ t.Fatalf("GetSandbox() = %#v, %v; want %#v", got, err, sandbox)
38 }27 }
39- if gotSandbox != duplicate {
40- t.Fatalf("GetSandbox() = %#v, want %#v", gotSandbox, duplicate)
41- }
42- 
43 if err := store.DeleteSandbox(ctx, sandbox.SandboxID); err != nil {28 if err := store.DeleteSandbox(ctx, sandbox.SandboxID); err != nil {
44 t.Fatalf("DeleteSandbox() error = %v", err)29 t.Fatalf("DeleteSandbox() error = %v", err)
45 }30 }
46 if _, err := store.GetSandbox(ctx, sandbox.SandboxID); err == nil {31 if _, err := store.GetSandbox(ctx, sandbox.SandboxID); err == nil {
47- t.Fatalf("GetSandbox() after delete got nil error")32+ t.Fatal("GetSandbox() after delete error = nil")
48 }33 }
49}34}
50 35 
51func TestBoltStoreRejectsIncompleteSandboxRecord(t *testing.T) {36func TestBoltStoreRejectsIncompleteSandboxRecord(t *testing.T) {
52- store, err := OpenBolt(t.TempDir() + "/state.db")37+ store := newBoltStore(t)
53- if err != nil {
54- t.Fatalf("OpenBolt() error = %v", err)
55- }
56- defer store.Close()
57- 
58 valid := SandboxRecord{38 valid := SandboxRecord{
59 SandboxID: "sandbox-1",39 SandboxID: "sandbox-1",
60 CheckpointHeadTemplateID: "template-1",40 CheckpointHeadTemplateID: "template-1",
61 }41 }
62- tests := []struct {42+ for _, tc := range []struct {
63 name string43 name string
64 mutate func(*SandboxRecord)44 mutate func(*SandboxRecord)
65 }{45 }{
66 {name: "sandbox id", mutate: func(rec *SandboxRecord) { rec.SandboxID = "" }},46 {name: "sandbox id", mutate: func(rec *SandboxRecord) { rec.SandboxID = "" }},
67- {name: "checkpoint head digest", mutate: func(rec *SandboxRecord) { rec.CheckpointHeadTemplateID = "" }},47+ {name: "checkpoint head", mutate: func(rec *SandboxRecord) { rec.CheckpointHeadTemplateID = "" }},
68- }48+ } {
69- for _, tc := range tests {
70 t.Run(tc.name, func(t *testing.T) {49 t.Run(tc.name, func(t *testing.T) {
71- rec := valid50+ record := valid
72- tc.mutate(&rec)51+ tc.mutate(&record)
73- if err := store.UpsertSandbox(context.Background(), rec); err == nil {52+ if err := store.UpsertSandbox(context.Background(), record); err == nil {
74- t.Fatal("UpsertSandbox() error = nil, want incomplete record rejection")53+ t.Fatal("UpsertSandbox() error = nil")
75 }54 }
76 })55 })
77 }56 }
78}57}
79 58 
80func TestBoltStoreAcceptsCreatingSandboxRecord(t *testing.T) {59func TestBoltStoreAcceptsCreatingSandboxRecord(t *testing.T) {
81- store, err := OpenBolt(t.TempDir() + "/state.db")60+ store := newBoltStore(t)
82- if err != nil {61+ record := SandboxRecord{
83- t.Fatalf("OpenBolt() error = %v", err)
84- }
85- defer store.Close()
86- 
87- rec := SandboxRecord{
88 SandboxID: "sandbox-creating",62 SandboxID: "sandbox-creating",
89 State: SandboxCreating,63 State: SandboxCreating,
90 SourceTemplateID: "template-1",64 SourceTemplateID: "template-1",
91 }65 }
92- if err := store.UpsertSandbox(context.Background(), rec); err != nil {66+ if err := store.UpsertSandbox(context.Background(), record); err != nil {
93 t.Fatalf("UpsertSandbox() error = %v", err)67 t.Fatalf("UpsertSandbox() error = %v", err)
94 }68 }
95- got, err := store.GetSandbox(context.Background(), rec.SandboxID)69+ got, err := store.GetSandbox(context.Background(), record.SandboxID)
96- if err != nil {70+ if err != nil || got != record {
97- t.Fatalf("GetSandbox() error = %v", err)71+ t.Fatalf("GetSandbox() = %#v, %v; want %#v", got, err, record)
98- }
99- if got != rec {
100- t.Fatalf("GetSandbox() = %#v, want %#v", got, rec)
101 }72 }
102}73}
103 74 
104-func TestBoltStoreInitializesCurrentBuckets(t *testing.T) {75+func TestBoltStoreInitializesOnlySandboxBucket(t *testing.T) {
105- store, err := OpenBolt(t.TempDir() + "/state.db")76+ store := newBoltStore(t)
106- if err != nil {
107- t.Fatalf("OpenBolt() error = %v", err)
108- }
109- defer store.Close()
110- 
111 if err := store.db.View(func(tx *bolt.Tx) error {77 if err := store.db.View(func(tx *bolt.Tx) error {
112- for _, bucket := range buckets {78+ if tx.Bucket([]byte("sandboxes")) == nil {
113- if tx.Bucket(bucket) == nil {79+ t.Fatal("sandboxes bucket is missing")
114- t.Fatalf("bucket %q is missing", bucket)80+ }
115- }81+ if tx.Bucket([]byte("templates")) != nil {
82+ t.Fatal("templates bucket must not be owned by state.db")
116 }83 }
117 return nil84 return nil
118 }); err != nil {85 }); err != nil {
@@ -120,179 +87,52 @@ func TestBoltStoreInitializesCurrentBuckets(t *testing.T) {
120 }87 }
121}88}
122 89 
123-func TestBoltStoreTemplateCRUD(t *testing.T) {90+func TestBoltStoreAdvanceCheckpointHead(t *testing.T) {
124- store, err := OpenBolt(t.TempDir() + "/state.db")91+ store := newBoltStore(t)
125- if err != nil {
126- t.Fatalf("OpenBolt() error = %v", err)
127- }
128- defer store.Close()
129- 
130 ctx := context.Background()92 ctx := context.Background()
131- rec := conchtemplate.Entry{
132- Origin: conchtemplate.OriginImage,
133- BootMode: conchtemplate.BootModeCold,
134- BootIndexDigest: digest.FromString("template-1").String(),
135- Labels: map[string]string{"purpose": "test"},
136- CreatedAt: 1,
137- }
138- if err := store.CreateTemplate(ctx, rec); err != nil {
139- t.Fatalf("CreateTemplate() error = %v", err)
140- }
141- got, err := store.GetTemplate(ctx, rec.BootIndexDigest)
142- if err != nil {
143- t.Fatalf("GetTemplate() error = %v", err)
144- }
145- if got.Origin != rec.Origin || got.Labels["purpose"] != "test" {
146- t.Fatalf("GetTemplate() = %#v, want %#v", got, rec)
147- }
148- 
149- duplicate := rec
150- duplicate.Origin = conchtemplate.OriginCheckpoint
151- duplicate.BootMode = conchtemplate.BootModeResume
152- if err := store.CreateTemplate(ctx, duplicate); !errors.Is(err, conchtemplate.ErrAlreadyExists) {
153- t.Fatalf("CreateTemplate(duplicate) error = %v, want ErrAlreadyExists", err)
154- }
155- got, err = store.GetTemplate(ctx, rec.BootIndexDigest)
156- if err != nil {
157- t.Fatalf("GetTemplate() after duplicate error = %v", err)
158- }
159- if got.BootIndexDigest != rec.BootIndexDigest {
160- t.Fatalf("duplicate CreateTemplate overwrote digest: got %q, want %q", got.BootIndexDigest, rec.BootIndexDigest)
161- }
162- items, err := store.ListTemplates(ctx)
163- if err != nil {
164- t.Fatalf("ListTemplates() error = %v", err)
165- }
166- if len(items) != 1 || items[0].BootIndexDigest != rec.BootIndexDigest {
167- t.Fatalf("ListTemplates() = %#v", items)
168- }
169- if err := store.DeleteTemplate(ctx, rec.BootIndexDigest); err != nil {
170- t.Fatalf("DeleteTemplate() error = %v", err)
171- }
172- if err := store.DeleteTemplate(ctx, rec.BootIndexDigest); err != nil {
173- t.Fatalf("DeleteTemplate() second call error = %v", err)
174- }
175- if _, err := store.GetTemplate(ctx, rec.BootIndexDigest); err == nil {
176- t.Fatalf("GetTemplate() after delete got nil error")
177- }
178-}
179- 
180-func TestBoltStoreLeavesTemplateValidationToDomain(t *testing.T) {
181- store, err := OpenBolt(t.TempDir() + "/state.db")
182- if err != nil {
183- t.Fatalf("OpenBolt() error = %v", err)
184- }
185- defer store.Close()
186- 
187- record := templateRecord{
188- Origin: string(conchtemplate.OriginImage),
189- BootMode: string(conchtemplate.BootModeCold),
190- BootIndexDigest: "not-a-digest",
191- }
192- data, err := json.Marshal(record)
193- if err != nil {
194- t.Fatal(err)
195- }
196- if err := store.db.Update(func(tx *bolt.Tx) error {
197- return tx.Bucket([]byte("templates")).Put([]byte(record.BootIndexDigest), data)
198- }); err != nil {
199- t.Fatal(err)
200- }
201- 
202- ctx := context.Background()
203- got, err := store.GetTemplate(ctx, record.BootIndexDigest)
204- if err != nil {
205- t.Fatalf("GetTemplate() error = %v", err)
206- }
207- if got.BootIndexDigest != record.BootIndexDigest {
208- t.Fatalf("GetTemplate() = %#v", got)
209- }
210- items, err := store.ListTemplates(ctx)
211- if err != nil {
212- t.Fatalf("ListTemplates() error = %v", err)
213- }
214- if len(items) != 1 || items[0].BootIndexDigest != record.BootIndexDigest {
215- t.Fatalf("ListTemplates() = %#v", items)
216- }
217- if _, err := conchtemplate.NewStore(store).List(ctx, conchtemplate.Filter{}); err == nil {
218- t.Fatal("domain List() error = nil, want invalid digest rejection")
219- }
220-}
221- 
222-func TestBoltStorePublishCheckpointAdvancesHeadAtomically(t *testing.T) {
223- store, err := OpenBolt(t.TempDir() + "/state.db")
224- if err != nil {
225- t.Fatalf("OpenBolt() error = %v", err)
226- }
227- defer store.Close()
228- 
229- ctx := context.Background()
230- checkpointDigest := digest.FromString("checkpoint").String()
231- sourceDigest := digest.FromString("source").String()
232- if err := store.UpsertSandbox(ctx, SandboxRecord{
233- SandboxID: "sb-1",
234- CheckpointHeadTemplateID: sourceDigest,
235- }); err != nil {
236- t.Fatal(err)
237- }
238- 
239- if err := store.PublishCheckpoint(ctx, conchtemplate.Entry{
240- Origin: conchtemplate.OriginCheckpoint,
241- BootMode: conchtemplate.BootModeResume,
242- BootIndexDigest: checkpointDigest,
243- ParentBootIndexDigest: sourceDigest,
244- SourceSandboxID: "sb-1",
245- }); err != nil {
246- t.Fatalf("PublishCheckpoint() error = %v", err)
247- }
248- templateRecord, err := store.GetTemplate(ctx, checkpointDigest)
249- if err != nil {
250- t.Fatal(err)
251- }
252- if templateRecord.BootIndexDigest != checkpointDigest ||
253- templateRecord.Origin != conchtemplate.OriginCheckpoint ||
254- templateRecord.BootMode != conchtemplate.BootModeResume {
255- t.Fatalf("published template = %#v", templateRecord)
256- }
257- sandboxRecord, err := store.GetSandbox(ctx, "sb-1")
258- if err != nil {
259- t.Fatal(err)
260- }
261- if sandboxRecord.CheckpointHeadTemplateID != checkpointDigest {
262- t.Fatalf("checkpoint head = %#v", sandboxRecord)
263- }
264-}
265- 
266-func TestBoltStorePublishCheckpointCASFailureLeavesBothRecordsUnchanged(t *testing.T) {
267- store, err := OpenBolt(t.TempDir() + "/state.db")
268- if err != nil {
269- t.Fatalf("OpenBolt() error = %v", err)
270- }
271- defer store.Close()
272- ctx := context.Background()
273- newHeadDigest := digest.FromString("new-head").String()
274- oldHeadDigest := digest.FromString("old-head").String()
275- checkpointDigest := digest.FromString("checkpoint").String()
276 if err := store.UpsertSandbox(ctx, SandboxRecord{93 if err := store.UpsertSandbox(ctx, SandboxRecord{
277 SandboxID: "sandbox-1",94 SandboxID: "sandbox-1",
278- CheckpointHeadTemplateID: newHeadDigest,95+ CheckpointHeadTemplateID: "source",
279 }); err != nil {96 }); err != nil {
280 t.Fatal(err)97 t.Fatal(err)
281 }98 }
282- if err := store.PublishCheckpoint(ctx, conchtemplate.Entry{99+ if err := store.AdvanceCheckpointHead(ctx, "sandbox-1", "source", "checkpoint"); err != nil {
283- Origin: conchtemplate.OriginCheckpoint,100+ t.Fatalf("AdvanceCheckpointHead() error = %v", err)
284- BootMode: conchtemplate.BootModeResume,
285- BootIndexDigest: checkpointDigest,
286- ParentBootIndexDigest: oldHeadDigest,
287- SourceSandboxID: "sandbox-1",
288- }); err == nil {
289- t.Fatal("PublishCheckpoint() error = nil, want CAS failure")
290 }101 }
291- if _, err := store.GetTemplate(ctx, checkpointDigest); !errors.Is(err, ErrNotFound) {102+ record, err := store.GetSandbox(ctx, "sandbox-1")
292- t.Fatalf("GetTemplate() error = %v, want ErrNotFound", err)103+ if err != nil || record.CheckpointHeadTemplateID != "checkpoint" {
293- }104+ t.Fatalf("checkpoint head = %q, %v", record.CheckpointHeadTemplateID, err)
294- sandboxRecord, _ := store.GetSandbox(ctx, "sandbox-1")
295- if sandboxRecord.CheckpointHeadTemplateID != newHeadDigest {
296- t.Fatalf("sandbox changed after failed transaction: %#v", sandboxRecord)
297 }105 }
298}106}
107+ 
108+func TestBoltStoreAdvanceCheckpointHeadCASFailureLeavesRecordUnchanged(t *testing.T) {
109+ store := newBoltStore(t)
110+ ctx := context.Background()
111+ if err := store.UpsertSandbox(ctx, SandboxRecord{
112+ SandboxID: "sandbox-1",
113+ CheckpointHeadTemplateID: "current",
114+ }); err != nil {
115+ t.Fatal(err)
116+ }
117+ if err := store.AdvanceCheckpointHead(ctx, "sandbox-1", "stale", "checkpoint"); err == nil {
118+ t.Fatal("AdvanceCheckpointHead() error = nil, want CAS failure")
119+ }
120+ record, err := store.GetSandbox(ctx, "sandbox-1")
121+ if err != nil || record.CheckpointHeadTemplateID != "current" {
122+ t.Fatalf("checkpoint head after CAS failure = %q, %v", record.CheckpointHeadTemplateID, err)
123+ }
124+}
125+ 
126+func newBoltStore(t *testing.T) *BoltStore {
127+ t.Helper()
128+ store, err := OpenBolt(t.TempDir() + "/state.db")
129+ if err != nil {
130+ t.Fatalf("OpenBolt() error = %v", err)
131+ }
132+ t.Cleanup(func() {
133+ if err := store.Close(); err != nil {
134+ t.Errorf("Close() error = %v", err)
135+ }
136+ })
137+ return store
138+}
@@ -2,8 +2,6 @@ package state
2 2 
3import (3import (
4 "context"4 "context"
5- 
6- "github.com/openeuler/Conch/internal/template"
7)5)
8 6 
9type Store interface {7type Store interface {
@@ -14,9 +12,5 @@ type Store interface {
14 ListSandboxes(context.Context) ([]SandboxRecord, error)12 ListSandboxes(context.Context) ([]SandboxRecord, error)
15 DeleteSandbox(context.Context, string) error13 DeleteSandbox(context.Context, string) error
16 14 
17- CreateTemplate(context.Context, template.Entry) error15+ AdvanceCheckpointHead(context.Context, string, string, string) error
18- GetTemplate(context.Context, string) (template.Entry, error)
19- ListTemplates(context.Context) ([]template.Entry, error)
20- DeleteTemplate(context.Context, string) error
21- PublishCheckpoint(context.Context, template.Entry) error
22}16}
@@ -6,11 +6,8 @@ import (
6 "strings"6 "strings"
7 7 
8 containerd "github.com/containerd/containerd/v2/client"8 containerd "github.com/containerd/containerd/v2/client"
9- "github.com/containerd/containerd/v2/core/images"
10 "github.com/containerd/containerd/v2/core/remotes/docker"9 "github.com/containerd/containerd/v2/core/remotes/docker"
11- "github.com/containerd/errdefs"
12 "github.com/opencontainers/go-digest"10 "github.com/opencontainers/go-digest"
13- ocispec "github.com/opencontainers/image-spec/specs-go/v1"
14 11 
15 containerdclient "github.com/openeuler/Conch/internal/adapters/containerd/client"12 containerdclient "github.com/openeuler/Conch/internal/adapters/containerd/client"
16)13)
@@ -45,73 +42,6 @@ func IsCanonicalTemplateRef(ref string) bool {
45 return err == nil && canonical == ref42 return err == nil && canonical == ref
46}43}
47 44 
48-// EnsureCanonicalBootIndexRecord creates or validates the canonical local
49-// image record for target. Content is not copied; the record is another GC
50-// root for the same immutable descriptor closure.
51-func EnsureCanonicalBootIndexRecord(
52- ctx context.Context,
53- client *containerdclient.Client,
54- target ocispec.Descriptor,
55- kind string,
56-) (string, error) {
57- if client == nil || client.Client == nil {
58- return "", fmt.Errorf("containerd client is required")
59- }
60- if target.Digest == "" {
61- return "", fmt.Errorf("%w: boot index target digest is required", ErrInvalidArgument)
62- }
63- if kind != ImageKindBootIndexCold && kind != ImageKindBootIndexResume {
64- return "", fmt.Errorf("%w: invalid boot index image kind %q", ErrInvalidArgument, kind)
65- }
66- name, err := CanonicalTemplateRef(target.Digest.String())
67- if err != nil {
68- return "", err
69- }
70- namespaceCtx := containerdclient.NewNamespaceContext(ctx)
71- existing, err := client.ImageService().Get(namespaceCtx, name)
72- if err == nil && existing.Target.Digest != target.Digest {
73- return "", fmt.Errorf("canonical template image %s targets %s, want %s", name, existing.Target.Digest, target.Digest)
74- }
75- if err != nil && !errdefs.IsNotFound(err) {
76- return "", fmt.Errorf("lookup canonical template image %s: %w", name, err)
77- }
78- if err := publishBootIndexRecord(namespaceCtx, client, name, target, kind); err != nil {
79- return "", err
80- }
81- return name, nil
82-}
83- 
84-// RemoveCanonicalBootIndexRecord removes only the image metadata root. The
85-// embedded containerd GC decides when the descriptor closure can be reclaimed.
86-func RemoveCanonicalBootIndexRecord(ctx context.Context, client *containerdclient.Client, rawDigest string) error {
87- if client == nil || client.Client == nil {
88- return fmt.Errorf("containerd client is required")
89- }
90- parsed, err := digest.Parse(strings.TrimSpace(rawDigest))
91- if err != nil {
92- return fmt.Errorf("%w: invalid boot index digest %q: %v", ErrInvalidArgument, rawDigest, err)
93- }
94- name, err := CanonicalTemplateRef(parsed.String())
95- if err != nil {
96- return err
97- }
98- namespaceCtx := containerdclient.NewNamespaceContext(ctx)
99- record, err := client.ImageService().Get(namespaceCtx, name)
100- if err != nil {
101- if errdefs.IsNotFound(err) {
102- return nil
103- }
104- return fmt.Errorf("lookup canonical template image %s: %w", name, err)
105- }
106- if record.Target.Digest != parsed {
107- return fmt.Errorf("canonical template image %s targets %s, want %s", name, record.Target.Digest, parsed)
108- }
109- if err := client.ImageService().Delete(namespaceCtx, name, images.DeleteTarget(&record.Target)); err != nil && !errdefs.IsNotFound(err) {
110- return fmt.Errorf("remove canonical template image %s: %w", name, err)
111- }
112- return nil
113-}
114- 
115func PublishBootIndex(ctx context.Context, client *containerdclient.Client, req PublishBootIndexOptions) (PublishBootIndexResult, error) {45func PublishBootIndex(ctx context.Context, client *containerdclient.Client, req PublishBootIndexOptions) (PublishBootIndexResult, error) {
116 if client == nil || client.Client == nil {46 if client == nil || client.Client == nil {
117 return PublishBootIndexResult{}, fmt.Errorf("containerd client is required")47 return PublishBootIndexResult{}, fmt.Errorf("containerd client is required")
@@ -125,18 +55,11 @@ func PublishBootIndex(ctx context.Context, client *containerdclient.Client, req
125 if req.InitrdPath == "" {55 if req.InitrdPath == "" {
126 return PublishBootIndexResult{}, fmt.Errorf("%w: initrd_path is required", ErrInvalidArgument)56 return PublishBootIndexResult{}, fmt.Errorf("%w: initrd_path is required", ErrInvalidArgument)
127 }57 }
128- namespaceCtx := containerdclient.NewNamespaceContext(ctx)58+ rootfsImage, err := client.ImageService().Get(ctx, req.RootfsImageName)
129- publishCtx, done, err := client.WithLease(namespaceCtx)
130- if err != nil {
131- return PublishBootIndexResult{}, fmt.Errorf("create content lease: %w", err)
132- }
133- defer done(publishCtx)
134- 
135- rootfsImage, err := client.ImageService().Get(publishCtx, req.RootfsImageName)
136 if err != nil {59 if err != nil {
137 return PublishBootIndexResult{}, fmt.Errorf("lookup rootfs image %s: %w", req.RootfsImageName, err)60 return PublishBootIndexResult{}, fmt.Errorf("lookup rootfs image %s: %w", req.RootfsImageName, err)
138 }61 }
139- indexDesc, err := BuildBootIndexInContent(publishCtx, client.ContentStore(), BootIndexContentOptions{62+ indexDesc, err := BuildBootIndexInContent(ctx, client.ContentStore(), BootIndexContentOptions{
140 RootfsDescriptor: rootfsImage.Target,63 RootfsDescriptor: rootfsImage.Target,
141 KernelPath: req.KernelPath,64 KernelPath: req.KernelPath,
142 InitrdPath: req.InitrdPath,65 InitrdPath: req.InitrdPath,
@@ -145,7 +68,7 @@ func PublishBootIndex(ctx context.Context, client *containerdclient.Client, req
145 return PublishBootIndexResult{}, fmt.Errorf("build boot index content: %w", err)68 return PublishBootIndexResult{}, fmt.Errorf("build boot index content: %w", err)
146 }69 }
147 70 
148- buildRef, err := EnsureCanonicalBootIndexRecord(publishCtx, client, indexDesc, ImageKindBootIndexCold)71+ buildRef, err := CanonicalTemplateRef(indexDesc.Digest.String())
149 if err != nil {72 if err != nil {
150 return PublishBootIndexResult{}, err73 return PublishBootIndexResult{}, err
151 }74 }
@@ -153,6 +76,7 @@ func PublishBootIndex(ctx context.Context, client *containerdclient.Client, req
153 return PublishBootIndexResult{76 return PublishBootIndexResult{
154 BootIndexDigest: indexDesc.Digest.String(),77 BootIndexDigest: indexDesc.Digest.String(),
155 BuildRef: buildRef,78 BuildRef: buildRef,
79+ Target: indexDesc,
156 }, nil80 }, nil
157}81}
158 82 
@@ -220,14 +144,7 @@ func PublishCheckpointBootIndex(
220 return PublishCheckpointBootIndexResult{}, fmt.Errorf("%w: memory_size_mb must be positive", ErrInvalidArgument)144 return PublishCheckpointBootIndexResult{}, fmt.Errorf("%w: memory_size_mb must be positive", ErrInvalidArgument)
221 }145 }
222 146 
223- namespaceCtx := containerdclient.NewNamespaceContext(ctx)147+ _, sourceInfo, err := inspectBootIndexByDigest(ctx, client.ContentStore(), req.SourceBootIndexDigest)
224- publishCtx, done, err := client.WithLease(namespaceCtx)
225- if err != nil {
226- return PublishCheckpointBootIndexResult{}, fmt.Errorf("create content lease: %w", err)
227- }
228- defer done(publishCtx)
229- 
230- _, sourceInfo, err := inspectBootIndexByDigest(publishCtx, client.ContentStore(), req.SourceBootIndexDigest)
231 if err != nil {148 if err != nil {
232 return PublishCheckpointBootIndexResult{}, fmt.Errorf("inspect source boot index: %w", err)149 return PublishCheckpointBootIndexResult{}, fmt.Errorf("inspect source boot index: %w", err)
233 }150 }
@@ -235,11 +152,11 @@ func PublishCheckpointBootIndex(
235 return PublishCheckpointBootIndexResult{}, fmt.Errorf("source boot index VMM %q does not match capture VMM %q", sourceInfo.VMMName, req.VMMName)152 return PublishCheckpointBootIndexResult{}, fmt.Errorf("source boot index VMM %q does not match capture VMM %q", sourceInfo.VMMName, req.VMMName)
236 }153 }
237 154 
238- memDesc, err := BuildNativeComponentInContent(publishCtx, client.ContentStore(), []string{req.MemRoot}, KindMemSnapshot)155+ memDesc, err := BuildNativeComponentInContent(ctx, client.ContentStore(), []string{req.MemRoot}, KindMemSnapshot)
239 if err != nil {156 if err != nil {
240 return PublishCheckpointBootIndexResult{}, fmt.Errorf("publish captured mem component: %w", err)157 return PublishCheckpointBootIndexResult{}, fmt.Errorf("publish captured mem component: %w", err)
241 }158 }
242- indexDesc, err := BuildBootIndexInContent(publishCtx, client.ContentStore(), BootIndexContentOptions{159+ indexDesc, err := BuildBootIndexInContent(ctx, client.ContentStore(), BootIndexContentOptions{
243 RootfsDescriptor: sourceInfo.RootfsDescriptor,160 RootfsDescriptor: sourceInfo.RootfsDescriptor,
244 MemDescriptor: memDesc,161 MemDescriptor: memDesc,
245 SandboxDescriptor: sourceInfo.SandboxDescriptor,162 SandboxDescriptor: sourceInfo.SandboxDescriptor,
@@ -249,38 +166,13 @@ func PublishCheckpointBootIndex(
249 if err != nil {166 if err != nil {
250 return PublishCheckpointBootIndexResult{}, fmt.Errorf("build checkpoint boot index: %w", err)167 return PublishCheckpointBootIndexResult{}, fmt.Errorf("build checkpoint boot index: %w", err)
251 }168 }
252- buildRef, err := EnsureCanonicalBootIndexRecord(publishCtx, client, indexDesc, ImageKindBootIndexResume)169+ buildRef, err := CanonicalTemplateRef(indexDesc.Digest.String())
253 if err != nil {170 if err != nil {
254 return PublishCheckpointBootIndexResult{}, err171 return PublishCheckpointBootIndexResult{}, err
255 }172 }
256 return PublishCheckpointBootIndexResult{173 return PublishCheckpointBootIndexResult{
257 BootIndexDigest: indexDesc.Digest.String(),174 BootIndexDigest: indexDesc.Digest.String(),
258 BuildRef: buildRef,175 BuildRef: buildRef,
176+ Target: indexDesc,
259 }, nil177 }, nil
260}178}
261- 
262-func publishBootIndexRecord(ctx context.Context, client *containerdclient.Client, tag string, indexDesc ocispec.Descriptor, kind string) error {
263- labelHandler := images.SetChildrenLabels(client.ContentStore(), images.ChildrenHandler(client.ContentStore()))
264- if err := images.WalkNotEmpty(ctx, labelHandler, indexDesc); err != nil {
265- return fmt.Errorf("label boot index content: %w", err)
266- }
267- imageRecord := images.Image{
268- Name: tag,
269- Target: indexDesc,
270- Labels: map[string]string{
271- ImageKindLabel: kind,
272- },
273- }
274- if _, err := client.ImageService().Update(ctx, imageRecord, "target", "labels."+ImageKindLabel); err != nil {
275- if !errdefs.IsNotFound(err) {
276- return fmt.Errorf("update boot image record %s: %w", tag, err)
277- }
278- if _, err := client.ImageService().Create(ctx, imageRecord); err != nil {
279- if errdefs.IsAlreadyExists(err) {
280- return ErrAlreadyExists.Wrap(err)
281- }
282- return fmt.Errorf("create boot image record %s: %w", tag, err)
283- }
284- }
285- return nil
286-}
@@ -2,9 +2,11 @@ package image
2 2 
3import (3import (
4 "context"4 "context"
5+ "errors"
5 "fmt"6 "fmt"
6 "sort"7 "sort"
7 "strings"8 "strings"
9+ "time"
8 10 
9 containerd "github.com/containerd/containerd/v2/client"11 containerd "github.com/containerd/containerd/v2/client"
10 "github.com/containerd/containerd/v2/core/images"12 "github.com/containerd/containerd/v2/core/images"
@@ -57,23 +59,44 @@ func PullBootIndex(ctx context.Context, client *containerdclient.Client, req Reg
57 }59 }
58 60 
59 pullCtx := containerdclient.NewNamespaceContext(ctx)61 pullCtx := containerdclient.NewNamespaceContext(ctx)
60- fetched, kind, err := pullRegistryContent(pullCtx, client, req, true)62+ fetched, _, err := pullRegistryContent(pullCtx, client, req, true)
61 if err != nil {63 if err != nil {
62 return PullBootIndexResult{}, translateRegistryError(err)64 return PullBootIndexResult{}, translateRegistryError(err)
63 }65 }
64 info, err := InspectBootIndexContent(pullCtx, client.ContentStore(), fetched.Target)66 info, err := InspectBootIndexContent(pullCtx, client.ContentStore(), fetched.Target)
65 if err != nil {67 if err != nil {
66- return PullBootIndexResult{}, fmt.Errorf("validate pulled Boot Index %s: %w", fetched.Name, err)68+ return PullBootIndexResult{}, errors.Join(
69+ fmt.Errorf("validate pulled Boot Index %s: %w", fetched.Name, err),
70+ RemoveFetchedImageRecord(ctx, client.ImageService(), fetched.Name, fetched.Target),
71+ )
67 }72 }
68- buildRef, err := EnsureCanonicalBootIndexRecord(pullCtx, client, fetched.Target, kind)73+ buildRef, err := CanonicalTemplateRef(fetched.Target.Digest.String())
69 if err != nil {74 if err != nil {
70- return PullBootIndexResult{}, err75+ return PullBootIndexResult{}, errors.Join(
76+ err,
77+ RemoveFetchedImageRecord(ctx, client.ImageService(), fetched.Name, fetched.Target),
78+ )
71 }79 }
72- if err := client.ImageService().Delete(pullCtx, fetched.Name, images.DeleteTarget(&fetched.Target)); err != nil && !errdefs.IsNotFound(err) {80+ return PullBootIndexResult{
73- _ = RemoveCanonicalBootIndexRecord(context.WithoutCancel(ctx), client, info.BootIndexDigest)81+ Info: info,
74- return PullBootIndexResult{}, fmt.Errorf("remove fetched source image record %s: %w", fetched.Name, err)82+ BuildRef: buildRef,
83+ SourceImageName: fetched.Name,
84+ Target: fetched.Target,
85+ }, nil
86+}
87+ 
88+// RemoveFetchedImageRecord releases the temporary image root without inheriting request cancellation.
89+func RemoveFetchedImageRecord(ctx context.Context, store images.Store, name string, target ocispec.Descriptor) error {
90+ cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second)
91+ defer cancel()
92+ if err := store.Delete(
93+ containerdclient.NewNamespaceContext(cleanupCtx),
94+ name,
95+ images.DeleteTarget(&target),
96+ ); err != nil && !errdefs.IsNotFound(err) {
97+ return fmt.Errorf("remove fetched image record %s: %w", name, err)
75 }98 }
76- return PullBootIndexResult{Info: info, BuildRef: buildRef}, nil99+ return nil
77}100}
78 101 
79func pullRegistryContent(102func pullRegistryContent(
@@ -1,6 +1,37 @@
1package image1package image
2 2 
3-import "testing"3+import (
4+ "context"
5+ "testing"
6+ 
7+ "github.com/containerd/containerd/v2/core/images"
8+ ocispec "github.com/opencontainers/image-spec/specs-go/v1"
9+)
10+ 
11+func TestRemoveFetchedImageRecordDetachesCleanupFromRequestCancellation(t *testing.T) {
12+ ctx, cancel := context.WithCancel(context.Background())
13+ cancel()
14+ store := &cleanupImageStore{}
15+ 
16+ if err := RemoveFetchedImageRecord(
17+ ctx, store, "registry.example.invalid/conch/template:latest", ocispec.Descriptor{},
18+ ); err != nil {
19+ t.Fatalf("RemoveFetchedImageRecord() error = %v", err)
20+ }
21+ if !store.hasDeadline {
22+ t.Fatal("Delete() context has no cleanup deadline")
23+ }
24+}
25+ 
26+type cleanupImageStore struct {
27+ images.Store
28+ hasDeadline bool
29+}
30+ 
31+func (s *cleanupImageStore) Delete(ctx context.Context, _ string, _ ...images.DeleteOpt) error {
32+ _, s.hasDeadline = ctx.Deadline()
33+ return ctx.Err()
34+}
4 35 
5func TestImageRepoDigests(t *testing.T) {36func TestImageRepoDigests(t *testing.T) {
6 tests := []struct {37 tests := []struct {
@@ -19,13 +19,16 @@ type PublishBootIndexOptions struct {
19}19}
20 20 
21type PublishBootIndexResult struct {21type PublishBootIndexResult struct {
22- BootIndexDigest string `json:"boot_index_digest"`22+ BootIndexDigest string `json:"boot_index_digest"`
23- BuildRef string `json:"build_ref"`23+ BuildRef string `json:"build_ref"`
24+ Target ocispec.Descriptor `json:"-"`
24}25}
25 26 
26type PullBootIndexResult struct {27type PullBootIndexResult struct {
27- Info BootIndexInfo28+ Info BootIndexInfo
28- BuildRef string29+ BuildRef string
30+ SourceImageName string
31+ Target ocispec.Descriptor `json:"-"`
29}32}
30 33 
31// BootIndexInfo is the validated, content-addressed view of a Conch Boot34// BootIndexInfo is the validated, content-addressed view of a Conch Boot
@@ -55,8 +58,9 @@ type PublishCheckpointBootIndexOptions struct {
55// PublishCheckpointBootIndexResult deliberately contains no snapshot keys:58// PublishCheckpointBootIndexResult deliberately contains no snapshot keys:
56// publishing checkpoint content must not create checkpoint snapshots.59// publishing checkpoint content must not create checkpoint snapshots.
57type PublishCheckpointBootIndexResult struct {60type PublishCheckpointBootIndexResult struct {
58- BootIndexDigest string `json:"boot_index_digest"`61+ BootIndexDigest string `json:"boot_index_digest"`
59- BuildRef string `json:"build_ref"`62+ BuildRef string `json:"build_ref"`
63+ Target ocispec.Descriptor `json:"-"`
60}64}
61 65 
62// PushBootIndexOptions publishes the descriptor closure rooted at an66// PushBootIndexOptions publishes the descriptor closure rooted at an
@@ -2,9 +2,8 @@ package template
2 2 
3import (3import (
4 "context"4 "context"
5- "fmt"5+ 
6- "strings"6+ ocispec "github.com/opencontainers/image-spec/specs-go/v1"
7- "time"
8)7)
9 8 
10type Filter struct {9type Filter struct {
@@ -13,108 +12,12 @@ type Filter struct {
13}12}
14 13 
15type Store interface {14type Store interface {
16- Create(context.Context, Entry) (Entry, error)15+ Create(context.Context, Entry, ocispec.Descriptor) (Entry, error)
17 Get(context.Context, string) (Entry, error)16 Get(context.Context, string) (Entry, error)
18 List(context.Context, Filter) ([]Entry, error)17 List(context.Context, Filter) ([]Entry, error)
19 Delete(context.Context, string) error18 Delete(context.Context, string) error
20}19}
21 20 
22-// StateStore is the persistence capability required by the Template domain.
23-// Implementations must provide insert-only CreateTemplate semantics.
24-type StateStore interface {
25- CreateTemplate(context.Context, Entry) error
26- GetTemplate(context.Context, string) (Entry, error)
27- ListTemplates(context.Context) ([]Entry, error)
28- DeleteTemplate(context.Context, string) error
29-}
30- 
31-type PersistentStore struct {
32- store StateStore
33- now func() time.Time
34-}
35- 
36-func NewStore(store StateStore) *PersistentStore {
37- return &PersistentStore{
38- store: store,
39- now: time.Now,
40- }
41-}
42- 
43-func (s *PersistentStore) Create(ctx context.Context, entry Entry) (Entry, error) {
44- if s == nil || s.store == nil {
45- return Entry{}, fmt.Errorf("template store is not configured")
46- }
47- if entry.CreatedAt == 0 {
48- entry.CreatedAt = s.now().UnixNano()
49- }
50- normalized, err := NormalizeEntry(entry)
51- if err != nil {
52- return Entry{}, err
53- }
54- if err := s.store.CreateTemplate(ctx, normalized); err != nil {
55- return Entry{}, err
56- }
57- return normalized, nil
58-}
59- 
60-func (s *PersistentStore) Get(ctx context.Context, rawDigest string) (Entry, error) {
61- if s == nil || s.store == nil {
62- return Entry{}, fmt.Errorf("template store is not configured")
63- }
64- bootIndexDigest := strings.TrimSpace(rawDigest)
65- entry, err := s.store.GetTemplate(ctx, bootIndexDigest)
66- if err != nil {
67- return Entry{}, err
68- }
69- return NormalizeEntry(entry)
70-}
71- 
72-func (s *PersistentStore) List(ctx context.Context, filter Filter) ([]Entry, error) {
73- if s == nil || s.store == nil {
74- return nil, fmt.Errorf("template store is not configured")
75- }
76- if filter.Origin != "" {
77- switch filter.Origin {
78- case OriginImage, OriginCheckpoint:
79- default:
80- return nil, ErrInvalidArgument.Wrap(fmt.Errorf("unknown template origin %q", filter.Origin))
81- }
82- }
83- if filter.BootMode != "" {
84- switch filter.BootMode {
85- case BootModeCold, BootModeResume:
86- default:
87- return nil, ErrInvalidArgument.Wrap(fmt.Errorf("unknown template boot mode %q", filter.BootMode))
88- }
89- }
90- items, err := s.store.ListTemplates(ctx)
91- if err != nil {
92- return nil, err
93- }
94- out := make([]Entry, 0, len(items))
95- for _, raw := range items {
96- item, err := NormalizeEntry(raw)
97- if err != nil {
98- return nil, err
99- }
100- if filter.Origin != "" && item.Origin != filter.Origin {
101- continue
102- }
103- if filter.BootMode != "" && item.BootMode != filter.BootMode {
104- continue
105- }
106- out = append(out, item)
107- }
108- return out, nil
109-}
110- 
111-func (s *PersistentStore) Delete(ctx context.Context, rawDigest string) error {
112- if s == nil || s.store == nil {
113- return fmt.Errorf("template store is not configured")
114- }
115- return s.store.DeleteTemplate(ctx, strings.TrimSpace(rawDigest))
116-}
117- 
118func copyMap(in map[string]string) map[string]string {21func copyMap(in map[string]string) map[string]string {
119 if len(in) == 0 {22 if len(in) == 0 {
120 return nil23 return nil
@@ -1,173 +0,0 @@
1-package template
2- 
3-import (
4- "context"
5- "errors"
6- "strings"
7- "testing"
8- "time"
9- 
10- "github.com/opencontainers/go-digest"
11-)
12- 
13-func TestStoreCRUDAndList(t *testing.T) {
14- ctx := context.Background()
15- raw := newMemoryStateStore()
16- store := NewStore(raw)
17- store.now = func() time.Time { return time.Unix(10, 0) }
18- 
19- bootIndexDigest := digest.FromString("cold boot index").String()
20- entry, err := store.Create(ctx, Entry{
21- Origin: OriginImage,
22- BootMode: BootModeCold,
23- BootIndexDigest: bootIndexDigest,
24- SourceRef: "image-ref",
25- Labels: map[string]string{"purpose": "test"},
26- })
27- if err != nil {
28- t.Fatalf("Create() error = %v", err)
29- }
30- if entry.CreatedAt != time.Unix(10, 0).UnixNano() {
31- t.Fatalf("CreatedAt = %d", entry.CreatedAt)
32- }
33- 
34- got, err := store.Get(ctx, " "+entry.BootIndexDigest+" ")
35- if err != nil {
36- t.Fatalf("Get() error = %v", err)
37- }
38- if got.BootIndexDigest != bootIndexDigest || got.BootMode != BootModeCold {
39- t.Fatalf("entry = %#v", got)
40- }
41- 
42- items, err := store.List(ctx, Filter{
43- Origin: OriginImage,
44- BootMode: BootModeCold,
45- })
46- if err != nil {
47- t.Fatalf("List() error = %v", err)
48- }
49- if len(items) != 1 || items[0].BootIndexDigest != entry.BootIndexDigest {
50- t.Fatalf("List() = %#v", items)
51- }
52- 
53- if err := store.Delete(ctx, " "+entry.BootIndexDigest+" "); err != nil {
54- t.Fatalf("Delete() error = %v", err)
55- }
56- if _, err := store.Get(ctx, entry.BootIndexDigest); err == nil {
57- t.Fatal("Get() after Delete() error = nil")
58- }
59-}
60- 
61-func TestStoreCreateValidatesCompleteEntry(t *testing.T) {
62- validDigest := digest.FromString("template").String()
63- for _, tt := range []struct {
64- name string
65- entry Entry
66- want string
67- }{
68- {
69- name: "missing digest",
70- entry: Entry{
71- Origin: OriginImage, BootMode: BootModeCold,
72- },
73- want: "invalid boot index digest",
74- },
75- {
76- name: "invalid origin",
77- entry: Entry{
78- Origin: "archive", BootMode: BootModeCold, BootIndexDigest: validDigest,
79- },
80- want: "unknown template origin",
81- },
82- {
83- name: "invalid boot mode",
84- entry: Entry{
85- Origin: OriginImage, BootMode: "warm", BootIndexDigest: validDigest,
86- },
87- want: "unknown template boot mode",
88- },
89- {
90- name: "invalid digest",
91- entry: Entry{
92- Origin: OriginImage, BootMode: BootModeCold, BootIndexDigest: "sha256:invalid",
93- },
94- want: "invalid boot index digest",
95- },
96- } {
97- t.Run(tt.name, func(t *testing.T) {
98- store := NewStore(newMemoryStateStore())
99- _, err := store.Create(context.Background(), tt.entry)
100- if err == nil || !strings.Contains(err.Error(), tt.want) {
101- t.Fatalf("Create() error = %v, want %q", err, tt.want)
102- }
103- })
104- }
105-}
106- 
107-func TestStoreDuplicateDigestDoesNotOverwrite(t *testing.T) {
108- ctx := context.Background()
109- raw := newMemoryStateStore()
110- store := NewStore(raw)
111- firstDigest := digest.FromString("first").String()
112- first, err := store.Create(ctx, Entry{
113- Origin: OriginImage,
114- BootMode: BootModeCold,
115- BootIndexDigest: firstDigest,
116- })
117- if err != nil {
118- t.Fatalf("first Create() error = %v", err)
119- }
120- 
121- _, err = store.Create(ctx, Entry{
122- Origin: OriginCheckpoint,
123- BootMode: BootModeResume,
124- BootIndexDigest: first.BootIndexDigest,
125- })
126- if !errors.Is(err, ErrAlreadyExists) {
127- t.Fatalf("second Create() error = %v, want ErrAlreadyExists", err)
128- }
129- got, err := store.Get(ctx, first.BootIndexDigest)
130- if err != nil {
131- t.Fatalf("Get() error = %v", err)
132- }
133- if got.BootIndexDigest != firstDigest || got.Origin != OriginImage || got.BootMode != BootModeCold {
134- t.Fatalf("first entry was overwritten: %#v", got)
135- }
136-}
137- 
138-type memoryStateStore struct {
139- entries map[string]Entry
140-}
141- 
142-func newMemoryStateStore() *memoryStateStore {
143- return &memoryStateStore{entries: make(map[string]Entry)}
144-}
145- 
146-func (s *memoryStateStore) CreateTemplate(_ context.Context, entry Entry) error {
147- if _, exists := s.entries[entry.BootIndexDigest]; exists {
148- return ErrAlreadyExists
149- }
150- s.entries[entry.BootIndexDigest] = entry
151- return nil
152-}
153- 
154-func (s *memoryStateStore) GetTemplate(_ context.Context, bootIndexDigest string) (Entry, error) {
155- entry, exists := s.entries[bootIndexDigest]
156- if !exists {
157- return Entry{}, errors.New("not found")
158- }
159- return entry, nil
160-}
161- 
162-func (s *memoryStateStore) ListTemplates(context.Context) ([]Entry, error) {
163- out := make([]Entry, 0, len(s.entries))
164- for _, entry := range s.entries {
165- out = append(out, entry)
166- }
167- return out, nil
168-}
169- 
170-func (s *memoryStateStore) DeleteTemplate(_ context.Context, bootIndexDigest string) error {
171- delete(s.entries, bootIndexDigest)
172- return nil
173-}