@hypequery/deployment 0.1.0 → 0.2.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/README.md +87 -0
- package/dist/activation.d.ts +51 -0
- package/dist/activation.d.ts.map +1 -0
- package/dist/activation.js +530 -0
- package/dist/filesystem-store.d.ts +25 -0
- package/dist/filesystem-store.d.ts.map +1 -0
- package/dist/filesystem-store.js +418 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -65,4 +65,91 @@ filesystem. It rejects symbolic links and undeclared entries, enforces byte
|
|
|
65
65
|
limits, recomputes every hash and identity, and returns an immutable verified
|
|
66
66
|
snapshot.
|
|
67
67
|
|
|
68
|
+
## Filesystem store
|
|
69
|
+
|
|
70
|
+
`createFileSystemDeploymentSubmissionStore` is the reference durable store for
|
|
71
|
+
a single host. It copies verified bundle bytes out of temporary intake storage,
|
|
72
|
+
revalidates the copy, and atomically publishes the release only after its bundle
|
|
73
|
+
is durable. Replaying the same release returns `already-exists` after the stored
|
|
74
|
+
state has been fully revalidated.
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
import {
|
|
78
|
+
createDeploymentIntake,
|
|
79
|
+
createFileSystemDeploymentSubmissionStore,
|
|
80
|
+
} from '@hypequery/deployment';
|
|
81
|
+
|
|
82
|
+
const store = createFileSystemDeploymentSubmissionStore({
|
|
83
|
+
directory: '/var/lib/hypequery/deployments',
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const intake = createDeploymentIntake({
|
|
87
|
+
authenticator,
|
|
88
|
+
authorizer,
|
|
89
|
+
store,
|
|
90
|
+
});
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
The closed layout is content-addressed:
|
|
94
|
+
|
|
95
|
+
```text
|
|
96
|
+
<directory>/
|
|
97
|
+
bundles/<bundle identity>/...
|
|
98
|
+
releases/<release identity>/release.json
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Bundle directories are published before release directories. A crash can leave
|
|
102
|
+
an unreferenced bundle that a later submission safely reuses, but cannot expose
|
|
103
|
+
a release whose bundle is incomplete. Files and directories are opened without
|
|
104
|
+
following symbolic links where Node exposes that facility, file contents and
|
|
105
|
+
directory entries are revalidated on reads and replays, and temporary staging
|
|
106
|
+
directories are removed after success or failure.
|
|
107
|
+
|
|
108
|
+
The configured directory is an operator-controlled local trust boundary. This
|
|
109
|
+
store does not provide remote replication, activation, lifecycle state, or
|
|
110
|
+
protection from an administrator modifying its files. `read(releaseIdentity)`
|
|
111
|
+
returns only a completely revalidated stored submission.
|
|
112
|
+
|
|
113
|
+
## Activation registry
|
|
114
|
+
|
|
115
|
+
`createFileSystemDeploymentActivationRegistry` adds explicit target activation
|
|
116
|
+
without changing accepted release or bundle bytes. The registry requires a
|
|
117
|
+
release reader that completely revalidates an accepted release and its closed
|
|
118
|
+
bundle before returning it; the filesystem submission store satisfies that
|
|
119
|
+
contract directly.
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
import {
|
|
123
|
+
createFileSystemDeploymentActivationRegistry,
|
|
124
|
+
createFileSystemDeploymentSubmissionStore,
|
|
125
|
+
} from '@hypequery/deployment';
|
|
126
|
+
|
|
127
|
+
const directory = '/var/lib/hypequery/deployments';
|
|
128
|
+
const releases = createFileSystemDeploymentSubmissionStore({ directory });
|
|
129
|
+
const activations = createFileSystemDeploymentActivationRegistry({
|
|
130
|
+
directory,
|
|
131
|
+
releases,
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
const target = { project: 'analytics', environment: 'production' };
|
|
135
|
+
const current = await activations.current(target);
|
|
136
|
+
const result = await activations.activate({
|
|
137
|
+
target,
|
|
138
|
+
releaseIdentity,
|
|
139
|
+
expectedRevision: current?.revision ?? null,
|
|
140
|
+
});
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
`expectedRevision` is a compare-and-swap precondition. A different active
|
|
144
|
+
revision returns `conflict`; requesting the release that is already active
|
|
145
|
+
returns `already-active`. Activating an older accepted release performs a
|
|
146
|
+
rollback through the same operation and produces a new revision, so stale
|
|
147
|
+
pre-rollback callers cannot pass the comparison after an ABA sequence.
|
|
148
|
+
|
|
149
|
+
The filesystem implementation stores an immutable, domain-separated activation
|
|
150
|
+
record for every transition and derives the current release by verifying the
|
|
151
|
+
append-only chain. It has no mutable pointer file or persistent lock to become
|
|
152
|
+
stale after a crash. Activation does not load runtime code, route traffic,
|
|
153
|
+
perform health checks, or authorize callers; those remain provider concerns.
|
|
154
|
+
|
|
68
155
|
The package is ESM-only and requires Node.js 20 or newer.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { type ProtocolDeploymentReleaseEnvelope, type ProtocolDeploymentReleaseTarget } from '@hypequery/protocol';
|
|
2
|
+
export type DeploymentActivationErrorCode = 'HQ_DEPLOYMENT_ACTIVATION_CONFIGURATION' | 'HQ_DEPLOYMENT_ACTIVATION_INVALID_REQUEST' | 'HQ_DEPLOYMENT_ACTIVATION_RELEASE_NOT_FOUND' | 'HQ_DEPLOYMENT_ACTIVATION_RELEASE_UNAVAILABLE' | 'HQ_DEPLOYMENT_ACTIVATION_TARGET_MISMATCH' | 'HQ_DEPLOYMENT_ACTIVATION_CORRUPT_STATE' | 'HQ_DEPLOYMENT_ACTIVATION_IO';
|
|
3
|
+
export declare class DeploymentActivationError extends Error {
|
|
4
|
+
readonly code: DeploymentActivationErrorCode;
|
|
5
|
+
constructor(code: DeploymentActivationErrorCode, message: string, options?: {
|
|
6
|
+
readonly cause?: unknown;
|
|
7
|
+
});
|
|
8
|
+
}
|
|
9
|
+
export interface DeploymentActivationRecord {
|
|
10
|
+
readonly kind: 'hypequery-deployment-activation';
|
|
11
|
+
readonly version: 1;
|
|
12
|
+
readonly revision: string;
|
|
13
|
+
readonly target: ProtocolDeploymentReleaseTarget;
|
|
14
|
+
readonly releaseIdentity: string;
|
|
15
|
+
readonly previousRevision: string | null;
|
|
16
|
+
readonly previousReleaseIdentity: string | null;
|
|
17
|
+
readonly activatedAt: string;
|
|
18
|
+
}
|
|
19
|
+
export interface DeploymentActivationRequest {
|
|
20
|
+
readonly target: ProtocolDeploymentReleaseTarget;
|
|
21
|
+
readonly releaseIdentity: string;
|
|
22
|
+
/** `null` means the caller expects this target to have no active release. */
|
|
23
|
+
readonly expectedRevision: string | null;
|
|
24
|
+
}
|
|
25
|
+
export type DeploymentActivationResult = {
|
|
26
|
+
readonly status: 'activated' | 'already-active';
|
|
27
|
+
readonly activation: DeploymentActivationRecord;
|
|
28
|
+
} | {
|
|
29
|
+
readonly status: 'conflict';
|
|
30
|
+
readonly current: DeploymentActivationRecord | null;
|
|
31
|
+
};
|
|
32
|
+
export interface DeploymentActivationRelease {
|
|
33
|
+
readonly release: ProtocolDeploymentReleaseEnvelope;
|
|
34
|
+
readonly releaseIdentity: string;
|
|
35
|
+
}
|
|
36
|
+
export interface DeploymentReleaseReader {
|
|
37
|
+
/** Return only accepted releases whose release and closed bundle remain completely valid. */
|
|
38
|
+
read(releaseIdentity: string): Promise<DeploymentActivationRelease | undefined>;
|
|
39
|
+
}
|
|
40
|
+
export interface FileSystemDeploymentActivationRegistryOptions {
|
|
41
|
+
readonly directory: string;
|
|
42
|
+
readonly releases: DeploymentReleaseReader;
|
|
43
|
+
readonly clock?: () => Date;
|
|
44
|
+
}
|
|
45
|
+
export interface DeploymentActivationRegistry {
|
|
46
|
+
activate(request: DeploymentActivationRequest): Promise<DeploymentActivationResult>;
|
|
47
|
+
current(target: ProtocolDeploymentReleaseTarget): Promise<DeploymentActivationRecord | undefined>;
|
|
48
|
+
history(target: ProtocolDeploymentReleaseTarget): Promise<readonly DeploymentActivationRecord[]>;
|
|
49
|
+
}
|
|
50
|
+
export declare function createFileSystemDeploymentActivationRegistry(options: FileSystemDeploymentActivationRegistryOptions): DeploymentActivationRegistry;
|
|
51
|
+
//# sourceMappingURL=activation.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"activation.d.ts","sourceRoot":"","sources":["../src/activation.ts"],"names":[],"mappings":"AAYA,OAAO,EAGL,KAAK,iCAAiC,EACtC,KAAK,+BAA+B,EACrC,MAAM,qBAAqB,CAAC;AAe7B,MAAM,MAAM,6BAA6B,GACrC,wCAAwC,GACxC,0CAA0C,GAC1C,4CAA4C,GAC5C,8CAA8C,GAC9C,0CAA0C,GAC1C,wCAAwC,GACxC,6BAA6B,CAAC;AAElC,qBAAa,yBAA0B,SAAQ,KAAK;IAClD,QAAQ,CAAC,IAAI,EAAE,6BAA6B,CAAC;gBAG3C,IAAI,EAAE,6BAA6B,EACnC,OAAO,EAAE,MAAM,EACf,OAAO,GAAE;QAAE,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,CAAA;KAAO;CAM7C;AAED,MAAM,WAAW,0BAA0B;IACzC,QAAQ,CAAC,IAAI,EAAE,iCAAiC,CAAC;IACjD,QAAQ,CAAC,OAAO,EAAE,CAAC,CAAC;IACpB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,MAAM,EAAE,+BAA+B,CAAC;IACjD,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC,QAAQ,CAAC,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;IACzC,QAAQ,CAAC,uBAAuB,EAAE,MAAM,GAAG,IAAI,CAAC;IAChD,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;CAC9B;AAED,MAAM,WAAW,2BAA2B;IAC1C,QAAQ,CAAC,MAAM,EAAE,+BAA+B,CAAC;IACjD,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC,6EAA6E;IAC7E,QAAQ,CAAC,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1C;AAED,MAAM,MAAM,0BAA0B,GAClC;IACA,QAAQ,CAAC,MAAM,EAAE,WAAW,GAAG,gBAAgB,CAAC;IAChD,QAAQ,CAAC,UAAU,EAAE,0BAA0B,CAAC;CACjD,GACC;IACA,QAAQ,CAAC,MAAM,EAAE,UAAU,CAAC;IAC5B,QAAQ,CAAC,OAAO,EAAE,0BAA0B,GAAG,IAAI,CAAC;CACrD,CAAC;AAEJ,MAAM,WAAW,2BAA2B;IAC1C,QAAQ,CAAC,OAAO,EAAE,iCAAiC,CAAC;IACpD,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;CAClC;AAED,MAAM,WAAW,uBAAuB;IACtC,6FAA6F;IAC7F,IAAI,CAAC,eAAe,EAAE,MAAM,GAAG,OAAO,CAAC,2BAA2B,GAAG,SAAS,CAAC,CAAC;CACjF;AAED,MAAM,WAAW,6CAA6C;IAC5D,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,QAAQ,EAAE,uBAAuB,CAAC;IAC3C,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,IAAI,CAAC;CAC7B;AAED,MAAM,WAAW,4BAA4B;IAC3C,QAAQ,CAAC,OAAO,EAAE,2BAA2B,GAAG,OAAO,CAAC,0BAA0B,CAAC,CAAC;IACpF,OAAO,CACL,MAAM,EAAE,+BAA+B,GACtC,OAAO,CAAC,0BAA0B,GAAG,SAAS,CAAC,CAAC;IACnD,OAAO,CACL,MAAM,EAAE,+BAA+B,GACtC,OAAO,CAAC,SAAS,0BAA0B,EAAE,CAAC,CAAC;CACnD;AAqdD,wBAAgB,4CAA4C,CAC1D,OAAO,EAAE,6CAA6C,GACrD,4BAA4B,CAqM9B"}
|
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { lstat, mkdir, mkdtemp, open, readdir, rename, rm, } from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { prepareProtocolDeploymentReleaseEnvelope, validateProtocolDeploymentReleaseTarget, } from '@hypequery/protocol';
|
|
6
|
+
const ACTIVATION_FILE = 'activation.json';
|
|
7
|
+
const CLAIMS_DIRECTORY = 'claims';
|
|
8
|
+
// Each predecessor has one filesystem slot for its successor. Renaming a
|
|
9
|
+
// complete record into that slot is the cross-process compare-and-swap.
|
|
10
|
+
const INITIAL_CLAIM = 'initial';
|
|
11
|
+
const TARGET_FILE = 'target.json';
|
|
12
|
+
const MAX_ACTIVATION_BYTES = 4096;
|
|
13
|
+
const MAX_TARGET_BYTES = 1024;
|
|
14
|
+
const IDENTITY_PATTERN = /^[0-9a-f]{64}$/;
|
|
15
|
+
const ACTIVATION_IDENTITY_DOMAIN = 'hypequery:deployment-activation:v1\0';
|
|
16
|
+
const TARGET_IDENTITY_DOMAIN = 'hypequery:deployment-target:v1\0';
|
|
17
|
+
const textDecoder = new TextDecoder('utf-8', { fatal: true });
|
|
18
|
+
export class DeploymentActivationError extends Error {
|
|
19
|
+
code;
|
|
20
|
+
constructor(code, message, options = {}) {
|
|
21
|
+
super(message, options.cause === undefined ? undefined : { cause: options.cause });
|
|
22
|
+
this.name = 'DeploymentActivationError';
|
|
23
|
+
this.code = code;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function activationError(code, message, cause) {
|
|
27
|
+
return new DeploymentActivationError(code, message, { cause });
|
|
28
|
+
}
|
|
29
|
+
function errorCode(error) {
|
|
30
|
+
return typeof error === 'object' && error !== null && 'code' in error
|
|
31
|
+
? String(error.code)
|
|
32
|
+
: undefined;
|
|
33
|
+
}
|
|
34
|
+
function isOperationalFileSystemError(error) {
|
|
35
|
+
const code = errorCode(error);
|
|
36
|
+
return code !== undefined && !['EISDIR', 'ELOOP', 'ENOENT', 'ENOTDIR'].includes(code);
|
|
37
|
+
}
|
|
38
|
+
function sha256(domain, value) {
|
|
39
|
+
return createHash('sha256').update(domain).update(value).digest('hex');
|
|
40
|
+
}
|
|
41
|
+
function targetCanonical(target) {
|
|
42
|
+
return JSON.stringify({ project: target.project, environment: target.environment });
|
|
43
|
+
}
|
|
44
|
+
function targetsEqual(left, right) {
|
|
45
|
+
return left.project === right.project && left.environment === right.environment;
|
|
46
|
+
}
|
|
47
|
+
function validateTarget(input, code) {
|
|
48
|
+
try {
|
|
49
|
+
return validateProtocolDeploymentReleaseTarget(input);
|
|
50
|
+
}
|
|
51
|
+
catch (error) {
|
|
52
|
+
throw activationError(code, 'Deployment activation target is invalid.', error);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function requireIdentity(input, description, code) {
|
|
56
|
+
if (typeof input !== 'string' || !IDENTITY_PATTERN.test(input)) {
|
|
57
|
+
throw activationError(code, `${description} must be 64 lowercase hexadecimal characters.`);
|
|
58
|
+
}
|
|
59
|
+
return input;
|
|
60
|
+
}
|
|
61
|
+
function requireNullableIdentity(input, description, code) {
|
|
62
|
+
return input === null ? null : requireIdentity(input, description, code);
|
|
63
|
+
}
|
|
64
|
+
function activationPayloadCanonical(input) {
|
|
65
|
+
// Every value has already been reduced to a closed primitive shape, so this
|
|
66
|
+
// fixed construction order is also the canonical byte order persisted below.
|
|
67
|
+
return JSON.stringify({
|
|
68
|
+
kind: 'hypequery-deployment-activation',
|
|
69
|
+
version: 1,
|
|
70
|
+
target: {
|
|
71
|
+
project: input.target.project,
|
|
72
|
+
environment: input.target.environment,
|
|
73
|
+
},
|
|
74
|
+
releaseIdentity: input.releaseIdentity,
|
|
75
|
+
previousRevision: input.previousRevision,
|
|
76
|
+
previousReleaseIdentity: input.previousReleaseIdentity,
|
|
77
|
+
activatedAt: input.activatedAt,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
function activationCanonical(record) {
|
|
81
|
+
return JSON.stringify({
|
|
82
|
+
kind: record.kind,
|
|
83
|
+
version: record.version,
|
|
84
|
+
revision: record.revision,
|
|
85
|
+
target: {
|
|
86
|
+
project: record.target.project,
|
|
87
|
+
environment: record.target.environment,
|
|
88
|
+
},
|
|
89
|
+
releaseIdentity: record.releaseIdentity,
|
|
90
|
+
previousRevision: record.previousRevision,
|
|
91
|
+
previousReleaseIdentity: record.previousReleaseIdentity,
|
|
92
|
+
activatedAt: record.activatedAt,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
function prepareActivation(input) {
|
|
96
|
+
const payload = activationPayloadCanonical(input);
|
|
97
|
+
const revision = sha256(ACTIVATION_IDENTITY_DOMAIN, payload);
|
|
98
|
+
const record = Object.freeze({
|
|
99
|
+
kind: 'hypequery-deployment-activation',
|
|
100
|
+
version: 1,
|
|
101
|
+
revision,
|
|
102
|
+
target: input.target,
|
|
103
|
+
releaseIdentity: input.releaseIdentity,
|
|
104
|
+
previousRevision: input.previousRevision,
|
|
105
|
+
previousReleaseIdentity: input.previousReleaseIdentity,
|
|
106
|
+
activatedAt: input.activatedAt,
|
|
107
|
+
});
|
|
108
|
+
const canonical = activationCanonical(record);
|
|
109
|
+
return Object.freeze({ record, canonical, bytes: Buffer.from(canonical) });
|
|
110
|
+
}
|
|
111
|
+
function requireRecord(input) {
|
|
112
|
+
if (typeof input !== 'object' || input === null || Array.isArray(input)) {
|
|
113
|
+
throw new Error('Activation record must be an object.');
|
|
114
|
+
}
|
|
115
|
+
const value = input;
|
|
116
|
+
const expected = [
|
|
117
|
+
'activatedAt',
|
|
118
|
+
'kind',
|
|
119
|
+
'previousReleaseIdentity',
|
|
120
|
+
'previousRevision',
|
|
121
|
+
'releaseIdentity',
|
|
122
|
+
'revision',
|
|
123
|
+
'target',
|
|
124
|
+
'version',
|
|
125
|
+
];
|
|
126
|
+
if (Object.keys(value).sort().join('\0') !== expected.join('\0')) {
|
|
127
|
+
throw new Error('Activation record fields are inconsistent.');
|
|
128
|
+
}
|
|
129
|
+
return value;
|
|
130
|
+
}
|
|
131
|
+
function validateStoredActivation(input, expectedTarget, expectedPreviousRevision, expectedPreviousReleaseIdentity) {
|
|
132
|
+
const value = requireRecord(input);
|
|
133
|
+
if (value.kind !== 'hypequery-deployment-activation' || value.version !== 1) {
|
|
134
|
+
throw new Error('Activation record kind or version is invalid.');
|
|
135
|
+
}
|
|
136
|
+
const target = validateTarget(value.target, 'HQ_DEPLOYMENT_ACTIVATION_CORRUPT_STATE');
|
|
137
|
+
if (!targetsEqual(target, expectedTarget)) {
|
|
138
|
+
throw new Error('Activation record target is inconsistent.');
|
|
139
|
+
}
|
|
140
|
+
const releaseIdentity = requireIdentity(value.releaseIdentity, 'Stored release identity', 'HQ_DEPLOYMENT_ACTIVATION_CORRUPT_STATE');
|
|
141
|
+
const previousRevision = requireNullableIdentity(value.previousRevision, 'Stored previous revision', 'HQ_DEPLOYMENT_ACTIVATION_CORRUPT_STATE');
|
|
142
|
+
const previousReleaseIdentity = requireNullableIdentity(value.previousReleaseIdentity, 'Stored previous release identity', 'HQ_DEPLOYMENT_ACTIVATION_CORRUPT_STATE');
|
|
143
|
+
if (previousRevision !== expectedPreviousRevision
|
|
144
|
+
|| previousReleaseIdentity !== expectedPreviousReleaseIdentity
|
|
145
|
+
|| (previousRevision === null) !== (previousReleaseIdentity === null)) {
|
|
146
|
+
throw new Error('Activation record predecessor is inconsistent.');
|
|
147
|
+
}
|
|
148
|
+
if (typeof value.activatedAt !== 'string') {
|
|
149
|
+
throw new Error('Activation timestamp is invalid.');
|
|
150
|
+
}
|
|
151
|
+
const timestamp = new Date(value.activatedAt);
|
|
152
|
+
if (!Number.isFinite(timestamp.valueOf()) || timestamp.toISOString() !== value.activatedAt) {
|
|
153
|
+
throw new Error('Activation timestamp is not a canonical ISO timestamp.');
|
|
154
|
+
}
|
|
155
|
+
const prepared = prepareActivation({
|
|
156
|
+
target,
|
|
157
|
+
releaseIdentity,
|
|
158
|
+
previousRevision,
|
|
159
|
+
previousReleaseIdentity,
|
|
160
|
+
activatedAt: value.activatedAt,
|
|
161
|
+
});
|
|
162
|
+
if (value.revision !== prepared.record.revision) {
|
|
163
|
+
throw new Error('Activation revision does not match its contents.');
|
|
164
|
+
}
|
|
165
|
+
return prepared;
|
|
166
|
+
}
|
|
167
|
+
async function pathExists(filePath) {
|
|
168
|
+
try {
|
|
169
|
+
await lstat(filePath);
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
if (errorCode(error) === 'ENOENT')
|
|
174
|
+
return false;
|
|
175
|
+
throw error;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
async function requireRegularDirectory(directory, description) {
|
|
179
|
+
const stat = await lstat(directory);
|
|
180
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
181
|
+
throw activationError('HQ_DEPLOYMENT_ACTIVATION_CORRUPT_STATE', `${description} must be a regular directory: ${directory}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
async function ensureRegularDirectory(directory, description) {
|
|
185
|
+
let created = false;
|
|
186
|
+
try {
|
|
187
|
+
created = await mkdir(directory, { recursive: true, mode: 0o700 }) !== undefined;
|
|
188
|
+
}
|
|
189
|
+
catch (error) {
|
|
190
|
+
if (errorCode(error) !== 'EEXIST')
|
|
191
|
+
throw error;
|
|
192
|
+
}
|
|
193
|
+
await requireRegularDirectory(directory, description);
|
|
194
|
+
return created;
|
|
195
|
+
}
|
|
196
|
+
async function syncDirectory(directory) {
|
|
197
|
+
if (process.platform === 'win32')
|
|
198
|
+
return;
|
|
199
|
+
const handle = await open(directory, constants.O_RDONLY);
|
|
200
|
+
try {
|
|
201
|
+
await handle.sync();
|
|
202
|
+
}
|
|
203
|
+
finally {
|
|
204
|
+
await handle.close();
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
async function writeExclusiveFile(filePath, bytes) {
|
|
208
|
+
const handle = await open(filePath, 'wx', 0o600);
|
|
209
|
+
try {
|
|
210
|
+
let offset = 0;
|
|
211
|
+
while (offset < bytes.byteLength) {
|
|
212
|
+
const result = await handle.write(bytes, offset, bytes.byteLength - offset);
|
|
213
|
+
if (result.bytesWritten < 1)
|
|
214
|
+
throw new Error('Filesystem write made no progress.');
|
|
215
|
+
offset += result.bytesWritten;
|
|
216
|
+
}
|
|
217
|
+
await handle.sync();
|
|
218
|
+
}
|
|
219
|
+
finally {
|
|
220
|
+
await handle.close();
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
async function readRegularFile(filePath, maximumBytes) {
|
|
224
|
+
const initial = await lstat(filePath);
|
|
225
|
+
if (initial.isSymbolicLink() || !initial.isFile()
|
|
226
|
+
|| initial.size < 1 || initial.size > maximumBytes) {
|
|
227
|
+
throw new Error(`Stored entry is not a bounded regular file: ${filePath}`);
|
|
228
|
+
}
|
|
229
|
+
const handle = await open(filePath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
230
|
+
try {
|
|
231
|
+
const stat = await handle.stat();
|
|
232
|
+
if (!stat.isFile() || stat.size < 1 || stat.size > maximumBytes) {
|
|
233
|
+
throw new Error(`Stored entry is not a bounded regular file: ${filePath}`);
|
|
234
|
+
}
|
|
235
|
+
return await handle.readFile();
|
|
236
|
+
}
|
|
237
|
+
finally {
|
|
238
|
+
await handle.close();
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
async function withStagingDirectory(root, prefix, action) {
|
|
242
|
+
const staging = await mkdtemp(path.join(root, prefix));
|
|
243
|
+
let result;
|
|
244
|
+
let failure;
|
|
245
|
+
try {
|
|
246
|
+
result = await action(staging);
|
|
247
|
+
}
|
|
248
|
+
catch (error) {
|
|
249
|
+
failure = { error };
|
|
250
|
+
}
|
|
251
|
+
try {
|
|
252
|
+
await rm(staging, { force: true, recursive: true });
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
throw activationError('HQ_DEPLOYMENT_ACTIVATION_IO', `Could not clean deployment activation staging directory: ${staging}`, failure ? new AggregateError([failure.error, error]) : error);
|
|
256
|
+
}
|
|
257
|
+
if (failure)
|
|
258
|
+
throw failure.error;
|
|
259
|
+
return result;
|
|
260
|
+
}
|
|
261
|
+
async function readCanonicalTarget(targetDirectory, expectedTarget) {
|
|
262
|
+
try {
|
|
263
|
+
await requireRegularDirectory(targetDirectory, 'Deployment activation target');
|
|
264
|
+
const entries = (await readdir(targetDirectory)).sort();
|
|
265
|
+
if (entries.join('\0') !== `${CLAIMS_DIRECTORY}\0${TARGET_FILE}`) {
|
|
266
|
+
throw new Error('Deployment activation target contains undeclared or missing entries.');
|
|
267
|
+
}
|
|
268
|
+
await requireRegularDirectory(path.join(targetDirectory, CLAIMS_DIRECTORY), 'Deployment activation claims');
|
|
269
|
+
const bytes = await readRegularFile(path.join(targetDirectory, TARGET_FILE), MAX_TARGET_BYTES);
|
|
270
|
+
let input;
|
|
271
|
+
try {
|
|
272
|
+
input = JSON.parse(textDecoder.decode(bytes));
|
|
273
|
+
}
|
|
274
|
+
catch (error) {
|
|
275
|
+
throw new Error('Stored deployment activation target is invalid JSON.', { cause: error });
|
|
276
|
+
}
|
|
277
|
+
const target = validateTarget(input, 'HQ_DEPLOYMENT_ACTIVATION_CORRUPT_STATE');
|
|
278
|
+
if (!targetsEqual(target, expectedTarget)
|
|
279
|
+
|| !Buffer.from(bytes).equals(Buffer.from(targetCanonical(target)))) {
|
|
280
|
+
throw new Error('Stored deployment activation target is inconsistent.');
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
catch (error) {
|
|
284
|
+
if (error instanceof DeploymentActivationError)
|
|
285
|
+
throw error;
|
|
286
|
+
if (isOperationalFileSystemError(error))
|
|
287
|
+
throw error;
|
|
288
|
+
throw activationError('HQ_DEPLOYMENT_ACTIVATION_CORRUPT_STATE', 'Stored deployment activation target is invalid.', error);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
async function ensureTargetDirectory(activationsRoot, target) {
|
|
292
|
+
const canonical = targetCanonical(target);
|
|
293
|
+
const targetKey = sha256(TARGET_IDENTITY_DOMAIN, canonical);
|
|
294
|
+
const destination = path.join(activationsRoot, targetKey);
|
|
295
|
+
if (await pathExists(destination)) {
|
|
296
|
+
await readCanonicalTarget(destination, target);
|
|
297
|
+
return destination;
|
|
298
|
+
}
|
|
299
|
+
await withStagingDirectory(activationsRoot, '.target-staging-', async (staging) => {
|
|
300
|
+
await mkdir(path.join(staging, CLAIMS_DIRECTORY), { mode: 0o700 });
|
|
301
|
+
await writeExclusiveFile(path.join(staging, TARGET_FILE), Buffer.from(canonical));
|
|
302
|
+
await syncDirectory(path.join(staging, CLAIMS_DIRECTORY));
|
|
303
|
+
await syncDirectory(staging);
|
|
304
|
+
try {
|
|
305
|
+
await rename(staging, destination);
|
|
306
|
+
}
|
|
307
|
+
catch (error) {
|
|
308
|
+
if (!await pathExists(destination))
|
|
309
|
+
throw error;
|
|
310
|
+
await readCanonicalTarget(destination, target);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
await syncDirectory(activationsRoot);
|
|
314
|
+
});
|
|
315
|
+
await readCanonicalTarget(destination, target);
|
|
316
|
+
return destination;
|
|
317
|
+
}
|
|
318
|
+
async function readStoredActivation(claimDirectory, target, previousRevision, previousReleaseIdentity) {
|
|
319
|
+
await requireRegularDirectory(claimDirectory, 'Deployment activation claim');
|
|
320
|
+
const entries = await readdir(claimDirectory);
|
|
321
|
+
if (entries.length !== 1 || entries[0] !== ACTIVATION_FILE) {
|
|
322
|
+
throw new Error('Deployment activation claim contains undeclared or missing entries.');
|
|
323
|
+
}
|
|
324
|
+
const bytes = await readRegularFile(path.join(claimDirectory, ACTIVATION_FILE), MAX_ACTIVATION_BYTES);
|
|
325
|
+
let input;
|
|
326
|
+
try {
|
|
327
|
+
input = JSON.parse(textDecoder.decode(bytes));
|
|
328
|
+
}
|
|
329
|
+
catch (error) {
|
|
330
|
+
throw new Error('Stored deployment activation is invalid JSON.', { cause: error });
|
|
331
|
+
}
|
|
332
|
+
const prepared = validateStoredActivation(input, target, previousRevision, previousReleaseIdentity);
|
|
333
|
+
if (!Buffer.from(bytes).equals(Buffer.from(prepared.bytes))) {
|
|
334
|
+
throw new Error('Stored deployment activation is not canonical JSON.');
|
|
335
|
+
}
|
|
336
|
+
return prepared;
|
|
337
|
+
}
|
|
338
|
+
async function resolveHistory(targetDirectory, target) {
|
|
339
|
+
try {
|
|
340
|
+
await readCanonicalTarget(targetDirectory, target);
|
|
341
|
+
const claimsDirectory = path.join(targetDirectory, CLAIMS_DIRECTORY);
|
|
342
|
+
const claimNames = (await readdir(claimsDirectory)).sort();
|
|
343
|
+
for (const name of claimNames) {
|
|
344
|
+
if (name !== INITIAL_CLAIM && !IDENTITY_PATTERN.test(name)) {
|
|
345
|
+
throw new Error(`Deployment activation claim name is invalid: ${name}`);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
const remaining = new Set(claimNames);
|
|
349
|
+
const revisions = new Set();
|
|
350
|
+
const history = [];
|
|
351
|
+
let previousRevision = null;
|
|
352
|
+
let previousReleaseIdentity = null;
|
|
353
|
+
// Following predecessor-named claims derives the head without trusting a
|
|
354
|
+
// mutable pointer. The remaining-set check rejects orphaned branches.
|
|
355
|
+
for (;;) {
|
|
356
|
+
const claimName = previousRevision ?? INITIAL_CLAIM;
|
|
357
|
+
if (!remaining.delete(claimName))
|
|
358
|
+
break;
|
|
359
|
+
const prepared = await readStoredActivation(path.join(claimsDirectory, claimName), target, previousRevision, previousReleaseIdentity);
|
|
360
|
+
if (revisions.has(prepared.record.revision)) {
|
|
361
|
+
throw new Error('Deployment activation history contains a cycle.');
|
|
362
|
+
}
|
|
363
|
+
revisions.add(prepared.record.revision);
|
|
364
|
+
history.push(prepared.record);
|
|
365
|
+
previousRevision = prepared.record.revision;
|
|
366
|
+
previousReleaseIdentity = prepared.record.releaseIdentity;
|
|
367
|
+
}
|
|
368
|
+
if (remaining.size > 0) {
|
|
369
|
+
throw new Error('Deployment activation history contains unreachable claims.');
|
|
370
|
+
}
|
|
371
|
+
return Object.freeze(history);
|
|
372
|
+
}
|
|
373
|
+
catch (error) {
|
|
374
|
+
if (error instanceof DeploymentActivationError)
|
|
375
|
+
throw error;
|
|
376
|
+
if (isOperationalFileSystemError(error))
|
|
377
|
+
throw error;
|
|
378
|
+
throw activationError('HQ_DEPLOYMENT_ACTIVATION_CORRUPT_STATE', 'Stored deployment activation history is invalid.', error);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
function currentActivation(history) {
|
|
382
|
+
return history[history.length - 1];
|
|
383
|
+
}
|
|
384
|
+
export function createFileSystemDeploymentActivationRegistry(options) {
|
|
385
|
+
if (typeof options.directory !== 'string' || options.directory.length < 1) {
|
|
386
|
+
throw activationError('HQ_DEPLOYMENT_ACTIVATION_CONFIGURATION', 'Deployment activation directory is required.');
|
|
387
|
+
}
|
|
388
|
+
if (typeof options.releases?.read !== 'function') {
|
|
389
|
+
throw activationError('HQ_DEPLOYMENT_ACTIVATION_CONFIGURATION', 'A deployment release reader is required.');
|
|
390
|
+
}
|
|
391
|
+
const root = path.resolve(options.directory);
|
|
392
|
+
if (root === path.parse(root).root) {
|
|
393
|
+
throw activationError('HQ_DEPLOYMENT_ACTIVATION_CONFIGURATION', 'Deployment activation directory cannot be a filesystem root.');
|
|
394
|
+
}
|
|
395
|
+
const clock = options.clock ?? (() => new Date());
|
|
396
|
+
const activations = path.join(root, 'activations');
|
|
397
|
+
let initializationPromise;
|
|
398
|
+
async function initializeActivationRoot() {
|
|
399
|
+
const rootCreated = await ensureRegularDirectory(root, 'Deployment store root');
|
|
400
|
+
const activationsCreated = await ensureRegularDirectory(activations, 'Deployment activation store');
|
|
401
|
+
if (activationsCreated)
|
|
402
|
+
await syncDirectory(root);
|
|
403
|
+
if (rootCreated)
|
|
404
|
+
await syncDirectory(path.dirname(root));
|
|
405
|
+
}
|
|
406
|
+
async function activationRoot() {
|
|
407
|
+
const pending = initializationPromise ??= initializeActivationRoot();
|
|
408
|
+
try {
|
|
409
|
+
await pending;
|
|
410
|
+
}
|
|
411
|
+
catch (error) {
|
|
412
|
+
if (initializationPromise === pending)
|
|
413
|
+
initializationPromise = undefined;
|
|
414
|
+
throw error;
|
|
415
|
+
}
|
|
416
|
+
await requireRegularDirectory(root, 'Deployment store root');
|
|
417
|
+
await requireRegularDirectory(activations, 'Deployment activation store');
|
|
418
|
+
return activations;
|
|
419
|
+
}
|
|
420
|
+
async function history(targetInput) {
|
|
421
|
+
const target = validateTarget(targetInput, 'HQ_DEPLOYMENT_ACTIVATION_INVALID_REQUEST');
|
|
422
|
+
try {
|
|
423
|
+
const activations = await activationRoot();
|
|
424
|
+
const key = sha256(TARGET_IDENTITY_DOMAIN, targetCanonical(target));
|
|
425
|
+
const targetDirectory = path.join(activations, key);
|
|
426
|
+
if (!await pathExists(targetDirectory))
|
|
427
|
+
return Object.freeze([]);
|
|
428
|
+
return await resolveHistory(targetDirectory, target);
|
|
429
|
+
}
|
|
430
|
+
catch (error) {
|
|
431
|
+
if (error instanceof DeploymentActivationError)
|
|
432
|
+
throw error;
|
|
433
|
+
throw activationError('HQ_DEPLOYMENT_ACTIVATION_IO', 'Could not read deployment activation history.', error);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
async function current(target) {
|
|
437
|
+
return currentActivation(await history(target));
|
|
438
|
+
}
|
|
439
|
+
async function activate(request) {
|
|
440
|
+
const target = validateTarget(request.target, 'HQ_DEPLOYMENT_ACTIVATION_INVALID_REQUEST');
|
|
441
|
+
const releaseIdentity = requireIdentity(request.releaseIdentity, 'Deployment release identity', 'HQ_DEPLOYMENT_ACTIVATION_INVALID_REQUEST');
|
|
442
|
+
const expectedRevision = requireNullableIdentity(request.expectedRevision, 'Expected deployment activation revision', 'HQ_DEPLOYMENT_ACTIVATION_INVALID_REQUEST');
|
|
443
|
+
let storedRelease;
|
|
444
|
+
try {
|
|
445
|
+
storedRelease = await options.releases.read(releaseIdentity);
|
|
446
|
+
}
|
|
447
|
+
catch (error) {
|
|
448
|
+
throw activationError('HQ_DEPLOYMENT_ACTIVATION_RELEASE_UNAVAILABLE', `Could not verify deployment release before activation: ${releaseIdentity}`, error);
|
|
449
|
+
}
|
|
450
|
+
if (!storedRelease) {
|
|
451
|
+
throw activationError('HQ_DEPLOYMENT_ACTIVATION_RELEASE_NOT_FOUND', `Deployment release is not stored: ${releaseIdentity}`);
|
|
452
|
+
}
|
|
453
|
+
let verifiedRelease;
|
|
454
|
+
try {
|
|
455
|
+
verifiedRelease = prepareProtocolDeploymentReleaseEnvelope(storedRelease.release);
|
|
456
|
+
}
|
|
457
|
+
catch (error) {
|
|
458
|
+
throw activationError('HQ_DEPLOYMENT_ACTIVATION_RELEASE_UNAVAILABLE', `Stored deployment release is invalid: ${releaseIdentity}`, error);
|
|
459
|
+
}
|
|
460
|
+
if (storedRelease.releaseIdentity !== releaseIdentity
|
|
461
|
+
|| verifiedRelease.identity !== releaseIdentity) {
|
|
462
|
+
throw activationError('HQ_DEPLOYMENT_ACTIVATION_RELEASE_UNAVAILABLE', `Stored deployment release identity is inconsistent: ${releaseIdentity}`);
|
|
463
|
+
}
|
|
464
|
+
if (!targetsEqual(verifiedRelease.release.target, target)) {
|
|
465
|
+
throw activationError('HQ_DEPLOYMENT_ACTIVATION_TARGET_MISMATCH', 'Deployment release target does not match the activation target.');
|
|
466
|
+
}
|
|
467
|
+
try {
|
|
468
|
+
const activations = await activationRoot();
|
|
469
|
+
const targetDirectory = await ensureTargetDirectory(activations, target);
|
|
470
|
+
const existingHistory = await resolveHistory(targetDirectory, target);
|
|
471
|
+
const existing = currentActivation(existingHistory);
|
|
472
|
+
if (existing?.releaseIdentity === releaseIdentity) {
|
|
473
|
+
return Object.freeze({ status: 'already-active', activation: existing });
|
|
474
|
+
}
|
|
475
|
+
if ((existing?.revision ?? null) !== expectedRevision) {
|
|
476
|
+
return Object.freeze({ status: 'conflict', current: existing ?? null });
|
|
477
|
+
}
|
|
478
|
+
let now;
|
|
479
|
+
try {
|
|
480
|
+
now = clock();
|
|
481
|
+
if (!(now instanceof Date) || !Number.isFinite(now.valueOf()))
|
|
482
|
+
throw new Error('Invalid date.');
|
|
483
|
+
}
|
|
484
|
+
catch (error) {
|
|
485
|
+
throw activationError('HQ_DEPLOYMENT_ACTIVATION_CONFIGURATION', 'Deployment activation clock returned an invalid value.', error);
|
|
486
|
+
}
|
|
487
|
+
const prepared = prepareActivation({
|
|
488
|
+
target,
|
|
489
|
+
releaseIdentity,
|
|
490
|
+
previousRevision: existing?.revision ?? null,
|
|
491
|
+
previousReleaseIdentity: existing?.releaseIdentity ?? null,
|
|
492
|
+
activatedAt: now.toISOString(),
|
|
493
|
+
});
|
|
494
|
+
const claimsDirectory = path.join(targetDirectory, CLAIMS_DIRECTORY);
|
|
495
|
+
const claimName = existing?.revision ?? INITIAL_CLAIM;
|
|
496
|
+
const destination = path.join(claimsDirectory, claimName);
|
|
497
|
+
const published = await withStagingDirectory(
|
|
498
|
+
// Staging lives outside the exact target directory, so interrupted
|
|
499
|
+
// writers are invisible to history readers until the atomic rename.
|
|
500
|
+
activations, '.activation-staging-', async (staging) => {
|
|
501
|
+
await writeExclusiveFile(path.join(staging, ACTIVATION_FILE), prepared.bytes);
|
|
502
|
+
await syncDirectory(staging);
|
|
503
|
+
try {
|
|
504
|
+
await rename(staging, destination);
|
|
505
|
+
}
|
|
506
|
+
catch (error) {
|
|
507
|
+
if (!await pathExists(destination))
|
|
508
|
+
throw error;
|
|
509
|
+
return false;
|
|
510
|
+
}
|
|
511
|
+
await syncDirectory(claimsDirectory);
|
|
512
|
+
return true;
|
|
513
|
+
});
|
|
514
|
+
if (published) {
|
|
515
|
+
return Object.freeze({ status: 'activated', activation: prepared.record });
|
|
516
|
+
}
|
|
517
|
+
const resolved = currentActivation(await resolveHistory(targetDirectory, target));
|
|
518
|
+
if (resolved?.releaseIdentity === releaseIdentity) {
|
|
519
|
+
return Object.freeze({ status: 'already-active', activation: resolved });
|
|
520
|
+
}
|
|
521
|
+
return Object.freeze({ status: 'conflict', current: resolved ?? null });
|
|
522
|
+
}
|
|
523
|
+
catch (error) {
|
|
524
|
+
if (error instanceof DeploymentActivationError)
|
|
525
|
+
throw error;
|
|
526
|
+
throw activationError('HQ_DEPLOYMENT_ACTIVATION_IO', 'Could not update deployment activation state.', error);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
return Object.freeze({ activate, current, history });
|
|
530
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type ProtocolDeploymentReleaseEnvelope } from '@hypequery/protocol';
|
|
2
|
+
import { type VerifiedDeploymentBundle } from './bundle.js';
|
|
3
|
+
import type { DeploymentSubmissionStore } from './types.js';
|
|
4
|
+
export type FileSystemDeploymentStoreErrorCode = 'HQ_DEPLOYMENT_STORE_CONFIGURATION' | 'HQ_DEPLOYMENT_STORE_INVALID_SUBMISSION' | 'HQ_DEPLOYMENT_STORE_CORRUPT_STATE' | 'HQ_DEPLOYMENT_STORE_IO';
|
|
5
|
+
export declare class FileSystemDeploymentStoreError extends Error {
|
|
6
|
+
readonly code: FileSystemDeploymentStoreErrorCode;
|
|
7
|
+
constructor(code: FileSystemDeploymentStoreErrorCode, message: string, options?: {
|
|
8
|
+
readonly cause?: unknown;
|
|
9
|
+
});
|
|
10
|
+
}
|
|
11
|
+
export interface FileSystemDeploymentSubmissionStoreOptions {
|
|
12
|
+
readonly directory: string;
|
|
13
|
+
}
|
|
14
|
+
export interface StoredDeploymentSubmission {
|
|
15
|
+
readonly releaseDirectory: string;
|
|
16
|
+
readonly release: ProtocolDeploymentReleaseEnvelope;
|
|
17
|
+
readonly releaseCanonical: string;
|
|
18
|
+
readonly releaseIdentity: string;
|
|
19
|
+
readonly bundle: VerifiedDeploymentBundle;
|
|
20
|
+
}
|
|
21
|
+
export interface FileSystemDeploymentSubmissionStore<Principal> extends DeploymentSubmissionStore<Principal> {
|
|
22
|
+
read(releaseIdentity: string): Promise<StoredDeploymentSubmission | undefined>;
|
|
23
|
+
}
|
|
24
|
+
export declare function createFileSystemDeploymentSubmissionStore<Principal = unknown>(options: FileSystemDeploymentSubmissionStoreOptions): FileSystemDeploymentSubmissionStore<Principal>;
|
|
25
|
+
//# sourceMappingURL=filesystem-store.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"filesystem-store.d.ts","sourceRoot":"","sources":["../src/filesystem-store.ts"],"names":[],"mappings":"AAWA,OAAO,EAEL,KAAK,iCAAiC,EACvC,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EAGL,KAAK,wBAAwB,EAC9B,MAAM,aAAa,CAAC;AACrB,OAAO,KAAK,EACV,yBAAyB,EAE1B,MAAM,YAAY,CAAC;AASpB,MAAM,MAAM,kCAAkC,GAC1C,mCAAmC,GACnC,wCAAwC,GACxC,mCAAmC,GACnC,wBAAwB,CAAC;AAE7B,qBAAa,8BAA+B,SAAQ,KAAK;IACvD,QAAQ,CAAC,IAAI,EAAE,kCAAkC,CAAC;gBAGhD,IAAI,EAAE,kCAAkC,EACxC,OAAO,EAAE,MAAM,EACf,OAAO,GAAE;QAAE,QAAQ,CAAC,KAAK,CAAC,EAAE,OAAO,CAAA;KAAO;CAM7C;AAED,MAAM,WAAW,0CAA0C;IACzD,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;CAC5B;AAED,MAAM,WAAW,0BAA0B;IACzC,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAC;IAClC,QAAQ,CAAC,OAAO,EAAE,iCAAiC,CAAC;IACpD,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAC;IAClC,QAAQ,CAAC,eAAe,EAAE,MAAM,CAAC;IACjC,QAAQ,CAAC,MAAM,EAAE,wBAAwB,CAAC;CAC3C;AAED,MAAM,WAAW,mCAAmC,CAAC,SAAS,CAC5D,SAAQ,yBAAyB,CAAC,SAAS,CAAC;IAC5C,IAAI,CAAC,eAAe,EAAE,MAAM,GAAG,OAAO,CAAC,0BAA0B,GAAG,SAAS,CAAC,CAAC;CAChF;AAobD,wBAAgB,yCAAyC,CAAC,SAAS,GAAG,OAAO,EAC3E,OAAO,EAAE,0CAA0C,GAClD,mCAAmC,CAAC,SAAS,CAAC,CAkHhD"}
|
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
import { constants } from 'node:fs';
|
|
2
|
+
import { lstat, mkdir, mkdtemp, open, readdir, rename, rm, } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { prepareProtocolDeploymentReleaseEnvelope, } from '@hypequery/protocol';
|
|
5
|
+
import { DEPLOYMENT_BUNDLE_MANIFEST, verifyDeploymentBundle, } from './bundle.js';
|
|
6
|
+
const RELEASE_FILE = 'release.json';
|
|
7
|
+
const MAX_MANIFEST_BYTES = 1024 * 1024;
|
|
8
|
+
const MAX_RELEASE_BYTES = 16 * 1024;
|
|
9
|
+
const COPY_BUFFER_BYTES = 64 * 1024;
|
|
10
|
+
const IDENTITY_PATTERN = /^[0-9a-f]{64}$/;
|
|
11
|
+
const textDecoder = new TextDecoder('utf-8', { fatal: true });
|
|
12
|
+
export class FileSystemDeploymentStoreError extends Error {
|
|
13
|
+
code;
|
|
14
|
+
constructor(code, message, options = {}) {
|
|
15
|
+
super(message, options.cause === undefined ? undefined : { cause: options.cause });
|
|
16
|
+
this.name = 'FileSystemDeploymentStoreError';
|
|
17
|
+
this.code = code;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function storeError(code, message, cause) {
|
|
21
|
+
return new FileSystemDeploymentStoreError(code, message, { cause });
|
|
22
|
+
}
|
|
23
|
+
function errorCode(error) {
|
|
24
|
+
return typeof error === 'object' && error !== null && 'code' in error
|
|
25
|
+
? String(error.code)
|
|
26
|
+
: undefined;
|
|
27
|
+
}
|
|
28
|
+
async function pathExists(filePath) {
|
|
29
|
+
try {
|
|
30
|
+
await lstat(filePath);
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
if (errorCode(error) === 'ENOENT')
|
|
35
|
+
return false;
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
async function requireRegularDirectory(directory, description, code = 'HQ_DEPLOYMENT_STORE_CORRUPT_STATE') {
|
|
40
|
+
const stat = await lstat(directory);
|
|
41
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
42
|
+
throw storeError(code, `${description} must be a regular directory: ${directory}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
async function ensureRegularStoreDirectory(directory, description) {
|
|
46
|
+
let created = false;
|
|
47
|
+
try {
|
|
48
|
+
created = await mkdir(directory, { recursive: true, mode: 0o700 }) !== undefined;
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
if (errorCode(error) !== 'EEXIST')
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
await requireRegularDirectory(directory, description);
|
|
55
|
+
return created;
|
|
56
|
+
}
|
|
57
|
+
async function ensureStoreDirectories(root) {
|
|
58
|
+
const rootCreated = await ensureRegularStoreDirectory(root, 'Deployment store root');
|
|
59
|
+
const bundles = path.join(root, 'bundles');
|
|
60
|
+
const releases = path.join(root, 'releases');
|
|
61
|
+
const bundlesCreated = await ensureRegularStoreDirectory(bundles, 'Deployment bundle store');
|
|
62
|
+
const releasesCreated = await ensureRegularStoreDirectory(releases, 'Deployment release store');
|
|
63
|
+
if (bundlesCreated || releasesCreated)
|
|
64
|
+
await syncDirectory(root);
|
|
65
|
+
if (rootCreated)
|
|
66
|
+
await syncDirectory(path.dirname(root));
|
|
67
|
+
return Object.freeze({ bundles, releases });
|
|
68
|
+
}
|
|
69
|
+
async function syncDirectory(directory) {
|
|
70
|
+
if (process.platform === 'win32')
|
|
71
|
+
return;
|
|
72
|
+
const handle = await open(directory, constants.O_RDONLY);
|
|
73
|
+
try {
|
|
74
|
+
await handle.sync();
|
|
75
|
+
}
|
|
76
|
+
finally {
|
|
77
|
+
await handle.close();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
async function writeAll(handle, bytes) {
|
|
81
|
+
let offset = 0;
|
|
82
|
+
while (offset < bytes.byteLength) {
|
|
83
|
+
const result = await handle.write(bytes, offset, bytes.byteLength - offset);
|
|
84
|
+
if (result.bytesWritten < 1)
|
|
85
|
+
throw new Error('Filesystem write made no progress.');
|
|
86
|
+
offset += result.bytesWritten;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
async function writeExclusiveFile(filePath, bytes) {
|
|
90
|
+
const handle = await open(filePath, 'wx', 0o600);
|
|
91
|
+
try {
|
|
92
|
+
await writeAll(handle, bytes);
|
|
93
|
+
await handle.sync();
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
await handle.close();
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
async function copyRegularFile(sourcePath, destinationPath, maximumBytes, expectedBytes) {
|
|
100
|
+
const initial = await lstat(sourcePath);
|
|
101
|
+
if (initial.isSymbolicLink() || !initial.isFile()) {
|
|
102
|
+
throw storeError('HQ_DEPLOYMENT_STORE_INVALID_SUBMISSION', `Verified bundle entry is no longer a regular file: ${sourcePath}`);
|
|
103
|
+
}
|
|
104
|
+
if (initial.size < 1 || initial.size > maximumBytes
|
|
105
|
+
|| (expectedBytes !== undefined && initial.size !== expectedBytes)) {
|
|
106
|
+
throw storeError('HQ_DEPLOYMENT_STORE_INVALID_SUBMISSION', `Verified bundle entry changed before persistence: ${sourcePath}`);
|
|
107
|
+
}
|
|
108
|
+
let source;
|
|
109
|
+
let destination;
|
|
110
|
+
try {
|
|
111
|
+
source = await open(sourcePath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
112
|
+
const stat = await source.stat();
|
|
113
|
+
if (!stat.isFile() || stat.size < 1 || stat.size > maximumBytes
|
|
114
|
+
|| (expectedBytes !== undefined && stat.size !== expectedBytes)) {
|
|
115
|
+
throw storeError('HQ_DEPLOYMENT_STORE_INVALID_SUBMISSION', `Verified bundle entry changed before persistence: ${sourcePath}`);
|
|
116
|
+
}
|
|
117
|
+
destination = await open(destinationPath, 'wx', 0o600);
|
|
118
|
+
const buffer = Buffer.allocUnsafe(COPY_BUFFER_BYTES);
|
|
119
|
+
let total = 0;
|
|
120
|
+
for (;;) {
|
|
121
|
+
const { bytesRead } = await source.read(buffer, 0, buffer.byteLength, null);
|
|
122
|
+
if (bytesRead === 0)
|
|
123
|
+
break;
|
|
124
|
+
total += bytesRead;
|
|
125
|
+
if (total > maximumBytes || (expectedBytes !== undefined && total > expectedBytes)) {
|
|
126
|
+
throw storeError('HQ_DEPLOYMENT_STORE_INVALID_SUBMISSION', `Verified bundle entry changed before persistence: ${sourcePath}`);
|
|
127
|
+
}
|
|
128
|
+
await writeAll(destination, buffer.subarray(0, bytesRead));
|
|
129
|
+
}
|
|
130
|
+
if (expectedBytes !== undefined && total !== expectedBytes) {
|
|
131
|
+
throw storeError('HQ_DEPLOYMENT_STORE_INVALID_SUBMISSION', `Verified bundle entry changed before persistence: ${sourcePath}`);
|
|
132
|
+
}
|
|
133
|
+
await destination.sync();
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
if (errorCode(error) === 'ELOOP') {
|
|
137
|
+
throw storeError('HQ_DEPLOYMENT_STORE_INVALID_SUBMISSION', `Verified bundle entry became a symbolic link: ${sourcePath}`, error);
|
|
138
|
+
}
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
finally {
|
|
142
|
+
try {
|
|
143
|
+
await destination?.close();
|
|
144
|
+
}
|
|
145
|
+
finally {
|
|
146
|
+
await source?.close();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
function bundleFilePaths(bundle) {
|
|
151
|
+
return Object.freeze([
|
|
152
|
+
{ path: DEPLOYMENT_BUNDLE_MANIFEST },
|
|
153
|
+
{
|
|
154
|
+
path: bundle.manifest.deployment.path,
|
|
155
|
+
byteLength: bundle.manifest.deployment.byteLength,
|
|
156
|
+
},
|
|
157
|
+
...bundle.manifest.artifacts.map(artifact => ({
|
|
158
|
+
path: artifact.path,
|
|
159
|
+
byteLength: artifact.byteLength,
|
|
160
|
+
})),
|
|
161
|
+
]);
|
|
162
|
+
}
|
|
163
|
+
async function syncBundleDirectories(bundleRoot, files) {
|
|
164
|
+
const directories = new Set([bundleRoot]);
|
|
165
|
+
for (const file of files) {
|
|
166
|
+
const segments = file.path.split('/');
|
|
167
|
+
for (let index = 1; index < segments.length; index += 1) {
|
|
168
|
+
directories.add(path.join(bundleRoot, ...segments.slice(0, index)));
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const ordered = [...directories].sort((left, right) => (right.split(path.sep).length - left.split(path.sep).length));
|
|
172
|
+
for (const directory of ordered)
|
|
173
|
+
await syncDirectory(directory);
|
|
174
|
+
}
|
|
175
|
+
async function copyBundleToStaging(bundle, staging) {
|
|
176
|
+
await requireRegularDirectory(bundle.directory, 'Verified deployment bundle', 'HQ_DEPLOYMENT_STORE_INVALID_SUBMISSION');
|
|
177
|
+
const files = bundleFilePaths(bundle);
|
|
178
|
+
for (const file of files) {
|
|
179
|
+
const destination = path.join(staging, ...file.path.split('/'));
|
|
180
|
+
await mkdir(path.dirname(destination), { recursive: true, mode: 0o700 });
|
|
181
|
+
await copyRegularFile(path.join(bundle.directory, ...file.path.split('/')), destination, file.byteLength ?? MAX_MANIFEST_BYTES, file.byteLength);
|
|
182
|
+
}
|
|
183
|
+
await syncBundleDirectories(staging, files);
|
|
184
|
+
let verified;
|
|
185
|
+
try {
|
|
186
|
+
verified = await verifyDeploymentBundle(staging);
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
throw storeError('HQ_DEPLOYMENT_STORE_INVALID_SUBMISSION', 'Deployment bundle changed before it could be persisted.', error);
|
|
190
|
+
}
|
|
191
|
+
if (verified.identity !== bundle.identity) {
|
|
192
|
+
throw storeError('HQ_DEPLOYMENT_STORE_INVALID_SUBMISSION', 'Persisted bundle identity does not match the verified submission.');
|
|
193
|
+
}
|
|
194
|
+
return verified;
|
|
195
|
+
}
|
|
196
|
+
async function readRegularFile(filePath, maximumBytes) {
|
|
197
|
+
const initial = await lstat(filePath);
|
|
198
|
+
if (initial.isSymbolicLink() || !initial.isFile()
|
|
199
|
+
|| initial.size < 1 || initial.size > maximumBytes) {
|
|
200
|
+
throw new Error(`Stored entry is not a bounded regular file: ${filePath}`);
|
|
201
|
+
}
|
|
202
|
+
const handle = await open(filePath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
203
|
+
try {
|
|
204
|
+
const stat = await handle.stat();
|
|
205
|
+
if (!stat.isFile() || stat.size < 1 || stat.size > maximumBytes) {
|
|
206
|
+
throw new Error(`Stored entry is not a bounded regular file: ${filePath}`);
|
|
207
|
+
}
|
|
208
|
+
const buffer = Buffer.allocUnsafe(Math.min(COPY_BUFFER_BYTES, maximumBytes + 1));
|
|
209
|
+
const chunks = [];
|
|
210
|
+
let total = 0;
|
|
211
|
+
while (total <= maximumBytes) {
|
|
212
|
+
const remaining = maximumBytes + 1 - total;
|
|
213
|
+
const { bytesRead } = await handle.read(buffer, 0, Math.min(buffer.byteLength, remaining), null);
|
|
214
|
+
if (bytesRead === 0)
|
|
215
|
+
break;
|
|
216
|
+
total += bytesRead;
|
|
217
|
+
if (total > maximumBytes) {
|
|
218
|
+
throw new Error(`Stored entry is not a bounded regular file: ${filePath}`);
|
|
219
|
+
}
|
|
220
|
+
chunks.push(Buffer.from(buffer.subarray(0, bytesRead)));
|
|
221
|
+
}
|
|
222
|
+
if (total < 1) {
|
|
223
|
+
throw new Error(`Stored entry is not a bounded regular file: ${filePath}`);
|
|
224
|
+
}
|
|
225
|
+
return Buffer.concat(chunks, total);
|
|
226
|
+
}
|
|
227
|
+
finally {
|
|
228
|
+
await handle.close();
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
async function readStoredRelease(releaseDirectory, expectedIdentity) {
|
|
232
|
+
await requireRegularDirectory(releaseDirectory, 'Stored deployment release');
|
|
233
|
+
const entries = (await readdir(releaseDirectory)).sort();
|
|
234
|
+
if (entries.length !== 1 || entries[0] !== RELEASE_FILE) {
|
|
235
|
+
throw new Error('Stored deployment release contains undeclared or missing entries.');
|
|
236
|
+
}
|
|
237
|
+
const bytes = await readRegularFile(path.join(releaseDirectory, RELEASE_FILE), MAX_RELEASE_BYTES);
|
|
238
|
+
let input;
|
|
239
|
+
try {
|
|
240
|
+
input = JSON.parse(textDecoder.decode(bytes));
|
|
241
|
+
}
|
|
242
|
+
catch (error) {
|
|
243
|
+
throw new Error('Stored deployment release is not valid UTF-8 JSON.', { cause: error });
|
|
244
|
+
}
|
|
245
|
+
const prepared = prepareProtocolDeploymentReleaseEnvelope(input);
|
|
246
|
+
if (prepared.identity !== expectedIdentity
|
|
247
|
+
|| !Buffer.from(bytes).equals(Buffer.from(prepared.bytes))) {
|
|
248
|
+
throw new Error('Stored deployment release identity or canonical bytes do not match.');
|
|
249
|
+
}
|
|
250
|
+
return Object.freeze({ release: prepared.release, canonical: prepared.canonical });
|
|
251
|
+
}
|
|
252
|
+
async function verifyStoredBundle(bundleDirectory, expectedIdentity) {
|
|
253
|
+
let bundle;
|
|
254
|
+
try {
|
|
255
|
+
bundle = await verifyDeploymentBundle(bundleDirectory);
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
throw storeError('HQ_DEPLOYMENT_STORE_CORRUPT_STATE', `Stored deployment bundle is invalid: ${expectedIdentity}`, error);
|
|
259
|
+
}
|
|
260
|
+
if (bundle.identity !== expectedIdentity) {
|
|
261
|
+
throw storeError('HQ_DEPLOYMENT_STORE_CORRUPT_STATE', `Stored deployment bundle identity is inconsistent: ${expectedIdentity}`);
|
|
262
|
+
}
|
|
263
|
+
return bundle;
|
|
264
|
+
}
|
|
265
|
+
async function ensureStoredBundle(root, bundlesDirectory, bundle) {
|
|
266
|
+
const destination = path.join(bundlesDirectory, bundle.identity);
|
|
267
|
+
if (await pathExists(destination)) {
|
|
268
|
+
await verifyStoredBundle(destination, bundle.identity);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
await withStagingDirectory(root, '.bundle-staging-', async (staging) => {
|
|
272
|
+
await copyBundleToStaging(bundle, staging);
|
|
273
|
+
await publishDirectory(staging, destination, bundlesDirectory, async () => { await verifyStoredBundle(destination, bundle.identity); });
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
async function publishDirectory(staging, destination, parent, verifyExisting) {
|
|
277
|
+
if (await pathExists(destination)) {
|
|
278
|
+
await verifyExisting();
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
try {
|
|
282
|
+
await rename(staging, destination);
|
|
283
|
+
}
|
|
284
|
+
catch (error) {
|
|
285
|
+
if (!await pathExists(destination))
|
|
286
|
+
throw error;
|
|
287
|
+
await verifyExisting();
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
await syncDirectory(parent);
|
|
291
|
+
return true;
|
|
292
|
+
}
|
|
293
|
+
async function withStagingDirectory(root, prefix, action) {
|
|
294
|
+
const staging = await mkdtemp(path.join(root, prefix));
|
|
295
|
+
let result;
|
|
296
|
+
let failure;
|
|
297
|
+
try {
|
|
298
|
+
result = await action(staging);
|
|
299
|
+
}
|
|
300
|
+
catch (error) {
|
|
301
|
+
failure = { error };
|
|
302
|
+
}
|
|
303
|
+
try {
|
|
304
|
+
await rm(staging, { force: true, recursive: true });
|
|
305
|
+
}
|
|
306
|
+
catch (error) {
|
|
307
|
+
throw storeError('HQ_DEPLOYMENT_STORE_IO', `Could not clean deployment store staging directory: ${staging}`, failure ? new AggregateError([failure.error, error]) : error);
|
|
308
|
+
}
|
|
309
|
+
if (failure)
|
|
310
|
+
throw failure.error;
|
|
311
|
+
return result;
|
|
312
|
+
}
|
|
313
|
+
function prepareSubmission(submission) {
|
|
314
|
+
let prepared;
|
|
315
|
+
try {
|
|
316
|
+
prepared = prepareProtocolDeploymentReleaseEnvelope(submission.release);
|
|
317
|
+
}
|
|
318
|
+
catch (error) {
|
|
319
|
+
throw storeError('HQ_DEPLOYMENT_STORE_INVALID_SUBMISSION', 'Deployment release is invalid.', error);
|
|
320
|
+
}
|
|
321
|
+
if (prepared.identity !== submission.releaseIdentity
|
|
322
|
+
|| prepared.canonical !== submission.releaseCanonical
|
|
323
|
+
|| prepared.release.bundleIdentity !== submission.bundle.identity) {
|
|
324
|
+
throw storeError('HQ_DEPLOYMENT_STORE_INVALID_SUBMISSION', 'Deployment submission identities or canonical release bytes are inconsistent.');
|
|
325
|
+
}
|
|
326
|
+
return prepared;
|
|
327
|
+
}
|
|
328
|
+
function requireIdentity(input) {
|
|
329
|
+
if (!IDENTITY_PATTERN.test(input)) {
|
|
330
|
+
throw storeError('HQ_DEPLOYMENT_STORE_INVALID_SUBMISSION', 'Deployment release identity must be 64 lowercase hexadecimal characters.');
|
|
331
|
+
}
|
|
332
|
+
return input;
|
|
333
|
+
}
|
|
334
|
+
export function createFileSystemDeploymentSubmissionStore(options) {
|
|
335
|
+
if (typeof options.directory !== 'string' || options.directory.length < 1) {
|
|
336
|
+
throw storeError('HQ_DEPLOYMENT_STORE_CONFIGURATION', 'Deployment store directory is required.');
|
|
337
|
+
}
|
|
338
|
+
const root = path.resolve(options.directory);
|
|
339
|
+
if (root === path.parse(root).root) {
|
|
340
|
+
throw storeError('HQ_DEPLOYMENT_STORE_CONFIGURATION', 'Deployment store directory cannot be a filesystem root.');
|
|
341
|
+
}
|
|
342
|
+
let directoriesPromise;
|
|
343
|
+
async function storeDirectories() {
|
|
344
|
+
const pending = directoriesPromise ??= ensureStoreDirectories(root);
|
|
345
|
+
try {
|
|
346
|
+
return await pending;
|
|
347
|
+
}
|
|
348
|
+
catch (error) {
|
|
349
|
+
if (directoriesPromise === pending)
|
|
350
|
+
directoriesPromise = undefined;
|
|
351
|
+
throw error;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
async function read(releaseIdentity) {
|
|
355
|
+
const identity = requireIdentity(releaseIdentity);
|
|
356
|
+
let directories;
|
|
357
|
+
try {
|
|
358
|
+
directories = await storeDirectories();
|
|
359
|
+
const releaseDirectory = path.join(directories.releases, identity);
|
|
360
|
+
if (!await pathExists(releaseDirectory))
|
|
361
|
+
return undefined;
|
|
362
|
+
let storedRelease;
|
|
363
|
+
try {
|
|
364
|
+
storedRelease = await readStoredRelease(releaseDirectory, identity);
|
|
365
|
+
}
|
|
366
|
+
catch (error) {
|
|
367
|
+
if (error instanceof FileSystemDeploymentStoreError)
|
|
368
|
+
throw error;
|
|
369
|
+
throw storeError('HQ_DEPLOYMENT_STORE_CORRUPT_STATE', `Stored deployment release is invalid: ${identity}`, error);
|
|
370
|
+
}
|
|
371
|
+
const bundle = await verifyStoredBundle(path.join(directories.bundles, storedRelease.release.bundleIdentity), storedRelease.release.bundleIdentity);
|
|
372
|
+
return Object.freeze({
|
|
373
|
+
releaseDirectory,
|
|
374
|
+
release: storedRelease.release,
|
|
375
|
+
releaseCanonical: storedRelease.canonical,
|
|
376
|
+
releaseIdentity: identity,
|
|
377
|
+
bundle,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
catch (error) {
|
|
381
|
+
if (error instanceof FileSystemDeploymentStoreError)
|
|
382
|
+
throw error;
|
|
383
|
+
throw storeError('HQ_DEPLOYMENT_STORE_IO', `Could not read deployment release from ${root}.`, error);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
async function accept(submission) {
|
|
387
|
+
const prepared = prepareSubmission(submission);
|
|
388
|
+
try {
|
|
389
|
+
const directories = await storeDirectories();
|
|
390
|
+
await ensureStoredBundle(root, directories.bundles, submission.bundle);
|
|
391
|
+
const releaseDestination = path.join(directories.releases, submission.releaseIdentity);
|
|
392
|
+
const published = await withStagingDirectory(root, '.release-staging-', async (staging) => {
|
|
393
|
+
await writeExclusiveFile(path.join(staging, RELEASE_FILE), prepared.bytes);
|
|
394
|
+
await syncDirectory(staging);
|
|
395
|
+
return publishDirectory(staging, releaseDestination, directories.releases, async () => {
|
|
396
|
+
let existing;
|
|
397
|
+
try {
|
|
398
|
+
existing = await readStoredRelease(releaseDestination, submission.releaseIdentity);
|
|
399
|
+
}
|
|
400
|
+
catch (error) {
|
|
401
|
+
throw storeError('HQ_DEPLOYMENT_STORE_CORRUPT_STATE', `Stored deployment release is invalid: ${submission.releaseIdentity}`, error);
|
|
402
|
+
}
|
|
403
|
+
if (existing.canonical !== prepared.canonical
|
|
404
|
+
|| existing.release.bundleIdentity !== submission.bundle.identity) {
|
|
405
|
+
throw storeError('HQ_DEPLOYMENT_STORE_CORRUPT_STATE', `Stored deployment release conflicts with its identity: ${submission.releaseIdentity}`);
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
});
|
|
409
|
+
return published ? 'accepted' : 'already-exists';
|
|
410
|
+
}
|
|
411
|
+
catch (error) {
|
|
412
|
+
if (error instanceof FileSystemDeploymentStoreError)
|
|
413
|
+
throw error;
|
|
414
|
+
throw storeError('HQ_DEPLOYMENT_STORE_IO', `Could not persist deployment submission in ${root}.`, error);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
return Object.freeze({ accept, read });
|
|
418
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
+
export { createFileSystemDeploymentActivationRegistry, DeploymentActivationError, } from './activation.js';
|
|
2
|
+
export type { DeploymentActivationErrorCode, DeploymentActivationRegistry, DeploymentActivationRelease, DeploymentActivationRecord, DeploymentActivationRequest, DeploymentActivationResult, DeploymentReleaseReader, FileSystemDeploymentActivationRegistryOptions, } from './activation.js';
|
|
1
3
|
export { DEPLOYMENT_BUNDLE_CONTRACT, DEPLOYMENT_BUNDLE_MANIFEST, verifyDeploymentBundle, } from './bundle.js';
|
|
2
4
|
export type { VerifiedDeploymentBundle } from './bundle.js';
|
|
3
5
|
export { DeploymentIntakeError, } from './errors.js';
|
|
4
6
|
export type { DeploymentIntakeErrorCode } from './errors.js';
|
|
7
|
+
export { createFileSystemDeploymentSubmissionStore, FileSystemDeploymentStoreError, } from './filesystem-store.js';
|
|
8
|
+
export type { FileSystemDeploymentStoreErrorCode, FileSystemDeploymentSubmissionStore, FileSystemDeploymentSubmissionStoreOptions, StoredDeploymentSubmission, } from './filesystem-store.js';
|
|
5
9
|
export { createDeploymentIntake, } from './intake.js';
|
|
6
10
|
export { DEFAULT_DEPLOYMENT_INTAKE_LIMITS, resolveDeploymentIntakeLimits, } from './limits.js';
|
|
7
11
|
export type { DeploymentIntakeLimits } from './limits.js';
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,0BAA0B,EAC1B,0BAA0B,EAC1B,sBAAsB,GACvB,MAAM,aAAa,CAAC;AACrB,YAAY,EAAE,wBAAwB,EAAE,MAAM,aAAa,CAAC;AAC5D,OAAO,EACL,qBAAqB,GACtB,MAAM,aAAa,CAAC;AACrB,YAAY,EAAE,yBAAyB,EAAE,MAAM,aAAa,CAAC;AAC7D,OAAO,EACL,sBAAsB,GACvB,MAAM,aAAa,CAAC;AACrB,OAAO,EACL,gCAAgC,EAChC,6BAA6B,GAC9B,MAAM,aAAa,CAAC;AACrB,YAAY,EAAE,sBAAsB,EAAE,MAAM,aAAa,CAAC;AAC1D,YAAY,EACV,6BAA6B,EAC7B,uBAAuB,EACvB,4BAA4B,EAC5B,oBAAoB,EACpB,gBAAgB,EAChB,uBAAuB,EACvB,uBAAuB,EACvB,wBAAwB,EACxB,4BAA4B,EAC5B,yBAAyB,EACzB,4BAA4B,GAC7B,MAAM,YAAY,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,4CAA4C,EAC5C,yBAAyB,GAC1B,MAAM,iBAAiB,CAAC;AACzB,YAAY,EACV,6BAA6B,EAC7B,4BAA4B,EAC5B,2BAA2B,EAC3B,0BAA0B,EAC1B,2BAA2B,EAC3B,0BAA0B,EAC1B,uBAAuB,EACvB,6CAA6C,GAC9C,MAAM,iBAAiB,CAAC;AACzB,OAAO,EACL,0BAA0B,EAC1B,0BAA0B,EAC1B,sBAAsB,GACvB,MAAM,aAAa,CAAC;AACrB,YAAY,EAAE,wBAAwB,EAAE,MAAM,aAAa,CAAC;AAC5D,OAAO,EACL,qBAAqB,GACtB,MAAM,aAAa,CAAC;AACrB,YAAY,EAAE,yBAAyB,EAAE,MAAM,aAAa,CAAC;AAC7D,OAAO,EACL,yCAAyC,EACzC,8BAA8B,GAC/B,MAAM,uBAAuB,CAAC;AAC/B,YAAY,EACV,kCAAkC,EAClC,mCAAmC,EACnC,0CAA0C,EAC1C,0BAA0B,GAC3B,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EACL,sBAAsB,GACvB,MAAM,aAAa,CAAC;AACrB,OAAO,EACL,gCAAgC,EAChC,6BAA6B,GAC9B,MAAM,aAAa,CAAC;AACrB,YAAY,EAAE,sBAAsB,EAAE,MAAM,aAAa,CAAC;AAC1D,YAAY,EACV,6BAA6B,EAC7B,uBAAuB,EACvB,4BAA4B,EAC5B,oBAAoB,EACpB,gBAAgB,EAChB,uBAAuB,EACvB,uBAAuB,EACvB,wBAAwB,EACxB,4BAA4B,EAC5B,yBAAyB,EACzB,4BAA4B,GAC7B,MAAM,YAAY,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
export { createFileSystemDeploymentActivationRegistry, DeploymentActivationError, } from './activation.js';
|
|
1
2
|
export { DEPLOYMENT_BUNDLE_CONTRACT, DEPLOYMENT_BUNDLE_MANIFEST, verifyDeploymentBundle, } from './bundle.js';
|
|
2
3
|
export { DeploymentIntakeError, } from './errors.js';
|
|
4
|
+
export { createFileSystemDeploymentSubmissionStore, FileSystemDeploymentStoreError, } from './filesystem-store.js';
|
|
3
5
|
export { createDeploymentIntake, } from './intake.js';
|
|
4
6
|
export { DEFAULT_DEPLOYMENT_INTAKE_LIMITS, resolveDeploymentIntakeLimits, } from './limits.js';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hypequery/deployment",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Provider-neutral deployment verification and intake for Hypequery",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
"README.md"
|
|
19
19
|
],
|
|
20
20
|
"dependencies": {
|
|
21
|
-
"@hypequery/protocol": "0.
|
|
21
|
+
"@hypequery/protocol": "0.6.0"
|
|
22
22
|
},
|
|
23
23
|
"devDependencies": {
|
|
24
24
|
"@types/node": "^22.5.0",
|