@opengeni/storage 0.2.126 → 0.2.127-canary.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/dist/index.d.ts +14 -0
- package/dist/index.js +339 -1
- package/dist/index.js.map +1 -1
- package/dist/workspace-archive-spool.d.ts +28 -0
- package/package.json +3 -3
- package/src/index.ts +103 -0
- package/src/workspace-archive-spool.ts +308 -0
package/dist/index.d.ts
CHANGED
|
@@ -14,6 +14,7 @@ export type ObjectHead = {
|
|
|
14
14
|
/** Opaque provider generation/etag used only for conditional internal reads. */
|
|
15
15
|
VersionToken?: string;
|
|
16
16
|
};
|
|
17
|
+
export { uploadWorkspaceArchiveSpool, downloadWorkspaceArchiveSpool, WorkspaceArchiveStorageError, } from "./workspace-archive-spool.js";
|
|
17
18
|
export type ObjectStorage = {
|
|
18
19
|
bucket: string;
|
|
19
20
|
backend: "s3-compatible" | "aws-s3" | "azure-blob" | "gcs";
|
|
@@ -85,6 +86,19 @@ export type ObjectStorage = {
|
|
|
85
86
|
body: Uint8Array;
|
|
86
87
|
sha256?: string | null;
|
|
87
88
|
}) => Promise<void>;
|
|
89
|
+
/**
|
|
90
|
+
* Unconditional authenticated upload from a bounded byte stream. May overwrite
|
|
91
|
+
* an existing key; this is NOT an atomic create-only operation. Callers needing
|
|
92
|
+
* write isolation must supply a fresh unique key and verify stored content.
|
|
93
|
+
*/
|
|
94
|
+
putObjectStream?: (args: {
|
|
95
|
+
key: string;
|
|
96
|
+
contentType: string;
|
|
97
|
+
chunks: AsyncIterable<Uint8Array>;
|
|
98
|
+
byteSize: number;
|
|
99
|
+
sha256?: string;
|
|
100
|
+
signal?: AbortSignal;
|
|
101
|
+
}) => Promise<void>;
|
|
88
102
|
/** Atomic create-only raw PUT. Returns false when the key already exists. */
|
|
89
103
|
putObjectIfAbsent?: (args: {
|
|
90
104
|
key: string;
|
package/dist/index.js
CHANGED
|
@@ -629,6 +629,263 @@ function throwIfAborted4(signal) {
|
|
|
629
629
|
if (signal?.aborted) throw new Error("Object storage operation was cancelled");
|
|
630
630
|
}
|
|
631
631
|
|
|
632
|
+
// src/workspace-archive-spool.ts
|
|
633
|
+
import { createHash as createHash3 } from "crypto";
|
|
634
|
+
import { createReadStream } from "fs";
|
|
635
|
+
import { chmod as chmod2, mkdtemp as mkdtemp2, open as open2, rm as rm2 } from "fs/promises";
|
|
636
|
+
import { tmpdir as tmpdir2 } from "os";
|
|
637
|
+
import { join as join2 } from "path";
|
|
638
|
+
var CHUNK_BYTES = DEFAULT_BOUNDED_OBJECT_CHUNK_BYTES;
|
|
639
|
+
var WorkspaceArchiveStorageError = class extends Error {
|
|
640
|
+
constructor(code, message, retryable, options) {
|
|
641
|
+
super(message, options);
|
|
642
|
+
this.code = code;
|
|
643
|
+
this.retryable = retryable;
|
|
644
|
+
this.name = "WorkspaceArchiveStorageError";
|
|
645
|
+
}
|
|
646
|
+
};
|
|
647
|
+
async function uploadWorkspaceArchiveSpool(storage, key, spool) {
|
|
648
|
+
requireBoundedReads(storage);
|
|
649
|
+
if (!storage.putObjectStream) {
|
|
650
|
+
throw new WorkspaceArchiveStorageError(
|
|
651
|
+
"archive_hydration_failed",
|
|
652
|
+
"Workspace archive storage unsupported: unconditional streaming upload required",
|
|
653
|
+
false
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
const expected = { bytes: spool.byteSize, sha256: spool.sha256 };
|
|
657
|
+
validateExpected(expected);
|
|
658
|
+
let validated = false;
|
|
659
|
+
let streamFailed = false;
|
|
660
|
+
let streamFailure;
|
|
661
|
+
const chunks = async function* () {
|
|
662
|
+
const digest = createHash3("sha256");
|
|
663
|
+
let bytes = 0;
|
|
664
|
+
try {
|
|
665
|
+
for await (const chunk of spool.open()) {
|
|
666
|
+
if (!(chunk instanceof Uint8Array) || chunk.byteLength > expected.bytes - bytes) {
|
|
667
|
+
throw new WorkspaceArchiveStorageError(
|
|
668
|
+
"archive_hash_mismatch",
|
|
669
|
+
"Workspace archive upload stream has invalid size",
|
|
670
|
+
false
|
|
671
|
+
);
|
|
672
|
+
}
|
|
673
|
+
bytes += chunk.byteLength;
|
|
674
|
+
digest.update(chunk);
|
|
675
|
+
yield chunk;
|
|
676
|
+
}
|
|
677
|
+
if (bytes !== expected.bytes || digest.digest("hex") !== expected.sha256) {
|
|
678
|
+
throw new WorkspaceArchiveStorageError(
|
|
679
|
+
"archive_hash_mismatch",
|
|
680
|
+
"Workspace archive upload stream digest or size mismatch",
|
|
681
|
+
false
|
|
682
|
+
);
|
|
683
|
+
}
|
|
684
|
+
validated = true;
|
|
685
|
+
} catch (error) {
|
|
686
|
+
streamFailed = true;
|
|
687
|
+
streamFailure = error;
|
|
688
|
+
throw error;
|
|
689
|
+
}
|
|
690
|
+
}();
|
|
691
|
+
try {
|
|
692
|
+
await storage.putObjectStream({
|
|
693
|
+
key,
|
|
694
|
+
contentType: "application/x-tar",
|
|
695
|
+
chunks,
|
|
696
|
+
byteSize: expected.bytes,
|
|
697
|
+
sha256: expected.sha256
|
|
698
|
+
});
|
|
699
|
+
if (!validated) {
|
|
700
|
+
if (streamFailed) throw streamFailure;
|
|
701
|
+
throw new WorkspaceArchiveStorageError(
|
|
702
|
+
"archive_hydration_failed",
|
|
703
|
+
"Workspace archive upload provider did not completely consume and validate the stream",
|
|
704
|
+
false
|
|
705
|
+
);
|
|
706
|
+
}
|
|
707
|
+
await verifyRanges(storage, key, expected);
|
|
708
|
+
} catch (error) {
|
|
709
|
+
const failure = streamFailed ? streamFailure : error;
|
|
710
|
+
if (failure instanceof WorkspaceArchiveStorageError) throw failure;
|
|
711
|
+
throw new WorkspaceArchiveStorageError(
|
|
712
|
+
"archive_hydration_failed",
|
|
713
|
+
"Workspace archive upload or readback failed",
|
|
714
|
+
true,
|
|
715
|
+
{ cause: failure }
|
|
716
|
+
);
|
|
717
|
+
} finally {
|
|
718
|
+
await chunks.return(void 0);
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
async function downloadWorkspaceArchiveSpool(storage, key, inputExpected) {
|
|
722
|
+
const expected = { bytes: inputExpected.bytes, sha256: inputExpected.sha256 };
|
|
723
|
+
requireBoundedReads(storage);
|
|
724
|
+
validateExpected(expected);
|
|
725
|
+
const directory = await mkdtemp2(join2(tmpdir2(), "opengeni-workspace-archive-"));
|
|
726
|
+
const path = join2(directory, "archive.tar");
|
|
727
|
+
let handle;
|
|
728
|
+
try {
|
|
729
|
+
await chmod2(directory, 448);
|
|
730
|
+
handle = await open2(path, "wx", 384);
|
|
731
|
+
await verifyRanges(storage, key, expected, async (chunk) => {
|
|
732
|
+
let offset = 0;
|
|
733
|
+
while (offset < chunk.byteLength) {
|
|
734
|
+
const { bytesWritten } = await handle.write(chunk, offset, chunk.byteLength - offset);
|
|
735
|
+
if (bytesWritten <= 0)
|
|
736
|
+
throw new WorkspaceArchiveStorageError(
|
|
737
|
+
"archive_hydration_failed",
|
|
738
|
+
"Workspace archive spool write made no progress",
|
|
739
|
+
true
|
|
740
|
+
);
|
|
741
|
+
offset += bytesWritten;
|
|
742
|
+
}
|
|
743
|
+
});
|
|
744
|
+
await handle.close();
|
|
745
|
+
handle = void 0;
|
|
746
|
+
let disposed = false;
|
|
747
|
+
return {
|
|
748
|
+
path,
|
|
749
|
+
byteSize: expected.bytes,
|
|
750
|
+
sha256: expected.sha256,
|
|
751
|
+
async *open() {
|
|
752
|
+
if (disposed) throw new Error("Workspace archive spool is disposed");
|
|
753
|
+
const stream = createReadStream(path, { highWaterMark: CHUNK_BYTES });
|
|
754
|
+
try {
|
|
755
|
+
for await (const chunk of stream) yield chunk;
|
|
756
|
+
} finally {
|
|
757
|
+
stream.destroy();
|
|
758
|
+
}
|
|
759
|
+
},
|
|
760
|
+
async dispose() {
|
|
761
|
+
disposed = true;
|
|
762
|
+
await rm2(directory, { recursive: true, force: true });
|
|
763
|
+
}
|
|
764
|
+
};
|
|
765
|
+
} catch (error) {
|
|
766
|
+
await handle?.close().catch(() => void 0);
|
|
767
|
+
await rm2(directory, { recursive: true, force: true });
|
|
768
|
+
if (error instanceof WorkspaceArchiveStorageError) throw error;
|
|
769
|
+
throw new WorkspaceArchiveStorageError(
|
|
770
|
+
"archive_hydration_failed",
|
|
771
|
+
"Workspace archive download failed",
|
|
772
|
+
true,
|
|
773
|
+
{ cause: error }
|
|
774
|
+
);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
function requireBoundedReads(storage) {
|
|
778
|
+
if (!storage.headObject || !storage.getObjectRange) {
|
|
779
|
+
throw new WorkspaceArchiveStorageError(
|
|
780
|
+
"archive_hydration_failed",
|
|
781
|
+
"Workspace archive storage unsupported: versioned head/range reads required",
|
|
782
|
+
false
|
|
783
|
+
);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
function validateExpected(expected) {
|
|
787
|
+
if (!Number.isSafeInteger(expected.bytes) || expected.bytes < 0 || !/^[0-9a-f]{64}$/u.test(expected.sha256)) {
|
|
788
|
+
throw new WorkspaceArchiveStorageError(
|
|
789
|
+
"archive_hydration_failed",
|
|
790
|
+
"Workspace archive expected size or SHA-256 is invalid",
|
|
791
|
+
false
|
|
792
|
+
);
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
async function verifyRanges(storage, key, expected, consume) {
|
|
796
|
+
const head = await storage.headObject(key);
|
|
797
|
+
if (!head)
|
|
798
|
+
throw new WorkspaceArchiveStorageError(
|
|
799
|
+
"archive_base64_invalid",
|
|
800
|
+
"Workspace archive object is missing",
|
|
801
|
+
false
|
|
802
|
+
);
|
|
803
|
+
if (head.ContentLength !== expected.bytes)
|
|
804
|
+
throw new WorkspaceArchiveStorageError(
|
|
805
|
+
"archive_hash_mismatch",
|
|
806
|
+
"Workspace archive object size mismatch",
|
|
807
|
+
false
|
|
808
|
+
);
|
|
809
|
+
const version = head.VersionToken;
|
|
810
|
+
if (typeof version !== "string" || version.length === 0 || version.length > 2048) {
|
|
811
|
+
throw new WorkspaceArchiveStorageError(
|
|
812
|
+
"archive_hydration_failed",
|
|
813
|
+
"Workspace archive storage unsupported: valid object version token required",
|
|
814
|
+
false
|
|
815
|
+
);
|
|
816
|
+
}
|
|
817
|
+
const digest = createHash3("sha256");
|
|
818
|
+
let bytes = 0;
|
|
819
|
+
while (bytes < expected.bytes) {
|
|
820
|
+
const length = Math.min(CHUNK_BYTES, expected.bytes - bytes);
|
|
821
|
+
const result = await storage.getObjectRange({
|
|
822
|
+
key,
|
|
823
|
+
start: bytes,
|
|
824
|
+
endInclusive: bytes + length - 1,
|
|
825
|
+
expectedVersionToken: version
|
|
826
|
+
});
|
|
827
|
+
if (!result) {
|
|
828
|
+
const current = await storage.headObject(key);
|
|
829
|
+
if (!current) {
|
|
830
|
+
throw new WorkspaceArchiveStorageError(
|
|
831
|
+
"archive_base64_invalid",
|
|
832
|
+
"Workspace archive object is missing during range read",
|
|
833
|
+
false
|
|
834
|
+
);
|
|
835
|
+
}
|
|
836
|
+
throw new WorkspaceArchiveStorageError(
|
|
837
|
+
"archive_hydration_failed",
|
|
838
|
+
current.VersionToken !== version ? "Workspace archive object version changed" : "Workspace archive pinned range is temporarily unavailable",
|
|
839
|
+
true
|
|
840
|
+
);
|
|
841
|
+
}
|
|
842
|
+
if (result.versionToken !== version)
|
|
843
|
+
throw new WorkspaceArchiveStorageError(
|
|
844
|
+
"archive_hydration_failed",
|
|
845
|
+
"Workspace archive object version changed",
|
|
846
|
+
true
|
|
847
|
+
);
|
|
848
|
+
if (!(result.bytes instanceof Uint8Array) || result.bytes.byteLength !== length) {
|
|
849
|
+
throw new WorkspaceArchiveStorageError(
|
|
850
|
+
"archive_hash_mismatch",
|
|
851
|
+
"Workspace archive object range is truncated or has invalid size",
|
|
852
|
+
false
|
|
853
|
+
);
|
|
854
|
+
}
|
|
855
|
+
digest.update(result.bytes);
|
|
856
|
+
await consume?.(result.bytes);
|
|
857
|
+
bytes += result.bytes.byteLength;
|
|
858
|
+
}
|
|
859
|
+
const finalHead = await storage.headObject(key);
|
|
860
|
+
if (!finalHead)
|
|
861
|
+
throw new WorkspaceArchiveStorageError(
|
|
862
|
+
"archive_base64_invalid",
|
|
863
|
+
"Workspace archive object is missing after range read",
|
|
864
|
+
false
|
|
865
|
+
);
|
|
866
|
+
if (finalHead.VersionToken !== version) {
|
|
867
|
+
throw new WorkspaceArchiveStorageError(
|
|
868
|
+
"archive_hydration_failed",
|
|
869
|
+
"Workspace archive object version changed",
|
|
870
|
+
true
|
|
871
|
+
);
|
|
872
|
+
}
|
|
873
|
+
if (finalHead.ContentLength !== expected.bytes) {
|
|
874
|
+
throw new WorkspaceArchiveStorageError(
|
|
875
|
+
"archive_hash_mismatch",
|
|
876
|
+
"Workspace archive object size changed",
|
|
877
|
+
false
|
|
878
|
+
);
|
|
879
|
+
}
|
|
880
|
+
if (bytes !== expected.bytes || digest.digest("hex") !== expected.sha256) {
|
|
881
|
+
throw new WorkspaceArchiveStorageError(
|
|
882
|
+
"archive_hash_mismatch",
|
|
883
|
+
"Workspace archive object digest or size mismatch",
|
|
884
|
+
false
|
|
885
|
+
);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
|
|
632
889
|
// src/index.ts
|
|
633
890
|
var MAX_SINGLE_PUT_SIZE_BYTES = 5e9;
|
|
634
891
|
var UPLOAD_URL_TTL_SECONDS = 15 * 60;
|
|
@@ -741,6 +998,46 @@ function createS3CompatibleObjectStorage(settings) {
|
|
|
741
998
|
throw error;
|
|
742
999
|
}
|
|
743
1000
|
},
|
|
1001
|
+
async putObjectStream(args) {
|
|
1002
|
+
const body = Readable.from(args.chunks, {
|
|
1003
|
+
objectMode: false,
|
|
1004
|
+
highWaterMark: INTERNAL_STREAM_BUFFER_BYTES
|
|
1005
|
+
});
|
|
1006
|
+
const request = new AbortController();
|
|
1007
|
+
let producerFailed = false;
|
|
1008
|
+
let producerError;
|
|
1009
|
+
const onBodyError = (error) => {
|
|
1010
|
+
producerFailed = true;
|
|
1011
|
+
producerError = error;
|
|
1012
|
+
request.abort(error);
|
|
1013
|
+
};
|
|
1014
|
+
const onCallerAbort = () => request.abort(args.signal?.reason);
|
|
1015
|
+
body.once("error", onBodyError);
|
|
1016
|
+
const detachBodyError = () => body.off("error", onBodyError);
|
|
1017
|
+
body.once("close", detachBodyError);
|
|
1018
|
+
args.signal?.addEventListener("abort", onCallerAbort, { once: true });
|
|
1019
|
+
if (args.signal?.aborted) onCallerAbort();
|
|
1020
|
+
try {
|
|
1021
|
+
await requestClient.send(
|
|
1022
|
+
new PutObjectCommand({
|
|
1023
|
+
Bucket: settings.objectStorageBucket,
|
|
1024
|
+
Key: args.key,
|
|
1025
|
+
ContentType: args.contentType,
|
|
1026
|
+
ContentLength: args.byteSize,
|
|
1027
|
+
Body: body,
|
|
1028
|
+
Metadata: args.sha256 ? { sha256: args.sha256 } : void 0
|
|
1029
|
+
}),
|
|
1030
|
+
{ abortSignal: request.signal }
|
|
1031
|
+
);
|
|
1032
|
+
if (producerFailed) throw producerError;
|
|
1033
|
+
} catch (error) {
|
|
1034
|
+
if (producerFailed) throw producerError;
|
|
1035
|
+
throw error;
|
|
1036
|
+
} finally {
|
|
1037
|
+
args.signal?.removeEventListener("abort", onCallerAbort);
|
|
1038
|
+
body.destroy();
|
|
1039
|
+
}
|
|
1040
|
+
},
|
|
744
1041
|
async putObjectStreamIfAbsent(args) {
|
|
745
1042
|
try {
|
|
746
1043
|
await requestClient.send(
|
|
@@ -986,6 +1283,23 @@ function createGcsObjectStorage(settings) {
|
|
|
986
1283
|
throw error;
|
|
987
1284
|
}
|
|
988
1285
|
},
|
|
1286
|
+
async putObjectStream(args) {
|
|
1287
|
+
const destination = bucket.file(args.key).createWriteStream({
|
|
1288
|
+
resumable: false,
|
|
1289
|
+
contentType: args.contentType,
|
|
1290
|
+
highWaterMark: INTERNAL_STREAM_BUFFER_BYTES,
|
|
1291
|
+
...args.sha256 ? { metadata: { metadata: { sha256: args.sha256 } } } : {}
|
|
1292
|
+
});
|
|
1293
|
+
const source = Readable.from(args.chunks, {
|
|
1294
|
+
objectMode: false,
|
|
1295
|
+
highWaterMark: INTERNAL_STREAM_BUFFER_BYTES
|
|
1296
|
+
});
|
|
1297
|
+
if (args.signal) {
|
|
1298
|
+
await pipeline(source, destination, { signal: args.signal });
|
|
1299
|
+
} else {
|
|
1300
|
+
await pipeline(source, destination);
|
|
1301
|
+
}
|
|
1302
|
+
},
|
|
989
1303
|
async putObjectStreamIfAbsent(args) {
|
|
990
1304
|
const destination = bucket.file(args.key).createWriteStream({
|
|
991
1305
|
resumable: false,
|
|
@@ -1165,6 +1479,27 @@ function createAzureBlobObjectStorage(settings) {
|
|
|
1165
1479
|
throw error;
|
|
1166
1480
|
}
|
|
1167
1481
|
},
|
|
1482
|
+
async putObjectStream(args) {
|
|
1483
|
+
const blobClient = requestContainerClient.getBlockBlobClient(args.key);
|
|
1484
|
+
const source = Readable.from(args.chunks, {
|
|
1485
|
+
objectMode: false,
|
|
1486
|
+
highWaterMark: INTERNAL_STREAM_BUFFER_BYTES
|
|
1487
|
+
});
|
|
1488
|
+
try {
|
|
1489
|
+
await blobClient.uploadStream(
|
|
1490
|
+
source,
|
|
1491
|
+
INTERNAL_STREAM_BUFFER_BYTES,
|
|
1492
|
+
INTERNAL_STREAM_CONCURRENCY,
|
|
1493
|
+
{
|
|
1494
|
+
blobHTTPHeaders: { blobContentType: args.contentType },
|
|
1495
|
+
...args.sha256 ? { metadata: { sha256: args.sha256 } } : {},
|
|
1496
|
+
...args.signal ? { abortSignal: args.signal } : {}
|
|
1497
|
+
}
|
|
1498
|
+
);
|
|
1499
|
+
} finally {
|
|
1500
|
+
source.destroy();
|
|
1501
|
+
}
|
|
1502
|
+
},
|
|
1168
1503
|
async putObjectStreamIfAbsent(args) {
|
|
1169
1504
|
const blobClient = requestContainerClient.getBlockBlobClient(args.key);
|
|
1170
1505
|
try {
|
|
@@ -1429,11 +1764,14 @@ export {
|
|
|
1429
1764
|
MAX_SINGLE_PUT_SIZE_BYTES,
|
|
1430
1765
|
OBJECT_VISIBILITY_RETRY_DELAYS_MS,
|
|
1431
1766
|
UPLOAD_URL_TTL_SECONDS,
|
|
1767
|
+
WorkspaceArchiveStorageError,
|
|
1432
1768
|
bytesToDataUrl,
|
|
1433
1769
|
createBoundedImmutableObjectWritePort,
|
|
1434
1770
|
createBoundedObjectReadPort,
|
|
1435
1771
|
createObjectStorage,
|
|
1436
1772
|
createObjectStorageBoundedPorts,
|
|
1437
|
-
|
|
1773
|
+
downloadWorkspaceArchiveSpool,
|
|
1774
|
+
retryWhileMissing,
|
|
1775
|
+
uploadWorkspaceArchiveSpool
|
|
1438
1776
|
};
|
|
1439
1777
|
//# sourceMappingURL=index.js.map
|