Skip to content

Commit 3ebbd7e

Browse files
authored
Add Go binary asset store foundation (#109)
* Add Go binary asset store foundation - Adds a dependency-free in-memory asset store with binary-safe byte storage, streaming helpers, metadata, checksums, ETags, and snapshot references. - Adds stable AWS asset identifiers for S3 objects, Lambda packages, Lambda layers, and CloudFormation templates. - Covers binary round trips, metadata cloning, defaults, stable snapshots, and AWS asset id behavior in Go tests. * Fix AWS asset purpose constants
1 parent e9b2e8d commit 3ebbd7e

4 files changed

Lines changed: 551 additions & 0 deletions

File tree

internal/core/assets/store.go

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
package assets
2+
3+
import (
4+
"bytes"
5+
"crypto/md5"
6+
"crypto/sha256"
7+
"encoding/hex"
8+
"fmt"
9+
"io"
10+
"sort"
11+
"sync"
12+
"time"
13+
)
14+
15+
const (
16+
DefaultContentType = "application/octet-stream"
17+
ReferenceDriver = "memory"
18+
)
19+
20+
type Store struct {
21+
mu sync.RWMutex
22+
assets map[string]asset
23+
now func() time.Time
24+
}
25+
26+
type Option func(*Store)
27+
28+
type PutOptions struct {
29+
Purpose string
30+
ContentType string
31+
ETag string
32+
LastModified time.Time
33+
UserMetadata map[string]string
34+
}
35+
36+
type Metadata struct {
37+
ID string `json:"id"`
38+
Purpose string `json:"purpose,omitempty"`
39+
ContentType string `json:"contentType"`
40+
ContentLength int64 `json:"contentLength"`
41+
ChecksumMD5 string `json:"checksumMd5"`
42+
ChecksumSHA256 string `json:"checksumSha256"`
43+
ETag string `json:"etag"`
44+
LastModified time.Time `json:"lastModified"`
45+
UserMetadata map[string]string `json:"userMetadata,omitempty"`
46+
}
47+
48+
type Reference struct {
49+
Purpose string `json:"purpose,omitempty"`
50+
Driver string `json:"driver"`
51+
Key string `json:"key"`
52+
ContentLength int64 `json:"contentLength"`
53+
ChecksumSHA256 string `json:"checksumSha256"`
54+
}
55+
56+
type AssetSnapshot struct {
57+
Metadata Metadata `json:"metadata"`
58+
Reference Reference `json:"reference"`
59+
}
60+
61+
type StoreSnapshot struct {
62+
Assets []AssetSnapshot `json:"assets"`
63+
}
64+
65+
type asset struct {
66+
metadata Metadata
67+
body []byte
68+
}
69+
70+
func New(options ...Option) *Store {
71+
store := &Store{
72+
assets: map[string]asset{},
73+
now: func() time.Time {
74+
return time.Now().UTC()
75+
},
76+
}
77+
for _, option := range options {
78+
option(store)
79+
}
80+
return store
81+
}
82+
83+
func WithClock(now func() time.Time) Option {
84+
return func(store *Store) {
85+
if now != nil {
86+
store.now = now
87+
}
88+
}
89+
}
90+
91+
func (store *Store) Put(id string, reader io.Reader, options PutOptions) (Metadata, error) {
92+
if reader == nil {
93+
reader = bytes.NewReader(nil)
94+
}
95+
body, err := io.ReadAll(reader)
96+
if err != nil {
97+
return Metadata{}, fmt.Errorf("read asset body: %w", err)
98+
}
99+
return store.PutBytes(id, body, options)
100+
}
101+
102+
func (store *Store) PutBytes(id string, body []byte, options PutOptions) (Metadata, error) {
103+
if id == "" {
104+
return Metadata{}, fmt.Errorf("asset id is required")
105+
}
106+
metadata := buildMetadata(id, body, options, store.now)
107+
stored := asset{
108+
metadata: cloneMetadata(metadata),
109+
body: append([]byte(nil), body...),
110+
}
111+
112+
store.mu.Lock()
113+
store.assets[id] = stored
114+
store.mu.Unlock()
115+
116+
return cloneMetadata(metadata), nil
117+
}
118+
119+
func (store *Store) Get(id string) (Metadata, bool) {
120+
store.mu.RLock()
121+
defer store.mu.RUnlock()
122+
123+
asset, ok := store.assets[id]
124+
if !ok {
125+
return Metadata{}, false
126+
}
127+
return cloneMetadata(asset.metadata), true
128+
}
129+
130+
func (store *Store) Bytes(id string) ([]byte, Metadata, bool) {
131+
store.mu.RLock()
132+
defer store.mu.RUnlock()
133+
134+
asset, ok := store.assets[id]
135+
if !ok {
136+
return nil, Metadata{}, false
137+
}
138+
return append([]byte(nil), asset.body...), cloneMetadata(asset.metadata), true
139+
}
140+
141+
func (store *Store) Open(id string) (io.ReadCloser, Metadata, bool) {
142+
body, metadata, ok := store.Bytes(id)
143+
if !ok {
144+
return nil, Metadata{}, false
145+
}
146+
return io.NopCloser(bytes.NewReader(body)), metadata, true
147+
}
148+
149+
func (store *Store) Delete(id string) bool {
150+
store.mu.Lock()
151+
defer store.mu.Unlock()
152+
153+
if _, ok := store.assets[id]; !ok {
154+
return false
155+
}
156+
delete(store.assets, id)
157+
return true
158+
}
159+
160+
func (store *Store) Reset() {
161+
store.mu.Lock()
162+
defer store.mu.Unlock()
163+
store.assets = map[string]asset{}
164+
}
165+
166+
func (store *Store) Count() int {
167+
store.mu.RLock()
168+
defer store.mu.RUnlock()
169+
return len(store.assets)
170+
}
171+
172+
func (store *Store) List() []Metadata {
173+
store.mu.RLock()
174+
defer store.mu.RUnlock()
175+
176+
ids := store.sortedIDsLocked()
177+
items := make([]Metadata, 0, len(ids))
178+
for _, id := range ids {
179+
items = append(items, cloneMetadata(store.assets[id].metadata))
180+
}
181+
return items
182+
}
183+
184+
func (store *Store) Snapshot() StoreSnapshot {
185+
store.mu.RLock()
186+
defer store.mu.RUnlock()
187+
188+
ids := store.sortedIDsLocked()
189+
snapshot := StoreSnapshot{Assets: make([]AssetSnapshot, 0, len(ids))}
190+
for _, id := range ids {
191+
asset := store.assets[id]
192+
metadata := cloneMetadata(asset.metadata)
193+
snapshot.Assets = append(snapshot.Assets, AssetSnapshot{
194+
Metadata: metadata,
195+
Reference: Reference{
196+
Purpose: metadata.Purpose,
197+
Driver: ReferenceDriver,
198+
Key: metadata.ID,
199+
ContentLength: metadata.ContentLength,
200+
ChecksumSHA256: metadata.ChecksumSHA256,
201+
},
202+
})
203+
}
204+
return snapshot
205+
}
206+
207+
func (store *Store) sortedIDsLocked() []string {
208+
ids := make([]string, 0, len(store.assets))
209+
for id := range store.assets {
210+
ids = append(ids, id)
211+
}
212+
sort.Strings(ids)
213+
return ids
214+
}
215+
216+
func buildMetadata(id string, body []byte, options PutOptions, now func() time.Time) Metadata {
217+
md5sum := md5.Sum(body)
218+
sha256sum := sha256.Sum256(body)
219+
contentType := options.ContentType
220+
if contentType == "" {
221+
contentType = DefaultContentType
222+
}
223+
lastModified := options.LastModified
224+
if lastModified.IsZero() {
225+
lastModified = now()
226+
}
227+
lastModified = lastModified.UTC()
228+
etag := options.ETag
229+
if etag == "" {
230+
etag = fmt.Sprintf("%q", hex.EncodeToString(md5sum[:]))
231+
}
232+
233+
return Metadata{
234+
ID: id,
235+
Purpose: options.Purpose,
236+
ContentType: contentType,
237+
ContentLength: int64(len(body)),
238+
ChecksumMD5: hex.EncodeToString(md5sum[:]),
239+
ChecksumSHA256: hex.EncodeToString(sha256sum[:]),
240+
ETag: etag,
241+
LastModified: lastModified,
242+
UserMetadata: cloneStringMap(options.UserMetadata),
243+
}
244+
}
245+
246+
func cloneMetadata(metadata Metadata) Metadata {
247+
metadata.UserMetadata = cloneStringMap(metadata.UserMetadata)
248+
return metadata
249+
}
250+
251+
func cloneStringMap(values map[string]string) map[string]string {
252+
if len(values) == 0 {
253+
return nil
254+
}
255+
out := make(map[string]string, len(values))
256+
for key, value := range values {
257+
out[key] = value
258+
}
259+
return out
260+
}

0 commit comments

Comments
 (0)