Skip to content

Commit bef7f07

Browse files
feat: add oci.DeletableStore
Signed-off-by: Xiaoxuan Wang <[email protected]>
1 parent 6456d16 commit bef7f07

File tree

2 files changed

+442
-0
lines changed

2 files changed

+442
-0
lines changed

content/oci/deletableoci.go

+357
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,357 @@
1+
/*
2+
Copyright The ORAS Authors.
3+
Licensed under the Apache License, Version 2.0 (the "License");
4+
you may not use this file except in compliance with the License.
5+
You may obtain a copy of the License at
6+
7+
http://www.apache.org/licenses/LICENSE-2.0
8+
9+
Unless required by applicable law or agreed to in writing, software
10+
distributed under the License is distributed on an "AS IS" BASIS,
11+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
See the License for the specific language governing permissions and
13+
limitations under the License.
14+
*/
15+
16+
// Package oci provides access to an OCI content store.
17+
// Reference: https://github.com/opencontainers/image-spec/blob/v1.1.0-rc4/image-layout.md
18+
package oci
19+
20+
import (
21+
"context"
22+
"encoding/json"
23+
"errors"
24+
"fmt"
25+
"io"
26+
"os"
27+
"path/filepath"
28+
"sync"
29+
30+
"github.com/opencontainers/go-digest"
31+
specs "github.com/opencontainers/image-spec/specs-go"
32+
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
33+
"oras.land/oras-go/v2/content"
34+
"oras.land/oras-go/v2/errdef"
35+
"oras.land/oras-go/v2/internal/container/set"
36+
"oras.land/oras-go/v2/internal/descriptor"
37+
"oras.land/oras-go/v2/internal/graph"
38+
"oras.land/oras-go/v2/internal/resolver"
39+
)
40+
41+
// DeletableStore implements `oras.Target`, and represents a content store
42+
// extended with the delete operation.
43+
// Reference: https://github.com/opencontainers/image-spec/blob/v1.1.0-rc4/image-layout.md
44+
type DeletableStore struct {
45+
// AutoSaveIndex controls if the OCI store will automatically save the index
46+
// file on each Tag() call.
47+
// - If AutoSaveIndex is set to true, the OCI store will automatically call
48+
// this method on each Tag() call.
49+
// - If AutoSaveIndex is set to false, it's the caller's responsibility
50+
// to manually call SaveIndex() when needed.
51+
// - Default value: true.
52+
AutoSaveIndex bool
53+
root string
54+
indexPath string
55+
index *ocispec.Index
56+
lock sync.RWMutex
57+
58+
storage *Storage
59+
tagResolver *resolver.Memory
60+
graph *graph.DeletableMemory
61+
}
62+
63+
// NewDeletableStore returns a new DeletableStore.
64+
func NewDeletableStore(root string) (*DeletableStore, error) {
65+
return NewDeletableStoreWithContext(context.Background(), root)
66+
}
67+
68+
// NewDeletableStoreWithContext creates a new DeletableStore.
69+
func NewDeletableStoreWithContext(ctx context.Context, root string) (*DeletableStore, error) {
70+
rootAbs, err := filepath.Abs(root)
71+
if err != nil {
72+
return nil, fmt.Errorf("failed to resolve absolute path for %s: %w", root, err)
73+
}
74+
storage, err := NewStorage(rootAbs)
75+
if err != nil {
76+
return nil, fmt.Errorf("failed to create storage: %w", err)
77+
}
78+
store := &DeletableStore{
79+
AutoSaveIndex: true,
80+
root: rootAbs,
81+
indexPath: filepath.Join(rootAbs, ociImageIndexFile),
82+
storage: storage,
83+
tagResolver: resolver.NewMemory(),
84+
graph: graph.NewDeletableMemory(),
85+
}
86+
if err := ensureDir(filepath.Join(rootAbs, ociBlobsDir)); err != nil {
87+
return nil, err
88+
}
89+
if err := store.ensureOCILayoutFile(); err != nil {
90+
return nil, fmt.Errorf("invalid OCI Image Layout: %w", err)
91+
}
92+
if err := store.loadIndexFile(ctx); err != nil {
93+
return nil, fmt.Errorf("invalid OCI Image Index: %w", err)
94+
}
95+
return store, nil
96+
}
97+
98+
// Fetch fetches the content identified by the descriptor.
99+
func (ds *DeletableStore) Fetch(ctx context.Context, target ocispec.Descriptor) (io.ReadCloser, error) {
100+
ds.lock.RLock()
101+
defer ds.lock.RUnlock()
102+
return ds.storage.Fetch(ctx, target)
103+
}
104+
105+
// Push pushes the content, matching the expected descriptor.
106+
func (ds *DeletableStore) Push(ctx context.Context, expected ocispec.Descriptor, reader io.Reader) error {
107+
ds.lock.Lock()
108+
defer ds.lock.Unlock()
109+
if err := ds.storage.Push(ctx, expected, reader); err != nil {
110+
return err
111+
}
112+
if err := ds.graph.Index(ctx, ds.storage, expected); err != nil {
113+
return err
114+
}
115+
if descriptor.IsManifest(expected) {
116+
// tag by digest
117+
return ds.tag(ctx, expected, expected.Digest.String())
118+
}
119+
return nil
120+
}
121+
122+
// Delete removes the content matching the descriptor from the store.
123+
func (ds *DeletableStore) Delete(ctx context.Context, target ocispec.Descriptor) error {
124+
ds.lock.Lock()
125+
defer ds.lock.Unlock()
126+
resolvers := ds.tagResolver.Map()
127+
for reference, desc := range resolvers {
128+
if content.Equal(desc, target) {
129+
ds.tagResolver.Untag(reference)
130+
}
131+
}
132+
if err := ds.graph.Remove(ctx, target); err != nil {
133+
return err
134+
}
135+
if ds.AutoSaveIndex {
136+
err := ds.saveIndex()
137+
if err != nil {
138+
return err
139+
}
140+
}
141+
return ds.storage.Delete(ctx, target)
142+
}
143+
144+
// Exists returns true if the described content exists.
145+
func (ds *DeletableStore) Exists(ctx context.Context, target ocispec.Descriptor) (bool, error) {
146+
ds.lock.RLock()
147+
defer ds.lock.RUnlock()
148+
return ds.storage.Exists(ctx, target)
149+
}
150+
151+
// Tag tags a descriptor with a reference string.
152+
// reference should be a valid tag (e.g. "latest").
153+
// Reference: https://github.com/opencontainers/image-spec/blob/v1.1.0-rc4/image-layout.md#indexjson-file
154+
func (ds *DeletableStore) Tag(ctx context.Context, desc ocispec.Descriptor, reference string) error {
155+
ds.lock.Lock()
156+
defer ds.lock.Unlock()
157+
if err := validateReference(reference); err != nil {
158+
return err
159+
}
160+
exists, err := ds.storage.Exists(ctx, desc)
161+
if err != nil {
162+
return err
163+
}
164+
if !exists {
165+
return fmt.Errorf("%s: %s: %w", desc.Digest, desc.MediaType, errdef.ErrNotFound)
166+
}
167+
return ds.tag(ctx, desc, reference)
168+
}
169+
170+
// tag tags a descriptor with a reference string.
171+
func (ds *DeletableStore) tag(ctx context.Context, desc ocispec.Descriptor, reference string) error {
172+
dgst := desc.Digest.String()
173+
if reference != dgst {
174+
// also tag desc by its digest
175+
if err := ds.tagResolver.Tag(ctx, desc, dgst); err != nil {
176+
return err
177+
}
178+
}
179+
if err := ds.tagResolver.Tag(ctx, desc, reference); err != nil {
180+
return err
181+
}
182+
if ds.AutoSaveIndex {
183+
return ds.saveIndex()
184+
}
185+
return nil
186+
}
187+
188+
// Resolve resolves a reference to a descriptor. If the reference to be resolved
189+
// is a tag, the returned descriptor will be a full descriptor declared by
190+
// github.com/opencontainers/image-spec/specs-go/v1. If the reference is a
191+
// digest the returned descriptor will be a plain descriptor (containing only
192+
// the digest, media type and size).
193+
func (ds *DeletableStore) Resolve(ctx context.Context, reference string) (ocispec.Descriptor, error) {
194+
ds.lock.RLock()
195+
defer ds.lock.RUnlock()
196+
if reference == "" {
197+
return ocispec.Descriptor{}, errdef.ErrMissingReference
198+
}
199+
// attempt resolving manifest
200+
desc, err := ds.tagResolver.Resolve(ctx, reference)
201+
if err != nil {
202+
if errors.Is(err, errdef.ErrNotFound) {
203+
// attempt resolving blob
204+
return resolveBlob(os.DirFS(ds.root), reference)
205+
}
206+
return ocispec.Descriptor{}, err
207+
}
208+
if reference == desc.Digest.String() {
209+
return descriptor.Plain(desc), nil
210+
}
211+
return desc, nil
212+
}
213+
214+
// Predecessors returns the nodes directly pointing to the current node.
215+
// Predecessors returns nil without error if the node does not exists in the
216+
// store.
217+
func (ds *DeletableStore) Predecessors(ctx context.Context, node ocispec.Descriptor) ([]ocispec.Descriptor, error) {
218+
ds.lock.RLock()
219+
defer ds.lock.RUnlock()
220+
return ds.graph.Predecessors(ctx, node)
221+
}
222+
223+
// Tags lists the tags presented in the `index.json` file of the OCI layout,
224+
// returned in ascending order.
225+
// If `last` is NOT empty, the entries in the response start after the tag
226+
// specified by `last`. Otherwise, the response starts from the top of the tags
227+
// list.
228+
//
229+
// See also `Tags()` in the package `registry`.
230+
func (ds *DeletableStore) Tags(ctx context.Context, last string, fn func(tags []string) error) error {
231+
ds.lock.RLock()
232+
defer ds.lock.RUnlock()
233+
return listTags(ctx, ds.tagResolver, last, fn)
234+
}
235+
236+
// ensureOCILayoutFile ensures the `oci-layout` file.
237+
func (ds *DeletableStore) ensureOCILayoutFile() error {
238+
layoutFilePath := filepath.Join(ds.root, ocispec.ImageLayoutFile)
239+
layoutFile, err := os.Open(layoutFilePath)
240+
if err != nil {
241+
if !os.IsNotExist(err) {
242+
return fmt.Errorf("failed to open OCI layout file: %w", err)
243+
}
244+
layout := ocispec.ImageLayout{
245+
Version: ocispec.ImageLayoutVersion,
246+
}
247+
layoutJSON, err := json.Marshal(layout)
248+
if err != nil {
249+
return fmt.Errorf("failed to marshal OCI layout file: %w", err)
250+
}
251+
return os.WriteFile(layoutFilePath, layoutJSON, 0666)
252+
}
253+
defer layoutFile.Close()
254+
var layout ocispec.ImageLayout
255+
err = json.NewDecoder(layoutFile).Decode(&layout)
256+
if err != nil {
257+
return fmt.Errorf("failed to decode OCI layout file: %w", err)
258+
}
259+
return validateOCILayout(&layout)
260+
}
261+
262+
// loadIndexFile reads index.json from the file system.
263+
// Create index.json if it does not exist.
264+
func (ds *DeletableStore) loadIndexFile(ctx context.Context) error {
265+
indexFile, err := os.Open(ds.indexPath)
266+
if err != nil {
267+
if !os.IsNotExist(err) {
268+
return fmt.Errorf("failed to open index file: %w", err)
269+
}
270+
// write index.json if it does not exist
271+
ds.index = &ocispec.Index{
272+
Versioned: specs.Versioned{
273+
SchemaVersion: 2, // historical value
274+
},
275+
Manifests: []ocispec.Descriptor{},
276+
}
277+
return ds.writeIndexFile()
278+
}
279+
defer indexFile.Close()
280+
var index ocispec.Index
281+
if err := json.NewDecoder(indexFile).Decode(&index); err != nil {
282+
return fmt.Errorf("failed to decode index file: %w", err)
283+
}
284+
ds.index = &index
285+
return loadIndexInDeletableMemory(ctx, ds.index, ds.storage, ds.tagResolver, ds.graph)
286+
}
287+
288+
// SaveIndex writes the `index.json` file to the file system.
289+
// - If AutoSaveIndex is set to true (default value),
290+
// the OCI store will automatically save the index on each Tag() call.
291+
// - If AutoSaveIndex is set to false, it's the caller's responsibility
292+
// to manually call this method when needed.
293+
func (ds *DeletableStore) SaveIndex() error {
294+
ds.lock.Lock()
295+
defer ds.lock.Unlock()
296+
return ds.saveIndex()
297+
}
298+
299+
func (ds *DeletableStore) saveIndex() error {
300+
var manifests []ocispec.Descriptor
301+
tagged := set.New[digest.Digest]()
302+
refMap := ds.tagResolver.Map()
303+
304+
// 1. Add descriptors that are associated with tags
305+
// Note: One descriptor can be associated with multiple tags.
306+
for ref, desc := range refMap {
307+
if ref != desc.Digest.String() {
308+
annotations := make(map[string]string, len(desc.Annotations)+1)
309+
for k, v := range desc.Annotations {
310+
annotations[k] = v
311+
}
312+
annotations[ocispec.AnnotationRefName] = ref
313+
desc.Annotations = annotations
314+
manifests = append(manifests, desc)
315+
// mark the digest as tagged for deduplication in step 2
316+
tagged.Add(desc.Digest)
317+
}
318+
}
319+
// 2. Add descriptors that are not associated with any tag
320+
for ref, desc := range refMap {
321+
if ref == desc.Digest.String() && !tagged.Contains(desc.Digest) {
322+
// skip tagged ones since they have been added in step 1
323+
manifests = append(manifests, deleteAnnotationRefName(desc))
324+
}
325+
}
326+
327+
ds.index.Manifests = manifests
328+
return ds.writeIndexFile()
329+
}
330+
331+
// writeIndexFile writes the `index.json` file.
332+
func (ds *DeletableStore) writeIndexFile() error {
333+
indexJSON, err := json.Marshal(ds.index)
334+
if err != nil {
335+
return fmt.Errorf("failed to marshal index file: %w", err)
336+
}
337+
return os.WriteFile(ds.indexPath, indexJSON, 0666)
338+
}
339+
340+
// loadIndexInDeletableMemory loads index into the memory.
341+
func loadIndexInDeletableMemory(ctx context.Context, index *ocispec.Index, fetcher content.Fetcher, tagger content.Tagger, graph *graph.DeletableMemory) error {
342+
for _, desc := range index.Manifests {
343+
if err := tagger.Tag(ctx, deleteAnnotationRefName(desc), desc.Digest.String()); err != nil {
344+
return err
345+
}
346+
if ref := desc.Annotations[ocispec.AnnotationRefName]; ref != "" {
347+
if err := tagger.Tag(ctx, desc, ref); err != nil {
348+
return err
349+
}
350+
}
351+
plain := descriptor.Plain(desc)
352+
if err := graph.IndexAll(ctx, fetcher, plain); err != nil {
353+
return err
354+
}
355+
}
356+
return nil
357+
}

0 commit comments

Comments
 (0)