@lossless.org/objectstorage 9.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.smartconfig.json +71 -0
- package/dist_rust/ruststorage_linux_amd64 +0 -0
- package/dist_rust/ruststorage_linux_amd64.tsrust-build.json +14 -0
- package/dist_rust/ruststorage_linux_arm64 +0 -0
- package/dist_rust/ruststorage_linux_arm64.tsrust-build.json +14 -0
- package/dist_ts/00_commitinfo_data.d.ts +8 -0
- package/dist_ts/00_commitinfo_data.js +9 -0
- package/dist_ts/index.d.ts +741 -0
- package/dist_ts/index.js +885 -0
- package/dist_ts/paths.d.ts +4 -0
- package/dist_ts/paths.js +6 -0
- package/dist_ts/plugins.d.ts +9 -0
- package/dist_ts/plugins.js +13 -0
- package/license.md +21 -0
- package/package.json +77 -0
- package/readme.md +1430 -0
- package/third_party_licenses/base64-0.22.1/LICENSE-MIT +21 -0
- package/third_party_licenses/crc32fast-1.5.0/LICENSE-MIT +21 -0
- package/third_party_licenses/ring-0.17.14/INDEX.md +15 -0
- package/third_party_licenses/ring-0.17.14/LICENSE +9 -0
- package/third_party_licenses/ring-0.17.14/LICENSE-BoringSSL +272 -0
- package/third_party_licenses/ring-0.17.14/LICENSE-fiat +15 -0
- package/third_party_licenses/ring-0.17.14/LICENSE-once_cell-APACHE +201 -0
- package/third_party_licenses/ring-0.17.14/LICENSE-once_cell-MIT +23 -0
- package/third_party_licenses/ring-0.17.14/LICENSE-other-bits +13 -0
- package/ts/00_commitinfo_data.ts +8 -0
- package/ts/index.ts +1932 -0
- package/ts/paths.ts +11 -0
- package/ts/plugins.ts +17 -0
package/readme.md
ADDED
|
@@ -0,0 +1,1430 @@
|
|
|
1
|
+
# @lossless.org/objectstorage
|
|
2
|
+
|
|
3
|
+
A high-performance, S3-compatible storage server powered by a **Rust core** with a clean TypeScript API. Runs standalone for dev/test โ or scales out as a **distributed, erasure-coded cluster** with QUIC-based inter-node communication. No cloud, no Docker. Just install the package and go. ๐
|
|
4
|
+
|
|
5
|
+
## Issue Reporting and Security
|
|
6
|
+
|
|
7
|
+
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
|
|
8
|
+
|
|
9
|
+
## Moving from @push.rocks/smartstorage
|
|
10
|
+
|
|
11
|
+
Install `@lossless.org/objectstorage` and update imports and the public API names:
|
|
12
|
+
|
|
13
|
+
| Previous name | New name |
|
|
14
|
+
| --- | --- |
|
|
15
|
+
| `@push.rocks/smartstorage` | `@lossless.org/objectstorage` |
|
|
16
|
+
| `SmartStorage` | `ObjectStorage` |
|
|
17
|
+
| `ISmartStorage*` / `TSmartStorage*` | `IObjectStorage*` / `TObjectStorage*` |
|
|
18
|
+
| `SmartStorageResourceFenceError` | `ObjectStorageResourceFenceError` |
|
|
19
|
+
| `computeSmartStorageResourceFenceEffectivePayloadSha256V1` | `computeObjectStorageResourceFenceEffectivePayloadSha256V1` |
|
|
20
|
+
| `smartStorageResourceFenceErrorCodes` | `objectStorageResourceFenceErrorCodes` |
|
|
21
|
+
| Prometheus `smartstorage_*` metric families | Prometheus `objectstorage_*` metric families |
|
|
22
|
+
| Test-only `SMARTSTORAGE_RESOURCE_FENCE_TEST_*` variables | `OBJECTSTORAGE_RESOURCE_FENCE_TEST_*` variables |
|
|
23
|
+
|
|
24
|
+
Update monitoring queries and dashboards alongside the package. The new module
|
|
25
|
+
exports the new API names without aliases for the old names. Generic types such
|
|
26
|
+
as `IStorageConfig`, `IBucketExport`, and `IClusterHealth` keep their names.
|
|
27
|
+
|
|
28
|
+
The rename preserves the current storage and cluster protocols. Existing bucket
|
|
29
|
+
roots, credentials, retention state, migration receipts, and resource-fencing
|
|
30
|
+
identities keep their byte formats. Versioned `smartstorage.*` format identifiers
|
|
31
|
+
and hash domains, `.smartstorage*` filesystem names, retention metadata keys,
|
|
32
|
+
the internal streaming-verification header, and the QUIC `smartstorage` protocol
|
|
33
|
+
identity remain unchanged. Do not rewrite these values in existing data.
|
|
34
|
+
The native `ruststorage` executable and its Linux amd64/arm64 artifact names also
|
|
35
|
+
remain unchanged. Earlier storage-layout migrations retain their existing rules
|
|
36
|
+
described below.
|
|
37
|
+
|
|
38
|
+
## Why objectstorage?
|
|
39
|
+
|
|
40
|
+
| Feature | objectstorage | MinIO | s3rver |
|
|
41
|
+
|---------|-------------|-------|--------|
|
|
42
|
+
| Install | `pnpm add` | Docker / binary | `npm install` |
|
|
43
|
+
| Startup time | ~20ms | seconds | ~200ms |
|
|
44
|
+
| Large file uploads | Streaming, bounded memory | Yes | OOM risk |
|
|
45
|
+
| Range requests | Seek-based | Yes | Full read |
|
|
46
|
+
| Language | Rust + TypeScript | Go | JavaScript |
|
|
47
|
+
| Multipart uploads | โ
Full support | Yes | No |
|
|
48
|
+
| Auth | AWS SigV4 (full verification) | Full IAM | Basic |
|
|
49
|
+
| Bucket policies | IAM-style evaluation | Yes | No |
|
|
50
|
+
| Clustering | โ
Erasure-coded, QUIC | Yes | No |
|
|
51
|
+
| Multi-drive awareness | โ
Per-drive health | Yes | No |
|
|
52
|
+
|
|
53
|
+
### Core Features
|
|
54
|
+
|
|
55
|
+
- ๐ฆ **Rust-powered HTTP server** โ hyper 1.x with streaming I/O, bounded buffering, backpressure
|
|
56
|
+
- ๐ฆ **S3-compatible API** โ supported operations work with AWS SDK v3 and SmartBucket
|
|
57
|
+
- ๐พ **Filesystem-backed storage** โ buckets map to directories, objects to files
|
|
58
|
+
- ๐ค **Streaming multipart uploads** โ large files with bounded memory use
|
|
59
|
+
- ๐ **Byte-range requests** โ `seek()` directly to the requested byte offset
|
|
60
|
+
- ๐ **AWS SigV4 authentication** โ full signature verification with constant-time comparison
|
|
61
|
+
- ๐ **Bucket policies** โ IAM-style JSON policies with Allow/Deny evaluation and wildcard matching
|
|
62
|
+
- ๐ **CORS middleware** โ configurable cross-origin support
|
|
63
|
+
- ๐งน **Clean slate mode** โ wipe storage on startup for test isolation
|
|
64
|
+
- ๐ **Runtime storage stats** โ cheap bucket summaries and global counts without S3 list scans
|
|
65
|
+
- ๐ **Runtime credential rotation** โ list and replace active auth credentials without mutating internals
|
|
66
|
+
- ๐งฉ **Bucket tenants** โ provision one scoped S3 credential per bucket with restart persistence
|
|
67
|
+
- โก **Test-first design** โ start/stop in milliseconds, no port conflicts
|
|
68
|
+
|
|
69
|
+
### Clustering Features
|
|
70
|
+
|
|
71
|
+
- ๐ **Erasure coding** โ Reed-Solomon (configurable k data + m parity shards) for storage efficiency and fault tolerance
|
|
72
|
+
- ๐ **QUIC transport** โ multiplexed, encrypted inter-node communication via `quinn` with zero head-of-line blocking
|
|
73
|
+
- ๐ฝ **Multi-drive awareness** โ each node manages multiple independent storage paths with health monitoring
|
|
74
|
+
- ๐ฉบ **Cluster health introspection** โ query native node, drive, quorum, and healing status for product dashboards
|
|
75
|
+
- ๐ค **Cluster membership** โ static seed config + runtime join, heartbeat-based failure detection
|
|
76
|
+
- โ๏ธ **Quorum writes** โ data is only acknowledged after k+1 shards are persisted
|
|
77
|
+
- ๐ **Quorum reads** โ reconstruct from any k available shards, local-first fast path
|
|
78
|
+
- ๐ฉน **Self-healing** โ background scanner detects and reconstructs missing/corrupt shards
|
|
79
|
+
|
|
80
|
+
## Installation
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
pnpm add @lossless.org/objectstorage -D
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
> **Note:** The package ships with precompiled Rust binaries for `linux_amd64` and `linux_arm64`. No Rust toolchain needed on your machine.
|
|
87
|
+
|
|
88
|
+
## Quick Start
|
|
89
|
+
|
|
90
|
+
### Standalone Mode (Dev & Test)
|
|
91
|
+
|
|
92
|
+
```typescript
|
|
93
|
+
import { ObjectStorage } from '@lossless.org/objectstorage';
|
|
94
|
+
|
|
95
|
+
// Start a local S3-compatible storage server
|
|
96
|
+
const storage = await ObjectStorage.createAndStart({
|
|
97
|
+
server: { port: 3000 },
|
|
98
|
+
storage: { cleanSlate: true },
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// Create a bucket
|
|
102
|
+
await storage.createBucket('my-bucket');
|
|
103
|
+
|
|
104
|
+
// Get connection details for a supported S3 client
|
|
105
|
+
const descriptor = await storage.getStorageDescriptor();
|
|
106
|
+
// โ { endpoint: 'localhost', port: 3000, accessKey: 'STORAGE', accessSecret: 'STORAGE', useSsl: false }
|
|
107
|
+
|
|
108
|
+
// When done
|
|
109
|
+
await storage.stop();
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### Cluster Mode (Distributed)
|
|
113
|
+
|
|
114
|
+
Cluster mode requires the control root and every drive root to exist before
|
|
115
|
+
startup. The process user must own them, no path component may be a symlink,
|
|
116
|
+
and group/world write permission must be disabled.
|
|
117
|
+
|
|
118
|
+
```typescript
|
|
119
|
+
import { ObjectStorage } from '@lossless.org/objectstorage';
|
|
120
|
+
|
|
121
|
+
const storage = await ObjectStorage.createAndStart({
|
|
122
|
+
server: { port: 3000 },
|
|
123
|
+
storage: {
|
|
124
|
+
directory: '/var/lib/objectstorage/control',
|
|
125
|
+
cleanSlate: false,
|
|
126
|
+
},
|
|
127
|
+
cluster: {
|
|
128
|
+
enabled: true,
|
|
129
|
+
nodeId: 'node-1',
|
|
130
|
+
quicPort: 4000,
|
|
131
|
+
seedNodes: ['192.168.1.11:4000', '192.168.1.12:4000'],
|
|
132
|
+
erasure: {
|
|
133
|
+
dataShards: 4, // k: minimum shards to reconstruct data
|
|
134
|
+
parityShards: 2, // m: fault tolerance (can lose up to m shards)
|
|
135
|
+
},
|
|
136
|
+
drives: {
|
|
137
|
+
paths: ['/mnt/disk1', '/mnt/disk2', '/mnt/disk3'],
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Objects are automatically split into chunks (default 4 MB), erasure-coded into 6 shards (4 data + 2 parity), and distributed across drives/nodes. Any 4 of 6 shards can reconstruct the original data.
|
|
144
|
+
|
|
145
|
+
## Configuration
|
|
146
|
+
|
|
147
|
+
All config fields are optional โ sensible defaults are applied automatically.
|
|
148
|
+
|
|
149
|
+
```typescript
|
|
150
|
+
import { ObjectStorage, IObjectStorageConfig } from '@lossless.org/objectstorage';
|
|
151
|
+
|
|
152
|
+
const config: IObjectStorageConfig = {
|
|
153
|
+
server: {
|
|
154
|
+
port: 3000, // Default: 3000
|
|
155
|
+
address: '0.0.0.0', // Default: '0.0.0.0'
|
|
156
|
+
silent: false, // Default: false
|
|
157
|
+
region: 'us-east-1', // Default: 'us-east-1' โ used for SigV4 signing
|
|
158
|
+
},
|
|
159
|
+
storage: {
|
|
160
|
+
directory: './my-data', // Default: .nogit/bucketsDir
|
|
161
|
+
cleanSlate: false, // Default: false โ set true to wipe on start
|
|
162
|
+
pool: { // Optional explicit identity for this process' one pool
|
|
163
|
+
id: 'fast-local',
|
|
164
|
+
directory: './my-data',
|
|
165
|
+
backend: { kind: 'localFs' },
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
auth: {
|
|
169
|
+
enabled: false, // Default: false
|
|
170
|
+
credentials: [{
|
|
171
|
+
accessKeyId: 'MY_KEY',
|
|
172
|
+
secretAccessKey: 'MY_SECRET',
|
|
173
|
+
}],
|
|
174
|
+
},
|
|
175
|
+
cors: {
|
|
176
|
+
enabled: false, // Default: false
|
|
177
|
+
allowedOrigins: ['*'],
|
|
178
|
+
allowedMethods: ['GET', 'POST', 'PUT', 'DELETE', 'HEAD', 'OPTIONS'],
|
|
179
|
+
allowedHeaders: ['*'],
|
|
180
|
+
exposedHeaders: ['ETag', 'x-amz-request-id', 'x-amz-version-id'],
|
|
181
|
+
maxAge: 86400,
|
|
182
|
+
allowCredentials: false,
|
|
183
|
+
},
|
|
184
|
+
logging: {
|
|
185
|
+
level: 'info', // 'error' | 'warn' | 'info' | 'debug'
|
|
186
|
+
format: 'text', // 'text' | 'json'
|
|
187
|
+
enabled: true,
|
|
188
|
+
},
|
|
189
|
+
limits: {
|
|
190
|
+
maxObjectSize: 5 * 1024 * 1024 * 1024, // 5 GB
|
|
191
|
+
maxMetadataSize: 2048,
|
|
192
|
+
requestTimeout: 300000, // 5 minutes
|
|
193
|
+
},
|
|
194
|
+
multipart: {
|
|
195
|
+
expirationDays: 7, // Must be greater than zero
|
|
196
|
+
cleanupIntervalMinutes: 60, // Must be greater than zero
|
|
197
|
+
},
|
|
198
|
+
cluster: { // Optional โ omit for standalone mode
|
|
199
|
+
enabled: true,
|
|
200
|
+
nodeId: 'node-1', // Auto-generated UUID if omitted
|
|
201
|
+
quicPort: 4000, // Default: 4000
|
|
202
|
+
seedNodes: [], // Addresses of existing cluster members
|
|
203
|
+
erasure: {
|
|
204
|
+
dataShards: 4, // Default: 4
|
|
205
|
+
parityShards: 2, // Default: 2
|
|
206
|
+
chunkSizeBytes: 4194304, // Default: 4 MB
|
|
207
|
+
},
|
|
208
|
+
drives: {
|
|
209
|
+
paths: ['/mnt/disk1', '/mnt/disk2'],
|
|
210
|
+
},
|
|
211
|
+
heartbeatIntervalMs: 5000, // Default: 5000
|
|
212
|
+
heartbeatTimeoutMs: 30000, // Default: 30000
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const storage = await ObjectStorage.createAndStart(config);
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
### Storage Pools and Mounted-Filesystem Serving
|
|
220
|
+
|
|
221
|
+
One ObjectStorage process serves one storage pool. Existing `storage.directory`
|
|
222
|
+
configuration remains compatible and normalizes to a `default` `localFs` pool.
|
|
223
|
+
For `localFs`, `storage.directory` and `storage.pool.directory` must resolve to
|
|
224
|
+
the same path when both are supplied. For `mountedFs`, both values must instead
|
|
225
|
+
be lexically exact absolute mountpoints and identical strings. A root is durably
|
|
226
|
+
bound to its pool ID and cannot later be reassigned to a different pool.
|
|
227
|
+
|
|
228
|
+
For standalone `localFs` pools, ObjectStorage creates a missing root with `0700`
|
|
229
|
+
permissions. An existing root must be owned by the process user and must not be
|
|
230
|
+
group- or world-writable; startup fails before opening the S3 listener when this
|
|
231
|
+
contract is not met. ObjectStorage never silently changes an existing root's
|
|
232
|
+
ownership or permissions.
|
|
233
|
+
|
|
234
|
+
On Linux, standalone ObjectStorage can serve an exact NFS or SMB mount. The
|
|
235
|
+
configured directory itself must be the absolute mountpoint (not a symlink or
|
|
236
|
+
canonical alias), `expectedSource` must contain a numeric IPv4 or IPv6 address,
|
|
237
|
+
the client mount must enable the Linux `nosymfollow` VFS option, and
|
|
238
|
+
`cleanSlate` is forbidden. Mounted pools are not supported in cluster mode.
|
|
239
|
+
Onebox and other mount-provisioning clients must include `nosymfollow` in their
|
|
240
|
+
NFS or CIFS mount options before ObjectStorage starts. NFS mounts must keep
|
|
241
|
+
server-coordinated locking enabled: `nolock` and
|
|
242
|
+
`local_lock=all|flock` are rejected, while an absent `local_lock` option,
|
|
243
|
+
`local_lock=none`, or `local_lock=posix` is accepted because ObjectStorage uses
|
|
244
|
+
`flock(2)` for its provider locks. CIFS mounts must not use `nobrl`.
|
|
245
|
+
Accepted source forms are `IP:/absolute/export` for NFS and
|
|
246
|
+
`//IP/share[/path]` for SMB. IPv6 addresses use brackets, for example
|
|
247
|
+
`[2001:db8::10]:/archive` and `//[2001:db8::10]/archive`.
|
|
248
|
+
|
|
249
|
+
Startup opens the mountpoint once and confines every standalone storage,
|
|
250
|
+
credential, policy, retention, fencing, and background operation beneath that
|
|
251
|
+
descriptor through `/proc/self/fd`. It verifies the descriptor's mount ID,
|
|
252
|
+
device, NFS/CIFS filesystem magic, and authoritative `ST_NOSYMFOLLOW` VFS flag,
|
|
253
|
+
plus the effective mount and superblock locking options, then rereads the
|
|
254
|
+
complete mount activation before serving. Every storage
|
|
255
|
+
boundary revalidates the activation and `ST_NOSYMFOLLOW`. Any drift permanently
|
|
256
|
+
poisons the process, fails storage-derived HTTP and management boundaries
|
|
257
|
+
closed, never follows descendant symlinks, and never falls back to a host
|
|
258
|
+
directory at the configured path. Lifecycle, liveness, cluster-status, and
|
|
259
|
+
cached-diagnostic operations remain available for shutdown and diagnosis.
|
|
260
|
+
|
|
261
|
+
Every server mode stops accepting HTTP connections, gracefully drains each
|
|
262
|
+
accepted connection, and joins independently owned request and multipart
|
|
263
|
+
workers before releasing the mount descriptor or root-maintenance lock. The
|
|
264
|
+
drain is bounded to 30 seconds. A timeout is reported as an incomplete stop:
|
|
265
|
+
the Rust process retains the server, descriptors, locks, and unfinished task
|
|
266
|
+
handles so the caller can retry `stop()` after hard-mounted I/O recovers.
|
|
267
|
+
Streamed object reads move their file descriptor into the same owned worker
|
|
268
|
+
lifecycle, so a disconnected client cannot detach a blocked filesystem read.
|
|
269
|
+
Active HTTP request lifecycles are capped at 4,096; excess requests receive a
|
|
270
|
+
retryable `503 Service Unavailable` response with `Retry-After: 1`. A terminal
|
|
271
|
+
shutdown result is reported only after all owned work is complete and remains
|
|
272
|
+
available to retrying stop waiters until the management process terminates.
|
|
273
|
+
|
|
274
|
+
```typescript
|
|
275
|
+
const storage = await ObjectStorage.createAndStart({
|
|
276
|
+
storage: {
|
|
277
|
+
directory: '/mnt/archive',
|
|
278
|
+
pool: {
|
|
279
|
+
id: 'archive',
|
|
280
|
+
directory: '/mnt/archive',
|
|
281
|
+
backend: {
|
|
282
|
+
kind: 'mountedFs',
|
|
283
|
+
expectedFilesystemType: 'nfs',
|
|
284
|
+
expectedSource: '192.0.2.10:/archive',
|
|
285
|
+
// IPv6 is canonicalized as: [2001:db8::10]:/archive
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
Serving startup also runs the root-ownership, same-filesystem,
|
|
293
|
+
server-coordinated lock-contention, directory-fsync, and durable
|
|
294
|
+
provider-identity checks through the descriptor anchor. A mounted runtime
|
|
295
|
+
observation is `productionEligible` after these runtime checks pass and while
|
|
296
|
+
the activation is unpoisoned. Atomic directory exchange is reported separately
|
|
297
|
+
as an optional capability: NFS filesystems without it can still serve S3,
|
|
298
|
+
atomic create-only uploads, fenced bucket ensure/delete, and migration cleanup,
|
|
299
|
+
while atomic whole-bucket replacement and isolated restore remain unavailable
|
|
300
|
+
and fail closed. Its `remoteLocking` check and
|
|
301
|
+
`filesystem.remoteLockingMode` observation distinguish server-coordinated
|
|
302
|
+
locking from unsafe local or disabled locking. `getStoragePoolDiagnostic()`
|
|
303
|
+
remains available after poison and returns cached activation identity and
|
|
304
|
+
poison evidence without accessing the storage filesystem.
|
|
305
|
+
|
|
306
|
+
Probe an existing exact mount without starting the S3 listener:
|
|
307
|
+
|
|
308
|
+
```typescript
|
|
309
|
+
const observation = await ObjectStorage.probeStoragePool({
|
|
310
|
+
id: 'archive',
|
|
311
|
+
directory: '/mnt/archive',
|
|
312
|
+
backend: {
|
|
313
|
+
kind: 'mountedFs',
|
|
314
|
+
expectedFilesystemType: 'nfs',
|
|
315
|
+
expectedSource: '192.0.2.10:/archive',
|
|
316
|
+
},
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
console.log(observation.supported);
|
|
320
|
+
console.log(observation.productionEligible); // false: this read-only probe skips write semantics
|
|
321
|
+
console.log(observation.checks);
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
The read-only probe verifies the exact active mount, numeric source,
|
|
325
|
+
descriptor-bound mount ID, device, filesystem magic, `ST_NOSYMFOLLOW`, and
|
|
326
|
+
server-coordinated locking options, and activation stability.
|
|
327
|
+
It reports observed block and transfer sizes but does not change mount tuning or
|
|
328
|
+
write to the target. Mutating semantic checks remain `notRun`, so a successful
|
|
329
|
+
diagnostic probe keeps `supported` at `null` and `productionEligible` at
|
|
330
|
+
`false`; production eligibility is established only by serving startup.
|
|
331
|
+
|
|
332
|
+
### Common Configurations
|
|
333
|
+
|
|
334
|
+
**CI/CD testing** โ silent, clean, fast:
|
|
335
|
+
```typescript
|
|
336
|
+
const storage = await ObjectStorage.createAndStart({
|
|
337
|
+
server: { port: 9999, silent: true },
|
|
338
|
+
storage: { cleanSlate: true },
|
|
339
|
+
});
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
**Auth enabled:**
|
|
343
|
+
```typescript
|
|
344
|
+
const storage = await ObjectStorage.createAndStart({
|
|
345
|
+
auth: {
|
|
346
|
+
enabled: true,
|
|
347
|
+
credentials: [{ accessKeyId: 'test', secretAccessKey: 'test123' }],
|
|
348
|
+
},
|
|
349
|
+
});
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
**CORS for local web dev:**
|
|
353
|
+
```typescript
|
|
354
|
+
const storage = await ObjectStorage.createAndStart({
|
|
355
|
+
cors: {
|
|
356
|
+
enabled: true,
|
|
357
|
+
allowedOrigins: ['http://localhost:5173'],
|
|
358
|
+
allowCredentials: true,
|
|
359
|
+
},
|
|
360
|
+
});
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
## Runtime Credentials
|
|
364
|
+
|
|
365
|
+
```typescript
|
|
366
|
+
const credentials = await storage.listCredentials();
|
|
367
|
+
|
|
368
|
+
await storage.replaceCredentials([
|
|
369
|
+
{
|
|
370
|
+
accessKeyId: 'ADMINA',
|
|
371
|
+
secretAccessKey: 'super-secret-a',
|
|
372
|
+
},
|
|
373
|
+
{
|
|
374
|
+
accessKeyId: 'ADMINB',
|
|
375
|
+
secretAccessKey: 'super-secret-b',
|
|
376
|
+
},
|
|
377
|
+
]);
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
```typescript
|
|
381
|
+
interface IStorageCredential {
|
|
382
|
+
accessKeyId: string;
|
|
383
|
+
secretAccessKey: string;
|
|
384
|
+
bucketName?: string;
|
|
385
|
+
region?: string;
|
|
386
|
+
}
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
- `listCredentials()` returns the Rust core's current runtime credential set.
|
|
390
|
+
- `replaceCredentials()` swaps the full set atomically and persists it under the storage root. On success, new requests use the new set immediately and the old credentials stop authenticating immediately.
|
|
391
|
+
- Requests that were already authenticated before the replacement keep running; auth is evaluated when each request starts.
|
|
392
|
+
- No restart is required, and runtime-created credentials survive restart unless `storage.cleanSlate` clears the bounded storage contents; the owned root, maintenance-lock inode, and any durable provider/pool identity are preserved.
|
|
393
|
+
- Replacement input must contain at least one credential, each `accessKeyId` and `secretAccessKey` must be non-empty, and `accessKeyId` values must be unique.
|
|
394
|
+
|
|
395
|
+
## Bucket Tenants
|
|
396
|
+
|
|
397
|
+
Bucket tenants are designed for platform services that need one bucket and one scoped S3 credential per app. Tenant credentials are enforced by the auth layer before the normal bucket-policy/default-auth pipeline, so a scoped credential cannot list all buckets or access another bucket even when it has a valid SigV4 signature.
|
|
398
|
+
|
|
399
|
+
```typescript
|
|
400
|
+
const tenant = await storage.createBucketTenant({
|
|
401
|
+
bucketName: 'workapp-123',
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
// Directly usable by AWS SDK v3 or env injection
|
|
405
|
+
const client = new S3Client({
|
|
406
|
+
endpoint: `http://${tenant.endpoint}:${tenant.port}`,
|
|
407
|
+
region: tenant.region,
|
|
408
|
+
credentials: {
|
|
409
|
+
accessKeyId: tenant.accessKeyId,
|
|
410
|
+
secretAccessKey: tenant.secretAccessKey,
|
|
411
|
+
},
|
|
412
|
+
forcePathStyle: true,
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
console.log(tenant.env.S3_BUCKET);
|
|
416
|
+
console.log(tenant.env.AWS_ACCESS_KEY_ID);
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
```typescript
|
|
420
|
+
await storage.rotateBucketTenantCredentials({ bucketName: 'workapp-123' });
|
|
421
|
+
await storage.deleteBucketTenant({ bucketName: 'workapp-123', accessKeyId: tenant.accessKeyId });
|
|
422
|
+
const descriptor = await storage.getBucketTenantDescriptor({ bucketName: 'workapp-123' });
|
|
423
|
+
const tenants = await storage.listBucketTenants();
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
- `createBucketTenant()` creates the bucket if needed and stores a scoped credential for that bucket.
|
|
427
|
+
- `rotateBucketTenantCredentials()` replaces the active scoped credential for the bucket and persists the new credential.
|
|
428
|
+
- `deleteBucketTenant({ bucketName, accessKeyId })` revokes one scoped credential and keeps the bucket.
|
|
429
|
+
- `deleteBucketTenant({ bucketName })` revokes scoped credentials for an existing tenant bucket and deletes that bucket's contents recursively.
|
|
430
|
+
- Tenant credentials can list, read, write, and delete objects in their assigned bucket, but cannot list all buckets, access other buckets, copy from other buckets, delete buckets, or mutate bucket policies.
|
|
431
|
+
- Bucket tenant APIs require `auth.enabled: true`.
|
|
432
|
+
|
|
433
|
+
## Bucket Backup/Restore
|
|
434
|
+
|
|
435
|
+
```typescript
|
|
436
|
+
const appBackup = await storage.exportBucket({ bucketName: 'workapp-123' });
|
|
437
|
+
await storage.importBucket({ bucketName: 'workapp-123-restore', source: appBackup });
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
- `exportBucket()` returns a self-contained `smartstorage.bucket.v1` JSON export with only the selected bucket's objects and object metadata. The legacy whole-bucket management export is intentionally bounded to 10,000 objects, 48 MiB of payload, and 8 MiB of serialized keys plus metadata; use the migration transfer surface for larger buckets.
|
|
441
|
+
- `importBucket()` validates object payload size and MD5 before creating the target bucket if needed, then restores the exported objects into that bucket.
|
|
442
|
+
- Exports do not include credentials, policies, or unrelated tenant data.
|
|
443
|
+
|
|
444
|
+
## Bucket Migration Control
|
|
445
|
+
|
|
446
|
+
Standalone storage exposes durable, provider-bound migration control records.
|
|
447
|
+
Discover the local provider identity, receipt key, and cleanup support with
|
|
448
|
+
`getBucketMigrationCapability()`. Create each side with
|
|
449
|
+
`createSourceMigration()` and `createDestinationMigration()`, then advance only
|
|
450
|
+
the explicit source (`active` โ `sealed` โ `cutoverCommitted` โ `retired`) and
|
|
451
|
+
destination (`holding` โ `verified` โ `cutoverAuthorized` โ `published`) paths.
|
|
452
|
+
Before cutover, a destination `aborted` terminal receipt can release a sealed
|
|
453
|
+
source. Every call binds a positive fence token, exact migration binding, and
|
|
454
|
+
opaque controller capability; exact same-token retries replay the durable
|
|
455
|
+
receipt while stale or mismatched requests fail.
|
|
456
|
+
|
|
457
|
+
Source creation additionally requires `sourceCleanupAccessKeyId`. ObjectStorage
|
|
458
|
+
verifies that it is the bucket's one exact scoped credential, durably binds its
|
|
459
|
+
hash before creating migration state, and revalidates that ownership before
|
|
460
|
+
every source phase transition and before persisting a cleanup request. A
|
|
461
|
+
provider can have only one nonterminal migration per role and logical bucket;
|
|
462
|
+
terminal records remain as history and do not prevent a later migration ID.
|
|
463
|
+
The standalone registry has a finite capacity of 100,000 migration states.
|
|
464
|
+
Terminal receipts and completed-cleanup tombstones continue to consume that
|
|
465
|
+
capacity because they are retained permanently; controllers must treat
|
|
466
|
+
migration IDs as lifecycle records and capacity-plan before exhaustion.
|
|
467
|
+
ObjectStorage serializes registry creation globally and atomically rejects a new
|
|
468
|
+
migration ID when 100,000 states already exist. Exact replays of existing IDs
|
|
469
|
+
remain available at the bound; the provider never deletes history or permits
|
|
470
|
+
manual registry-file reuse to make capacity.
|
|
471
|
+
Registry enumeration budgets durable locks, canonical crash-left atomic
|
|
472
|
+
temporary files, and unrecognized junk separately. This leaves bounded recovery
|
|
473
|
+
headroom at full state capacity without deleting a temporary file another
|
|
474
|
+
process may still own; every partition remains finite and fails closed when its
|
|
475
|
+
own limit is exceeded.
|
|
476
|
+
|
|
477
|
+
Source sealing drains bucket mutations and blocks later object, multipart,
|
|
478
|
+
policy, bucket, tenant, and fenced mutation paths. Reads, heads, listings,
|
|
479
|
+
policy reads, and multipart listings remain available while sealed, but they
|
|
480
|
+
hold the bucket data-plane read guard so a later retirement cleanup drains every
|
|
481
|
+
accepted source read before deletion. A retained bucket cannot become a
|
|
482
|
+
migration source.
|
|
483
|
+
|
|
484
|
+
After the destination is durably `published` and the source is durably
|
|
485
|
+
`retired`, the controller can permanently retire the source data:
|
|
486
|
+
|
|
487
|
+
```typescript
|
|
488
|
+
const sourceState = await source.inspectBucketMigration({ migrationId });
|
|
489
|
+
const destinationState = await destination.inspectBucketMigration({ migrationId });
|
|
490
|
+
|
|
491
|
+
const cleanup = await source.cleanupSourceMigration({
|
|
492
|
+
binding,
|
|
493
|
+
cleanupFenceToken: 1,
|
|
494
|
+
opaqueCapability: sourceCapability,
|
|
495
|
+
sourceRetiredReceiptSha256: sourceState!.terminalReceipt!.receiptSha256,
|
|
496
|
+
destinationPublishedReceipt: destinationState!.terminalReceipt!,
|
|
497
|
+
resourceFence: {
|
|
498
|
+
version: 1,
|
|
499
|
+
scopeId: `${binding.authorityId}.${binding.logicalBucket}`,
|
|
500
|
+
token: 42,
|
|
501
|
+
mutationId: `${migrationId}.source-cleanup`,
|
|
502
|
+
payloadSha256: cleanupIntentSha256,
|
|
503
|
+
},
|
|
504
|
+
accessKeyId: sourceTenant.accessKeyId,
|
|
505
|
+
});
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
`cleanupSourceMigration()` first persists a permanent requested cleanup
|
|
509
|
+
subrecord that binds the exact retired-source receipt, fully verified
|
|
510
|
+
destination-published receipt, cleanup token, resource fence, and hashed access
|
|
511
|
+
key. It then reuses exact fenced deletion to remove bucket data, every matching
|
|
512
|
+
multipart upload, the exact credential, and the bucket policy before persisting
|
|
513
|
+
the canonical cleanup receipt and generic delete-result digest. Multipart
|
|
514
|
+
deletion immediately reconciles the runtime admission counter and cleanup queue.
|
|
515
|
+
The lock order drains reads and mutations before credential revocation.
|
|
516
|
+
|
|
517
|
+
The controller owns recovery: ObjectStorage never starts source deletion
|
|
518
|
+
automatically. Retry the exact `cleanupSourceMigration()` request after any
|
|
519
|
+
timeout, process exit, or ambiguous response. A requested-only retry repeats
|
|
520
|
+
the exact resource deletion; if deletion was already durably complete, its
|
|
521
|
+
generic receipt is replayed and the migration cleanup record is finalized.
|
|
522
|
+
After completion the request cannot be rebound, the bucket can never be
|
|
523
|
+
recreated or credentialed, and unrelated global credential replacement resumes.
|
|
524
|
+
Generic bucket deletion and multipart abort remain blocked throughout the
|
|
525
|
+
migration seal.
|
|
526
|
+
|
|
527
|
+
`inspectBucketMigration()` returns the durable phase, terminal receipts, and
|
|
528
|
+
the optional `sourceCleanup` requested/completed subrecord. Terminal receipts
|
|
529
|
+
and completed cleanup receipts remain available across restart.
|
|
530
|
+
`storage.cleanSlate: true` cannot erase or bypass that history: standalone
|
|
531
|
+
startup is refused whenever any durable migration state exists, including a
|
|
532
|
+
terminal receipt or completed-cleanup tombstone.
|
|
533
|
+
|
|
534
|
+
## Durable Resource Fencing
|
|
535
|
+
|
|
536
|
+
Standalone storage with `cleanSlate: false` supports monotonic, crash-safe bucket
|
|
537
|
+
replacement and deletion. The legacy immediate mode (`holdPublication` omitted
|
|
538
|
+
or `false`) still requires callers to drain normal writes before issuing a
|
|
539
|
+
fenced mutation.
|
|
540
|
+
|
|
541
|
+
```typescript
|
|
542
|
+
const fence = {
|
|
543
|
+
version: 1 as const,
|
|
544
|
+
scopeId: 'corestore-node-1.workapp-123',
|
|
545
|
+
token: 42,
|
|
546
|
+
mutationId: 'delete-workapp-123',
|
|
547
|
+
payloadSha256: 'a'.repeat(64),
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
const result = await storage.deleteBucket({
|
|
551
|
+
bucketName: 'workapp-123',
|
|
552
|
+
accessKeyId: tenant.accessKeyId,
|
|
553
|
+
fence,
|
|
554
|
+
});
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
For control planes that cannot atomically publish the provider result, request a
|
|
558
|
+
durable write-publication hold:
|
|
559
|
+
|
|
560
|
+
```typescript
|
|
561
|
+
const held = await storage.importBucket({
|
|
562
|
+
bucketName: 'workapp-123',
|
|
563
|
+
source: appBackup,
|
|
564
|
+
accessKeyId: tenant.accessKeyId,
|
|
565
|
+
fence,
|
|
566
|
+
holdPublication: true,
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
// Persist the provider result and update the control-plane resource first.
|
|
570
|
+
const release = await storage.commitResourcePublication({
|
|
571
|
+
barrier: held.resourcePublicationBarrier!,
|
|
572
|
+
resourceFenceReceipt: held.resourceFence!,
|
|
573
|
+
});
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
`holdPublication: true` is part of the effective mutation identity. ObjectStorage
|
|
577
|
+
drains active target-bucket mutations before the destructive provider operation,
|
|
578
|
+
then keeps all new target mutations behind a durable barrier after the provider
|
|
579
|
+
effects and receipt are fully persisted. Mutations include object, multipart,
|
|
580
|
+
bucket, policy, tenant, and credential changes. They receive retryable S3
|
|
581
|
+
`WritePublicationHeld` (`503`, `Retry-After: 1`) or
|
|
582
|
+
`EFENCE_PUBLICATION_HELD` through the management API. Unrelated buckets remain
|
|
583
|
+
available. Reads are not blocked, so this protocol controls write publication;
|
|
584
|
+
it does not hide newly restored data from readers.
|
|
585
|
+
|
|
586
|
+
The exact mutation replay returns the same barrier. Different and higher-token
|
|
587
|
+
mutations cannot clear it. `commitResourcePublication()` validates the complete
|
|
588
|
+
barrier, the canonical fence receipt, and its 256-bit opaque commit capability;
|
|
589
|
+
it is safe to retry and returns the same release receipt. Treat
|
|
590
|
+
`commitCapability` as a secret: do not log it or persist it outside protected
|
|
591
|
+
control-plane state. A successful commit removes the raw capability from provider
|
|
592
|
+
state and retains only its hash.
|
|
593
|
+
|
|
594
|
+
There is no timeout, shutdown release, or administrative bypass. A held barrier
|
|
595
|
+
survives restart and is restored before the listener accepts requests. Invalid,
|
|
596
|
+
unknown, oversized, or insecure publication state fails startup closed.
|
|
597
|
+
`replaceCredentials()` is also rejected while any publication remains held.
|
|
598
|
+
Capability discovery keeps `requiresDrain: true` for legacy immediate mode and
|
|
599
|
+
reports `publicationHoldSupported: true`, `publicationHoldVersion: 1`, and
|
|
600
|
+
`publicationHoldRequiresExternalDrain: false` when the provider can enforce the
|
|
601
|
+
hold internally.
|
|
602
|
+
|
|
603
|
+
When supplied, the exact `accessKeyId` is bound into the durable mutation
|
|
604
|
+
identity. Its delete receipt is committed only after bucket data is durably
|
|
605
|
+
absent, that exact bucket-scoped credential is durably revoked, and the bucket
|
|
606
|
+
policy is durably absent. Retrying the same fence returns the stored receipt.
|
|
607
|
+
Recovery after a process crash repeats the credential and policy cleanup
|
|
608
|
+
idempotently, so `deletedCredentials` can be `0`; a higher fence against an
|
|
609
|
+
already absent bucket can likewise return `bucketDeleted: false`. Completed
|
|
610
|
+
exact deletes return `credentialsPreserved: false` and
|
|
611
|
+
`policyPreserved: false`.
|
|
612
|
+
|
|
613
|
+
For backward compatibility, omitting `accessKeyId` retains the original fenced
|
|
614
|
+
data-only delete identity and receipt: tenant credentials and bucket policy are
|
|
615
|
+
preserved, and both preservation fields are `true`.
|
|
616
|
+
|
|
617
|
+
After an exact delete, a new higher-token authority can recreate the same target
|
|
618
|
+
without removing its durable fence history. Call `ensureBucketTenant()` with the
|
|
619
|
+
new fence first, then issue an exact fenced `importBucket()` with the same
|
|
620
|
+
resource token. Ensure receipts use the
|
|
621
|
+
`smartstorage.bucket.ensure.v1` profile and attest the applied access key,
|
|
622
|
+
provider-computed secret hash, and effective region; the secret itself is never
|
|
623
|
+
written to the receipt or fencing registry.
|
|
624
|
+
|
|
625
|
+
`computeObjectStorageResourceFenceEffectivePayloadSha256V1()` computes the same
|
|
626
|
+
canonical effective payload digest as the Rust provider for replacement,
|
|
627
|
+
exact-delete, and ensure profiles. A completed fenced ensure returns its receipt
|
|
628
|
+
on `descriptor.resourceFence`.
|
|
629
|
+
|
|
630
|
+
Absent-bucket fenced ensure removes any residual policy before creating the new
|
|
631
|
+
resource; existing-bucket ensure preserves policy and object data. Once a bucket
|
|
632
|
+
has durable fence history, legacy `createBucket()`, `createBucketTenant()`,
|
|
633
|
+
`rotateBucketTenantCredentials()`, `deleteBucketTenant()`, and unfenced
|
|
634
|
+
`importBucket()` mutations fail with `EFENCE_REQUIRED`. Use fenced ensure,
|
|
635
|
+
exact import, and exact delete for its lifecycle.
|
|
636
|
+
|
|
637
|
+
`replaceCredentials()` is a trusted process-local administrative override, not
|
|
638
|
+
a fenced lifecycle API. If it changes exact-owned credential material, completed
|
|
639
|
+
receipt replay detects the drift and fails closed with an ownership or durable
|
|
640
|
+
state error.
|
|
641
|
+
|
|
642
|
+
## Health and Metrics APIs
|
|
643
|
+
|
|
644
|
+
```typescript
|
|
645
|
+
const health = await storage.getHealth();
|
|
646
|
+
const metrics = await storage.getMetrics();
|
|
647
|
+
```
|
|
648
|
+
|
|
649
|
+
- `getHealth()` reports running state, storage directory and pool observation, auth enabled state, credential counts, bucket count, object count, total bytes, and cluster health.
|
|
650
|
+
- `getMetrics()` returns numeric counters and a Prometheus text snippet for bucket, object, byte, tenant credential, and cluster-enabled metrics.
|
|
651
|
+
|
|
652
|
+
## Runtime Stats
|
|
653
|
+
|
|
654
|
+
```typescript
|
|
655
|
+
const stats = await storage.getStorageStats();
|
|
656
|
+
const bucketSummaries = await storage.listBucketSummaries();
|
|
657
|
+
|
|
658
|
+
console.log(stats.bucketCount);
|
|
659
|
+
console.log(stats.totalObjectCount);
|
|
660
|
+
console.log(stats.totalStorageBytes);
|
|
661
|
+
console.log(bucketSummaries[0]?.name, bucketSummaries[0]?.objectCount);
|
|
662
|
+
```
|
|
663
|
+
|
|
664
|
+
```typescript
|
|
665
|
+
interface IBucketSummary {
|
|
666
|
+
name: string;
|
|
667
|
+
objectCount: number;
|
|
668
|
+
totalSizeBytes: number;
|
|
669
|
+
creationDate?: number;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
interface IStorageLocationSummary {
|
|
673
|
+
path: string;
|
|
674
|
+
totalBytes?: number;
|
|
675
|
+
availableBytes?: number;
|
|
676
|
+
usedBytes?: number;
|
|
677
|
+
pool?: IStoragePoolObservation;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
interface IStorageStats {
|
|
681
|
+
bucketCount: number;
|
|
682
|
+
totalObjectCount: number;
|
|
683
|
+
totalStorageBytes: number;
|
|
684
|
+
buckets: IBucketSummary[];
|
|
685
|
+
storageDirectory: string;
|
|
686
|
+
storageLocations?: IStorageLocationSummary[];
|
|
687
|
+
}
|
|
688
|
+
```
|
|
689
|
+
|
|
690
|
+
- `bucketCount`, `totalObjectCount`, `totalStorageBytes`, and per-bucket totals are logical object stats maintained by the Rust runtime. They count object payload bytes, not sidecar files or erasure-coded shard overhead.
|
|
691
|
+
- objectstorage initializes these values from native on-disk state at startup, then keeps them in memory and updates them when bucket/object mutations succeed. Stats reads do not issue S3 `ListObjects` or rescan every object.
|
|
692
|
+
- Values are exact for mutations performed through objectstorage after startup. Direct filesystem edits outside objectstorage are not watched; restart the server to resync.
|
|
693
|
+
- `storageLocations` is a cheap filesystem-capacity snapshot. Standalone mode reports the storage directory plus its pool observation. Cluster mode reports the configured drive paths.
|
|
694
|
+
|
|
695
|
+
## Cluster Health
|
|
696
|
+
|
|
697
|
+
```typescript
|
|
698
|
+
const clusterHealth = await storage.getClusterHealth();
|
|
699
|
+
|
|
700
|
+
if (!clusterHealth.enabled) {
|
|
701
|
+
console.log('Cluster mode is disabled');
|
|
702
|
+
} else {
|
|
703
|
+
console.log(clusterHealth.nodeId, clusterHealth.quorumHealthy);
|
|
704
|
+
console.log(clusterHealth.peers);
|
|
705
|
+
console.log(clusterHealth.drives);
|
|
706
|
+
}
|
|
707
|
+
```
|
|
708
|
+
|
|
709
|
+
```typescript
|
|
710
|
+
interface IClusterHealth {
|
|
711
|
+
enabled: boolean;
|
|
712
|
+
nodeId?: string;
|
|
713
|
+
quorumHealthy?: boolean;
|
|
714
|
+
majorityHealthy?: boolean;
|
|
715
|
+
peers?: IClusterPeerHealth[];
|
|
716
|
+
drives?: IClusterDriveHealth[];
|
|
717
|
+
erasure?: IClusterErasureHealth;
|
|
718
|
+
repairs?: IClusterRepairHealth;
|
|
719
|
+
}
|
|
720
|
+
```
|
|
721
|
+
|
|
722
|
+
- `getClusterHealth()` is served by the Rust core. The TypeScript wrapper does not infer values from static config.
|
|
723
|
+
- Standalone mode returns `{ enabled: false }`.
|
|
724
|
+
- Peer status is the local node's current view of cluster membership and heartbeats, so it is best-effort and may lag real network state.
|
|
725
|
+
- Drive health is based on live native probe checks on the configured local drive paths. Capacity values are cheap filesystem snapshots.
|
|
726
|
+
- `quorumHealthy` means the local node currently sees majority quorum and enough available placements in every erasure set to satisfy the configured write quorum.
|
|
727
|
+
- Repair fields expose the background healer's currently available runtime state. They are best-effort and limited to what the engine tracks today, such as whether a scan is active, the last completed run, and the last error.
|
|
728
|
+
|
|
729
|
+
## Usage with AWS SDK v3
|
|
730
|
+
|
|
731
|
+
```typescript
|
|
732
|
+
import { S3Client, PutObjectCommand, GetObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3';
|
|
733
|
+
|
|
734
|
+
const descriptor = await storage.getStorageDescriptor();
|
|
735
|
+
|
|
736
|
+
const client = new S3Client({
|
|
737
|
+
endpoint: `http://${descriptor.endpoint}:${descriptor.port}`,
|
|
738
|
+
region: 'us-east-1',
|
|
739
|
+
credentials: {
|
|
740
|
+
accessKeyId: descriptor.accessKey,
|
|
741
|
+
secretAccessKey: descriptor.accessSecret,
|
|
742
|
+
},
|
|
743
|
+
forcePathStyle: true, // Required for path-style access
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
// Upload
|
|
747
|
+
await client.send(new PutObjectCommand({
|
|
748
|
+
Bucket: 'my-bucket',
|
|
749
|
+
Key: 'hello.txt',
|
|
750
|
+
Body: 'Hello, Storage!',
|
|
751
|
+
ContentType: 'text/plain',
|
|
752
|
+
}));
|
|
753
|
+
|
|
754
|
+
// Download
|
|
755
|
+
const { Body } = await client.send(new GetObjectCommand({
|
|
756
|
+
Bucket: 'my-bucket',
|
|
757
|
+
Key: 'hello.txt',
|
|
758
|
+
}));
|
|
759
|
+
const content = await Body.transformToString(); // "Hello, Storage!"
|
|
760
|
+
|
|
761
|
+
// Delete
|
|
762
|
+
await client.send(new DeleteObjectCommand({
|
|
763
|
+
Bucket: 'my-bucket',
|
|
764
|
+
Key: 'hello.txt',
|
|
765
|
+
}));
|
|
766
|
+
```
|
|
767
|
+
|
|
768
|
+
## Usage with SmartBucket
|
|
769
|
+
|
|
770
|
+
```typescript
|
|
771
|
+
import { SmartBucket } from '@push.rocks/smartbucket';
|
|
772
|
+
|
|
773
|
+
const smartbucket = new SmartBucket(await storage.getStorageDescriptor());
|
|
774
|
+
const bucket = await smartbucket.createBucket('my-bucket');
|
|
775
|
+
const dir = await bucket.getBaseDirectory();
|
|
776
|
+
|
|
777
|
+
// Upload
|
|
778
|
+
await dir.fastPut({ path: 'docs/readme.txt', contents: 'Hello!' });
|
|
779
|
+
|
|
780
|
+
// Download
|
|
781
|
+
const content = await dir.fastGet('docs/readme.txt');
|
|
782
|
+
|
|
783
|
+
// List
|
|
784
|
+
const files = await dir.listFiles();
|
|
785
|
+
```
|
|
786
|
+
|
|
787
|
+
Standalone ObjectStorage honors `If-None-Match: *` on both `PutObject` and
|
|
788
|
+
`CompleteMultipartUpload`, publishes through an atomic no-replace operation,
|
|
789
|
+
and implements bounded `ListParts` responses so exact-streaming clients can
|
|
790
|
+
verify multipart abort cleanup. Conditional create-only writes and active
|
|
791
|
+
`ListParts` fail closed in cluster mode until those semantics have complete
|
|
792
|
+
durable cluster metadata. Descriptor-confined standalone startup reclaims
|
|
793
|
+
abandoned private object materializations from interrupted create-only, copy,
|
|
794
|
+
or multipart publication without removing an already published hard link.
|
|
795
|
+
|
|
796
|
+
Standalone `DeleteObject` also honors `If-Match: *` and comma-separated strong
|
|
797
|
+
ETags. It compares the current ETag and deletes inside the same bucket mutation
|
|
798
|
+
gate used by PUT, COPY, and multipart completion. A mismatch returns the S3
|
|
799
|
+
`PreconditionFailed` response without changing object or runtime-stat state; an
|
|
800
|
+
absent conditional target returns `NoSuchKey`. This supplies the conditional
|
|
801
|
+
delete semantics used by SmartBucket's verified exact-path purge capability.
|
|
802
|
+
Conditional deletion returns `NotImplemented` in cluster mode until the
|
|
803
|
+
precondition can be enforced across cluster nodes.
|
|
804
|
+
|
|
805
|
+
AWS SDK streaming `PutObject` and `UploadPart` requests are decoded before
|
|
806
|
+
storage. ObjectStorage verifies the declared decoded length and CRC32 trailer
|
|
807
|
+
for `STREAMING-UNSIGNED-PAYLOAD-TRAILER`, and verifies the complete chained
|
|
808
|
+
signature sequence for `STREAMING-AWS4-HMAC-SHA256-PAYLOAD`. The
|
|
809
|
+
`aws-chunked` transport encoding is not retained as object metadata. Every
|
|
810
|
+
`aws-chunked` request requires a verified SigV4 envelope, even when
|
|
811
|
+
`auth.enabled` is `false`; ordinary non-streaming SDK requests retain the
|
|
812
|
+
configured auth-disabled behavior. Unsupported, malformed, truncated,
|
|
813
|
+
checksum-invalid, or signature-invalid bodies fail closed. Standalone
|
|
814
|
+
overwrites use private same-filesystem staging and are atomically published
|
|
815
|
+
only after terminal verification succeeds. Body verification failures and
|
|
816
|
+
cancellation before publication preserve the previous object.
|
|
817
|
+
Decoder memory is globally bounded, and `limits.requestTimeout` (milliseconds)
|
|
818
|
+
bounds inactivity while waiting for each incoming body frame; timeout or
|
|
819
|
+
memory-pressure failures publish no partial object.
|
|
820
|
+
|
|
821
|
+
## Multipart Uploads
|
|
822
|
+
|
|
823
|
+
For files larger than 5 MB, use multipart uploads. objectstorage handles them
|
|
824
|
+
with **bounded-memory streaming I/O**. AWS streaming transport chunks are
|
|
825
|
+
decoded through per-chunk buffers of at most 16 MiB under a shared 64 MiB
|
|
826
|
+
decoder budget before the part data is written. In cluster mode, each part is
|
|
827
|
+
independently erasure-coded and distributed.
|
|
828
|
+
|
|
829
|
+
```typescript
|
|
830
|
+
import {
|
|
831
|
+
CreateMultipartUploadCommand,
|
|
832
|
+
UploadPartCommand,
|
|
833
|
+
CompleteMultipartUploadCommand,
|
|
834
|
+
} from '@aws-sdk/client-s3';
|
|
835
|
+
|
|
836
|
+
// 1. Initiate
|
|
837
|
+
const { UploadId } = await client.send(new CreateMultipartUploadCommand({
|
|
838
|
+
Bucket: 'my-bucket',
|
|
839
|
+
Key: 'large-file.bin',
|
|
840
|
+
}));
|
|
841
|
+
|
|
842
|
+
// 2. Upload parts
|
|
843
|
+
const parts = [];
|
|
844
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
845
|
+
const { ETag } = await client.send(new UploadPartCommand({
|
|
846
|
+
Bucket: 'my-bucket',
|
|
847
|
+
Key: 'large-file.bin',
|
|
848
|
+
UploadId,
|
|
849
|
+
PartNumber: i + 1,
|
|
850
|
+
Body: chunks[i],
|
|
851
|
+
}));
|
|
852
|
+
parts.push({ PartNumber: i + 1, ETag });
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// 3. Complete
|
|
856
|
+
await client.send(new CompleteMultipartUploadCommand({
|
|
857
|
+
Bucket: 'my-bucket',
|
|
858
|
+
Key: 'large-file.bin',
|
|
859
|
+
UploadId,
|
|
860
|
+
MultipartUpload: { Parts: parts },
|
|
861
|
+
}));
|
|
862
|
+
```
|
|
863
|
+
|
|
864
|
+
## Bucket Policies
|
|
865
|
+
|
|
866
|
+
objectstorage supports AWS-style bucket policies for fine-grained access control. Policies use the same IAM JSON format as real S3 โ so you can develop and test your policy logic locally before deploying.
|
|
867
|
+
|
|
868
|
+
When `auth.enabled` is `true`, the auth pipeline works as follows:
|
|
869
|
+
1. **Authenticate** โ verify the AWS SigV4 signature (anonymous requests skip this step)
|
|
870
|
+
2. **Authorize** โ evaluate bucket policies against the request action, resource, and caller identity
|
|
871
|
+
3. **Default** โ authenticated users get full access; anonymous requests are denied unless a policy explicitly allows them
|
|
872
|
+
|
|
873
|
+
### Setting a Bucket Policy
|
|
874
|
+
|
|
875
|
+
```typescript
|
|
876
|
+
import { PutBucketPolicyCommand } from '@aws-sdk/client-s3';
|
|
877
|
+
|
|
878
|
+
// Allow anonymous read access to all objects in a bucket
|
|
879
|
+
await client.send(new PutBucketPolicyCommand({
|
|
880
|
+
Bucket: 'public-assets',
|
|
881
|
+
Policy: JSON.stringify({
|
|
882
|
+
Version: '2012-10-17',
|
|
883
|
+
Statement: [{
|
|
884
|
+
Sid: 'PublicRead',
|
|
885
|
+
Effect: 'Allow',
|
|
886
|
+
Principal: '*',
|
|
887
|
+
Action: ['s3:GetObject'],
|
|
888
|
+
Resource: ['arn:aws:s3:::public-assets/*'],
|
|
889
|
+
}],
|
|
890
|
+
}),
|
|
891
|
+
}));
|
|
892
|
+
```
|
|
893
|
+
|
|
894
|
+
### Policy Features
|
|
895
|
+
|
|
896
|
+
- **Effect**: `Allow` and `Deny` (explicit Deny always wins)
|
|
897
|
+
- **Principal**: `"*"` (everyone) or `{ "AWS": ["arn:..."] }` for specific identities
|
|
898
|
+
- **Action**: IAM-style actions like `s3:GetObject`, `s3:PutObject`, `s3:*`, or prefix wildcards like `s3:Get*`
|
|
899
|
+
- **Resource**: ARN patterns with `*` and `?` wildcards (e.g. `arn:aws:s3:::my-bucket/*`)
|
|
900
|
+
- **Persistence**: Policies survive server restarts โ stored as JSON on disk alongside your data
|
|
901
|
+
|
|
902
|
+
### Policy CRUD Operations
|
|
903
|
+
|
|
904
|
+
| Operation | AWS SDK Command | HTTP |
|
|
905
|
+
|-----------|----------------|------|
|
|
906
|
+
| Get policy | `GetBucketPolicyCommand` | `GET /{bucket}?policy` |
|
|
907
|
+
| Set policy | `PutBucketPolicyCommand` | `PUT /{bucket}?policy` |
|
|
908
|
+
| Delete policy | `DeleteBucketPolicyCommand` | `DELETE /{bucket}?policy` |
|
|
909
|
+
|
|
910
|
+
Deleting a bucket automatically removes its associated policy.
|
|
911
|
+
|
|
912
|
+
## Clustering Deep Dive ๐
|
|
913
|
+
|
|
914
|
+
objectstorage can run as a distributed storage cluster where multiple nodes cooperate to store and retrieve data with built-in redundancy.
|
|
915
|
+
|
|
916
|
+
### How It Works
|
|
917
|
+
|
|
918
|
+
```
|
|
919
|
+
Client โโHTTP PUTโโโถ Node A (coordinator)
|
|
920
|
+
โ
|
|
921
|
+
โโ Split object into 4 MB chunks
|
|
922
|
+
โโ Erasure-code each chunk (4 data + 2 parity = 6 shards)
|
|
923
|
+
โ
|
|
924
|
+
โโโQUICโโโถ Node B (shard writes)
|
|
925
|
+
โโโQUICโโโถ Node C (shard writes)
|
|
926
|
+
โโ Local disk (shard writes)
|
|
927
|
+
```
|
|
928
|
+
|
|
929
|
+
1. **Any node can coordinate** โ the client connects to any cluster member
|
|
930
|
+
2. **Objects are chunked** โ large objects split into fixed-size pieces (default 4 MB)
|
|
931
|
+
3. **Each chunk is erasure-coded** โ Reed-Solomon produces k data + m parity shards
|
|
932
|
+
4. **Shards are distributed** โ placed across different nodes and drives for fault isolation
|
|
933
|
+
5. **Quorum guarantees consistency** โ writes need k+1 acks, reads need k shards
|
|
934
|
+
|
|
935
|
+
### Cluster Root and Upgrade Requirements
|
|
936
|
+
|
|
937
|
+
Cluster mode never creates configured control or drive roots. Before startup,
|
|
938
|
+
create `storage.directory` and every `cluster.drives.paths` entry, make them
|
|
939
|
+
owned by the ObjectStorage process user, and remove group/world write permission.
|
|
940
|
+
Every configured root must be a real directory with no symlink path component,
|
|
941
|
+
and every drive root must resolve to a distinct device/inode identity. The
|
|
942
|
+
control root may be distinct or may be the same identity as one drive root; it
|
|
943
|
+
cannot make two drive entries aliases of one another. Every root must support
|
|
944
|
+
the filesystem durability and locking semantics validated at startup.
|
|
945
|
+
ObjectStorage fails closed before accepting traffic when a root does not meet
|
|
946
|
+
these requirements.
|
|
947
|
+
|
|
948
|
+
This release migrates clustered control records, manifests, and shards to
|
|
949
|
+
descriptor-anchored, digest-addressed formats with durable tombstones. Legacy
|
|
950
|
+
records are migrated in bounded steps and the new records become authoritative.
|
|
951
|
+
Before a manifest listing can issue a continuation token, ObjectStorage drains
|
|
952
|
+
all remaining legacy manifests through batches retaining at most 32 MiB, so a
|
|
953
|
+
later migration cannot introduce a key behind an issued token. Background
|
|
954
|
+
healing can stop that drain between committed record migrations during
|
|
955
|
+
shutdown.
|
|
956
|
+
Upgrade every member during one coordinated maintenance window. Once any member
|
|
957
|
+
has written or migrated the new state, do not restart an older binary and do not
|
|
958
|
+
run a mixed-version cluster. Back up the cluster roots before upgrading; rollback
|
|
959
|
+
requires restoring those roots from the pre-upgrade backup.
|
|
960
|
+
|
|
961
|
+
### Erasure Coding
|
|
962
|
+
|
|
963
|
+
With the default `4+2` configuration:
|
|
964
|
+
- Storage overhead: **33%** (vs. 200% for 3x replication)
|
|
965
|
+
- Fault tolerance: **any 2 drives/nodes can fail** simultaneously
|
|
966
|
+
- Read efficiency: only **4 of 6 shards** needed to reconstruct data
|
|
967
|
+
|
|
968
|
+
| Config | Total Shards | Overhead | Tolerance | Min Nodes |
|
|
969
|
+
|--------|-------------|----------|-----------|-----------|
|
|
970
|
+
| 4+2 | 6 | 33% | 2 failures | 3 |
|
|
971
|
+
| 6+3 | 9 | 50% | 3 failures | 5 |
|
|
972
|
+
| 2+1 | 3 | 50% | 1 failure | 2 |
|
|
973
|
+
|
|
974
|
+
### QUIC Transport
|
|
975
|
+
|
|
976
|
+
Inter-node communication uses [QUIC](https://en.wikipedia.org/wiki/QUIC) via the `quinn` library:
|
|
977
|
+
- ๐ **Built-in TLS** โ self-signed certs auto-generated at cluster init
|
|
978
|
+
- ๐ **Multiplexed streams** โ concurrent shard transfers without head-of-line blocking
|
|
979
|
+
- โก **Connection pooling** โ persistent connections to peer nodes
|
|
980
|
+
- ๐ **Natural backpressure** โ QUIC flow control prevents overloading slow peers
|
|
981
|
+
|
|
982
|
+
### Cluster Membership
|
|
983
|
+
|
|
984
|
+
- **Static seed nodes** โ initial cluster defined in config
|
|
985
|
+
- **Runtime join** โ new nodes can join a running cluster
|
|
986
|
+
- **Heartbeat monitoring** โ every 5s (configurable), with suspect/offline detection
|
|
987
|
+
- **Split-brain prevention** โ nodes only mark peers offline when they have majority
|
|
988
|
+
|
|
989
|
+
### Self-Healing
|
|
990
|
+
|
|
991
|
+
A background scanner periodically (default: every 24h):
|
|
992
|
+
1. Checks shard checksums (CRC32C) for bit-rot detection
|
|
993
|
+
2. Identifies shards on offline nodes
|
|
994
|
+
3. Reconstructs missing shards from remaining data using Reed-Solomon
|
|
995
|
+
4. Places healed shards on healthy drives
|
|
996
|
+
|
|
997
|
+
Healing runs at low priority to avoid impacting foreground I/O.
|
|
998
|
+
|
|
999
|
+
### Erasure Set Formation
|
|
1000
|
+
|
|
1001
|
+
Drives are organized into fixed **erasure sets** at cluster initialization:
|
|
1002
|
+
|
|
1003
|
+
```
|
|
1004
|
+
3 nodes ร 4 drives each = 12 drives total
|
|
1005
|
+
With 6-shard erasure sets โ 2 erasure sets
|
|
1006
|
+
|
|
1007
|
+
Set 0: Node1-Disk0, Node2-Disk0, Node3-Disk0, Node1-Disk1, Node2-Disk1, Node3-Disk1
|
|
1008
|
+
Set 1: Node1-Disk2, Node2-Disk2, Node3-Disk2, Node1-Disk3, Node2-Disk3, Node3-Disk3
|
|
1009
|
+
```
|
|
1010
|
+
|
|
1011
|
+
Drives are interleaved across nodes for maximum fault isolation. New nodes form new erasure sets โ existing data is never rebalanced.
|
|
1012
|
+
|
|
1013
|
+
## Testing Integration
|
|
1014
|
+
|
|
1015
|
+
```typescript
|
|
1016
|
+
import { ObjectStorage } from '@lossless.org/objectstorage';
|
|
1017
|
+
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
|
1018
|
+
|
|
1019
|
+
let storage: ObjectStorage;
|
|
1020
|
+
|
|
1021
|
+
tap.test('setup', async () => {
|
|
1022
|
+
storage = await ObjectStorage.createAndStart({
|
|
1023
|
+
server: { port: 4567, silent: true },
|
|
1024
|
+
storage: { cleanSlate: true },
|
|
1025
|
+
});
|
|
1026
|
+
});
|
|
1027
|
+
|
|
1028
|
+
tap.test('should store and retrieve objects', async () => {
|
|
1029
|
+
await storage.createBucket('test');
|
|
1030
|
+
// ... your test logic using AWS SDK or SmartBucket
|
|
1031
|
+
});
|
|
1032
|
+
|
|
1033
|
+
tap.test('teardown', async () => {
|
|
1034
|
+
await storage.stop();
|
|
1035
|
+
});
|
|
1036
|
+
|
|
1037
|
+
export default tap.start();
|
|
1038
|
+
```
|
|
1039
|
+
|
|
1040
|
+
## API Reference
|
|
1041
|
+
|
|
1042
|
+
### `ObjectStorage` Class
|
|
1043
|
+
|
|
1044
|
+
#### `static createAndStart(config?: IObjectStorageConfig): Promise<ObjectStorage>`
|
|
1045
|
+
|
|
1046
|
+
Create and start a server in one call.
|
|
1047
|
+
|
|
1048
|
+
#### `static probeStoragePool(pool: IMountedStoragePoolConfig): Promise<IStoragePoolObservation>`
|
|
1049
|
+
|
|
1050
|
+
Inspect an exact host-mounted NFS or SMB pool without writing to it or starting
|
|
1051
|
+
the S3 listener. The observation reports the active mount source, filesystem
|
|
1052
|
+
type, capacity/tuning metadata, stability checks, and current production
|
|
1053
|
+
eligibility.
|
|
1054
|
+
|
|
1055
|
+
#### `start(): Promise<void>`
|
|
1056
|
+
|
|
1057
|
+
Spawn the Rust binary and start the HTTP server. Concurrent starts join the same
|
|
1058
|
+
attempt; starting an already running instance succeeds without restarting it.
|
|
1059
|
+
A start is rejected while shutdown or unconfirmed startup cleanup remains
|
|
1060
|
+
pending. After completed shutdown, an explicit start begins a new lifecycle.
|
|
1061
|
+
|
|
1062
|
+
#### `stop(): Promise<void>`
|
|
1063
|
+
|
|
1064
|
+
Gracefully stop the server and then terminate the Rust process. In every server
|
|
1065
|
+
mode, an incomplete 30-second drain rejects this call without terminating the
|
|
1066
|
+
process; call `stop()` again after the blocked operation recovers. A terminal
|
|
1067
|
+
shutdown error is reported only after the completed Rust server has released
|
|
1068
|
+
its task and resource ownership; the wrapper terminates the management process
|
|
1069
|
+
before rejecting with that error.
|
|
1070
|
+
|
|
1071
|
+
Concurrent stops join the same attempt, including any admitted startup. A stop
|
|
1072
|
+
after failed startup joins any remaining bridge cleanup without sending a stop
|
|
1073
|
+
command to an already terminated process. Repeated completed stops replay the
|
|
1074
|
+
same outcome, including terminal shutdown errors. Incomplete drain or process
|
|
1075
|
+
termination remains retryable; completed stop commands are retained so a
|
|
1076
|
+
termination retry cannot issue a duplicate command. A successful stop therefore
|
|
1077
|
+
confirms that the instance no longer owns its listener, work, or process; no
|
|
1078
|
+
health polling is required before removing a disposable storage root.
|
|
1079
|
+
|
|
1080
|
+
The Rust management process reads stdin on a dedicated task. Ordinary commands
|
|
1081
|
+
enter a bounded nonblocking queue and continue to execute one at a time, while
|
|
1082
|
+
`stop` uses an independent retained lifecycle signal plus bounded waiters. The
|
|
1083
|
+
first stop intent immediately closes management and HTTP admission and signals
|
|
1084
|
+
HTTP, multipart, and clustered background producers even when an active mounted
|
|
1085
|
+
filesystem operation is blocked. Already queued ordinary commands are rejected
|
|
1086
|
+
as stopping. A stop waiter that reaches its deadline receives one incomplete
|
|
1087
|
+
error and is never sent a later completion; a retry uses a new request ID and
|
|
1088
|
+
receives the retained terminal result when ownership completes.
|
|
1089
|
+
|
|
1090
|
+
IPC EOF requests the same graceful shutdown. If an owned blocked operation
|
|
1091
|
+
cannot finish by the EOF ownership deadline, the Rust process exits nonzero
|
|
1092
|
+
instead of returning through runtime teardown that could wait forever on a
|
|
1093
|
+
blocked native filesystem worker. Output is serialized through a dedicated
|
|
1094
|
+
writer with a 144 MiB per-line ceiling, a 160 MiB owned queue budget, and a
|
|
1095
|
+
five-second Unix stdout write deadline; writer failure or sustained stdout
|
|
1096
|
+
backpressure fails closed rather than growing memory without bound.
|
|
1097
|
+
|
|
1098
|
+
#### `getStoragePoolDiagnostic(): Promise<IStoragePoolObservation | null>`
|
|
1099
|
+
|
|
1100
|
+
Return the active pool observation. For a poisoned mounted pool, this uses
|
|
1101
|
+
cached activation evidence and remains available without touching the storage
|
|
1102
|
+
filesystem. It returns `null` before the Rust server has started.
|
|
1103
|
+
|
|
1104
|
+
#### `createBucket(name: string): Promise<{ name: string }>`
|
|
1105
|
+
|
|
1106
|
+
Create a storage bucket.
|
|
1107
|
+
|
|
1108
|
+
#### `createBucketTenant(options): Promise<IBucketTenantDescriptor>`
|
|
1109
|
+
|
|
1110
|
+
Create a bucket tenant with a generated or supplied scoped credential. Options: `{ bucketName, accessKeyId?, secretAccessKey?, region? }`.
|
|
1111
|
+
|
|
1112
|
+
#### `ensureBucketTenant(options): Promise<IBucketTenantDescriptor>`
|
|
1113
|
+
|
|
1114
|
+
Atomically create an absent bucket and exact scoped credential, resume an exact
|
|
1115
|
+
orphan credential, or rotate the credential for an existing exact-owned bucket.
|
|
1116
|
+
Foreign or ambiguous ownership is rejected before mutation. Options:
|
|
1117
|
+
`{ bucketName, accessKeyId, secretAccessKey, region?, fence? }`. Once a bucket
|
|
1118
|
+
has durable fence history, supply `fence`; the returned descriptor then includes
|
|
1119
|
+
the durable `resourceFence` receipt.
|
|
1120
|
+
|
|
1121
|
+
#### `deleteBucketTenant(options): Promise<void>`
|
|
1122
|
+
|
|
1123
|
+
Revoke a tenant credential or delete a bucket that still has tenant credentials. Options: `{ bucketName, accessKeyId? }`.
|
|
1124
|
+
|
|
1125
|
+
#### `rotateBucketTenantCredentials(options): Promise<IBucketTenantDescriptor>`
|
|
1126
|
+
|
|
1127
|
+
Replace the scoped credential for a bucket tenant. Options: `{ bucketName, accessKeyId?, secretAccessKey?, region? }`.
|
|
1128
|
+
|
|
1129
|
+
#### `listBucketTenants(): Promise<IBucketTenantMetadata[]>`
|
|
1130
|
+
|
|
1131
|
+
List scoped tenant credential metadata without returning secrets.
|
|
1132
|
+
|
|
1133
|
+
#### `getBucketTenantDescriptor(options): Promise<IBucketTenantDescriptor>`
|
|
1134
|
+
|
|
1135
|
+
Return endpoint, port, region, bucket, access key, secret key, SSL flag, legacy descriptor fields, and S3/AWS env values for the bucket tenant.
|
|
1136
|
+
|
|
1137
|
+
#### `getBucketMigrationCapability(): Promise<IObjectStorageBucketMigrationCapability>`
|
|
1138
|
+
|
|
1139
|
+
Return standalone provider identity and receipt-key evidence plus source seal,
|
|
1140
|
+
destination publication, and controller-retried source-cleanup capabilities.
|
|
1141
|
+
Cluster mode reports this migration metadata surface as unsupported.
|
|
1142
|
+
|
|
1143
|
+
#### `createSourceMigration(options): Promise<IObjectStorageMigrationTransitionReceipt>`
|
|
1144
|
+
|
|
1145
|
+
Create durable source migration ownership for an existing non-retained bucket.
|
|
1146
|
+
`sourceCleanupAccessKeyId` is required and must name the bucket's one exact
|
|
1147
|
+
scoped credential; its hash is permanently bound to the migration.
|
|
1148
|
+
|
|
1149
|
+
#### `createDestinationMigration(options): Promise<IObjectStorageMigrationTransitionReceipt>`
|
|
1150
|
+
|
|
1151
|
+
Create durable destination migration ownership for an existing bucket.
|
|
1152
|
+
|
|
1153
|
+
#### `transitionSourceMigration(options): Promise<IObjectStorageMigrationTransitionReceipt>`
|
|
1154
|
+
|
|
1155
|
+
Advance the source across an allowed phase edge. Sealing introduces the
|
|
1156
|
+
manifest digest; releasing requires the exact signed destination-abort receipt.
|
|
1157
|
+
|
|
1158
|
+
#### `transitionDestinationMigration(options): Promise<IObjectStorageMigrationTransitionReceipt>`
|
|
1159
|
+
|
|
1160
|
+
Advance the destination across an allowed phase edge. Verification introduces
|
|
1161
|
+
the manifest digest and terminal publication produces a signed receipt.
|
|
1162
|
+
|
|
1163
|
+
#### `cleanupSourceMigration(options): Promise<IObjectStorageSourceMigrationCleanupReceipt>`
|
|
1164
|
+
|
|
1165
|
+
Permanently delete a retired source after verifying the exact signed
|
|
1166
|
+
destination-published receipt. The request includes the migration binding,
|
|
1167
|
+
cleanup fence token, source capability, retired receipt hash, published receipt,
|
|
1168
|
+
exact resource fence, and exact source `accessKeyId`. Exact retries return the
|
|
1169
|
+
same durable cleanup receipt.
|
|
1170
|
+
|
|
1171
|
+
#### `inspectBucketMigration(options): Promise<IObjectStorageMigrationInspection | null>`
|
|
1172
|
+
|
|
1173
|
+
Return durable migration, terminal-receipt, and source-cleanup state for one
|
|
1174
|
+
`migrationId`.
|
|
1175
|
+
|
|
1176
|
+
#### `exportBucket(options): Promise<IBucketExport>`
|
|
1177
|
+
|
|
1178
|
+
Export one bucket's objects and metadata into a `smartstorage.bucket.v1` JSON
|
|
1179
|
+
object, subject to the bounded legacy management-export limits documented
|
|
1180
|
+
above.
|
|
1181
|
+
|
|
1182
|
+
#### `importBucket(options): Promise<IObjectStorageBucketMutationResult>`
|
|
1183
|
+
|
|
1184
|
+
Import a `smartstorage.bucket.v1` JSON object into the target bucket after
|
|
1185
|
+
validating object size and MD5. An exact fenced import binds the replacement to
|
|
1186
|
+
the bucket's exclusive credential. Options:
|
|
1187
|
+
`{ bucketName, source, accessKeyId?, fence?, holdPublication? }`. Setting
|
|
1188
|
+
`holdPublication: true` requires a fence and returns
|
|
1189
|
+
`resourcePublicationBarrier`; omitted or `false` preserves immediate behavior.
|
|
1190
|
+
|
|
1191
|
+
#### `deleteBucket(options): Promise<IObjectStorageBucketMutationResult>`
|
|
1192
|
+
|
|
1193
|
+
Delete a fenced bucket. Supplying `accessKeyId` also durably revokes that
|
|
1194
|
+
exact tenant credential and deletes the policy; omitting it preserves the
|
|
1195
|
+
legacy credential/policy behavior. Options:
|
|
1196
|
+
`{ bucketName, accessKeyId?, fence, holdPublication? }`. Setting
|
|
1197
|
+
`holdPublication: true` returns a durable `resourcePublicationBarrier`; omitted
|
|
1198
|
+
or `false` preserves immediate behavior.
|
|
1199
|
+
|
|
1200
|
+
#### `commitResourcePublication(options): Promise<IObjectStorageResourcePublicationReleaseReceipt>`
|
|
1201
|
+
|
|
1202
|
+
Release a durable write-publication hold after the caller has persisted and
|
|
1203
|
+
published the exact provider receipt. Options:
|
|
1204
|
+
`{ barrier, resourceFenceReceipt }`. The operation is exact-match and idempotent.
|
|
1205
|
+
|
|
1206
|
+
#### `getResourceFencingCapability(): Promise<IObjectStorageResourceFencingCapability>`
|
|
1207
|
+
|
|
1208
|
+
Report resource-fencing support, drain requirements, publication-hold version,
|
|
1209
|
+
whether publication holds are supported, and whether the provider must perform
|
|
1210
|
+
an additional external drain.
|
|
1211
|
+
|
|
1212
|
+
#### `getStorageDescriptor(options?): Promise<IS3Descriptor>`
|
|
1213
|
+
|
|
1214
|
+
Get connection details for S3-compatible clients. Returns:
|
|
1215
|
+
|
|
1216
|
+
| Field | Type | Description |
|
|
1217
|
+
|-------|------|-------------|
|
|
1218
|
+
| `endpoint` | `string` | Server hostname (`localhost` by default) |
|
|
1219
|
+
| `port` | `number` | Server port |
|
|
1220
|
+
| `accessKey` | `string` | Access key from first configured credential |
|
|
1221
|
+
| `accessSecret` | `string` | Secret key from first configured credential |
|
|
1222
|
+
| `useSsl` | `boolean` | Always `false` (plain HTTP) |
|
|
1223
|
+
|
|
1224
|
+
#### `getStorageStats(): Promise<IStorageStats>`
|
|
1225
|
+
|
|
1226
|
+
Read cached logical bucket and object totals from the Rust runtime without issuing S3 list calls.
|
|
1227
|
+
|
|
1228
|
+
#### `listBucketSummaries(): Promise<IBucketSummary[]>`
|
|
1229
|
+
|
|
1230
|
+
Get per-bucket logical object counts and total payload sizes.
|
|
1231
|
+
|
|
1232
|
+
#### `listCredentials(): Promise<IStorageCredentialMetadata[]>`
|
|
1233
|
+
|
|
1234
|
+
Return metadata for the currently active runtime credential set without `secretAccessKey` values.
|
|
1235
|
+
|
|
1236
|
+
#### `replaceCredentials(credentials: IStorageCredential[]): Promise<void>`
|
|
1237
|
+
|
|
1238
|
+
Atomically replace the active runtime credential set without restarting the server.
|
|
1239
|
+
|
|
1240
|
+
#### `getClusterHealth(): Promise<IClusterHealth>`
|
|
1241
|
+
|
|
1242
|
+
Read the Rust core's current cluster, drive, quorum, and repair health snapshot. Standalone mode returns `{ enabled: false }`.
|
|
1243
|
+
|
|
1244
|
+
#### `getHealth(): Promise<IObjectStorageHealth>`
|
|
1245
|
+
|
|
1246
|
+
Return running state, storage directory, per-location pool observations, auth
|
|
1247
|
+
state, credential counts, bucket count, object count, total bytes, cluster
|
|
1248
|
+
health, resource-fencing capability, and publication-hold capability fields.
|
|
1249
|
+
|
|
1250
|
+
#### `getMetrics(): Promise<IObjectStorageMetrics>`
|
|
1251
|
+
|
|
1252
|
+
Return numeric metrics plus a Prometheus text snippet for operational scraping.
|
|
1253
|
+
|
|
1254
|
+
## Architecture
|
|
1255
|
+
|
|
1256
|
+
objectstorage uses a **hybrid Rust + TypeScript** architecture:
|
|
1257
|
+
|
|
1258
|
+
```
|
|
1259
|
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
1260
|
+
โ Your Code (AWS SDK, SmartBucket, etc.) โ
|
|
1261
|
+
โ โ HTTP (localhost:3000) โ
|
|
1262
|
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
|
|
1263
|
+
โ ruststorage binary (Rust) โ
|
|
1264
|
+
โ โโ hyper 1.x HTTP server โ
|
|
1265
|
+
โ โโ S3 path-style routing โ
|
|
1266
|
+
โ โโ StorageBackend (Standalone or Clustered) โ
|
|
1267
|
+
โ โ โโ FileStore (single-node mode) โ
|
|
1268
|
+
โ โ โโ DistributedStore (cluster mode) โ
|
|
1269
|
+
โ โ โโ ErasureCoder (Reed-Solomon) โ
|
|
1270
|
+
โ โ โโ ShardStore (per-drive storage) โ
|
|
1271
|
+
โ โ โโ QuicTransport (quinn) โ
|
|
1272
|
+
โ โ โโ ClusterState & Membership โ
|
|
1273
|
+
โ โ โโ HealingService โ
|
|
1274
|
+
โ โโ SigV4 auth + policy engine โ
|
|
1275
|
+
โ โโ CORS middleware โ
|
|
1276
|
+
โ โโ S3 XML response builder โ
|
|
1277
|
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
|
|
1278
|
+
โ TypeScript (thin IPC wrapper) โ
|
|
1279
|
+
โ โโ ObjectStorage class โ
|
|
1280
|
+
โ โโ RustBridge (stdin/stdout JSON IPC) โ
|
|
1281
|
+
โ โโ Config & S3 descriptor โ
|
|
1282
|
+
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
1283
|
+
```
|
|
1284
|
+
|
|
1285
|
+
**Why Rust?** The original TypeScript implementation had critical perf issues: OOM on multipart uploads (parts buffered in memory), double stream copying, file descriptor leaks on HEAD requests, full-file reads for range requests, and no backpressure. The Rust binary solves these with bounded-memory streaming I/O, backpressure, and direct `seek()` for range requests.
|
|
1286
|
+
|
|
1287
|
+
**IPC Protocol:** TypeScript communicates with the `ruststorage` binary over newline-delimited JSON via stdin/stdout. The current management commands are `probeStoragePool`, `getStoragePoolDiagnostic`, `start`, `stop`, `createBucket`, `createBucketTenant`, `ensureBucketTenant`, `deleteBucketTenant`, `rotateBucketTenantCredentials`, `listBucketTenants`, `getBucketTenantCredential`, `getBucketRetentionCapability`, `getBucketRetentionReceipt`, `getBucketMigrationCapability`, `createSourceMigration`, `createDestinationMigration`, `transitionSourceMigration`, `transitionDestinationMigration`, `cleanupSourceMigration`, `inspectBucketMigration`, `exportBucket`, `importBucket`, `deleteBucket`, `commitResourcePublication`, `getResourceFencingCapability`, `getStorageStats`, `listBucketSummaries`, `listCredentials`, `replaceCredentials`, and `getClusterHealth`. `getStoragePoolDiagnostic` reads a weak, cached diagnostic source and remains responsive while another management command owns the server; it does not retain the mounted root after server ownership completes.
|
|
1288
|
+
|
|
1289
|
+
### S3-Compatible Operations
|
|
1290
|
+
|
|
1291
|
+
Bucket names are validated consistently at HTTP, management, policy, and
|
|
1292
|
+
storage boundaries: they must be 3-63 lowercase letters, digits, dots, or
|
|
1293
|
+
hyphens, begin and end with a letter or digit, contain no consecutive dots,
|
|
1294
|
+
and not be formatted as an IPv4 address.
|
|
1295
|
+
|
|
1296
|
+
Object keys remain S3-compatible UTF-8 strings of up to 1,024 bytes, including
|
|
1297
|
+
leading, repeated, embedded, and trailing `/` characters. On disk, every new
|
|
1298
|
+
object key is encoded below the reserved `.smartstorage-objects-v2` bucket
|
|
1299
|
+
directory as canonical, prefix-free lowercase-hex frames. Frames and fixed
|
|
1300
|
+
payload/sidecar names stay within the common 255-byte `NAME_MAX`, remain safe on
|
|
1301
|
+
CIFS, and listings return the exact original key.
|
|
1302
|
+
|
|
1303
|
+
Existing v7 identity-layout objects remain readable on local and NFS storage
|
|
1304
|
+
when their raw path can be reconstructed without traversal. Their first
|
|
1305
|
+
mutation stages and publishes the canonical v2 object set before removing the
|
|
1306
|
+
legacy payload and sidecars. The migration uses descriptor-confined,
|
|
1307
|
+
digest-bound durable state and reconciles every preparation, publication, and
|
|
1308
|
+
partial-cleanup crash on startup or next access. Canonical and legacy payloads
|
|
1309
|
+
for the same key without that exact owned state, noncanonical entries below the
|
|
1310
|
+
reserved v2 root, and unsafe legacy aliases on case-folding CIFS fail closed
|
|
1311
|
+
instead of choosing an ambiguous object.
|
|
1312
|
+
|
|
1313
|
+
The standalone v2 object-layout upgrade is one-way. After the first canonical
|
|
1314
|
+
v2 object write or legacy-object migration in a standalone storage root,
|
|
1315
|
+
downgrading that root to `@push.rocks/smartstorage` 7.0.0 or earlier is unsupported because
|
|
1316
|
+
those versions cannot read the canonical v2 objects.
|
|
1317
|
+
|
|
1318
|
+
| Operation | Method | Path |
|
|
1319
|
+
|-----------|--------|------|
|
|
1320
|
+
| ListBuckets | `GET /` | |
|
|
1321
|
+
| CreateBucket | `PUT /{bucket}` | |
|
|
1322
|
+
| DeleteBucket | `DELETE /{bucket}` | |
|
|
1323
|
+
| HeadBucket | `HEAD /{bucket}` | |
|
|
1324
|
+
| ListObjects (v1/v2) | `GET /{bucket}` | `?list-type=2` for v2 |
|
|
1325
|
+
| PutObject | `PUT /{bucket}/{key}` | |
|
|
1326
|
+
| GetObject | `GET /{bucket}/{key}` | Supports `Range` header |
|
|
1327
|
+
| HeadObject | `HEAD /{bucket}/{key}` | |
|
|
1328
|
+
| DeleteObject | `DELETE /{bucket}/{key}` | Standalone `If-Match: *` or strong ETag list |
|
|
1329
|
+
| DeleteObjects | `POST /{bucket}?delete` | Up to 1,000 keys with CRC32 or Content-MD5 integrity |
|
|
1330
|
+
| CopyObject | `PUT /{bucket}/{key}` | `x-amz-copy-source` header |
|
|
1331
|
+
| InitiateMultipartUpload | `POST /{bucket}/{key}?uploads` | |
|
|
1332
|
+
| UploadPart | `PUT /{bucket}/{key}?partNumber&uploadId` | |
|
|
1333
|
+
| ListParts | `GET /{bucket}/{key}?uploadId` | Optional `max-parts` and `part-number-marker` |
|
|
1334
|
+
| CompleteMultipartUpload | `POST /{bucket}/{key}?uploadId` | |
|
|
1335
|
+
| AbortMultipartUpload | `DELETE /{bucket}/{key}?uploadId` | |
|
|
1336
|
+
| ListMultipartUploads | `GET /{bucket}?uploads` | |
|
|
1337
|
+
| GetBucketPolicy | `GET /{bucket}?policy` | |
|
|
1338
|
+
| PutBucketPolicy | `PUT /{bucket}?policy` | |
|
|
1339
|
+
| DeleteBucketPolicy | `DELETE /{bucket}?policy` | |
|
|
1340
|
+
|
|
1341
|
+
### On-Disk Format
|
|
1342
|
+
|
|
1343
|
+
**Standalone mode:**
|
|
1344
|
+
```
|
|
1345
|
+
{storage.directory}/
|
|
1346
|
+
{bucket}/
|
|
1347
|
+
.smartstorage-objects-v2/
|
|
1348
|
+
{whole-key-hex-frames}/
|
|
1349
|
+
object._storage_object_v2 # Object data; completed multipart objects include
|
|
1350
|
+
# an atomic payload/ETag/metadata trailer
|
|
1351
|
+
object._storage_object_v2.metadata.json # Legacy/direct-write metadata sidecar
|
|
1352
|
+
object._storage_object_v2.md5 # Legacy/direct-write cached MD5 hash
|
|
1353
|
+
.multipart/
|
|
1354
|
+
{upload-id}/
|
|
1355
|
+
metadata.json # Upload metadata
|
|
1356
|
+
part-1, part-2, ... # Part data files
|
|
1357
|
+
.policies/
|
|
1358
|
+
{bucket}.policy.json # Bucket policy (IAM JSON format)
|
|
1359
|
+
```
|
|
1360
|
+
|
|
1361
|
+
**Cluster mode:**
|
|
1362
|
+
```
|
|
1363
|
+
{storage.directory}/
|
|
1364
|
+
.buckets/
|
|
1365
|
+
v2/{bucket-address}.bucket.json # Active/tombstoned bucket state
|
|
1366
|
+
.policies/{bucket}.policy.json # Bucket policies
|
|
1367
|
+
.manifests/
|
|
1368
|
+
v2/{bucket-prefix}/{bucket-address}/{object-prefix}/
|
|
1369
|
+
{object-address}.state.json
|
|
1370
|
+
# Active/tombstoned object manifest
|
|
1371
|
+
.multipart/{upload-id}/ # Descriptor-anchored multipart state
|
|
1372
|
+
.get-tmp/ # Unlinked GET reconstruction files
|
|
1373
|
+
.smartstorage/cluster/
|
|
1374
|
+
identity.json # Node and cluster identity
|
|
1375
|
+
topology.json # Last durable topology
|
|
1376
|
+
generation-inventory/{address}.json # Deferred physical-generation cleanup
|
|
1377
|
+
|
|
1378
|
+
{drive_path}/.smartstorage/data/
|
|
1379
|
+
v2/{bucket-prefix}/{bucket-address}/{key-prefix}/{generation-address}/
|
|
1380
|
+
{shard-address}.shard # Immutable erasure-coded shard record
|
|
1381
|
+
{shard-address}.tombstone # Crash-safe deletion authority
|
|
1382
|
+
```
|
|
1383
|
+
|
|
1384
|
+
Superseded physical generations are queued with their exact shard placements.
|
|
1385
|
+
Cluster reads are bounded, cleanup waits a 24-hour reader grace period, and the
|
|
1386
|
+
hourly cleanup worker purges both shard records and tombstones. Multipart
|
|
1387
|
+
uploads older than `multipart.expirationDays`, plus committed uploads whose
|
|
1388
|
+
first cleanup attempt failed, are retried during bounded cleanup passes
|
|
1389
|
+
scheduled every `multipart.cleanupIntervalMinutes`; large registries are
|
|
1390
|
+
visited page by page across successive passes. Generation cleanup retains at
|
|
1391
|
+
most 32 MiB of serialized records per page, multipart session listings process
|
|
1392
|
+
documents one at a time, policy startup loading is bounded and streaming, and
|
|
1393
|
+
both workers observe shutdown within an active pass. Both multipart settings
|
|
1394
|
+
must be greater than zero; ObjectStorage rejects invalid values during startup.
|
|
1395
|
+
Shutdown never aborts or detaches an active multipart descriptor worker; it
|
|
1396
|
+
waits for the worker under the same retryable bounded drain used by mounted
|
|
1397
|
+
HTTP requests.
|
|
1398
|
+
Multipart admission is bounded at 100,000 registry entries per storage
|
|
1399
|
+
process, including cleanup-pending and unreadable entries. Cleanup only removes
|
|
1400
|
+
descriptor-verified expired uploads and cleanup-marked committed entries;
|
|
1401
|
+
unreadable or incomplete descriptors fail closed and require operator repair
|
|
1402
|
+
or removal after their bucket and clustered shard ownership has been
|
|
1403
|
+
established.
|
|
1404
|
+
|
|
1405
|
+
## Related Packages
|
|
1406
|
+
|
|
1407
|
+
- [`@push.rocks/smartbucket`](https://code.foss.global/push.rocks/smartbucket) โ High-level S3-compatible abstraction layer
|
|
1408
|
+
- [`@push.rocks/smartrust`](https://code.foss.global/push.rocks/smartrust) โ TypeScript โ Rust IPC bridge
|
|
1409
|
+
- [`@git.zone/tsrust`](https://code.foss.global/git.zone/tsrust) โ Rust cross-compilation for npm packages
|
|
1410
|
+
|
|
1411
|
+
## License and Legal Information
|
|
1412
|
+
|
|
1413
|
+
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](./license.md) file.
|
|
1414
|
+
|
|
1415
|
+
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
|
|
1416
|
+
|
|
1417
|
+
### Trademarks
|
|
1418
|
+
|
|
1419
|
+
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
|
|
1420
|
+
|
|
1421
|
+
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
|
|
1422
|
+
|
|
1423
|
+
### Company Information
|
|
1424
|
+
|
|
1425
|
+
Task Venture Capital GmbH
|
|
1426
|
+
Registered at District Court Bremen HRB 35230 HB, Germany
|
|
1427
|
+
|
|
1428
|
+
For any legal inquiries or further information, please contact us via email at hello@task.vc.
|
|
1429
|
+
|
|
1430
|
+
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
|