@quantum-hub/qhubctl 2.0.0 → 2.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/dist/commands/compress.js +3 -12
- package/dist/commands/login.js +10 -17
- package/dist/commands/logout.js +9 -2
- package/dist/commands/up.js +174 -62
- package/dist/lib/up/qhubignore-help.d.ts +7 -0
- package/dist/lib/up/qhubignore-help.js +19 -0
- package/dist/lib/up/source.d.ts +37 -0
- package/dist/lib/up/source.js +63 -0
- package/dist/model/command.d.ts +1 -0
- package/dist/model/command.js +4 -0
- package/dist/model/managed-service-config.d.ts +6 -0
- package/dist/model/user-config.d.ts +9 -0
- package/dist/model/user-config.js +19 -0
- package/dist/service/quantum-hub-service.d.ts +13 -1
- package/dist/service/quantum-hub-service.js +31 -6
- package/package.json +1 -1
|
@@ -3,22 +3,13 @@ import fs from 'fs-extra';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { zipProjectFolder } from '../lib/up/zip.js';
|
|
5
5
|
import { enforceRequiredFiles } from '../lib/up/required-files.js';
|
|
6
|
+
import { QHUBIGNORE_HELP } from '../lib/up/qhubignore-help.js';
|
|
6
7
|
import { error, spinner, stdout } from '../lib/output.js';
|
|
7
8
|
const EXAMPLES = `
|
|
8
9
|
Examples:
|
|
9
|
-
$ qhubctl compress
|
|
10
|
-
|
|
11
|
-
The '.qhubignore' file uses gitignore syntax. To ship only 'src' and the
|
|
12
|
-
Dockerfile, re-include the directory AND its contents:
|
|
13
|
-
|
|
14
|
-
*
|
|
15
|
-
!src
|
|
16
|
-
!src/**
|
|
17
|
-
!Dockerfile
|
|
18
|
-
|
|
19
|
-
These paths are always excluded: .git, node_modules, .venv, __pycache__, service.zip`;
|
|
10
|
+
$ qhubctl compress`;
|
|
20
11
|
export const compressCommand = new Command('compress')
|
|
21
|
-
.description(
|
|
12
|
+
.description(`Compresses the current project and creates a ZIP file. ${QHUBIGNORE_HELP}`)
|
|
22
13
|
.addHelpText('after', EXAMPLES)
|
|
23
14
|
.action(async () => {
|
|
24
15
|
const spin = spinner('Compressing project');
|
package/dist/commands/login.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { password } from '@inquirer/prompts';
|
|
2
2
|
import { Command } from 'commander';
|
|
3
3
|
import { spinner } from '../lib/output.js';
|
|
4
|
-
import { configDir, getUserConfig, showCustomBasePathWarning } from '../model/command.js';
|
|
5
|
-
import { AuthType, defaultBasePath } from '../model/user-config.js';
|
|
4
|
+
import { configDir, getUserConfig, showBasePathRevertNotice, showCustomBasePathWarning } from '../model/command.js';
|
|
5
|
+
import { AuthType, defaultBasePath, hasCustomBasePath, resolveEndpoint } from '../model/user-config.js';
|
|
6
6
|
import AuthService from '../service/auth-service.js';
|
|
7
7
|
import { writeUserConfig } from '../service/user-config-service.js';
|
|
8
8
|
const EXAMPLES = `
|
|
@@ -23,19 +23,14 @@ export const loginCommand = new Command('login')
|
|
|
23
23
|
if (!token) {
|
|
24
24
|
throw new Error('Invalid credentials. Please try again.');
|
|
25
25
|
}
|
|
26
|
+
const userConfig = getUserConfig();
|
|
27
|
+
const endpoint = resolveEndpoint(userConfig, basePath);
|
|
28
|
+
const revertingToDefault = !basePath && hasCustomBasePath(userConfig);
|
|
26
29
|
if (basePath) {
|
|
27
30
|
showCustomBasePathWarning();
|
|
28
31
|
}
|
|
29
32
|
const spin = spinner('Login...');
|
|
30
|
-
const
|
|
31
|
-
const tempUserConfig = basePath ? {
|
|
32
|
-
...userConfig,
|
|
33
|
-
endpoint: {
|
|
34
|
-
basePath,
|
|
35
|
-
defaultHeaders: userConfig.endpoint?.defaultHeaders || {}
|
|
36
|
-
}
|
|
37
|
-
} : userConfig;
|
|
38
|
-
const user = await new AuthService(tempUserConfig).authenticate(token);
|
|
33
|
+
const user = await new AuthService({ ...userConfig, endpoint }).authenticate(token);
|
|
39
34
|
const newUserConfig = {
|
|
40
35
|
...userConfig,
|
|
41
36
|
auth: {
|
|
@@ -47,13 +42,11 @@ export const loginCommand = new Command('login')
|
|
|
47
42
|
displayName: `${user.firstname} ${user.lastname}`,
|
|
48
43
|
isOrganization: false,
|
|
49
44
|
},
|
|
50
|
-
|
|
51
|
-
endpoint: {
|
|
52
|
-
basePath,
|
|
53
|
-
defaultHeaders: userConfig.endpoint?.defaultHeaders || {}
|
|
54
|
-
}
|
|
55
|
-
})
|
|
45
|
+
endpoint,
|
|
56
46
|
};
|
|
57
47
|
writeUserConfig(configDir, newUserConfig);
|
|
58
48
|
spin.succeed('Login successful');
|
|
49
|
+
if (revertingToDefault) {
|
|
50
|
+
showBasePathRevertNotice();
|
|
51
|
+
}
|
|
59
52
|
});
|
package/dist/commands/logout.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
2
|
import { spinner } from '../lib/output.js';
|
|
3
|
-
import { configDir, getUserConfig } from '../model/command.js';
|
|
3
|
+
import { configDir, getUserConfig, showBasePathRevertNotice } from '../model/command.js';
|
|
4
|
+
import { hasCustomBasePath } from '../model/user-config.js';
|
|
4
5
|
import { writeUserConfig } from '../service/user-config-service.js';
|
|
5
6
|
const EXAMPLES = `
|
|
6
7
|
Examples:
|
|
@@ -9,11 +10,17 @@ export const logoutCommand = new Command('logout')
|
|
|
9
10
|
.description('Logout')
|
|
10
11
|
.addHelpText('after', EXAMPLES)
|
|
11
12
|
.action(async () => {
|
|
13
|
+
const userConfig = getUserConfig();
|
|
14
|
+
const revertingToDefault = hasCustomBasePath(userConfig);
|
|
12
15
|
const spin = spinner('Logout');
|
|
13
16
|
writeUserConfig(configDir, {
|
|
14
|
-
...
|
|
17
|
+
...userConfig,
|
|
15
18
|
auth: undefined,
|
|
16
19
|
context: undefined,
|
|
20
|
+
endpoint: undefined,
|
|
17
21
|
});
|
|
18
22
|
spin.succeed('Logout successful');
|
|
23
|
+
if (revertingToDefault) {
|
|
24
|
+
showBasePathRevertNotice();
|
|
25
|
+
}
|
|
19
26
|
});
|
package/dist/commands/up.js
CHANGED
|
@@ -1,18 +1,25 @@
|
|
|
1
|
-
import { confirm } from '@inquirer/prompts';
|
|
2
|
-
import {
|
|
1
|
+
import { confirm, input, password } from '@inquirer/prompts';
|
|
2
|
+
import { HubPlatform } from '@quantum-hub/qhub-api/platform';
|
|
3
3
|
import { waitUntil } from 'async-wait-until';
|
|
4
|
+
import { Command } from 'commander';
|
|
4
5
|
import fs from 'node:fs';
|
|
5
6
|
import path from 'node:path';
|
|
6
|
-
import {
|
|
7
|
+
import { error, spinner, stdout, warn } from '../lib/output.js';
|
|
7
8
|
import { streamToBlob } from '../lib/stream-to-blob.js';
|
|
8
9
|
import { getDescriptionText } from '../lib/up/get-description.js';
|
|
9
|
-
import {
|
|
10
|
+
import { QHUBIGNORE_HELP } from '../lib/up/qhubignore-help.js';
|
|
10
11
|
import { enforceRequiredFiles } from '../lib/up/required-files.js';
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
12
|
+
import { buildServiceSource, isRegistrySource, resolveRegistryCredentials } from '../lib/up/source.js';
|
|
13
|
+
import { zipProjectFolder } from '../lib/up/zip.js';
|
|
13
14
|
import { requireAuth } from '../model/command.js';
|
|
14
15
|
import QuantumHubService from '../service/quantum-hub-service.js';
|
|
15
16
|
import { readServiceConfig, writeServiceConfig } from '../service/service-config-service.js';
|
|
17
|
+
/**
|
|
18
|
+
* Prints an error message and exits. Service-layer errors already carry a resolved message.
|
|
19
|
+
*/
|
|
20
|
+
function fail(err) {
|
|
21
|
+
error(err instanceof Error ? err.message : String(err));
|
|
22
|
+
}
|
|
16
23
|
async function getOpenapi() {
|
|
17
24
|
const openapiYamlPath = path.join(process.cwd(), 'openapi.yaml');
|
|
18
25
|
const openapiYmlPath = path.join(process.cwd(), 'openapi.yml');
|
|
@@ -25,6 +32,28 @@ async function getOpenapi() {
|
|
|
25
32
|
return streamToBlob(openapi);
|
|
26
33
|
return undefined;
|
|
27
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* Gathers the optional service metadata files (OpenAPI contract and the
|
|
37
|
+
* configured description) from the project. Both are independent of the source
|
|
38
|
+
* kind, so file and registry deploys gather them the same way.
|
|
39
|
+
*/
|
|
40
|
+
async function getServiceMetadata() {
|
|
41
|
+
return {
|
|
42
|
+
openapi: await getOpenapi(),
|
|
43
|
+
descriptionText: await getDescriptionText(process.cwd()),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Uploads the optional service metadata that is independent of the source: the
|
|
48
|
+
* description and the OpenAPI contract. Each is uploaded only when present, so
|
|
49
|
+
* an image-only service deploys with neither.
|
|
50
|
+
*/
|
|
51
|
+
async function uploadServiceMetadata(qhubService, serviceConfig, metadata) {
|
|
52
|
+
if (metadata.descriptionText)
|
|
53
|
+
await qhubService.updateDescription(serviceConfig, metadata.descriptionText);
|
|
54
|
+
if (metadata.openapi)
|
|
55
|
+
await qhubService.updateOpenapi(serviceConfig, metadata.openapi);
|
|
56
|
+
}
|
|
28
57
|
async function buildServiceJob(qhubService, service, silentMode) {
|
|
29
58
|
let buildJob;
|
|
30
59
|
let spin;
|
|
@@ -66,47 +95,56 @@ async function buildServiceJob(qhubService, service, silentMode) {
|
|
|
66
95
|
}
|
|
67
96
|
return buildJob;
|
|
68
97
|
}
|
|
69
|
-
async function createService(qhubService, serviceConfig,
|
|
98
|
+
async function createService(qhubService, serviceConfig, source, metadata, sourceCode, silentMode, noSaveId = false, customName) {
|
|
70
99
|
const spin = silentMode ? undefined : spinner('Creating service');
|
|
71
100
|
const configToUse = { ...serviceConfig };
|
|
72
101
|
if (customName)
|
|
73
102
|
configToUse.name = customName;
|
|
74
|
-
const service = await qhubService.createService(configToUse,
|
|
75
|
-
serviceConfig.serviceId = service
|
|
103
|
+
const service = await qhubService.createService(configToUse, source, sourceCode);
|
|
104
|
+
serviceConfig.serviceId = service.id;
|
|
76
105
|
if (!noSaveId)
|
|
77
106
|
writeServiceConfig(process.cwd(), serviceConfig);
|
|
78
|
-
|
|
79
|
-
await qhubService.updateDescription(serviceConfig, files.descriptionText);
|
|
80
|
-
if (files.openapi)
|
|
81
|
-
await qhubService.updateOpenapi(serviceConfig, files.openapi);
|
|
107
|
+
await uploadServiceMetadata(qhubService, serviceConfig, metadata);
|
|
82
108
|
spin?.succeed();
|
|
83
109
|
return service;
|
|
84
110
|
}
|
|
85
|
-
async function updateService(qhubService, serviceConfig,
|
|
111
|
+
async function updateService(qhubService, serviceConfig, source, metadata, sourceCode, silentMode) {
|
|
86
112
|
const spin = silentMode ? undefined : spinner('Updating service');
|
|
87
|
-
|
|
113
|
+
// A registry source is set via the platform's update-source operation; a file
|
|
114
|
+
// source uploads a fresh code archive. Either way the optional metadata follows.
|
|
115
|
+
if (source.type === 'registry') {
|
|
116
|
+
await qhubService.updateServiceSource(serviceConfig.serviceId, source);
|
|
117
|
+
await uploadServiceMetadata(qhubService, serviceConfig, metadata);
|
|
118
|
+
spin?.succeed();
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
const result = await qhubService.updateService(serviceConfig, metadata.descriptionText, sourceCode, metadata.openapi);
|
|
88
122
|
spin?.succeed();
|
|
89
123
|
return result;
|
|
90
124
|
}
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
.
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
125
|
+
/**
|
|
126
|
+
* Resolves registry pull credentials, falling back to an interactive prompt
|
|
127
|
+
* when none are supplied via flags or environment. A blank username means a
|
|
128
|
+
* public/anonymous pull. Never prompts in silent mode. Credentials are never
|
|
129
|
+
* persisted to qhub.json (see ADR 0001).
|
|
130
|
+
*/
|
|
131
|
+
async function resolveCredentialsInteractively(opts, silentMode) {
|
|
132
|
+
const credentials = resolveRegistryCredentials({ username: opts.registryUsername, password: opts.registryPassword }, process.env);
|
|
133
|
+
if (credentials.username || silentMode) {
|
|
134
|
+
return credentials;
|
|
135
|
+
}
|
|
136
|
+
const username = await input({ message: 'Registry username (leave blank for a public image)' });
|
|
137
|
+
if (!username) {
|
|
138
|
+
return credentials;
|
|
139
|
+
}
|
|
140
|
+
const registryPassword = await password({ message: 'Registry password' });
|
|
141
|
+
return { username, password: registryPassword };
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Emits non-fatal warnings for deprecated qhub.json fields and legacy file
|
|
145
|
+
* names in the project. Purely informational; never throws.
|
|
146
|
+
*/
|
|
147
|
+
function warnAboutDeprecations(serviceConfig) {
|
|
110
148
|
if (!('descriptionFile' in serviceConfig)) {
|
|
111
149
|
stdout('Warning: Since version 2.14.0, the "description" property in qhub.json has changed to "descriptionFile".\nFor more information, visit: https://docs.hub.kipu-quantum.com/qhub-json-reference.html\n');
|
|
112
150
|
}
|
|
@@ -114,46 +152,67 @@ export const upCommand = new Command('up')
|
|
|
114
152
|
if (fs.existsSync(deprecatedOpenapiFile)) {
|
|
115
153
|
warn('You are having the deprecated file "openapi-spec.yml" in your project. Please consider renaming it to "openapi.yaml".');
|
|
116
154
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Checks whether the configured service still exists in the platform. When a
|
|
158
|
+
* service ID is set in qhub.json but no matching service is found, prompts to
|
|
159
|
+
* create a new one and exits if the user declines.
|
|
160
|
+
*/
|
|
161
|
+
async function resolveServiceExists(qhubService, serviceConfig) {
|
|
162
|
+
if (!serviceConfig.serviceId)
|
|
163
|
+
return false;
|
|
164
|
+
let serviceExistsInPlatform;
|
|
165
|
+
try {
|
|
166
|
+
await qhubService.getService(serviceConfig.serviceId);
|
|
167
|
+
serviceExistsInPlatform = true;
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
serviceExistsInPlatform = false;
|
|
126
171
|
}
|
|
127
|
-
if (
|
|
172
|
+
if (!serviceExistsInPlatform) {
|
|
128
173
|
stdout(`We could not find a service with id '${serviceConfig.serviceId}' in the current context!`);
|
|
129
174
|
const createNewService = await confirm({ message: 'Do you want to create a new service?' });
|
|
130
175
|
if (!createNewService)
|
|
131
176
|
process.exit(0);
|
|
132
177
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
178
|
+
return serviceExistsInPlatform;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Resolves the platform ServiceSource for this deploy: enforces the required
|
|
182
|
+
* files for a file source, gathers pull credentials for a registry source, and
|
|
183
|
+
* builds the final source descriptor. Exits on a build error.
|
|
184
|
+
*/
|
|
185
|
+
async function resolveServiceSource(serviceConfig, opts, silentMode) {
|
|
186
|
+
const registrySource = isRegistrySource(serviceConfig, opts.image);
|
|
187
|
+
// A registry source is pre-built and never packaged locally, so the
|
|
188
|
+
// required-files check (Dockerfile / requirements.txt) does not apply.
|
|
189
|
+
if (!registrySource) {
|
|
190
|
+
try {
|
|
191
|
+
await enforceRequiredFiles(process.cwd());
|
|
192
|
+
}
|
|
193
|
+
catch (err) {
|
|
194
|
+
error(err.message);
|
|
195
|
+
}
|
|
138
196
|
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
let service;
|
|
197
|
+
// A registry source needs pull credentials (flags / env / prompt); a file
|
|
198
|
+
// source needs none. Both then resolve to a single platform ServiceSource.
|
|
199
|
+
const credentials = registrySource
|
|
200
|
+
? await resolveCredentialsInteractively(opts, silentMode)
|
|
201
|
+
: undefined;
|
|
145
202
|
try {
|
|
146
|
-
|
|
147
|
-
? await updateService(qhubService, serviceConfig, serviceFiles, silentMode)
|
|
148
|
-
: await createService(qhubService, serviceConfig, serviceFiles, silentMode, noSaveId, opts.name);
|
|
203
|
+
return buildServiceSource(serviceConfig, opts.image, credentials);
|
|
149
204
|
}
|
|
150
205
|
catch (err) {
|
|
151
|
-
|
|
206
|
+
fail(err);
|
|
152
207
|
}
|
|
153
|
-
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Reports the outcome of a build job: a success message, a failure with the
|
|
211
|
+
* platform's validation summary when available, or a pending notice.
|
|
212
|
+
*/
|
|
213
|
+
function reportBuildResult(buildJob, serviceExistsInPlatform) {
|
|
154
214
|
if (buildJob?.status === HubPlatform.BuildJobDto.Status.Success) {
|
|
155
|
-
|
|
156
|
-
stdout(msg);
|
|
215
|
+
stdout(`Service ${serviceExistsInPlatform ? 'updated' : 'created'} \u{1F680}`);
|
|
157
216
|
}
|
|
158
217
|
else if (buildJob?.status === HubPlatform.BuildJobDto.Status.Failure) {
|
|
159
218
|
const { validationResult } = buildJob;
|
|
@@ -166,4 +225,57 @@ export const upCommand = new Command('up')
|
|
|
166
225
|
else {
|
|
167
226
|
error('Still pending, please check the platform UI to determine the status');
|
|
168
227
|
}
|
|
228
|
+
}
|
|
229
|
+
const EXAMPLES = `
|
|
230
|
+
Examples:
|
|
231
|
+
$ qhubctl up
|
|
232
|
+
$ qhubctl up --no-save-id
|
|
233
|
+
$ qhubctl up --name "my-custom-service-name"
|
|
234
|
+
$ qhubctl up --name "test-service" --no-save-id
|
|
235
|
+
$ qhubctl up --image ghcr.io/acme/my-service:1.2.3`;
|
|
236
|
+
export const upCommand = new Command('up')
|
|
237
|
+
.description(`Creates or updates a service.\n\n${QHUBIGNORE_HELP}`)
|
|
238
|
+
.option('--silent', 'Suppresses all outputs, helpful when executed in a CI/CD pipeline.', false)
|
|
239
|
+
.option('--no-save-id', 'Prevents storing the created service ID in qhub.json file.')
|
|
240
|
+
.option('--name <value>', 'Override the service name from qhub.json with a custom name.')
|
|
241
|
+
.option('--image <ref>', 'Deploy a pre-built image from a container registry, given as registry/repo:tag (the tag defaults to "latest").')
|
|
242
|
+
.option('--registry-username <value>', 'Username to pull a private registry image. Falls back to the QHUB_REGISTRY_USERNAME environment variable.')
|
|
243
|
+
.option('--registry-password <value>', 'Password to pull a private registry image. Falls back to the QHUB_REGISTRY_PASSWORD environment variable.')
|
|
244
|
+
.addHelpText('after', EXAMPLES)
|
|
245
|
+
.action(async (opts) => {
|
|
246
|
+
const userConfig = await requireAuth();
|
|
247
|
+
const qhubService = new QuantumHubService(userConfig);
|
|
248
|
+
const silentMode = opts.silent;
|
|
249
|
+
// Commander inverts --no-save-id into opts.saveId = false
|
|
250
|
+
const noSaveId = !opts.saveId;
|
|
251
|
+
const serviceConfig = readServiceConfig(process.cwd());
|
|
252
|
+
warnAboutDeprecations(serviceConfig);
|
|
253
|
+
const serviceExistsInPlatform = await resolveServiceExists(qhubService, serviceConfig);
|
|
254
|
+
const source = await resolveServiceSource(serviceConfig, opts, silentMode);
|
|
255
|
+
// Metadata (openapi + description) is independent of the source kind.
|
|
256
|
+
const metadata = await getServiceMetadata();
|
|
257
|
+
// Only a file source ships local code; a registry source is pre-built.
|
|
258
|
+
const sourceCode = source.type === 'file' ? await zipProjectFolder('./') : undefined;
|
|
259
|
+
// The platform has no rename operation, so --name only takes effect on create.
|
|
260
|
+
if (serviceExistsInPlatform && opts.name && !silentMode) {
|
|
261
|
+
warn('Ignoring --name: the service name cannot be changed on update.');
|
|
262
|
+
}
|
|
263
|
+
let service;
|
|
264
|
+
try {
|
|
265
|
+
service = serviceExistsInPlatform
|
|
266
|
+
? await updateService(qhubService, serviceConfig, source, metadata, sourceCode, silentMode)
|
|
267
|
+
: await createService(qhubService, serviceConfig, source, metadata, sourceCode, silentMode, noSaveId, opts.name);
|
|
268
|
+
}
|
|
269
|
+
catch (err) {
|
|
270
|
+
fail(err);
|
|
271
|
+
}
|
|
272
|
+
// A registry source is pre-built — the platform runs no build job, so
|
|
273
|
+
// success is reported immediately. A file source must wait for the build.
|
|
274
|
+
if (source.type !== 'file') {
|
|
275
|
+
if (!silentMode)
|
|
276
|
+
stdout(`Service ${serviceExistsInPlatform ? 'updated' : 'created'} \u{1F680}`);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
const buildJob = await buildServiceJob(qhubService, service, silentMode);
|
|
280
|
+
reportBuildResult(buildJob, serviceExistsInPlatform);
|
|
169
281
|
});
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Markdown help text describing '.qhubignore' behaviour. Used in the
|
|
3
|
+
* command descriptions of both `compress` and `up`, which each package the
|
|
4
|
+
* project into a ZIP and honour the same ignore rules. Markdown so the
|
|
5
|
+
* generated CLI reference (docs:generate) renders the fenced code block.
|
|
6
|
+
*/
|
|
7
|
+
export declare const QHUBIGNORE_HELP: string;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Markdown help text describing '.qhubignore' behaviour. Used in the
|
|
3
|
+
* command descriptions of both `compress` and `up`, which each package the
|
|
4
|
+
* project into a ZIP and honour the same ignore rules. Markdown so the
|
|
5
|
+
* generated CLI reference (docs:generate) renders the fenced code block.
|
|
6
|
+
*/
|
|
7
|
+
export const QHUBIGNORE_HELP = [
|
|
8
|
+
"Use a '.qhubignore' file (gitignore syntax) to exclude files and directories from the ZIP file.",
|
|
9
|
+
'',
|
|
10
|
+
'`.git`, `node_modules`, `.venv`, `__pycache__`, and `service.zip` are always excluded.',
|
|
11
|
+
"Because `.qhubignore` uses gitignore syntax, re-including a directory's contents requires both the directory and its contents — for example, to ship only `src` and the `Dockerfile`:",
|
|
12
|
+
'',
|
|
13
|
+
'```',
|
|
14
|
+
'*',
|
|
15
|
+
'!src',
|
|
16
|
+
'!src/**',
|
|
17
|
+
'!Dockerfile',
|
|
18
|
+
'```',
|
|
19
|
+
].join('\n');
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { HubPlatform } from '@quantum-hub/qhub-api/platform';
|
|
2
|
+
import ManagedServiceConfig from '../../model/managed-service-config.js';
|
|
3
|
+
export interface RegistryCredentials {
|
|
4
|
+
username?: string;
|
|
5
|
+
password?: string;
|
|
6
|
+
}
|
|
7
|
+
export interface ImageRef {
|
|
8
|
+
image: string;
|
|
9
|
+
tag: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Splits an image reference (`registry/repo:tag`) into its image and tag.
|
|
13
|
+
* The tag defaults to `latest`. A colon belonging to a registry `host:port`
|
|
14
|
+
* (i.e. before the last path segment) is not treated as a tag separator.
|
|
15
|
+
*/
|
|
16
|
+
export declare function parseImageRef(ref: string): ImageRef;
|
|
17
|
+
/**
|
|
18
|
+
* Resolves registry pull credentials with precedence flag > environment.
|
|
19
|
+
* Returns an empty object when nothing is supplied; credentials are never
|
|
20
|
+
* persisted to qhub.json (see ADR 0001).
|
|
21
|
+
*/
|
|
22
|
+
export declare function resolveRegistryCredentials(flags: {
|
|
23
|
+
username?: string;
|
|
24
|
+
password?: string;
|
|
25
|
+
}, env: NodeJS.ProcessEnv): RegistryCredentials;
|
|
26
|
+
/**
|
|
27
|
+
* A service uses a registry source when its qhub.json declares
|
|
28
|
+
* `source.type: "registry"`, or when an `--image` override is supplied.
|
|
29
|
+
*/
|
|
30
|
+
export declare function isRegistrySource(config: ManagedServiceConfig, imageOverride?: string): boolean;
|
|
31
|
+
/**
|
|
32
|
+
* Builds the platform ServiceSource for an `up` deploy.
|
|
33
|
+
* Returns a file source unless the config or an `--image` override selects a
|
|
34
|
+
* registry source, in which case the image (override wins over qhub.json) and
|
|
35
|
+
* optional pull credentials are included.
|
|
36
|
+
*/
|
|
37
|
+
export declare function buildServiceSource(config: ManagedServiceConfig, imageOverride?: string, credentials?: RegistryCredentials): HubPlatform.ServiceSource;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
const DEFAULT_TAG = 'latest';
|
|
2
|
+
/**
|
|
3
|
+
* Splits an image reference (`registry/repo:tag`) into its image and tag.
|
|
4
|
+
* The tag defaults to `latest`. A colon belonging to a registry `host:port`
|
|
5
|
+
* (i.e. before the last path segment) is not treated as a tag separator.
|
|
6
|
+
*/
|
|
7
|
+
export function parseImageRef(ref) {
|
|
8
|
+
const lastSlash = ref.lastIndexOf('/');
|
|
9
|
+
const lastColon = ref.lastIndexOf(':');
|
|
10
|
+
if (lastColon > lastSlash) {
|
|
11
|
+
return { image: ref.slice(0, lastColon), tag: ref.slice(lastColon + 1) || DEFAULT_TAG };
|
|
12
|
+
}
|
|
13
|
+
return { image: ref, tag: DEFAULT_TAG };
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Resolves registry pull credentials with precedence flag > environment.
|
|
17
|
+
* Returns an empty object when nothing is supplied; credentials are never
|
|
18
|
+
* persisted to qhub.json (see ADR 0001).
|
|
19
|
+
*/
|
|
20
|
+
export function resolveRegistryCredentials(flags, env) {
|
|
21
|
+
const username = flags.username ?? env.QHUB_REGISTRY_USERNAME;
|
|
22
|
+
const password = flags.password ?? env.QHUB_REGISTRY_PASSWORD;
|
|
23
|
+
const credentials = {};
|
|
24
|
+
if (username)
|
|
25
|
+
credentials.username = username;
|
|
26
|
+
if (password)
|
|
27
|
+
credentials.password = password;
|
|
28
|
+
return credentials;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* A service uses a registry source when its qhub.json declares
|
|
32
|
+
* `source.type: "registry"`, or when an `--image` override is supplied.
|
|
33
|
+
*/
|
|
34
|
+
export function isRegistrySource(config, imageOverride) {
|
|
35
|
+
return Boolean(imageOverride) || config.source?.type === 'registry';
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Builds the platform ServiceSource for an `up` deploy.
|
|
39
|
+
* Returns a file source unless the config or an `--image` override selects a
|
|
40
|
+
* registry source, in which case the image (override wins over qhub.json) and
|
|
41
|
+
* optional pull credentials are included.
|
|
42
|
+
*/
|
|
43
|
+
export function buildServiceSource(config, imageOverride, credentials) {
|
|
44
|
+
if (!isRegistrySource(config, imageOverride)) {
|
|
45
|
+
return { type: 'file' };
|
|
46
|
+
}
|
|
47
|
+
const { image, tag } = resolveImageRef(config, imageOverride);
|
|
48
|
+
if (!image) {
|
|
49
|
+
throw new Error('A registry source requires an image. Set "source.image" in qhub.json or pass --image.');
|
|
50
|
+
}
|
|
51
|
+
const source = { type: 'registry', image, tag };
|
|
52
|
+
if (credentials?.username)
|
|
53
|
+
source.username = credentials.username;
|
|
54
|
+
if (credentials?.password)
|
|
55
|
+
source.password = credentials.password;
|
|
56
|
+
return source;
|
|
57
|
+
}
|
|
58
|
+
function resolveImageRef(config, imageOverride) {
|
|
59
|
+
if (imageOverride) {
|
|
60
|
+
return parseImageRef(imageOverride);
|
|
61
|
+
}
|
|
62
|
+
return { image: config.source?.image ?? '', tag: config.source?.tag ?? DEFAULT_TAG };
|
|
63
|
+
}
|
package/dist/model/command.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ export { configDir } from '../lib/config-dir.js';
|
|
|
3
3
|
export declare function getUserConfig(): UserConfig;
|
|
4
4
|
export declare function getContextDisplayString(userConfig: UserConfig): string;
|
|
5
5
|
export declare function showCustomBasePathWarning(): void;
|
|
6
|
+
export declare function showBasePathRevertNotice(): void;
|
|
6
7
|
/**
|
|
7
8
|
* Validates that the user is authenticated and returns the current UserConfig.
|
|
8
9
|
* Replaces AuthenticatedCommand.init().
|
package/dist/model/command.js
CHANGED
|
@@ -17,6 +17,10 @@ export function showCustomBasePathWarning() {
|
|
|
17
17
|
warn('Using custom base path. This is intended for internal development use only.');
|
|
18
18
|
stdout();
|
|
19
19
|
}
|
|
20
|
+
export function showBasePathRevertNotice() {
|
|
21
|
+
warn('Custom base path removed. Using the default base path.');
|
|
22
|
+
stdout();
|
|
23
|
+
}
|
|
20
24
|
/**
|
|
21
25
|
* Validates that the user is authenticated and returns the current UserConfig.
|
|
22
26
|
* Replaces AuthenticatedCommand.init().
|
|
@@ -5,6 +5,12 @@ export default interface ManagedServiceConfig {
|
|
|
5
5
|
resources?: ResourceConfiguration;
|
|
6
6
|
runtime?: Runtime;
|
|
7
7
|
serviceId?: string;
|
|
8
|
+
source?: ServiceSourceConfig;
|
|
9
|
+
}
|
|
10
|
+
export interface ServiceSourceConfig {
|
|
11
|
+
type: 'file' | 'registry';
|
|
12
|
+
image?: string;
|
|
13
|
+
tag?: string;
|
|
8
14
|
}
|
|
9
15
|
export declare enum Runtime {
|
|
10
16
|
DOCKER = "DOCKER",
|
|
@@ -20,3 +20,12 @@ export default interface UserConfig {
|
|
|
20
20
|
context?: Context;
|
|
21
21
|
endpoint?: Endpoint;
|
|
22
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* Resolves the endpoint to use for a login attempt.
|
|
25
|
+
*
|
|
26
|
+
* A truthy `basePath` always yields a custom endpoint (carrying over any
|
|
27
|
+
* previously stored defaultHeaders); an absent `basePath` always reverts
|
|
28
|
+
* to the default base path, regardless of what `current` holds.
|
|
29
|
+
*/
|
|
30
|
+
export declare function resolveEndpoint(current: UserConfig, basePath?: string): Endpoint | undefined;
|
|
31
|
+
export declare function hasCustomBasePath(config: UserConfig): boolean;
|
|
@@ -3,3 +3,22 @@ export var AuthType;
|
|
|
3
3
|
AuthType["API_KEY"] = "API_KEY";
|
|
4
4
|
})(AuthType || (AuthType = {}));
|
|
5
5
|
export const defaultBasePath = 'https://api.hub.kipu-quantum.com';
|
|
6
|
+
/**
|
|
7
|
+
* Resolves the endpoint to use for a login attempt.
|
|
8
|
+
*
|
|
9
|
+
* A truthy `basePath` always yields a custom endpoint (carrying over any
|
|
10
|
+
* previously stored defaultHeaders); an absent `basePath` always reverts
|
|
11
|
+
* to the default base path, regardless of what `current` holds.
|
|
12
|
+
*/
|
|
13
|
+
export function resolveEndpoint(current, basePath) {
|
|
14
|
+
if (!basePath) {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
return {
|
|
18
|
+
basePath,
|
|
19
|
+
defaultHeaders: current.endpoint?.defaultHeaders ?? {},
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
export function hasCustomBasePath(config) {
|
|
23
|
+
return Boolean(config.endpoint?.basePath);
|
|
24
|
+
}
|
|
@@ -6,7 +6,7 @@ export default class QuantumHubService extends CommandService {
|
|
|
6
6
|
apiClient: HubPlatformClient;
|
|
7
7
|
constructor(userConfig: UserConfig);
|
|
8
8
|
getAccounts(): Promise<HubPlatform.AccountDto[]>;
|
|
9
|
-
createService(serviceConfig: ManagedServiceConfig,
|
|
9
|
+
createService(serviceConfig: ManagedServiceConfig, source: HubPlatform.ServiceSource, sourceCode?: Blob): Promise<HubPlatform.ServiceDto>;
|
|
10
10
|
getBuildJob(id: string): Promise<HubPlatform.BuildJobDto>;
|
|
11
11
|
getBuildLogs(id: string): Promise<HubPlatform.LogEntry[]>;
|
|
12
12
|
getJobById(id: string): Promise<HubPlatform.ServiceJobDto>;
|
|
@@ -15,6 +15,18 @@ export default class QuantumHubService extends CommandService {
|
|
|
15
15
|
runJob(request: HubPlatform.CreateServiceJobRequest): Promise<HubPlatform.ServiceJobDto>;
|
|
16
16
|
updateDescription(serviceConfig: ManagedServiceConfig, description: string): Promise<void>;
|
|
17
17
|
updateOpenapi(serviceConfig: ManagedServiceConfig, openapi: Blob): Promise<void>;
|
|
18
|
+
/**
|
|
19
|
+
* Updates an existing service's source via the platform's update-source
|
|
20
|
+
* operation, instead of uploading a code archive. A registry source forces
|
|
21
|
+
* the DOCKER runtime; switching between file and registry sources goes
|
|
22
|
+
* through this same operation, so the service is not recreated.
|
|
23
|
+
*
|
|
24
|
+
* The platform exposes no endpoint that sets runtime and source together, so
|
|
25
|
+
* a registry update is two sequential calls and is not atomic. The runtime is
|
|
26
|
+
* set first, so a failure leaves the (unchanged) source on the DOCKER runtime
|
|
27
|
+
* rather than a registry source stranded on a non-DOCKER runtime.
|
|
28
|
+
*/
|
|
29
|
+
updateServiceSource(serviceId: string, source: HubPlatform.ServiceSource): Promise<void>;
|
|
18
30
|
updateService(serviceConfig: ManagedServiceConfig, description: string, sourceCode: Blob, openapi?: Blob): Promise<HubPlatform.ServiceDto>;
|
|
19
31
|
private uploadSourceCode;
|
|
20
32
|
}
|
|
@@ -24,16 +24,18 @@ export default class QuantumHubService extends CommandService {
|
|
|
24
24
|
throw new Error(errorMessage, { cause: error });
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
|
-
async createService(serviceConfig,
|
|
27
|
+
async createService(serviceConfig, source, sourceCode) {
|
|
28
28
|
const milliCpus = (serviceConfig.resources?.cpu || 1) * 1000;
|
|
29
29
|
const memoryInMegabytes = (serviceConfig.resources?.memory || 2) * 1024;
|
|
30
30
|
const gpuAccelerator = serviceConfig.resources?.gpu?.type || 'NONE';
|
|
31
31
|
const gpuCount = serviceConfig.resources?.gpu?.count || 0;
|
|
32
|
+
// A registry source references a pre-built image, so it always runs the DOCKER runtime.
|
|
33
|
+
const runtime = source.type === 'registry' ? 'DOCKER' : serviceConfig.runtime;
|
|
32
34
|
try {
|
|
33
35
|
const service = await this.apiClient.managedServices.createManagedService({
|
|
34
36
|
name: serviceConfig.name,
|
|
35
37
|
runtimeConfiguration: {
|
|
36
|
-
runtime:
|
|
38
|
+
runtime: runtime,
|
|
37
39
|
},
|
|
38
40
|
resourceConfigurations: [
|
|
39
41
|
{ type: 'cpu', amount: milliCpus, unit: 'm' },
|
|
@@ -44,12 +46,12 @@ export default class QuantumHubService extends CommandService {
|
|
|
44
46
|
accelerator: gpuAccelerator
|
|
45
47
|
},
|
|
46
48
|
],
|
|
47
|
-
source
|
|
49
|
+
source,
|
|
48
50
|
summary: serviceConfig.summary,
|
|
49
51
|
});
|
|
50
|
-
|
|
51
|
-
if (
|
|
52
|
-
await this.
|
|
52
|
+
// A registry source is pre-built — there is no local code to upload.
|
|
53
|
+
if (sourceCode) {
|
|
54
|
+
await this.uploadSourceCode(service.id, sourceCode);
|
|
53
55
|
}
|
|
54
56
|
return service;
|
|
55
57
|
}
|
|
@@ -133,6 +135,29 @@ export default class QuantumHubService extends CommandService {
|
|
|
133
135
|
throw new Error(errorMessage, { cause: error });
|
|
134
136
|
}
|
|
135
137
|
}
|
|
138
|
+
/**
|
|
139
|
+
* Updates an existing service's source via the platform's update-source
|
|
140
|
+
* operation, instead of uploading a code archive. A registry source forces
|
|
141
|
+
* the DOCKER runtime; switching between file and registry sources goes
|
|
142
|
+
* through this same operation, so the service is not recreated.
|
|
143
|
+
*
|
|
144
|
+
* The platform exposes no endpoint that sets runtime and source together, so
|
|
145
|
+
* a registry update is two sequential calls and is not atomic. The runtime is
|
|
146
|
+
* set first, so a failure leaves the (unchanged) source on the DOCKER runtime
|
|
147
|
+
* rather than a registry source stranded on a non-DOCKER runtime.
|
|
148
|
+
*/
|
|
149
|
+
async updateServiceSource(serviceId, source) {
|
|
150
|
+
try {
|
|
151
|
+
if (source.type === 'registry') {
|
|
152
|
+
await this.apiClient.managedServices.updateManagedServiceRuntimeConfiguration(serviceId, { runtime: 'DOCKER' });
|
|
153
|
+
}
|
|
154
|
+
await this.apiClient.managedServices.updateManagedServiceSource(serviceId, source);
|
|
155
|
+
}
|
|
156
|
+
catch (error) {
|
|
157
|
+
const errorMessage = await handleError(error);
|
|
158
|
+
throw new Error(errorMessage, { cause: error });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
136
161
|
async updateService(serviceConfig, description, sourceCode, openapi) {
|
|
137
162
|
const serviceId = serviceConfig.serviceId;
|
|
138
163
|
const milliCpus = (serviceConfig.resources?.cpu || 1) * 1000;
|