@hypequery/cli 1.8.0 → 1.9.1
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 +27 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +10 -0
- package/dist/commands/deploy.d.ts +11 -0
- package/dist/commands/deploy.d.ts.map +1 -0
- package/dist/commands/deploy.js +102 -0
- package/dist/utils/deployment-bundle.d.ts +4 -4
- package/dist/utils/deployment-bundle.d.ts.map +1 -1
- package/dist/utils/deployment-bundle.js +4 -137
- package/dist/utils/deployment-upload.d.ts +37 -0
- package/dist/utils/deployment-upload.d.ts.map +1 -0
- package/dist/utils/deployment-upload.js +359 -0
- package/package.json +4 -3
package/README.md
CHANGED
|
@@ -204,6 +204,33 @@ Options:
|
|
|
204
204
|
- `--environment <environment>`: required target environment identifier
|
|
205
205
|
- `--output <path>`: release JSON path, default beside the bundle
|
|
206
206
|
|
|
207
|
+
### `hypequery deploy`
|
|
208
|
+
|
|
209
|
+
Submits a verified deployment bundle with an already-prepared target-bound
|
|
210
|
+
release. The command verifies both inputs again, requires their bundle
|
|
211
|
+
identities to match, and streams only the files declared by the bundle.
|
|
212
|
+
|
|
213
|
+
```bash
|
|
214
|
+
HYPEQUERY_API_TOKEN=<token> \
|
|
215
|
+
npx hypequery deploy analytics/hypequery-deployment \
|
|
216
|
+
--release analytics/hypequery-deployment.release.json \
|
|
217
|
+
--endpoint https://deploy.example.com/v1/releases
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
The token is accepted only through `HYPEQUERY_API_TOKEN`, keeping it out of
|
|
221
|
+
shell history. The submission endpoint must use HTTPS, may also be supplied by
|
|
222
|
+
`HYPEQUERY_DEPLOYMENT_ENDPOINT`, and must not contain credentials or a URL
|
|
223
|
+
fragment. The release identity is sent as the idempotency key, so an unchanged
|
|
224
|
+
release can be submitted safely again.
|
|
225
|
+
|
|
226
|
+
This command submits immutable deployment inputs. Activation, status changes,
|
|
227
|
+
promotion, and rollback remain control-plane operations.
|
|
228
|
+
|
|
229
|
+
Options:
|
|
230
|
+
|
|
231
|
+
- `--release <path>`: required target-bound release JSON
|
|
232
|
+
- `--endpoint <url>`: HTTPS submission endpoint; defaults to `HYPEQUERY_DEPLOYMENT_ENDPOINT`
|
|
233
|
+
|
|
207
234
|
## Non-interactive Setup
|
|
208
235
|
|
|
209
236
|
For ClickHouse, `hypequery init --no-interactive` reads:
|
package/dist/cli.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;
|
|
1
|
+
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,OAAO,EAAE,MAAM,WAAW,CAAC;AAkBpC,QAAA,MAAM,OAAO,SAAgB,CAAC;AAa9B,wBAAgB,oBAAoB,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;;EAKpE;AAgLD,OAAO,EAAE,OAAO,EAAE,CAAC"}
|
package/dist/cli.js
CHANGED
|
@@ -7,6 +7,7 @@ import { generateCommand } from './commands/generate.js';
|
|
|
7
7
|
import { generateDatasetsCommand } from './commands/generate-datasets.js';
|
|
8
8
|
import { generateManifestCommand } from './commands/generate-manifest.js';
|
|
9
9
|
import { buildDeploymentCommand, prepareDeploymentReleaseCommand, validateDeploymentCommand, } from './commands/deployment.js';
|
|
10
|
+
import { deployCommand, } from './commands/deploy.js';
|
|
10
11
|
const program = new Command();
|
|
11
12
|
function getCliVersion() {
|
|
12
13
|
try {
|
|
@@ -138,6 +139,14 @@ program
|
|
|
138
139
|
.action(runCommand(async (bundle, options) => {
|
|
139
140
|
await prepareDeploymentReleaseCommand(bundle, options);
|
|
140
141
|
}));
|
|
142
|
+
program
|
|
143
|
+
.command('deploy <bundle>')
|
|
144
|
+
.description('Submit a verified deployment bundle and release')
|
|
145
|
+
.requiredOption('--release <path>', 'Target-bound release JSON path')
|
|
146
|
+
.option('--endpoint <url>', 'HTTPS submission endpoint (or HYPEQUERY_DEPLOYMENT_ENDPOINT)')
|
|
147
|
+
.action(runCommand(async (bundle, options) => {
|
|
148
|
+
await deployCommand(bundle, options);
|
|
149
|
+
}));
|
|
141
150
|
// Help command
|
|
142
151
|
program
|
|
143
152
|
.command('help [command]')
|
|
@@ -171,6 +180,7 @@ program.on('--help', () => {
|
|
|
171
180
|
console.log(' hypequery deployment:build analytics/api.ts');
|
|
172
181
|
console.log(' hypequery deployment:validate analytics/hypequery-deployment');
|
|
173
182
|
console.log(' hypequery deployment:release analytics/hypequery-deployment --project my-project --environment production');
|
|
183
|
+
console.log(' hypequery deploy analytics/hypequery-deployment --release analytics/hypequery-deployment.release.json');
|
|
174
184
|
console.log('');
|
|
175
185
|
console.log('Docs: https://hypequery.com/docs');
|
|
176
186
|
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { createHttpDeploymentUploadTransport, type DeploymentSubmissionResponse } from '../utils/deployment-upload.js';
|
|
2
|
+
export interface DeployOptions {
|
|
3
|
+
release?: string;
|
|
4
|
+
endpoint?: string;
|
|
5
|
+
}
|
|
6
|
+
export interface DeployDependencies {
|
|
7
|
+
readonly env?: Readonly<Record<string, string | undefined>>;
|
|
8
|
+
readonly createTransport?: typeof createHttpDeploymentUploadTransport;
|
|
9
|
+
}
|
|
10
|
+
export declare function deployCommand(bundlePath: string | undefined, options?: DeployOptions, dependencies?: DeployDependencies): Promise<DeploymentSubmissionResponse>;
|
|
11
|
+
//# sourceMappingURL=deploy.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"deploy.d.ts","sourceRoot":"","sources":["../../src/commands/deploy.ts"],"names":[],"mappings":"AAKA,OAAO,EACL,mCAAmC,EAEnC,KAAK,4BAA4B,EAElC,MAAM,+BAA+B,CAAC;AAMvC,MAAM,WAAW,aAAa;IAC5B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,GAAG,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC,CAAC;IAC5D,QAAQ,CAAC,eAAe,CAAC,EAAE,OAAO,mCAAmC,CAAC;CACvE;AA4DD,wBAAsB,aAAa,CACjC,UAAU,EAAE,MAAM,GAAG,SAAS,EAC9B,OAAO,GAAE,aAAkB,EAC3B,YAAY,GAAE,kBAAuB,GACpC,OAAO,CAAC,4BAA4B,CAAC,CA0DvC"}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { constants, open } from 'node:fs/promises';
|
|
2
|
+
import { prepareProtocolDeploymentReleaseEnvelope, } from '@hypequery/protocol';
|
|
3
|
+
import { verifyDeploymentBundle } from '../utils/deployment-bundle.js';
|
|
4
|
+
import { createHttpDeploymentUploadTransport, DeploymentUploadError, } from '../utils/deployment-upload.js';
|
|
5
|
+
import { logger } from '../utils/logger.js';
|
|
6
|
+
const MAX_RELEASE_FILE_BYTES = 16 * 1024;
|
|
7
|
+
const utf8Decoder = new TextDecoder('utf-8', { fatal: true });
|
|
8
|
+
async function readReleaseFile(releasePath) {
|
|
9
|
+
let handle;
|
|
10
|
+
let bytes;
|
|
11
|
+
let fileFailure;
|
|
12
|
+
try {
|
|
13
|
+
handle = await open(releasePath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
14
|
+
const stat = await handle.stat();
|
|
15
|
+
if (!stat.isFile())
|
|
16
|
+
throw new Error('Release JSON must be a regular file.');
|
|
17
|
+
if (stat.size > MAX_RELEASE_FILE_BYTES) {
|
|
18
|
+
throw new Error('Release JSON exceeds 16384 bytes.');
|
|
19
|
+
}
|
|
20
|
+
const buffer = new Uint8Array(MAX_RELEASE_FILE_BYTES + 1);
|
|
21
|
+
let total = 0;
|
|
22
|
+
while (total < buffer.byteLength) {
|
|
23
|
+
const { bytesRead } = await handle.read(buffer, total, buffer.byteLength - total, total);
|
|
24
|
+
if (bytesRead === 0)
|
|
25
|
+
break;
|
|
26
|
+
total += bytesRead;
|
|
27
|
+
}
|
|
28
|
+
if (total > MAX_RELEASE_FILE_BYTES) {
|
|
29
|
+
throw new Error('Release JSON exceeds 16384 bytes.');
|
|
30
|
+
}
|
|
31
|
+
bytes = buffer.subarray(0, total);
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
fileFailure = { error };
|
|
35
|
+
}
|
|
36
|
+
if (handle) {
|
|
37
|
+
try {
|
|
38
|
+
await handle.close();
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
fileFailure ??= { error };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (fileFailure) {
|
|
45
|
+
throw new Error(`Cannot read deployment release file: ${releasePath}\n\n`
|
|
46
|
+
+ (fileFailure.error instanceof Error
|
|
47
|
+
? fileFailure.error.message
|
|
48
|
+
: String(fileFailure.error)));
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
return JSON.parse(utf8Decoder.decode(bytes));
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
throw new Error(`Invalid deployment release JSON: ${releasePath}\n\n`
|
|
55
|
+
+ (error instanceof Error ? error.message : String(error)));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function requiredConfiguration(value, message) {
|
|
59
|
+
if (value === undefined || value.length === 0)
|
|
60
|
+
throw new Error(message);
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
export async function deployCommand(bundlePath, options = {}, dependencies = {}) {
|
|
64
|
+
if (!bundlePath) {
|
|
65
|
+
throw new Error('Missing deployment bundle path.\n\n'
|
|
66
|
+
+ 'Usage: hypequery deploy analytics/hypequery-deployment '
|
|
67
|
+
+ '--release analytics/hypequery-deployment.release.json');
|
|
68
|
+
}
|
|
69
|
+
const releasePath = requiredConfiguration(options.release, 'Missing required --release <path>.');
|
|
70
|
+
const env = dependencies.env ?? process.env;
|
|
71
|
+
const endpoint = requiredConfiguration(options.endpoint ?? env.HYPEQUERY_DEPLOYMENT_ENDPOINT, 'Missing deployment endpoint. Pass --endpoint or set HYPEQUERY_DEPLOYMENT_ENDPOINT.');
|
|
72
|
+
const token = requiredConfiguration(env.HYPEQUERY_API_TOKEN, 'Missing HYPEQUERY_API_TOKEN.');
|
|
73
|
+
let bundle;
|
|
74
|
+
try {
|
|
75
|
+
bundle = await verifyDeploymentBundle(bundlePath);
|
|
76
|
+
}
|
|
77
|
+
catch (error) {
|
|
78
|
+
throw new Error(`Cannot push an invalid deployment bundle: ${bundlePath}\n\n`
|
|
79
|
+
+ (error instanceof Error ? error.message : String(error)));
|
|
80
|
+
}
|
|
81
|
+
const releaseInput = await readReleaseFile(releasePath);
|
|
82
|
+
let release;
|
|
83
|
+
try {
|
|
84
|
+
release = prepareProtocolDeploymentReleaseEnvelope(releaseInput);
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
throw new Error(`Invalid deployment release: ${releasePath}\n\n`
|
|
88
|
+
+ (error instanceof Error ? error.message : String(error)));
|
|
89
|
+
}
|
|
90
|
+
if (release.release.bundleIdentity !== bundle.identity) {
|
|
91
|
+
throw new DeploymentUploadError('HQ_UPLOAD_IDENTITY_MISMATCH', 'Release bundle identity does not match the verified deployment bundle.');
|
|
92
|
+
}
|
|
93
|
+
const createTransport = dependencies.createTransport ?? createHttpDeploymentUploadTransport;
|
|
94
|
+
const transportOptions = { endpoint, token };
|
|
95
|
+
const result = await createTransport(transportOptions).submit(bundle, release);
|
|
96
|
+
logger.success(result.status === 'accepted'
|
|
97
|
+
? 'Deployment release accepted'
|
|
98
|
+
: 'Deployment release already exists');
|
|
99
|
+
logger.info(`Release identity: ${result.releaseIdentity}`);
|
|
100
|
+
logger.info(`Bundle identity: ${result.bundleIdentity}`);
|
|
101
|
+
return result;
|
|
102
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { type PreparedProtocolDeploymentContract, type ProtocolDeploymentBundleManifest, type ProtocolDeploymentContract } from '@hypequery/protocol';
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
import { DEPLOYMENT_BUNDLE_CONTRACT, DEPLOYMENT_BUNDLE_MANIFEST, verifyDeploymentBundle } from '@hypequery/deployment';
|
|
3
|
+
import type { VerifiedDeploymentBundle } from '@hypequery/deployment';
|
|
4
|
+
export { DEPLOYMENT_BUNDLE_CONTRACT, DEPLOYMENT_BUNDLE_MANIFEST, verifyDeploymentBundle, };
|
|
5
|
+
export type { VerifiedDeploymentBundle };
|
|
4
6
|
export interface DeploymentBundleRuntimeFile {
|
|
5
7
|
readonly runtime: 'node' | 'python';
|
|
6
8
|
readonly sha256: string;
|
|
@@ -12,8 +14,6 @@ export interface WrittenDeploymentBundle {
|
|
|
12
14
|
readonly identity: string;
|
|
13
15
|
readonly contract: ProtocolDeploymentContract;
|
|
14
16
|
}
|
|
15
|
-
export type VerifiedDeploymentBundle = WrittenDeploymentBundle;
|
|
16
17
|
export declare function writeDeploymentBundle(outputDirectory: string, prepared: PreparedProtocolDeploymentContract, runtimeFiles: readonly DeploymentBundleRuntimeFile[]): Promise<WrittenDeploymentBundle>;
|
|
17
18
|
export declare function readDeploymentRuntimeFile(filePath: string): Promise<Uint8Array>;
|
|
18
|
-
export declare function verifyDeploymentBundle(bundleDirectory: string): Promise<VerifiedDeploymentBundle>;
|
|
19
19
|
//# sourceMappingURL=deployment-bundle.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"deployment-bundle.d.ts","sourceRoot":"","sources":["../../src/utils/deployment-bundle.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"deployment-bundle.d.ts","sourceRoot":"","sources":["../../src/utils/deployment-bundle.ts"],"names":[],"mappings":"AAYA,OAAO,EAGL,KAAK,kCAAkC,EACvC,KAAK,gCAAgC,EACrC,KAAK,0BAA0B,EAChC,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EACL,0BAA0B,EAC1B,0BAA0B,EAC1B,sBAAsB,EACvB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,uBAAuB,CAAC;AAGtE,OAAO,EACL,0BAA0B,EAC1B,0BAA0B,EAC1B,sBAAsB,GACvB,CAAC;AACF,YAAY,EAAE,wBAAwB,EAAE,CAAC;AAEzC,MAAM,WAAW,2BAA2B;IAC1C,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,QAAQ,CAAC;IACpC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,KAAK,EAAE,UAAU,CAAC;CAC5B;AAED,MAAM,WAAW,uBAAuB;IACtC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,QAAQ,CAAC,QAAQ,EAAE,gCAAgC,CAAC;IACpD,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,QAAQ,EAAE,0BAA0B,CAAC;CAC/C;AA+FD,wBAAsB,qBAAqB,CACzC,eAAe,EAAE,MAAM,EACvB,QAAQ,EAAE,kCAAkC,EAC5C,YAAY,EAAE,SAAS,2BAA2B,EAAE,GACnD,OAAO,CAAC,uBAAuB,CAAC,CA6ElC;AAoCD,wBAAsB,yBAAyB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,CAAC,CAMrF"}
|
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
-
import { constants, lstat, mkdir, mkdtemp, open,
|
|
2
|
+
import { constants, lstat, mkdir, mkdtemp, open, rename, rm, writeFile, } from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import { DEFAULT_PROTOCOL_DEPLOYMENT_BUNDLE_LIMITS, prepareProtocolDeploymentBundleManifest,
|
|
4
|
+
import { DEFAULT_PROTOCOL_DEPLOYMENT_BUNDLE_LIMITS, prepareProtocolDeploymentBundleManifest, } from '@hypequery/protocol';
|
|
5
|
+
import { DEPLOYMENT_BUNDLE_CONTRACT, DEPLOYMENT_BUNDLE_MANIFEST, verifyDeploymentBundle, } from '@hypequery/deployment';
|
|
5
6
|
import { logger } from './logger.js';
|
|
6
|
-
export
|
|
7
|
-
export const DEPLOYMENT_BUNDLE_CONTRACT = 'deployment.json';
|
|
8
|
-
const utf8Decoder = new TextDecoder('utf-8', { fatal: true });
|
|
7
|
+
export { DEPLOYMENT_BUNDLE_CONTRACT, DEPLOYMENT_BUNDLE_MANIFEST, verifyDeploymentBundle, };
|
|
9
8
|
const utf8Encoder = new TextEncoder();
|
|
10
9
|
function sha256(bytes) {
|
|
11
10
|
return createHash('sha256').update(bytes).digest('hex');
|
|
@@ -185,138 +184,6 @@ async function readBoundedAbsoluteRegularFile(absolutePath, displayPath, maximum
|
|
|
185
184
|
await handle?.close();
|
|
186
185
|
}
|
|
187
186
|
}
|
|
188
|
-
async function readBoundedRegularFile(root, relativePath, maximum) {
|
|
189
|
-
return readBoundedAbsoluteRegularFile(path.join(root, ...relativePath.split('/')), relativePath, maximum);
|
|
190
|
-
}
|
|
191
187
|
export async function readDeploymentRuntimeFile(filePath) {
|
|
192
188
|
return readBoundedAbsoluteRegularFile(path.resolve(filePath), filePath, DEFAULT_PROTOCOL_DEPLOYMENT_BUNDLE_LIMITS.maxArtifactBytes);
|
|
193
189
|
}
|
|
194
|
-
function expectedDirectories(files) {
|
|
195
|
-
const result = new Set();
|
|
196
|
-
for (const file of files) {
|
|
197
|
-
const segments = file.split('/');
|
|
198
|
-
for (let index = 1; index < segments.length; index += 1) {
|
|
199
|
-
result.add(segments.slice(0, index).join('/'));
|
|
200
|
-
}
|
|
201
|
-
}
|
|
202
|
-
return result;
|
|
203
|
-
}
|
|
204
|
-
async function verifyExactEntries(root, manifest) {
|
|
205
|
-
const expectedFiles = new Set([
|
|
206
|
-
DEPLOYMENT_BUNDLE_MANIFEST,
|
|
207
|
-
manifest.deployment.path,
|
|
208
|
-
...manifest.artifacts.map(artifact => artifact.path),
|
|
209
|
-
]);
|
|
210
|
-
const expectedDirectoryPaths = expectedDirectories([...expectedFiles]);
|
|
211
|
-
const seenFiles = new Set();
|
|
212
|
-
const seenDirectories = new Set();
|
|
213
|
-
async function visit(relativeDirectory) {
|
|
214
|
-
const absoluteDirectory = relativeDirectory
|
|
215
|
-
? path.join(root, ...relativeDirectory.split('/'))
|
|
216
|
-
: root;
|
|
217
|
-
const names = await readdir(absoluteDirectory);
|
|
218
|
-
names.sort();
|
|
219
|
-
for (const name of names) {
|
|
220
|
-
const relativePath = relativeDirectory ? `${relativeDirectory}/${name}` : name;
|
|
221
|
-
const stat = await lstat(path.join(absoluteDirectory, name));
|
|
222
|
-
if (stat.isSymbolicLink()) {
|
|
223
|
-
throw new Error(`Bundle entries must not be symbolic links: ${relativePath}`);
|
|
224
|
-
}
|
|
225
|
-
if (stat.isDirectory()) {
|
|
226
|
-
if (!expectedDirectoryPaths.has(relativePath)) {
|
|
227
|
-
throw new Error(`Deployment bundle contains an undeclared directory: ${relativePath}`);
|
|
228
|
-
}
|
|
229
|
-
seenDirectories.add(relativePath);
|
|
230
|
-
await visit(relativePath);
|
|
231
|
-
}
|
|
232
|
-
else if (stat.isFile()) {
|
|
233
|
-
if (!expectedFiles.has(relativePath)) {
|
|
234
|
-
throw new Error(`Deployment bundle contains an undeclared file: ${relativePath}`);
|
|
235
|
-
}
|
|
236
|
-
seenFiles.add(relativePath);
|
|
237
|
-
}
|
|
238
|
-
else {
|
|
239
|
-
throw new Error(`Bundle entry is not a regular file or directory: ${relativePath}`);
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
await visit('');
|
|
244
|
-
const missingFiles = [...expectedFiles].filter(file => !seenFiles.has(file)).sort();
|
|
245
|
-
if (missingFiles.length > 0) {
|
|
246
|
-
throw new Error(`Deployment bundle is missing files: ${missingFiles.join(', ')}`);
|
|
247
|
-
}
|
|
248
|
-
const missingDirectories = [...expectedDirectoryPaths]
|
|
249
|
-
.filter(directory => !seenDirectories.has(directory))
|
|
250
|
-
.sort();
|
|
251
|
-
if (missingDirectories.length > 0) {
|
|
252
|
-
throw new Error(`Deployment bundle is missing directories: ${missingDirectories.join(', ')}`);
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
function verifyFile(bytes, file) {
|
|
256
|
-
if (bytes.byteLength !== file.byteLength) {
|
|
257
|
-
throw new Error(`Bundle entry byte length does not match the manifest: ${file.path}`);
|
|
258
|
-
}
|
|
259
|
-
if (sha256(bytes) !== file.sha256) {
|
|
260
|
-
throw new Error(`Bundle entry SHA-256 does not match the manifest: ${file.path}`);
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
function verifyRuntimeReferences(contract, artifacts) {
|
|
264
|
-
requireClosedContractArtifactSet(contract);
|
|
265
|
-
const declared = new Map(contract.artifacts.map(artifact => [artifact.artifactSha256, artifact.runtime]));
|
|
266
|
-
if (declared.size !== artifacts.length) {
|
|
267
|
-
throw new Error('Bundle runtime artifacts do not match the deployment contract.');
|
|
268
|
-
}
|
|
269
|
-
for (const artifact of artifacts) {
|
|
270
|
-
if (declared.get(artifact.sha256) !== artifact.runtime) {
|
|
271
|
-
throw new Error(`Bundle artifact ${artifact.sha256} does not match a deployment runtime reference.`);
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
export async function verifyDeploymentBundle(bundleDirectory) {
|
|
276
|
-
const root = path.resolve(bundleDirectory);
|
|
277
|
-
const rootStat = await lstat(root);
|
|
278
|
-
if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
|
|
279
|
-
throw new Error(`Deployment bundle path must be a regular directory: ${bundleDirectory}`);
|
|
280
|
-
}
|
|
281
|
-
const manifestBytes = await readBoundedRegularFile(root, DEPLOYMENT_BUNDLE_MANIFEST, 1024 * 1024);
|
|
282
|
-
let input;
|
|
283
|
-
try {
|
|
284
|
-
input = JSON.parse(utf8Decoder.decode(manifestBytes));
|
|
285
|
-
}
|
|
286
|
-
catch (error) {
|
|
287
|
-
throw new Error(`Invalid deployment bundle manifest: ${path.join(root, DEPLOYMENT_BUNDLE_MANIFEST)}\n\n`
|
|
288
|
-
+ (error instanceof Error ? error.message : String(error)));
|
|
289
|
-
}
|
|
290
|
-
const preparedManifest = prepareProtocolDeploymentBundleManifest(input);
|
|
291
|
-
const expectedManifestBytes = utf8Encoder.encode(`${preparedManifest.canonical}\n`);
|
|
292
|
-
if (!Buffer.from(manifestBytes).equals(Buffer.from(expectedManifestBytes))) {
|
|
293
|
-
throw new Error('Deployment bundle manifest must contain canonical JSON followed by one newline.');
|
|
294
|
-
}
|
|
295
|
-
await verifyExactEntries(root, preparedManifest.manifest);
|
|
296
|
-
const deployment = preparedManifest.manifest.deployment;
|
|
297
|
-
const deploymentBytes = await readBoundedRegularFile(root, deployment.path, DEFAULT_PROTOCOL_DEPLOYMENT_BUNDLE_LIMITS.maxDeploymentBytes);
|
|
298
|
-
verifyFile(deploymentBytes, deployment);
|
|
299
|
-
let contractInput;
|
|
300
|
-
try {
|
|
301
|
-
contractInput = JSON.parse(utf8Decoder.decode(deploymentBytes));
|
|
302
|
-
}
|
|
303
|
-
catch (error) {
|
|
304
|
-
throw new Error(`Invalid deployment JSON in bundle: ${deployment.path}\n\n`
|
|
305
|
-
+ (error instanceof Error ? error.message : String(error)));
|
|
306
|
-
}
|
|
307
|
-
const preparedContract = prepareProtocolDeploymentContract(contractInput);
|
|
308
|
-
if (preparedContract.identity !== deployment.identity) {
|
|
309
|
-
throw new Error('Deployment identity does not match the bundle manifest.');
|
|
310
|
-
}
|
|
311
|
-
for (const artifact of preparedManifest.manifest.artifacts) {
|
|
312
|
-
const bytes = await readBoundedRegularFile(root, artifact.path, DEFAULT_PROTOCOL_DEPLOYMENT_BUNDLE_LIMITS.maxArtifactBytes);
|
|
313
|
-
verifyFile(bytes, artifact);
|
|
314
|
-
}
|
|
315
|
-
verifyRuntimeReferences(preparedContract.contract, preparedManifest.manifest.artifacts);
|
|
316
|
-
return Object.freeze({
|
|
317
|
-
directory: root,
|
|
318
|
-
manifest: preparedManifest.manifest,
|
|
319
|
-
identity: preparedManifest.identity,
|
|
320
|
-
contract: preparedContract.contract,
|
|
321
|
-
});
|
|
322
|
-
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { ReadableStream } from 'node:stream/web';
|
|
2
|
+
import { type PreparedProtocolDeploymentReleaseEnvelope } from '@hypequery/protocol';
|
|
3
|
+
import { type VerifiedDeploymentBundle } from './deployment-bundle.js';
|
|
4
|
+
import type { DeploymentSubmissionResponse } from '@hypequery/deployment';
|
|
5
|
+
export type { DeploymentSubmissionResponse } from '@hypequery/deployment';
|
|
6
|
+
export type DeploymentUploadErrorCode = 'HQ_UPLOAD_CONFIGURATION' | 'HQ_UPLOAD_IDENTITY_MISMATCH' | 'HQ_UPLOAD_BUNDLE_CHANGED' | 'HQ_UPLOAD_NETWORK' | 'HQ_UPLOAD_REJECTED' | 'HQ_UPLOAD_INVALID_RESPONSE';
|
|
7
|
+
export declare class DeploymentUploadError extends Error {
|
|
8
|
+
readonly code: DeploymentUploadErrorCode;
|
|
9
|
+
readonly status?: number;
|
|
10
|
+
constructor(code: DeploymentUploadErrorCode, message: string, status?: number);
|
|
11
|
+
}
|
|
12
|
+
export interface DeploymentHttpResponse {
|
|
13
|
+
readonly ok: boolean;
|
|
14
|
+
readonly status: number;
|
|
15
|
+
readonly statusText: string;
|
|
16
|
+
readonly body: ReadableStream<Uint8Array> | null;
|
|
17
|
+
}
|
|
18
|
+
export interface DeploymentFetchInit {
|
|
19
|
+
readonly method: 'POST';
|
|
20
|
+
readonly headers: Readonly<Record<string, string>>;
|
|
21
|
+
readonly body: AsyncIterable<Uint8Array>;
|
|
22
|
+
readonly duplex: 'half';
|
|
23
|
+
readonly redirect: 'error';
|
|
24
|
+
readonly signal: AbortSignal;
|
|
25
|
+
}
|
|
26
|
+
export type DeploymentFetch = (input: string, init: DeploymentFetchInit) => Promise<DeploymentHttpResponse>;
|
|
27
|
+
export interface HttpDeploymentUploadTransportOptions {
|
|
28
|
+
readonly endpoint: string;
|
|
29
|
+
readonly token: string;
|
|
30
|
+
readonly timeoutMs?: number;
|
|
31
|
+
readonly fetch?: DeploymentFetch;
|
|
32
|
+
}
|
|
33
|
+
export interface DeploymentUploadTransport {
|
|
34
|
+
submit(bundle: VerifiedDeploymentBundle, release: PreparedProtocolDeploymentReleaseEnvelope): Promise<DeploymentSubmissionResponse>;
|
|
35
|
+
}
|
|
36
|
+
export declare function createHttpDeploymentUploadTransport(options: HttpDeploymentUploadTransportOptions): DeploymentUploadTransport;
|
|
37
|
+
//# sourceMappingURL=deployment-upload.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"deployment-upload.d.ts","sourceRoot":"","sources":["../../src/utils/deployment-upload.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AACtD,OAAO,EAIL,KAAK,yCAAyC,EAC/C,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EAEL,KAAK,wBAAwB,EAC9B,MAAM,wBAAwB,CAAC;AAChC,OAAO,KAAK,EAAE,4BAA4B,EAAE,MAAM,uBAAuB,CAAC;AAE1E,YAAY,EAAE,4BAA4B,EAAE,MAAM,uBAAuB,CAAC;AAQ1E,MAAM,MAAM,yBAAyB,GACjC,yBAAyB,GACzB,6BAA6B,GAC7B,0BAA0B,GAC1B,mBAAmB,GACnB,oBAAoB,GACpB,4BAA4B,CAAC;AAEjC,qBAAa,qBAAsB,SAAQ,KAAK;IAC9C,QAAQ,CAAC,IAAI,EAAE,yBAAyB,CAAC;IACzC,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;gBAEb,IAAI,EAAE,yBAAyB,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM;CAM9E;AAED,MAAM,WAAW,sBAAsB;IACrC,QAAQ,CAAC,EAAE,EAAE,OAAO,CAAC;IACrB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,IAAI,EAAE,cAAc,CAAC,UAAU,CAAC,GAAG,IAAI,CAAC;CAClD;AAED,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,OAAO,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAC;IACnD,QAAQ,CAAC,IAAI,EAAE,aAAa,CAAC,UAAU,CAAC,CAAC;IACzC,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,QAAQ,EAAE,OAAO,CAAC;IAC3B,QAAQ,CAAC,MAAM,EAAE,WAAW,CAAC;CAC9B;AAED,MAAM,MAAM,eAAe,GAAG,CAC5B,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,mBAAmB,KACtB,OAAO,CAAC,sBAAsB,CAAC,CAAC;AAErC,MAAM,WAAW,oCAAoC;IACnD,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,KAAK,CAAC,EAAE,eAAe,CAAC;CAClC;AAED,MAAM,WAAW,yBAAyB;IACxC,MAAM,CACJ,MAAM,EAAE,wBAAwB,EAChC,OAAO,EAAE,yCAAyC,GACjD,OAAO,CAAC,4BAA4B,CAAC,CAAC;CAC1C;AAmVD,wBAAgB,mCAAmC,CACjD,OAAO,EAAE,oCAAoC,GAC5C,yBAAyB,CAsF3B"}
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { constants, open } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { prepareProtocolDeploymentBundleManifest, prepareProtocolDeploymentReleaseEnvelope, } from '@hypequery/protocol';
|
|
5
|
+
import { DEPLOYMENT_BUNDLE_MANIFEST, } from './deployment-bundle.js';
|
|
6
|
+
const SHA256_PATTERN = /^[0-9a-f]{64}$/;
|
|
7
|
+
const MAX_RESPONSE_BYTES = 64 * 1024;
|
|
8
|
+
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
9
|
+
const textEncoder = new TextEncoder();
|
|
10
|
+
const textDecoder = new TextDecoder('utf-8', { fatal: true });
|
|
11
|
+
export class DeploymentUploadError extends Error {
|
|
12
|
+
code;
|
|
13
|
+
status;
|
|
14
|
+
constructor(code, message, status) {
|
|
15
|
+
super(`${code}: ${message}`);
|
|
16
|
+
this.name = 'DeploymentUploadError';
|
|
17
|
+
this.code = code;
|
|
18
|
+
if (status !== undefined)
|
|
19
|
+
this.status = status;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function configurationError(message) {
|
|
23
|
+
throw new DeploymentUploadError('HQ_UPLOAD_CONFIGURATION', message);
|
|
24
|
+
}
|
|
25
|
+
function endpointUrl(input) {
|
|
26
|
+
let url;
|
|
27
|
+
try {
|
|
28
|
+
url = new URL(input);
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
configurationError('Deployment endpoint must be an absolute HTTPS URL.');
|
|
32
|
+
}
|
|
33
|
+
if (url.protocol !== 'https:') {
|
|
34
|
+
configurationError('Deployment endpoint must use HTTPS.');
|
|
35
|
+
}
|
|
36
|
+
if (url.username || url.password) {
|
|
37
|
+
configurationError('Deployment endpoint must not contain credentials.');
|
|
38
|
+
}
|
|
39
|
+
if (url.hash)
|
|
40
|
+
configurationError('Deployment endpoint must not contain a URL fragment.');
|
|
41
|
+
return url.toString();
|
|
42
|
+
}
|
|
43
|
+
function bearerToken(input) {
|
|
44
|
+
if (input.length < 1 || input.length > 4096 || input.trim() !== input
|
|
45
|
+
|| [...input].some(character => {
|
|
46
|
+
const code = character.charCodeAt(0);
|
|
47
|
+
return code < 0x21 || code > 0x7e;
|
|
48
|
+
})) {
|
|
49
|
+
configurationError('HYPEQUERY_API_TOKEN contains invalid characters or length.');
|
|
50
|
+
}
|
|
51
|
+
return input;
|
|
52
|
+
}
|
|
53
|
+
function timeout(input) {
|
|
54
|
+
const value = input ?? DEFAULT_TIMEOUT_MS;
|
|
55
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > 10 * 60_000) {
|
|
56
|
+
configurationError('Deployment upload timeout must be between 1 and 600000 milliseconds.');
|
|
57
|
+
}
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
60
|
+
function multipartBundlePath(input) {
|
|
61
|
+
if (!/^[\x21-\x7e]+$/.test(input) || input.includes('"') || input.includes('\\')) {
|
|
62
|
+
throw new DeploymentUploadError('HQ_UPLOAD_BUNDLE_CHANGED', 'A bundle path cannot be safely encoded as a multipart header.');
|
|
63
|
+
}
|
|
64
|
+
return input;
|
|
65
|
+
}
|
|
66
|
+
function validateMultipartBundlePaths(bundle) {
|
|
67
|
+
multipartBundlePath(bundle.manifest.deployment.path);
|
|
68
|
+
for (const artifact of bundle.manifest.artifacts)
|
|
69
|
+
multipartBundlePath(artifact.path);
|
|
70
|
+
}
|
|
71
|
+
function partHeader(boundary, part) {
|
|
72
|
+
const headers = [
|
|
73
|
+
`--${boundary}`,
|
|
74
|
+
`Content-Disposition: form-data; name="${part.name}"; filename="${part.filename}"`,
|
|
75
|
+
`Content-Type: ${part.contentType}`,
|
|
76
|
+
...(part.bundlePath
|
|
77
|
+
? [`X-HypeQuery-Bundle-Path: ${multipartBundlePath(part.bundlePath)}`]
|
|
78
|
+
: []),
|
|
79
|
+
'',
|
|
80
|
+
'',
|
|
81
|
+
].join('\r\n');
|
|
82
|
+
return textEncoder.encode(headers);
|
|
83
|
+
}
|
|
84
|
+
function uploadParts(bundle, release, manifest) {
|
|
85
|
+
const root = bundle.directory;
|
|
86
|
+
return Object.freeze([
|
|
87
|
+
{
|
|
88
|
+
kind: 'bytes',
|
|
89
|
+
name: 'release',
|
|
90
|
+
filename: 'release.json',
|
|
91
|
+
contentType: 'application/json',
|
|
92
|
+
bytes: release.bytes,
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
kind: 'bytes',
|
|
96
|
+
name: 'bundle',
|
|
97
|
+
filename: DEPLOYMENT_BUNDLE_MANIFEST,
|
|
98
|
+
contentType: 'application/json',
|
|
99
|
+
bundlePath: DEPLOYMENT_BUNDLE_MANIFEST,
|
|
100
|
+
bytes: textEncoder.encode(`${manifest.canonical}\n`),
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
kind: 'file',
|
|
104
|
+
name: 'bundle',
|
|
105
|
+
filename: path.basename(manifest.manifest.deployment.path),
|
|
106
|
+
contentType: 'application/json',
|
|
107
|
+
absolutePath: path.join(root, ...manifest.manifest.deployment.path.split('/')),
|
|
108
|
+
bundlePath: manifest.manifest.deployment.path,
|
|
109
|
+
byteLength: manifest.manifest.deployment.byteLength,
|
|
110
|
+
sha256: manifest.manifest.deployment.sha256,
|
|
111
|
+
},
|
|
112
|
+
...manifest.manifest.artifacts.map(artifact => ({
|
|
113
|
+
kind: 'file',
|
|
114
|
+
name: 'bundle',
|
|
115
|
+
filename: path.basename(artifact.path),
|
|
116
|
+
contentType: 'application/octet-stream',
|
|
117
|
+
absolutePath: path.join(root, ...artifact.path.split('/')),
|
|
118
|
+
bundlePath: artifact.path,
|
|
119
|
+
byteLength: artifact.byteLength,
|
|
120
|
+
sha256: artifact.sha256,
|
|
121
|
+
})),
|
|
122
|
+
]);
|
|
123
|
+
}
|
|
124
|
+
async function* streamVerifiedFile(part) {
|
|
125
|
+
let handle;
|
|
126
|
+
try {
|
|
127
|
+
handle = await open(part.absolutePath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
128
|
+
const initial = await handle.stat();
|
|
129
|
+
if (!initial.isFile() || initial.size !== part.byteLength) {
|
|
130
|
+
throw new DeploymentUploadError('HQ_UPLOAD_BUNDLE_CHANGED', `Bundle entry changed after verification: ${part.bundlePath}`);
|
|
131
|
+
}
|
|
132
|
+
const digest = createHash('sha256');
|
|
133
|
+
let bytesRead = 0;
|
|
134
|
+
for await (const value of handle.createReadStream({ autoClose: false })) {
|
|
135
|
+
const chunk = value;
|
|
136
|
+
bytesRead += chunk.byteLength;
|
|
137
|
+
if (bytesRead > part.byteLength) {
|
|
138
|
+
throw new DeploymentUploadError('HQ_UPLOAD_BUNDLE_CHANGED', `Bundle entry changed after verification: ${part.bundlePath}`);
|
|
139
|
+
}
|
|
140
|
+
digest.update(chunk);
|
|
141
|
+
yield chunk;
|
|
142
|
+
}
|
|
143
|
+
if (bytesRead !== part.byteLength || digest.digest('hex') !== part.sha256) {
|
|
144
|
+
throw new DeploymentUploadError('HQ_UPLOAD_BUNDLE_CHANGED', `Bundle entry changed after verification: ${part.bundlePath}`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
if (error instanceof DeploymentUploadError)
|
|
149
|
+
throw error;
|
|
150
|
+
throw new DeploymentUploadError('HQ_UPLOAD_BUNDLE_CHANGED', `Cannot safely read verified bundle entry ${part.bundlePath}: `
|
|
151
|
+
+ (error instanceof Error ? error.message : String(error)));
|
|
152
|
+
}
|
|
153
|
+
finally {
|
|
154
|
+
await handle?.close();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
function multipartLength(boundary, parts) {
|
|
158
|
+
const separatorBytes = 2;
|
|
159
|
+
const ending = textEncoder.encode(`--${boundary}--\r\n`).byteLength;
|
|
160
|
+
return parts.reduce((total, part) => (total + partHeader(boundary, part).byteLength
|
|
161
|
+
+ (part.kind === 'bytes' ? part.bytes.byteLength : part.byteLength)
|
|
162
|
+
+ separatorBytes), ending);
|
|
163
|
+
}
|
|
164
|
+
async function* multipartBody(boundary, parts) {
|
|
165
|
+
const separator = textEncoder.encode('\r\n');
|
|
166
|
+
for (const part of parts) {
|
|
167
|
+
yield partHeader(boundary, part);
|
|
168
|
+
if (part.kind === 'bytes') {
|
|
169
|
+
yield part.bytes;
|
|
170
|
+
}
|
|
171
|
+
else {
|
|
172
|
+
yield* streamVerifiedFile(part);
|
|
173
|
+
}
|
|
174
|
+
yield separator;
|
|
175
|
+
}
|
|
176
|
+
yield textEncoder.encode(`--${boundary}--\r\n`);
|
|
177
|
+
}
|
|
178
|
+
async function readBoundedResponse(response) {
|
|
179
|
+
if (!response.body)
|
|
180
|
+
return '';
|
|
181
|
+
const reader = response.body.getReader();
|
|
182
|
+
const chunks = [];
|
|
183
|
+
let total = 0;
|
|
184
|
+
try {
|
|
185
|
+
for (;;) {
|
|
186
|
+
const { done, value } = await reader.read();
|
|
187
|
+
if (done)
|
|
188
|
+
break;
|
|
189
|
+
total += value.byteLength;
|
|
190
|
+
if (total > MAX_RESPONSE_BYTES) {
|
|
191
|
+
await reader.cancel().catch(() => undefined);
|
|
192
|
+
throw new DeploymentUploadError('HQ_UPLOAD_INVALID_RESPONSE', 'Deployment service response exceeds 65536 bytes.', response.status);
|
|
193
|
+
}
|
|
194
|
+
chunks.push(value);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
finally {
|
|
198
|
+
reader.releaseLock();
|
|
199
|
+
}
|
|
200
|
+
const bytes = new Uint8Array(total);
|
|
201
|
+
let offset = 0;
|
|
202
|
+
for (const chunk of chunks) {
|
|
203
|
+
bytes.set(chunk, offset);
|
|
204
|
+
offset += chunk.byteLength;
|
|
205
|
+
}
|
|
206
|
+
try {
|
|
207
|
+
return textDecoder.decode(bytes);
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
throw new DeploymentUploadError('HQ_UPLOAD_INVALID_RESPONSE', 'Deployment service response is not valid UTF-8.', response.status);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
function exactRecord(value, fields) {
|
|
214
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)
|
|
215
|
+
|| Object.getPrototypeOf(value) !== Object.prototype)
|
|
216
|
+
return undefined;
|
|
217
|
+
const record = value;
|
|
218
|
+
const keys = Object.keys(record).sort();
|
|
219
|
+
const expected = [...fields].sort();
|
|
220
|
+
if (keys.length !== expected.length || keys.some((key, index) => key !== expected[index])) {
|
|
221
|
+
return undefined;
|
|
222
|
+
}
|
|
223
|
+
return record;
|
|
224
|
+
}
|
|
225
|
+
function validateSubmissionResponse(input, bundleIdentity, releaseIdentity, status) {
|
|
226
|
+
const value = exactRecord(input, [
|
|
227
|
+
'kind',
|
|
228
|
+
'version',
|
|
229
|
+
'status',
|
|
230
|
+
'releaseIdentity',
|
|
231
|
+
'bundleIdentity',
|
|
232
|
+
]);
|
|
233
|
+
if (!value
|
|
234
|
+
|| value.kind !== 'hypequery-deployment-submission'
|
|
235
|
+
|| value.version !== 1
|
|
236
|
+
|| (value.status !== 'accepted' && value.status !== 'already-exists')
|
|
237
|
+
|| typeof value.releaseIdentity !== 'string'
|
|
238
|
+
|| typeof value.bundleIdentity !== 'string'
|
|
239
|
+
|| !SHA256_PATTERN.test(value.releaseIdentity)
|
|
240
|
+
|| !SHA256_PATTERN.test(value.bundleIdentity)
|
|
241
|
+
|| value.releaseIdentity !== releaseIdentity
|
|
242
|
+
|| value.bundleIdentity !== bundleIdentity) {
|
|
243
|
+
throw new DeploymentUploadError('HQ_UPLOAD_INVALID_RESPONSE', 'Deployment service returned an invalid or mismatched submission response.', status);
|
|
244
|
+
}
|
|
245
|
+
return Object.freeze({
|
|
246
|
+
kind: value.kind,
|
|
247
|
+
version: value.version,
|
|
248
|
+
status: value.status,
|
|
249
|
+
releaseIdentity: value.releaseIdentity,
|
|
250
|
+
bundleIdentity: value.bundleIdentity,
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
function rejectedMessage(response, body) {
|
|
254
|
+
let detail = '';
|
|
255
|
+
try {
|
|
256
|
+
const parsed = JSON.parse(body);
|
|
257
|
+
const error = typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
|
|
258
|
+
? parsed.error
|
|
259
|
+
: undefined;
|
|
260
|
+
if (typeof error === 'object' && error !== null && !Array.isArray(error)) {
|
|
261
|
+
const code = error.code;
|
|
262
|
+
const message = error.message;
|
|
263
|
+
if (typeof code === 'string' && /^[A-Z0-9_:-]{1,64}$/.test(code))
|
|
264
|
+
detail += ` ${code}`;
|
|
265
|
+
if (typeof message === 'string') {
|
|
266
|
+
const safe = [...message].map(character => {
|
|
267
|
+
const codePoint = character.codePointAt(0) ?? 0;
|
|
268
|
+
return codePoint <= 0x1f || codePoint === 0x7f ? ' ' : character;
|
|
269
|
+
}).join('').slice(0, 512).trim();
|
|
270
|
+
if (safe)
|
|
271
|
+
detail += `: ${safe}`;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
// Rejection bodies are optional; status remains authoritative.
|
|
277
|
+
}
|
|
278
|
+
return `Deployment service rejected the submission (${response.status} ${response.statusText})${detail}.`;
|
|
279
|
+
}
|
|
280
|
+
function nestedUploadError(input) {
|
|
281
|
+
const seen = new Set();
|
|
282
|
+
let current = input;
|
|
283
|
+
while (typeof current === 'object' && current !== null && !seen.has(current)) {
|
|
284
|
+
if (current instanceof DeploymentUploadError)
|
|
285
|
+
return current;
|
|
286
|
+
seen.add(current);
|
|
287
|
+
if (!('cause' in current))
|
|
288
|
+
return undefined;
|
|
289
|
+
current = current.cause;
|
|
290
|
+
}
|
|
291
|
+
return undefined;
|
|
292
|
+
}
|
|
293
|
+
const defaultFetch = async (input, init) => fetch(input, init);
|
|
294
|
+
export function createHttpDeploymentUploadTransport(options) {
|
|
295
|
+
const endpoint = endpointUrl(options.endpoint);
|
|
296
|
+
const token = bearerToken(options.token);
|
|
297
|
+
const timeoutMs = timeout(options.timeoutMs);
|
|
298
|
+
const fetchImplementation = options.fetch ?? defaultFetch;
|
|
299
|
+
const transport = {
|
|
300
|
+
async submit(bundle, release) {
|
|
301
|
+
validateMultipartBundlePaths(bundle);
|
|
302
|
+
const manifest = prepareProtocolDeploymentBundleManifest(bundle.manifest);
|
|
303
|
+
const preparedRelease = prepareProtocolDeploymentReleaseEnvelope(release.release);
|
|
304
|
+
if (manifest.identity !== bundle.identity
|
|
305
|
+
|| preparedRelease.identity !== release.identity
|
|
306
|
+
|| preparedRelease.release.bundleIdentity !== manifest.identity) {
|
|
307
|
+
throw new DeploymentUploadError('HQ_UPLOAD_IDENTITY_MISMATCH', 'Release and bundle identities do not match their verified content.');
|
|
308
|
+
}
|
|
309
|
+
const boundary = `hypequery-${randomUUID()}`;
|
|
310
|
+
const parts = uploadParts(bundle, preparedRelease, manifest);
|
|
311
|
+
let response;
|
|
312
|
+
try {
|
|
313
|
+
response = await fetchImplementation(endpoint, {
|
|
314
|
+
method: 'POST',
|
|
315
|
+
headers: {
|
|
316
|
+
Accept: 'application/json',
|
|
317
|
+
Authorization: `Bearer ${token}`,
|
|
318
|
+
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
|
319
|
+
'Content-Length': String(multipartLength(boundary, parts)),
|
|
320
|
+
'Idempotency-Key': preparedRelease.identity,
|
|
321
|
+
'X-HypeQuery-Bundle-Identity': manifest.identity,
|
|
322
|
+
'X-HypeQuery-Release-Identity': preparedRelease.identity,
|
|
323
|
+
},
|
|
324
|
+
body: multipartBody(boundary, parts),
|
|
325
|
+
duplex: 'half',
|
|
326
|
+
redirect: 'error',
|
|
327
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
catch (error) {
|
|
331
|
+
const uploadError = nestedUploadError(error);
|
|
332
|
+
if (uploadError)
|
|
333
|
+
throw uploadError;
|
|
334
|
+
throw new DeploymentUploadError('HQ_UPLOAD_NETWORK', `Deployment service request failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
335
|
+
}
|
|
336
|
+
let body;
|
|
337
|
+
try {
|
|
338
|
+
body = await readBoundedResponse(response);
|
|
339
|
+
}
|
|
340
|
+
catch (error) {
|
|
341
|
+
if (error instanceof DeploymentUploadError)
|
|
342
|
+
throw error;
|
|
343
|
+
throw new DeploymentUploadError('HQ_UPLOAD_NETWORK', `Deployment service response failed: ${error instanceof Error ? error.message : String(error)}`, response.status);
|
|
344
|
+
}
|
|
345
|
+
if (!response.ok) {
|
|
346
|
+
throw new DeploymentUploadError('HQ_UPLOAD_REJECTED', rejectedMessage(response, body), response.status);
|
|
347
|
+
}
|
|
348
|
+
let input;
|
|
349
|
+
try {
|
|
350
|
+
input = JSON.parse(body);
|
|
351
|
+
}
|
|
352
|
+
catch {
|
|
353
|
+
throw new DeploymentUploadError('HQ_UPLOAD_INVALID_RESPONSE', 'Deployment service returned invalid JSON.', response.status);
|
|
354
|
+
}
|
|
355
|
+
return validateSubmissionResponse(input, manifest.identity, preparedRelease.identity, response.status);
|
|
356
|
+
},
|
|
357
|
+
};
|
|
358
|
+
return Object.freeze(transport);
|
|
359
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hypequery/cli",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.9.1",
|
|
4
4
|
"description": "Command-line interface for hypequery",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -19,7 +19,8 @@
|
|
|
19
19
|
"open": "^10.0.0",
|
|
20
20
|
"ora": "^8.0.1",
|
|
21
21
|
"prompts": "^2.4.2",
|
|
22
|
-
"@hypequery/
|
|
22
|
+
"@hypequery/deployment": "0.2.0",
|
|
23
|
+
"@hypequery/protocol": "0.6.0"
|
|
23
24
|
},
|
|
24
25
|
"peerDependencies": {
|
|
25
26
|
"@hypequery/clickhouse": "*",
|
|
@@ -37,7 +38,7 @@
|
|
|
37
38
|
"typescript": "^5.7.3",
|
|
38
39
|
"vitest": "^3.2.6",
|
|
39
40
|
"@hypequery/clickhouse": "2.4.0",
|
|
40
|
-
"@hypequery/serve": "0.13.
|
|
41
|
+
"@hypequery/serve": "0.13.3"
|
|
41
42
|
},
|
|
42
43
|
"repository": {
|
|
43
44
|
"type": "git",
|