swarm-deploy 0.1.0 → 0.3.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/CHANGELOG.md +60 -0
- package/README.md +780 -50
- package/RELEASING.md +2 -2
- package/SECURITY.md +2 -3
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +182 -25
- package/dist/cli.js.map +1 -1
- package/dist/client.d.ts +32 -27
- package/dist/client.js +103 -54
- package/dist/client.js.map +1 -1
- package/dist/errors.d.ts +6 -0
- package/dist/errors.js +6 -0
- package/dist/errors.js.map +1 -1
- package/dist/files.d.ts +13 -28
- package/dist/files.js +13 -78
- package/dist/files.js.map +1 -1
- package/dist/hooks-module.d.ts +9 -0
- package/dist/hooks-module.js +118 -0
- package/dist/hooks-module.js.map +1 -0
- package/dist/hooks.d.ts +69 -0
- package/dist/hooks.js +88 -0
- package/dist/hooks.js.map +1 -0
- package/dist/index.d.ts +8 -4
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/release.d.ts +26 -0
- package/dist/release.js +419 -0
- package/dist/release.js.map +1 -0
- package/dist/server.d.ts +97 -0
- package/dist/server.js +527 -53
- package/dist/server.js.map +1 -1
- package/dist/storage/commit-journal.d.ts +39 -2
- package/dist/storage/commit-journal.js +140 -2
- package/dist/storage/commit-journal.js.map +1 -1
- package/dist/storage/commit-store.d.ts +69 -8
- package/dist/storage/commit-store.js +374 -22
- package/dist/storage/commit-store.js.map +1 -1
- package/dist/storage/layout.js +7 -1
- package/dist/storage/layout.js.map +1 -1
- package/dist/storage/link-store.d.ts +88 -0
- package/dist/storage/link-store.js +448 -0
- package/dist/storage/link-store.js.map +1 -0
- package/dist/storage/recovery.d.ts +2 -1
- package/dist/storage/recovery.js +8 -1
- package/dist/storage/recovery.js.map +1 -1
- package/dist/storage/retention.d.ts +41 -21
- package/dist/storage/retention.js +162 -8
- package/dist/storage/retention.js.map +1 -1
- package/dist/storage/tar-session-store.d.ts +36 -5
- package/dist/storage/tar-session-store.js +283 -55
- package/dist/storage/tar-session-store.js.map +1 -1
- package/dist/storage/tree-fs.d.ts +44 -0
- package/dist/storage/tree-fs.js +580 -0
- package/dist/storage/tree-fs.js.map +1 -0
- package/dist/storage/tree-staging.d.ts +9 -0
- package/dist/storage/tree-staging.js +64 -0
- package/dist/storage/tree-staging.js.map +1 -0
- package/dist/storage/types.d.ts +15 -0
- package/dist/symlinks.d.ts +44 -0
- package/dist/symlinks.js +153 -0
- package/dist/symlinks.js.map +1 -0
- package/dist/tar-protocol/controls.d.ts +52 -0
- package/dist/tar-protocol/controls.js +160 -2
- package/dist/tar-protocol/controls.js.map +1 -1
- package/dist/tar-protocol/direct-wire.d.ts +8 -3
- package/dist/tar-protocol/direct-wire.js +14 -3
- package/dist/tar-protocol/direct-wire.js.map +1 -1
- package/dist/tar-protocol/manifest.d.ts +13 -1
- package/dist/tar-protocol/manifest.js +26 -1
- package/dist/tar-protocol/manifest.js.map +1 -1
- package/dist/tar-protocol/tree-extract.d.ts +37 -0
- package/dist/tar-protocol/tree-extract.js +240 -0
- package/dist/tar-protocol/tree-extract.js.map +1 -0
- package/dist/tar-protocol/tree-manifest.d.ts +45 -0
- package/dist/tar-protocol/tree-manifest.js +277 -0
- package/dist/tar-protocol/tree-manifest.js.map +1 -0
- package/dist/tar-protocol/tree-path.d.ts +15 -0
- package/dist/tar-protocol/tree-path.js +68 -0
- package/dist/tar-protocol/tree-path.js.map +1 -0
- package/dist/tar-protocol/tree.d.ts +68 -0
- package/dist/tar-protocol/tree.js +269 -0
- package/dist/tar-protocol/tree.js.map +1 -0
- package/dist/tar-protocol/ustar.d.ts +14 -0
- package/dist/tar-protocol/ustar.js +81 -1
- package/dist/tar-protocol/ustar.js.map +1 -1
- package/dist/types.d.ts +3 -0
- package/package.json +3 -1
|
@@ -13,6 +13,7 @@ const errors_js_1 = require("../errors.js");
|
|
|
13
13
|
const files_js_1 = require("../files.js");
|
|
14
14
|
const hash_js_1 = require("../tar-protocol/hash.js");
|
|
15
15
|
const manifest_js_1 = require("../tar-protocol/manifest.js");
|
|
16
|
+
const tree_manifest_js_1 = require("../tar-protocol/tree-manifest.js");
|
|
16
17
|
const validation_js_1 = require("../validation.js");
|
|
17
18
|
const layout_js_1 = require("./layout.js");
|
|
18
19
|
const atomic_file_js_1 = require("./atomic-file.js");
|
|
@@ -21,6 +22,7 @@ Object.defineProperty(exports, "CorruptJournalError", { enumerable: true, get: f
|
|
|
21
22
|
Object.defineProperty(exports, "COMMIT_VERSION", { enumerable: true, get: function () { return commit_journal_js_1.COMMIT_VERSION; } });
|
|
22
23
|
Object.defineProperty(exports, "MAX_COMMIT_METADATA_BYTES", { enumerable: true, get: function () { return commit_journal_js_1.MAX_COMMIT_METADATA_BYTES; } });
|
|
23
24
|
const root_coordinator_js_1 = require("./root-coordinator.js");
|
|
25
|
+
const tree_fs_js_1 = require("./tree-fs.js");
|
|
24
26
|
function storageError(message, cause = null) {
|
|
25
27
|
return new errors_js_1.SwarmDeployError(errors_js_1.ERRORS.PROTOCOL_INVALID, message, cause);
|
|
26
28
|
}
|
|
@@ -60,19 +62,37 @@ function replacementsEqual(left, right) {
|
|
|
60
62
|
sameHex32(left.transferId, right.transferId) &&
|
|
61
63
|
left.historyName === right.historyName);
|
|
62
64
|
}
|
|
65
|
+
function releasesEqual(left, right) {
|
|
66
|
+
if (left === undefined || right === undefined)
|
|
67
|
+
return left === right;
|
|
68
|
+
return left.series === right.series && left.version === right.version;
|
|
69
|
+
}
|
|
63
70
|
function recordsEqual(left, right) {
|
|
64
71
|
return (left.version === right.version &&
|
|
72
|
+
left.kind === right.kind &&
|
|
65
73
|
left.name === right.name &&
|
|
66
74
|
left.size === right.size &&
|
|
75
|
+
left.entryCount === right.entryCount &&
|
|
67
76
|
sameHex32(left.sha256, right.sha256) &&
|
|
68
77
|
left.committedAt === right.committedAt &&
|
|
69
78
|
left.uploaderFingerprint === right.uploaderFingerprint &&
|
|
70
79
|
sameHex32(left.transferId, right.transferId) &&
|
|
80
|
+
releasesEqual(left.release, right.release) &&
|
|
71
81
|
replacementsEqual(left.replaces, right.replaces));
|
|
72
82
|
}
|
|
73
83
|
function sameContent(left, right) {
|
|
74
84
|
return left.size === right.size && sameHex32(left.sha256, right.sha256);
|
|
75
85
|
}
|
|
86
|
+
function snapshotRelease(release) {
|
|
87
|
+
if (release === null || release === undefined)
|
|
88
|
+
return undefined;
|
|
89
|
+
const snapshot = {
|
|
90
|
+
series: release.series,
|
|
91
|
+
...(release.version === undefined ? {} : { version: release.version })
|
|
92
|
+
};
|
|
93
|
+
(0, commit_journal_js_1.assertCommitRelease)(snapshot);
|
|
94
|
+
return snapshot;
|
|
95
|
+
}
|
|
76
96
|
/** The record the old sidecar becomes once its inode is only reachable as history. */
|
|
77
97
|
function historyRecord(oldRecord, name) {
|
|
78
98
|
return (0, commit_journal_js_1.assertCommitRecord)({ ...oldRecord, name });
|
|
@@ -97,9 +117,34 @@ function assertSession(session) {
|
|
|
97
117
|
}
|
|
98
118
|
(0, validation_js_1.assertSafeUint)(candidate.tarSize, 'session TAR size');
|
|
99
119
|
(0, validation_js_1.assertFixed32)(candidate.tarDigest, 'session TAR digest');
|
|
120
|
+
if (candidate.kind === 'directory') {
|
|
121
|
+
(0, validation_js_1.assertSafeUint)(candidate.entryCount, 'session entry count');
|
|
122
|
+
(0, tree_manifest_js_1.assertTreeMetadataTransferId)(candidate.ownerKey, {
|
|
123
|
+
v: 1,
|
|
124
|
+
kind: 'directory',
|
|
125
|
+
name: candidate.name,
|
|
126
|
+
...(candidate.sourceParent === undefined
|
|
127
|
+
? {}
|
|
128
|
+
: { sourceParent: candidate.sourceParent }),
|
|
129
|
+
entryCount: candidate.entryCount,
|
|
130
|
+
payloadBytes: candidate.size,
|
|
131
|
+
treeSha256: toHex(candidate.digest),
|
|
132
|
+
tarSize: candidate.tarSize,
|
|
133
|
+
tarSha256: toHex(candidate.tarDigest),
|
|
134
|
+
transferId: candidate.id,
|
|
135
|
+
reset: false
|
|
136
|
+
});
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (candidate.kind !== 'file' && candidate.kind !== undefined) {
|
|
140
|
+
throw storageError('Invalid session artifact kind');
|
|
141
|
+
}
|
|
100
142
|
(0, manifest_js_1.assertMetadataTransferId)(candidate.ownerKey, {
|
|
101
143
|
v: 1,
|
|
102
144
|
name: candidate.name,
|
|
145
|
+
...(candidate.sourceParent === undefined
|
|
146
|
+
? {}
|
|
147
|
+
: { sourceParent: candidate.sourceParent }),
|
|
103
148
|
fileSize: candidate.size,
|
|
104
149
|
fileSha256: toHex(candidate.digest),
|
|
105
150
|
tarSize: candidate.tarSize,
|
|
@@ -199,6 +244,16 @@ class CommitStore {
|
|
|
199
244
|
_tarStagingPath(id) {
|
|
200
245
|
return _path_1.default.join(this.layout.staging, `${id}.tar.part`);
|
|
201
246
|
}
|
|
247
|
+
_treeStagingPath(id) {
|
|
248
|
+
return _path_1.default.join(this.layout.staging, `${id}.tree`);
|
|
249
|
+
}
|
|
250
|
+
_trashTreePath(id) {
|
|
251
|
+
return _path_1.default.join(this.layout.trash, `${id}.tree`);
|
|
252
|
+
}
|
|
253
|
+
/** The visible state of one top-level directory path, without following links. */
|
|
254
|
+
_rootTreeState(name) {
|
|
255
|
+
return (0, tree_fs_js_1.inspectTreePath)(this._finalPath(name), this.layout.root, this.storage);
|
|
256
|
+
}
|
|
202
257
|
_sessionPath(id) {
|
|
203
258
|
return _path_1.default.join(this.layout.sessions, `${id}.json`);
|
|
204
259
|
}
|
|
@@ -384,8 +439,8 @@ class CommitStore {
|
|
|
384
439
|
}, id);
|
|
385
440
|
return id;
|
|
386
441
|
}
|
|
387
|
-
/** A replacement always starts a fresh attempt; leftovers belong to recovery. */
|
|
388
|
-
async
|
|
442
|
+
/** A replacement or directory commit always starts a fresh attempt; leftovers belong to recovery. */
|
|
443
|
+
async _writeFreshJournal(journal) {
|
|
389
444
|
const id = journal.transferId;
|
|
390
445
|
if (await this._safeFileOrAbsent(this._journalPath(id), this.layout.journals)) {
|
|
391
446
|
let existing = null;
|
|
@@ -551,18 +606,25 @@ class CommitStore {
|
|
|
551
606
|
const result = await digestExactFile(filePath, record.size, this.storage, syncFirst);
|
|
552
607
|
return result !== null && (0, hash_js_1.digestMatches)(result.digest, b4a_1.default.from(record.sha256, 'hex'));
|
|
553
608
|
}
|
|
554
|
-
_recordFromSession(session) {
|
|
609
|
+
_recordFromSession(session, release) {
|
|
555
610
|
assertSession(session);
|
|
556
|
-
const
|
|
557
|
-
version: commit_journal_js_1.COMMIT_VERSION,
|
|
611
|
+
const base = {
|
|
558
612
|
name: session.name,
|
|
559
613
|
size: session.size,
|
|
560
614
|
sha256: toHex(session.digest),
|
|
561
615
|
committedAt: this.clock.now(),
|
|
562
616
|
uploaderFingerprint: fingerprint(session.ownerKey),
|
|
563
|
-
transferId: session.id
|
|
617
|
+
transferId: session.id,
|
|
618
|
+
...(release === undefined ? {} : { release })
|
|
564
619
|
};
|
|
565
|
-
return (0, commit_journal_js_1.assertCommitRecord)(
|
|
620
|
+
return (0, commit_journal_js_1.assertCommitRecord)(session.kind === 'directory'
|
|
621
|
+
? {
|
|
622
|
+
version: commit_journal_js_1.DIRECTORY_COMMIT_VERSION,
|
|
623
|
+
kind: 'directory',
|
|
624
|
+
entryCount: session.entryCount,
|
|
625
|
+
...base
|
|
626
|
+
}
|
|
627
|
+
: { version: commit_journal_js_1.COMMIT_VERSION, ...base });
|
|
566
628
|
}
|
|
567
629
|
async inspect(name, offer, { replaceNames } = {}) {
|
|
568
630
|
await this._assertLayout();
|
|
@@ -577,10 +639,18 @@ class CommitStore {
|
|
|
577
639
|
(0, validation_js_1.assertSafeUint)(offer.size, 'commit offer size');
|
|
578
640
|
(0, validation_js_1.assertFixed32)(offer.digest, 'commit offer digest');
|
|
579
641
|
(0, validation_js_1.assertFixed32)(offer.transferId, 'commit offer transfer ID');
|
|
642
|
+
if (offer.kind !== undefined && offer.kind !== 'file' && offer.kind !== 'directory') {
|
|
643
|
+
throw storageError('Invalid commit offer kind');
|
|
644
|
+
}
|
|
645
|
+
if (offer.kind === 'directory')
|
|
646
|
+
(0, validation_js_1.assertSafeUint)(offer.entryCount, 'commit offer entry count');
|
|
647
|
+
const release = snapshotRelease(offer.release);
|
|
580
648
|
const mutable = (0, files_js_1.validateReplaceNames)(replaceNames);
|
|
581
|
-
return (0, root_coordinator_js_1.withNameLease)(this.layout.root, name, () => this._inspect(name, offer, mutable));
|
|
649
|
+
return (0, root_coordinator_js_1.withNameLease)(this.layout.root, name, () => this._inspect(name, offer, mutable, release));
|
|
582
650
|
}
|
|
583
|
-
async _inspect(name, offer, mutable) {
|
|
651
|
+
async _inspect(name, offer, mutable, release) {
|
|
652
|
+
if (offer.kind === 'directory')
|
|
653
|
+
return this._inspectDirectory(name, offer);
|
|
584
654
|
const finalPath = this._finalPath(name);
|
|
585
655
|
const final = await this._safeFileOrAbsent(finalPath, this.layout.root);
|
|
586
656
|
if (!final)
|
|
@@ -592,6 +662,7 @@ class CommitStore {
|
|
|
592
662
|
record.size === offer.size &&
|
|
593
663
|
sameHex32(record.sha256, toHex(offer.digest)) &&
|
|
594
664
|
sameHex32(record.transferId, id) &&
|
|
665
|
+
releasesEqual(record.release, release) &&
|
|
595
666
|
(await this._matchesRecord(finalPath, this.layout.root, record))) {
|
|
596
667
|
return { status: 'ALREADY_COMMITTED', record };
|
|
597
668
|
}
|
|
@@ -601,12 +672,52 @@ class CommitStore {
|
|
|
601
672
|
if (!current || !(0, commit_journal_js_1.identitiesEqual)(current.identity, (0, commit_journal_js_1.fileIdentity)(final))) {
|
|
602
673
|
return { status: 'FILE_EXISTS' };
|
|
603
674
|
}
|
|
604
|
-
|
|
675
|
+
const sameRelease = releasesEqual(current.record.release, release);
|
|
676
|
+
if (sameHex32(current.record.transferId, id) && !sameRelease) {
|
|
677
|
+
// One transfer ID names one authenticated upload; its release identity is immutable.
|
|
678
|
+
throw storageError('Release identity conflicts with the committed transfer');
|
|
679
|
+
}
|
|
680
|
+
if (sameRelease &&
|
|
681
|
+
current.record.size === offer.size &&
|
|
605
682
|
sameHex32(current.record.sha256, toHex(offer.digest))) {
|
|
606
683
|
return { status: 'ALREADY_COMMITTED', record: current.record };
|
|
607
684
|
}
|
|
608
685
|
return { status: 'REPLACEABLE', record: current.record };
|
|
609
686
|
}
|
|
687
|
+
async _inspectDirectory(name, offer) {
|
|
688
|
+
if ((await this._rootTreeState(name)) === 'MISSING') {
|
|
689
|
+
const occupied = await this._safeFileOrAbsent(this._finalPath(name), this.layout.root);
|
|
690
|
+
return occupied ? { status: 'FILE_EXISTS' } : { status: 'AVAILABLE' };
|
|
691
|
+
}
|
|
692
|
+
const id = toHex(offer.transferId);
|
|
693
|
+
const record = await this._readRecordOrAbsent(this._recordPath(id));
|
|
694
|
+
if (!record ||
|
|
695
|
+
record.kind !== 'directory' ||
|
|
696
|
+
record.name !== name ||
|
|
697
|
+
record.size !== offer.size ||
|
|
698
|
+
record.entryCount !== offer.entryCount ||
|
|
699
|
+
!sameHex32(record.sha256, toHex(offer.digest)) ||
|
|
700
|
+
!sameHex32(record.transferId, id)) {
|
|
701
|
+
return { status: 'FILE_EXISTS' };
|
|
702
|
+
}
|
|
703
|
+
let digested;
|
|
704
|
+
try {
|
|
705
|
+
digested = await (0, tree_fs_js_1.digestTree)(this._finalPath(name), this.storage);
|
|
706
|
+
}
|
|
707
|
+
catch (error) {
|
|
708
|
+
if (error instanceof errors_js_1.SwarmDeployError &&
|
|
709
|
+
(error.code === errors_js_1.ERRORS.INVALID_FILENAME || error.code === errors_js_1.ERRORS.PROTOCOL_INVALID)) {
|
|
710
|
+
return { status: 'FILE_EXISTS' };
|
|
711
|
+
}
|
|
712
|
+
throw error;
|
|
713
|
+
}
|
|
714
|
+
if (digested.entryCount !== record.entryCount ||
|
|
715
|
+
digested.payloadBytes !== record.size ||
|
|
716
|
+
!(0, hash_js_1.digestMatches)(digested.treeSha256, b4a_1.default.from(record.sha256, 'hex'))) {
|
|
717
|
+
return { status: 'FILE_EXISTS' };
|
|
718
|
+
}
|
|
719
|
+
return { status: 'ALREADY_COMMITTED', record };
|
|
720
|
+
}
|
|
610
721
|
/** The managed record whose verified content currently owns a visible name. */
|
|
611
722
|
async _managedCurrent(name) {
|
|
612
723
|
const finalPath = this._finalPath(name);
|
|
@@ -626,7 +737,9 @@ class CommitStore {
|
|
|
626
737
|
if (session.name !== record.name ||
|
|
627
738
|
session.size !== record.size ||
|
|
628
739
|
toHex(session.digest) !== record.sha256 ||
|
|
629
|
-
fingerprint(session.ownerKey) !== record.uploaderFingerprint
|
|
740
|
+
fingerprint(session.ownerKey) !== record.uploaderFingerprint ||
|
|
741
|
+
(record.kind === 'directory' &&
|
|
742
|
+
(session.kind !== 'directory' || session.entryCount !== record.entryCount))) {
|
|
630
743
|
throw new commit_journal_js_1.CorruptJournalError(message);
|
|
631
744
|
}
|
|
632
745
|
}
|
|
@@ -650,9 +763,10 @@ class CommitStore {
|
|
|
650
763
|
}
|
|
651
764
|
return { sessionFile, staging };
|
|
652
765
|
}
|
|
653
|
-
commit(session, { retentionManager = null, signal = null, replaceNames } = {}) {
|
|
766
|
+
commit(session, { retentionManager = null, signal = null, replaceNames, release = null, deferPostCommitRetention = false } = {}) {
|
|
654
767
|
try {
|
|
655
768
|
assertSession(session);
|
|
769
|
+
const releaseSnapshot = snapshotRelease(release);
|
|
656
770
|
if (retentionManager !== null &&
|
|
657
771
|
(typeof retentionManager.run !== 'function' ||
|
|
658
772
|
typeof retentionManager.afterCommit !== 'function' ||
|
|
@@ -664,19 +778,22 @@ class CommitStore {
|
|
|
664
778
|
return Promise.reject(storageError('Invalid commit abort signal'));
|
|
665
779
|
}
|
|
666
780
|
const mutable = (0, files_js_1.validateReplaceNames)(replaceNames);
|
|
667
|
-
return (0, root_coordinator_js_1.withNameLease)(this.layout.root, session.name, () => (0, root_coordinator_js_1.withRootLease)(this.layout.root, () => this._commit(session, retentionManager, signal, mutable)));
|
|
781
|
+
return (0, root_coordinator_js_1.withNameLease)(this.layout.root, session.name, () => (0, root_coordinator_js_1.withRootLease)(this.layout.root, () => this._commit(session, retentionManager, signal, mutable, releaseSnapshot, deferPostCommitRetention === true)));
|
|
668
782
|
}
|
|
669
783
|
catch (error) {
|
|
670
784
|
return Promise.reject(error);
|
|
671
785
|
}
|
|
672
786
|
}
|
|
673
|
-
async _commit(session, retentionManager, signal, mutable = new Set()) {
|
|
787
|
+
async _commit(session, retentionManager, signal, mutable = new Set(), release, deferPostCommitRetention = false) {
|
|
674
788
|
assertNotAborted(signal);
|
|
675
789
|
await this._assertLayout();
|
|
676
|
-
const record = this._recordFromSession(session);
|
|
790
|
+
const record = this._recordFromSession(session, release);
|
|
677
791
|
if ((0, files_js_1.isReservedHistoryName)(record.name)) {
|
|
678
792
|
throw new errors_js_1.SwarmDeployError(errors_js_1.ERRORS.INVALID_FILENAME, 'Reserved artifact name');
|
|
679
793
|
}
|
|
794
|
+
if (session.kind === 'directory') {
|
|
795
|
+
return this._commitDirectory(record, retentionManager, signal, mutable, deferPostCommitRetention);
|
|
796
|
+
}
|
|
680
797
|
const stagingPath = this._stagingPath(record.transferId);
|
|
681
798
|
const finalPath = this._finalPath(record.name);
|
|
682
799
|
const stagingDigest = await digestExactFile(stagingPath, record.size, this.storage, true);
|
|
@@ -692,7 +809,7 @@ class CommitStore {
|
|
|
692
809
|
return plan.record;
|
|
693
810
|
}
|
|
694
811
|
if (plan.mode === 'replace') {
|
|
695
|
-
return this._replace(record, stagingDigest.identity, plan, retentionManager, signal);
|
|
812
|
+
return this._replace(record, stagingDigest.identity, plan, retentionManager, signal, deferPostCommitRetention);
|
|
696
813
|
}
|
|
697
814
|
}
|
|
698
815
|
if (retentionManager) {
|
|
@@ -717,8 +834,9 @@ class CommitStore {
|
|
|
717
834
|
await this._removeFile(this._tarStagingPath(record.transferId), this.layout.staging);
|
|
718
835
|
assertNotAborted(signal);
|
|
719
836
|
await this._discardJournal(record.transferId, journalAttemptId);
|
|
720
|
-
if (retentionManager)
|
|
837
|
+
if (retentionManager && !deferPostCommitRetention) {
|
|
721
838
|
await retentionManager._afterCommitUnlocked();
|
|
839
|
+
}
|
|
722
840
|
return record;
|
|
723
841
|
}
|
|
724
842
|
catch (err) {
|
|
@@ -758,12 +876,122 @@ class CommitStore {
|
|
|
758
876
|
throw commitError('Unable to create final artifact', err);
|
|
759
877
|
}
|
|
760
878
|
}
|
|
879
|
+
/**
|
|
880
|
+
* Publishes one verified staging tree by rename. Directory artifacts are
|
|
881
|
+
* create-only: a configured mutable name, an occupied destination, and a
|
|
882
|
+
* kind change are all existing-name conflicts.
|
|
883
|
+
*/
|
|
884
|
+
async _commitDirectory(record, retentionManager, signal, mutable, deferPostCommitRetention = false) {
|
|
885
|
+
if (mutable.has(record.name)) {
|
|
886
|
+
throw existsError('Directory artifacts cannot be replaced');
|
|
887
|
+
}
|
|
888
|
+
const treePath = this._treeStagingPath(record.transferId);
|
|
889
|
+
const finalPath = this._finalPath(record.name);
|
|
890
|
+
if ((await (0, tree_fs_js_1.inspectTreePath)(treePath, this.layout.staging, this.storage)) !== 'DIRECTORY') {
|
|
891
|
+
throw new errors_js_1.SwarmDeployError(errors_js_1.ERRORS.CHECKSUM_MISMATCH, 'Verified staging tree is missing');
|
|
892
|
+
}
|
|
893
|
+
const digested = await (0, tree_fs_js_1.digestTree)(treePath, this.storage);
|
|
894
|
+
if (digested.entryCount !== record.entryCount ||
|
|
895
|
+
digested.payloadBytes !== record.size ||
|
|
896
|
+
!(0, hash_js_1.digestMatches)(digested.treeSha256, b4a_1.default.from(record.sha256, 'hex'))) {
|
|
897
|
+
throw new errors_js_1.SwarmDeployError(errors_js_1.ERRORS.CHECKSUM_MISMATCH, 'Staging tree checksum mismatch');
|
|
898
|
+
}
|
|
899
|
+
const stagingTreeIdentity = (0, commit_journal_js_1.fileIdentity)(await (0, layout_js_1.withSafeDirectoryIdentity)(this.layout.staging, this.storage, () => this.storage.lstat(treePath)));
|
|
900
|
+
for (const existing of await this._scanRecords()) {
|
|
901
|
+
if (existing.name === record.name)
|
|
902
|
+
throw existsError('Destination already exists');
|
|
903
|
+
}
|
|
904
|
+
if ((await this._rootTreeState(record.name)) !== 'MISSING') {
|
|
905
|
+
throw existsError('Destination already exists');
|
|
906
|
+
}
|
|
907
|
+
if (retentionManager) {
|
|
908
|
+
await retentionManager._runUnlocked({ incomingBytes: record.size, trigger: 'commit' });
|
|
909
|
+
}
|
|
910
|
+
assertNotAborted(signal);
|
|
911
|
+
const attempt = attemptId();
|
|
912
|
+
let journal = {
|
|
913
|
+
version: commit_journal_js_1.DIRECTORY_JOURNAL_VERSION,
|
|
914
|
+
intent: 'create-directory',
|
|
915
|
+
state: 'committing',
|
|
916
|
+
phase: 'journaled',
|
|
917
|
+
transferId: record.transferId,
|
|
918
|
+
attemptId: attempt,
|
|
919
|
+
name: record.name,
|
|
920
|
+
stagingTreeName: `${record.transferId}.tree`,
|
|
921
|
+
stagingTreeIdentity,
|
|
922
|
+
record
|
|
923
|
+
};
|
|
924
|
+
await this._writeFreshJournal(journal);
|
|
925
|
+
let linearized = false;
|
|
926
|
+
try {
|
|
927
|
+
assertNotAborted(signal);
|
|
928
|
+
if ((await this._rootTreeState(record.name)) !== 'MISSING') {
|
|
929
|
+
throw existsError('Destination already exists');
|
|
930
|
+
}
|
|
931
|
+
await (0, layout_js_1.withSafeDirectoryIdentity)(this.layout.root, this.storage, () => (0, layout_js_1.withSafeDirectoryIdentity)(this.layout.staging, this.storage, () => this.storage.rename(treePath, finalPath)));
|
|
932
|
+
await this._syncRoot();
|
|
933
|
+
journal = await this._setDirectoryPhase(journal, 'renamed');
|
|
934
|
+
assertNotAborted(signal);
|
|
935
|
+
await this._writeRecord(record);
|
|
936
|
+
linearized = true;
|
|
937
|
+
journal = await this._setDirectoryPhase(journal, 'sidecar');
|
|
938
|
+
await this._removeFile(this._sessionPath(record.transferId), this.layout.sessions);
|
|
939
|
+
await this._removeFile(this._tarStagingPath(record.transferId), this.layout.staging);
|
|
940
|
+
await this._discardJournal(record.transferId, attempt);
|
|
941
|
+
if (retentionManager && !deferPostCommitRetention) {
|
|
942
|
+
await retentionManager._afterCommitUnlocked();
|
|
943
|
+
}
|
|
944
|
+
return record;
|
|
945
|
+
}
|
|
946
|
+
catch (err) {
|
|
947
|
+
if (linearized) {
|
|
948
|
+
this._reportCleanupPending(record, err);
|
|
949
|
+
return record;
|
|
950
|
+
}
|
|
951
|
+
try {
|
|
952
|
+
await this._rollbackDirectory(journal);
|
|
953
|
+
}
|
|
954
|
+
catch (cleanupError) {
|
|
955
|
+
throw new AggregateError([err, cleanupError], 'Unable to roll back directory commit');
|
|
956
|
+
}
|
|
957
|
+
throw err;
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
async _setDirectoryPhase(journal, phase) {
|
|
961
|
+
const next = { ...journal, phase };
|
|
962
|
+
await (0, atomic_file_js_1.writeAtomic)(this._journalPath(journal.transferId), b4a_1.default.from(JSON.stringify((0, commit_journal_js_1.serializeJournal)(next))), this.storage);
|
|
963
|
+
return next;
|
|
964
|
+
}
|
|
965
|
+
/** Returns a proven renamed tree to staging; a foreign destination is preserved. */
|
|
966
|
+
async _rollbackDirectory(journal) {
|
|
967
|
+
const finalPath = this._finalPath(journal.name);
|
|
968
|
+
const treePath = this._treeStagingPath(journal.transferId);
|
|
969
|
+
if ((await this._rootTreeState(journal.name)) !== 'DIRECTORY') {
|
|
970
|
+
if ((await (0, tree_fs_js_1.inspectTreePath)(treePath, this.layout.staging, this.storage)) === 'DIRECTORY') {
|
|
971
|
+
await this._discardJournal(journal.transferId, journal.attemptId);
|
|
972
|
+
}
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
const visible = (0, commit_journal_js_1.fileIdentity)(await (0, layout_js_1.withSafeDirectoryIdentity)(this.layout.root, this.storage, () => this.storage.lstat(finalPath)));
|
|
976
|
+
if (!(0, commit_journal_js_1.identitiesEqual)(visible, journal.stagingTreeIdentity)) {
|
|
977
|
+
throw storageError('Foreign artifact at directory destination');
|
|
978
|
+
}
|
|
979
|
+
if ((await (0, tree_fs_js_1.inspectTreePath)(treePath, this.layout.staging, this.storage)) !== 'MISSING') {
|
|
980
|
+
throw storageError('Directory staging tree already exists');
|
|
981
|
+
}
|
|
982
|
+
await (0, layout_js_1.withSafeDirectoryIdentity)(this.layout.root, this.storage, () => (0, layout_js_1.withSafeDirectoryIdentity)(this.layout.staging, this.storage, () => this.storage.rename(finalPath, treePath)));
|
|
983
|
+
await this._syncRoot();
|
|
984
|
+
await this._discardJournal(journal.transferId, journal.attemptId);
|
|
985
|
+
}
|
|
761
986
|
/**
|
|
762
987
|
* Classifies a configured mutable name. Only an exact managed current record
|
|
763
988
|
* whose verified content owns the visible path may be replaced; every other
|
|
764
989
|
* occupied path is an existing-name conflict.
|
|
765
990
|
*/
|
|
766
991
|
async _planReplacement(record) {
|
|
992
|
+
if ((await this._rootTreeState(record.name)) === 'DIRECTORY') {
|
|
993
|
+
throw existsError('Destination already exists');
|
|
994
|
+
}
|
|
767
995
|
const state = await this._rootPathState(record.name);
|
|
768
996
|
if (!state.present)
|
|
769
997
|
return { mode: 'create' };
|
|
@@ -773,8 +1001,14 @@ class CommitStore {
|
|
|
773
1001
|
if (!current || !(0, commit_journal_js_1.identitiesEqual)(current.identity, (0, commit_journal_js_1.fileIdentity)(state.stat))) {
|
|
774
1002
|
throw existsError('Destination already exists');
|
|
775
1003
|
}
|
|
776
|
-
|
|
1004
|
+
const sameRelease = releasesEqual(current.record.release, record.release);
|
|
1005
|
+
if (current.record.transferId === record.transferId && !sameRelease) {
|
|
1006
|
+
// One transfer ID names one authenticated upload; its release identity is immutable.
|
|
1007
|
+
throw storageError('Release identity conflicts with the committed transfer');
|
|
1008
|
+
}
|
|
1009
|
+
if (sameContent(current.record, record) && sameRelease) {
|
|
777
1010
|
return { mode: 'idempotent', record: current.record };
|
|
1011
|
+
}
|
|
778
1012
|
return { mode: 'replace', oldRecord: current.record, finalIdentity: current.identity };
|
|
779
1013
|
}
|
|
780
1014
|
/**
|
|
@@ -787,7 +1021,9 @@ class CommitStore {
|
|
|
787
1021
|
const existing = await this._readRecordOrAbsent(this._recordPath(record.transferId));
|
|
788
1022
|
if (!existing)
|
|
789
1023
|
return null;
|
|
790
|
-
if (existing.name !== name ||
|
|
1024
|
+
if (existing.name !== name ||
|
|
1025
|
+
!sameContent(existing, record) ||
|
|
1026
|
+
!releasesEqual(existing.release, record.release)) {
|
|
791
1027
|
throw storageError('Conflicting commit record');
|
|
792
1028
|
}
|
|
793
1029
|
const state = await this._rootPathState(name);
|
|
@@ -801,7 +1037,7 @@ class CommitStore {
|
|
|
801
1037
|
}
|
|
802
1038
|
return { name, identity: digested.identity };
|
|
803
1039
|
}
|
|
804
|
-
async _replace(record, stagingIdentity, plan, retentionManager, signal) {
|
|
1040
|
+
async _replace(record, stagingIdentity, plan, retentionManager, signal, deferPostCommitRetention = false) {
|
|
805
1041
|
const { oldRecord, finalIdentity } = plan;
|
|
806
1042
|
const history = (0, files_js_1.historyName)(oldRecord.transferId);
|
|
807
1043
|
const newRecord = (0, commit_journal_js_1.assertCommitRecord)({
|
|
@@ -846,7 +1082,7 @@ class CommitStore {
|
|
|
846
1082
|
record: newRecord
|
|
847
1083
|
};
|
|
848
1084
|
const publicationPath = this._publicationPath(journal.publicationName);
|
|
849
|
-
await this.
|
|
1085
|
+
await this._writeFreshJournal(journal);
|
|
850
1086
|
let linearized = false;
|
|
851
1087
|
try {
|
|
852
1088
|
assertNotAborted(signal);
|
|
@@ -881,8 +1117,9 @@ class CommitStore {
|
|
|
881
1117
|
await this._removeFile(stagingPath, this.layout.staging);
|
|
882
1118
|
await this._removeFile(this._tarStagingPath(newRecord.transferId), this.layout.staging);
|
|
883
1119
|
await this._discardJournal(newRecord.transferId, attempt);
|
|
884
|
-
if (retentionManager)
|
|
1120
|
+
if (retentionManager && !deferPostCommitRetention) {
|
|
885
1121
|
await retentionManager._afterCommitUnlocked();
|
|
1122
|
+
}
|
|
886
1123
|
return newRecord;
|
|
887
1124
|
}
|
|
888
1125
|
catch (err) {
|
|
@@ -1068,6 +1305,59 @@ class CommitStore {
|
|
|
1068
1305
|
await this._removeFile(this._tarStagingPath(id), this.layout.staging);
|
|
1069
1306
|
}
|
|
1070
1307
|
/** Proves the verified session and staging an aborted attempt must retain. */
|
|
1308
|
+
/**
|
|
1309
|
+
* Converges one directory attempt. The durable sidecar is the linearization
|
|
1310
|
+
* point: before it a proven renamed tree returns to staging, after it the
|
|
1311
|
+
* published tree is kept and only residue is removed. A destination that
|
|
1312
|
+
* cannot be proven is preserved and fails closed.
|
|
1313
|
+
*/
|
|
1314
|
+
async _recoverDirectoryJournal(id, journal, sessionStore, isAuthorized = null) {
|
|
1315
|
+
const { record, name, attemptId: attempt } = journal;
|
|
1316
|
+
const sidecar = await this._readRecordOrAbsent(this._recordPath(id));
|
|
1317
|
+
if (sidecar && !recordsEqual(sidecar, record)) {
|
|
1318
|
+
throw storageError('Commit sidecar does not match journal');
|
|
1319
|
+
}
|
|
1320
|
+
const state = await this._rootTreeState(name);
|
|
1321
|
+
if (state === 'UNMANAGED')
|
|
1322
|
+
throw storageError('Foreign artifact at directory destination');
|
|
1323
|
+
if (state === 'DIRECTORY') {
|
|
1324
|
+
const digested = await (0, tree_fs_js_1.digestTree)(this._finalPath(name), this.storage);
|
|
1325
|
+
if (digested.entryCount !== record.entryCount ||
|
|
1326
|
+
digested.payloadBytes !== record.size ||
|
|
1327
|
+
!(0, hash_js_1.digestMatches)(digested.treeSha256, b4a_1.default.from(record.sha256, 'hex'))) {
|
|
1328
|
+
throw storageError('Published directory does not match its journal');
|
|
1329
|
+
}
|
|
1330
|
+
if (!sidecar && isAuthorized) {
|
|
1331
|
+
if (!sessionStore || typeof sessionStore.readVerified !== 'function') {
|
|
1332
|
+
throw storageError('Session store cannot validate recovery state');
|
|
1333
|
+
}
|
|
1334
|
+
const session = await sessionStore.readVerified(b4a_1.default.from(id, 'hex'));
|
|
1335
|
+
if (!isAuthorized(session.ownerKey)) {
|
|
1336
|
+
await this._rollbackDirectory(journal);
|
|
1337
|
+
return { status: 'RESUMABLE', record };
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
if (!sidecar)
|
|
1341
|
+
await this._writeRecord(record);
|
|
1342
|
+
await this._removeFile(this._sessionPath(id), this.layout.sessions);
|
|
1343
|
+
await this._removeFile(this._tarStagingPath(id), this.layout.staging);
|
|
1344
|
+
await this._discardJournal(id, attempt);
|
|
1345
|
+
return { status: 'COMMITTED', record };
|
|
1346
|
+
}
|
|
1347
|
+
if (sidecar)
|
|
1348
|
+
await this._removeFile(this._recordPath(id), this.layout.commits);
|
|
1349
|
+
if (!sessionStore || typeof sessionStore.readVerified !== 'function') {
|
|
1350
|
+
throw storageError('Session store cannot validate recovery state');
|
|
1351
|
+
}
|
|
1352
|
+
const session = await sessionStore.readVerified(b4a_1.default.from(id, 'hex'));
|
|
1353
|
+
this._assertSessionMatchesRecord(session, record, 'Directory journal does not match verified session');
|
|
1354
|
+
const treePath = this._treeStagingPath(id);
|
|
1355
|
+
if ((await (0, tree_fs_js_1.inspectTreePath)(treePath, this.layout.staging, this.storage)) !== 'DIRECTORY') {
|
|
1356
|
+
throw new commit_journal_js_1.CorruptJournalError('Directory journal does not own verified staging');
|
|
1357
|
+
}
|
|
1358
|
+
await this._discardJournal(id, attempt);
|
|
1359
|
+
return { status: 'RESUMABLE', record };
|
|
1360
|
+
}
|
|
1071
1361
|
async _assertReplacementRetryable(id, journal, sessionStore) {
|
|
1072
1362
|
if (!sessionStore || typeof sessionStore.readVerified !== 'function') {
|
|
1073
1363
|
throw storageError('Session store cannot validate commit journal');
|
|
@@ -1089,6 +1379,9 @@ class CommitStore {
|
|
|
1089
1379
|
const journal = await this._readJournal(id);
|
|
1090
1380
|
if (!journal)
|
|
1091
1381
|
return { status: 'MISSING' };
|
|
1382
|
+
if ((0, commit_journal_js_1.isDirectoryJournal)(journal)) {
|
|
1383
|
+
return (0, root_coordinator_js_1.withNameLease)(this.layout.root, journal.name, () => this._recoverDirectoryJournal(id, journal, sessionStore, isAuthorized));
|
|
1384
|
+
}
|
|
1092
1385
|
if ((0, commit_journal_js_1.isReplacementJournal)(journal)) {
|
|
1093
1386
|
return (0, root_coordinator_js_1.withNameLease)(this.layout.root, journal.name, () => this._recoverReplacementJournal(id, journal, sessionStore, isAuthorized));
|
|
1094
1387
|
}
|
|
@@ -1296,11 +1589,61 @@ class CommitStore {
|
|
|
1296
1589
|
}
|
|
1297
1590
|
throw storageError('Commit record changed before deletion');
|
|
1298
1591
|
}
|
|
1592
|
+
/**
|
|
1593
|
+
* Removes proven private trash trees left by an interrupted deletion. Only a
|
|
1594
|
+
* `<64-hex>.tree` entry is ours; anything else in the directory is left alone.
|
|
1595
|
+
*/
|
|
1596
|
+
async sweepTrash() {
|
|
1597
|
+
await this._assertLayout();
|
|
1598
|
+
const names = await (0, layout_js_1.withSafeDirectoryIdentity)(this.layout.trash, this.storage, () => this.storage.readdir(this.layout.trash));
|
|
1599
|
+
let removed = 0;
|
|
1600
|
+
for (const name of names.sort()) {
|
|
1601
|
+
if (!/^[0-9a-f]{64}\.tree$/.test(name))
|
|
1602
|
+
continue;
|
|
1603
|
+
if (await (0, tree_fs_js_1.removeTree)(_path_1.default.join(this.layout.trash, name), this.layout.trash, this.storage)) {
|
|
1604
|
+
removed++;
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
return removed;
|
|
1608
|
+
}
|
|
1609
|
+
/**
|
|
1610
|
+
* Removes a managed directory by renaming it into the private trash first, so
|
|
1611
|
+
* the visible name disappears atomically, and only then removing the sidecar
|
|
1612
|
+
* and recursively deleting the trash tree.
|
|
1613
|
+
*/
|
|
1614
|
+
async _removeManagedTree(record) {
|
|
1615
|
+
const finalPath = this._finalPath(record.name);
|
|
1616
|
+
const trashPath = this._trashTreePath(record.transferId);
|
|
1617
|
+
const state = await this._rootTreeState(record.name);
|
|
1618
|
+
if (state === 'MISSING')
|
|
1619
|
+
return { removed: false, preservedPath: false };
|
|
1620
|
+
if (state === 'UNMANAGED')
|
|
1621
|
+
return { removed: false, preservedPath: true };
|
|
1622
|
+
if ((await (0, tree_fs_js_1.inspectTreePath)(trashPath, this.layout.trash, this.storage)) !== 'MISSING') {
|
|
1623
|
+
await (0, tree_fs_js_1.removeTree)(trashPath, this.layout.trash, this.storage);
|
|
1624
|
+
}
|
|
1625
|
+
await (0, layout_js_1.withSafeDirectoryIdentity)(this.layout.root, this.storage, () => (0, layout_js_1.withSafeDirectoryIdentity)(this.layout.trash, this.storage, () => this.storage.rename(finalPath, trashPath)));
|
|
1626
|
+
await this._syncRoot();
|
|
1627
|
+
await this._removeFile(this._recordPath(record.transferId), this.layout.commits);
|
|
1628
|
+
await (0, tree_fs_js_1.removeTree)(trashPath, this.layout.trash, this.storage);
|
|
1629
|
+
return { removed: true, preservedPath: false };
|
|
1630
|
+
}
|
|
1299
1631
|
async delete(record) {
|
|
1300
1632
|
await this._assertLayout();
|
|
1301
1633
|
(0, commit_journal_js_1.assertCommitRecord)(record);
|
|
1302
1634
|
if (!(await this._storedRecordFor(record)))
|
|
1303
1635
|
return false;
|
|
1636
|
+
if (record.kind === 'directory') {
|
|
1637
|
+
const removed = await this._removeManagedTree(record);
|
|
1638
|
+
if (removed.preservedPath) {
|
|
1639
|
+
await this._removeFile(this._recordPath(record.transferId), this.layout.commits);
|
|
1640
|
+
return true;
|
|
1641
|
+
}
|
|
1642
|
+
if (!removed.removed) {
|
|
1643
|
+
await this._removeFile(this._recordPath(record.transferId), this.layout.commits);
|
|
1644
|
+
}
|
|
1645
|
+
return true;
|
|
1646
|
+
}
|
|
1304
1647
|
const finalPath = this._finalPath(record.name);
|
|
1305
1648
|
const final = await this._safeFileOrAbsent(finalPath, this.layout.root);
|
|
1306
1649
|
if (final)
|
|
@@ -1313,6 +1656,15 @@ class CommitStore {
|
|
|
1313
1656
|
(0, commit_journal_js_1.assertCommitRecord)(record);
|
|
1314
1657
|
if (!(await this._storedRecordFor(record)))
|
|
1315
1658
|
return false;
|
|
1659
|
+
if (record.kind === 'directory') {
|
|
1660
|
+
if (preservePath) {
|
|
1661
|
+
await this._removeFile(this._recordPath(record.transferId), this.layout.commits);
|
|
1662
|
+
return { purged: true, preservedPath: true };
|
|
1663
|
+
}
|
|
1664
|
+
const removed = await this._removeManagedTree(record);
|
|
1665
|
+
await this._removeFile(this._recordPath(record.transferId), this.layout.commits);
|
|
1666
|
+
return { purged: true, preservedPath: removed.preservedPath };
|
|
1667
|
+
}
|
|
1316
1668
|
let final;
|
|
1317
1669
|
if (preservePath) {
|
|
1318
1670
|
let preservedPath = false;
|