@quantum-hub/qhubctl 1.19.2 → 2.1.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.
@@ -2,18 +2,36 @@ import { Command } from 'commander';
2
2
  import fs from 'fs-extra';
3
3
  import path from 'node:path';
4
4
  import { zipProjectFolder } from '../lib/up/zip.js';
5
- import { spinner, stdout } from '../lib/output.js';
5
+ import { enforceRequiredFiles } from '../lib/up/required-files.js';
6
+ import { error, spinner, stdout } from '../lib/output.js';
6
7
  const EXAMPLES = `
7
8
  Examples:
8
- $ qhubctl compress`;
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`;
9
20
  export const compressCommand = new Command('compress')
10
- .description("Compresses the current project and creates a ZIP file. Use the '.dockerignore' file to exclude files and directories from the ZIP file.")
21
+ .description("Compresses the current project and creates a ZIP file. Use a '.qhubignore' file (gitignore syntax) to exclude files and directories from the ZIP file.")
11
22
  .addHelpText('after', EXAMPLES)
12
23
  .action(async () => {
13
24
  const spin = spinner('Compressing project');
14
25
  const targetDirectory = process.cwd();
15
26
  const targetFilename = 'service.zip';
16
27
  const targetFilePath = path.join(targetDirectory, '/', targetFilename);
28
+ try {
29
+ await enforceRequiredFiles(targetDirectory);
30
+ }
31
+ catch (err) {
32
+ spin.fail('Compression aborted');
33
+ error(err.message);
34
+ }
17
35
  if (fs.existsSync(targetFilePath)) {
18
36
  fs.unlinkSync(targetFilePath);
19
37
  }
@@ -68,7 +68,7 @@ function isProjectSupported(opts) {
68
68
  return false;
69
69
  }
70
70
  const hasExplicitEntrypoint = !!(opts.entrypoint || opts.package || opts.method);
71
- const supportedRuntimes = [Runtime.PYTHON_TEMPLATE, Runtime.PYTHON, Runtime.DOCKER];
71
+ const supportedRuntimes = [Runtime.PYTHON_TEMPLATE, Runtime.DOCKER];
72
72
  if (hasExplicitEntrypoint) {
73
73
  const isSupported = supportedRuntimes.includes(serviceConfig.runtime);
74
74
  if (!isSupported) {
@@ -76,7 +76,7 @@ function isProjectSupported(opts) {
76
76
  warn('');
77
77
  warn('Supported configurations:');
78
78
  warn(' \u2022 PYTHON_TEMPLATE: Automatic (requires src/program.py)');
79
- warn(' \u2022 PYTHON/DOCKER: With explicit entrypoint flags');
79
+ warn(' \u2022 DOCKER: With explicit entrypoint flags');
80
80
  warn('');
81
81
  warn('Examples:');
82
82
  warn(' qhubctl openapi --entrypoint=myapp.main:handler');
@@ -176,7 +176,7 @@ Examples:
176
176
  export const openapiCommand = new Command('openapi')
177
177
  .description('Generates the OpenAPI description for your project based on the parameter and return types of your run() method. ' +
178
178
  'The output of this command will be used when creating or updating your service. ' +
179
- 'Supports PYTHON_TEMPLATE (with src/program.py), PYTHON and DOCKER runtimes (with explicit entrypoint). ' +
179
+ 'Supports PYTHON_TEMPLATE (with src/program.py) and DOCKER runtime (with explicit entrypoint). ' +
180
180
  'Requires uv and a valid uv project in the workspace. The "qhub-commons" dependency is installed/upgraded automatically as a dependency.')
181
181
  .option('-f, --force', 'Overwrite the output file if it already exists', false)
182
182
  .option('--file <value>', 'The file to write the OpenAPI description to')
@@ -204,7 +204,7 @@ export const openapiCommand = new Command('openapi')
204
204
  if (!shouldGenerateFile) {
205
205
  warn(`Cannot generate OpenAPI description from your code. Supported configurations:\n` +
206
206
  ' \u2022 PYTHON_TEMPLATE runtime with "./src/program.py" file\n' +
207
- ' \u2022 DOCKER, PYTHON, or PYTHON_TEMPLATE runtimes with explicit entrypoint (--entrypoint, or (--package, --method))');
207
+ ' \u2022 DOCKER or PYTHON_TEMPLATE runtimes with explicit entrypoint (--entrypoint, or (--package, --method))');
208
208
  return;
209
209
  }
210
210
  // Ensure qhub-commons is installed (or upgraded) in the project venv
@@ -1,17 +1,24 @@
1
- import { confirm } from '@inquirer/prompts';
2
- import { Command } from 'commander';
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 { HubPlatform } from '@quantum-hub/qhub-api/platform';
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';
10
+ import { enforceRequiredFiles } from '../lib/up/required-files.js';
11
+ import { buildServiceSource, isRegistrySource, resolveRegistryCredentials } from '../lib/up/source.js';
9
12
  import { zipProjectFolder } from '../lib/up/zip.js';
10
- import { handleError } from '../lib/error.js';
11
- import { error, spinner, stdout, warn } from '../lib/output.js';
12
13
  import { requireAuth } from '../model/command.js';
13
14
  import QuantumHubService from '../service/quantum-hub-service.js';
14
15
  import { readServiceConfig, writeServiceConfig } from '../service/service-config-service.js';
16
+ /**
17
+ * Prints an error message and exits. Service-layer errors already carry a resolved message.
18
+ */
19
+ function fail(err) {
20
+ error(err instanceof Error ? err.message : String(err));
21
+ }
15
22
  async function getOpenapi() {
16
23
  const openapiYamlPath = path.join(process.cwd(), 'openapi.yaml');
17
24
  const openapiYmlPath = path.join(process.cwd(), 'openapi.yml');
@@ -24,6 +31,28 @@ async function getOpenapi() {
24
31
  return streamToBlob(openapi);
25
32
  return undefined;
26
33
  }
34
+ /**
35
+ * Gathers the optional service metadata files (OpenAPI contract and the
36
+ * configured description) from the project. Both are independent of the source
37
+ * kind, so file and registry deploys gather them the same way.
38
+ */
39
+ async function getServiceMetadata() {
40
+ return {
41
+ openapi: await getOpenapi(),
42
+ descriptionText: await getDescriptionText(process.cwd()),
43
+ };
44
+ }
45
+ /**
46
+ * Uploads the optional service metadata that is independent of the source: the
47
+ * description and the OpenAPI contract. Each is uploaded only when present, so
48
+ * an image-only service deploys with neither.
49
+ */
50
+ async function uploadServiceMetadata(qhubService, serviceConfig, metadata) {
51
+ if (metadata.descriptionText)
52
+ await qhubService.updateDescription(serviceConfig, metadata.descriptionText);
53
+ if (metadata.openapi)
54
+ await qhubService.updateOpenapi(serviceConfig, metadata.openapi);
55
+ }
27
56
  async function buildServiceJob(qhubService, service, silentMode) {
28
57
  let buildJob;
29
58
  let spin;
@@ -65,47 +94,56 @@ async function buildServiceJob(qhubService, service, silentMode) {
65
94
  }
66
95
  return buildJob;
67
96
  }
68
- async function createService(qhubService, serviceConfig, files, silentMode, noSaveId = false, customName) {
97
+ async function createService(qhubService, serviceConfig, source, metadata, sourceCode, silentMode, noSaveId = false, customName) {
69
98
  const spin = silentMode ? undefined : spinner('Creating service');
70
99
  const configToUse = { ...serviceConfig };
71
100
  if (customName)
72
101
  configToUse.name = customName;
73
- const service = await qhubService.createService(configToUse, files.sourceCode, files.openapi);
74
- serviceConfig.serviceId = service?.id;
102
+ const service = await qhubService.createService(configToUse, source, sourceCode);
103
+ serviceConfig.serviceId = service.id;
75
104
  if (!noSaveId)
76
105
  writeServiceConfig(process.cwd(), serviceConfig);
77
- if (files.descriptionText)
78
- await qhubService.updateDescription(serviceConfig, files.descriptionText);
79
- if (files.openapi)
80
- await qhubService.updateOpenapi(serviceConfig, files.openapi);
106
+ await uploadServiceMetadata(qhubService, serviceConfig, metadata);
81
107
  spin?.succeed();
82
108
  return service;
83
109
  }
84
- async function updateService(qhubService, serviceConfig, files, silentMode) {
110
+ async function updateService(qhubService, serviceConfig, source, metadata, sourceCode, silentMode) {
85
111
  const spin = silentMode ? undefined : spinner('Updating service');
86
- const result = await qhubService.updateService(serviceConfig, files.descriptionText, files.sourceCode, files.openapi);
112
+ // A registry source is set via the platform's update-source operation; a file
113
+ // source uploads a fresh code archive. Either way the optional metadata follows.
114
+ if (source.type === 'registry') {
115
+ await qhubService.updateServiceSource(serviceConfig.serviceId, source);
116
+ await uploadServiceMetadata(qhubService, serviceConfig, metadata);
117
+ spin?.succeed();
118
+ return undefined;
119
+ }
120
+ const result = await qhubService.updateService(serviceConfig, metadata.descriptionText, sourceCode, metadata.openapi);
87
121
  spin?.succeed();
88
122
  return result;
89
123
  }
90
- const EXAMPLES = `
91
- Examples:
92
- $ qhubctl up
93
- $ qhubctl up --no-save-id
94
- $ qhubctl up --name "my-custom-service-name"
95
- $ qhubctl up --name "test-service" --no-save-id`;
96
- export const upCommand = new Command('up')
97
- .description('Creates or updates a service')
98
- .option('--silent', 'Suppresses all outputs, helpful when executed in a CI/CD pipeline.', false)
99
- .option('--no-save-id', 'Prevents storing the created service ID in qhub.json file.')
100
- .option('--name <value>', 'Override the service name from qhub.json with a custom name.')
101
- .addHelpText('after', EXAMPLES)
102
- .action(async (opts) => {
103
- const userConfig = await requireAuth();
104
- const qhubService = new QuantumHubService(userConfig);
105
- const silentMode = opts.silent;
106
- // Commander inverts --no-save-id into opts.saveId = false
107
- const noSaveId = !opts.saveId;
108
- const serviceConfig = readServiceConfig(process.cwd());
124
+ /**
125
+ * Resolves registry pull credentials, falling back to an interactive prompt
126
+ * when none are supplied via flags or environment. A blank username means a
127
+ * public/anonymous pull. Never prompts in silent mode. Credentials are never
128
+ * persisted to qhub.json (see ADR 0001).
129
+ */
130
+ async function resolveCredentialsInteractively(opts, silentMode) {
131
+ const credentials = resolveRegistryCredentials({ username: opts.registryUsername, password: opts.registryPassword }, process.env);
132
+ if (credentials.username || silentMode) {
133
+ return credentials;
134
+ }
135
+ const username = await input({ message: 'Registry username (leave blank for a public image)' });
136
+ if (!username) {
137
+ return credentials;
138
+ }
139
+ const registryPassword = await password({ message: 'Registry password' });
140
+ return { username, password: registryPassword };
141
+ }
142
+ /**
143
+ * Emits non-fatal warnings for deprecated qhub.json fields and legacy file
144
+ * names in the project. Purely informational; never throws.
145
+ */
146
+ function warnAboutDeprecations(serviceConfig) {
109
147
  if (!('descriptionFile' in serviceConfig)) {
110
148
  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');
111
149
  }
@@ -113,40 +151,67 @@ export const upCommand = new Command('up')
113
151
  if (fs.existsSync(deprecatedOpenapiFile)) {
114
152
  warn('You are having the deprecated file "openapi-spec.yml" in your project. Please consider renaming it to "openapi.yaml".');
115
153
  }
116
- let serviceExistsInPlatform = false;
117
- if (serviceConfig.serviceId) {
118
- try {
119
- await qhubService.getService(serviceConfig.serviceId);
120
- serviceExistsInPlatform = true;
121
- }
122
- catch {
123
- serviceExistsInPlatform = false;
124
- }
154
+ }
155
+ /**
156
+ * Checks whether the configured service still exists in the platform. When a
157
+ * service ID is set in qhub.json but no matching service is found, prompts to
158
+ * create a new one and exits if the user declines.
159
+ */
160
+ async function resolveServiceExists(qhubService, serviceConfig) {
161
+ if (!serviceConfig.serviceId)
162
+ return false;
163
+ let serviceExistsInPlatform;
164
+ try {
165
+ await qhubService.getService(serviceConfig.serviceId);
166
+ serviceExistsInPlatform = true;
167
+ }
168
+ catch {
169
+ serviceExistsInPlatform = false;
125
170
  }
126
- if (serviceConfig.serviceId && !serviceExistsInPlatform) {
171
+ if (!serviceExistsInPlatform) {
127
172
  stdout(`We could not find a service with id '${serviceConfig.serviceId}' in the current context!`);
128
173
  const createNewService = await confirm({ message: 'Do you want to create a new service?' });
129
174
  if (!createNewService)
130
175
  process.exit(0);
131
176
  }
132
- const serviceFiles = {
133
- openapi: await getOpenapi(),
134
- descriptionText: await getDescriptionText(process.cwd()),
135
- sourceCode: await zipProjectFolder('./'),
136
- };
137
- let service;
177
+ return serviceExistsInPlatform;
178
+ }
179
+ /**
180
+ * Resolves the platform ServiceSource for this deploy: enforces the required
181
+ * files for a file source, gathers pull credentials for a registry source, and
182
+ * builds the final source descriptor. Exits on a build error.
183
+ */
184
+ async function resolveServiceSource(serviceConfig, opts, silentMode) {
185
+ const registrySource = isRegistrySource(serviceConfig, opts.image);
186
+ // A registry source is pre-built and never packaged locally, so the
187
+ // required-files check (Dockerfile / requirements.txt) does not apply.
188
+ if (!registrySource) {
189
+ try {
190
+ await enforceRequiredFiles(process.cwd());
191
+ }
192
+ catch (err) {
193
+ error(err.message);
194
+ }
195
+ }
196
+ // A registry source needs pull credentials (flags / env / prompt); a file
197
+ // source needs none. Both then resolve to a single platform ServiceSource.
198
+ const credentials = registrySource
199
+ ? await resolveCredentialsInteractively(opts, silentMode)
200
+ : undefined;
138
201
  try {
139
- service = serviceExistsInPlatform
140
- ? await updateService(qhubService, serviceConfig, serviceFiles, silentMode)
141
- : await createService(qhubService, serviceConfig, serviceFiles, silentMode, noSaveId, opts.name);
202
+ return buildServiceSource(serviceConfig, opts.image, credentials);
142
203
  }
143
204
  catch (err) {
144
- error(await handleError(err));
205
+ fail(err);
145
206
  }
146
- const buildJob = await buildServiceJob(qhubService, service, silentMode);
207
+ }
208
+ /**
209
+ * Reports the outcome of a build job: a success message, a failure with the
210
+ * platform's validation summary when available, or a pending notice.
211
+ */
212
+ function reportBuildResult(buildJob, serviceExistsInPlatform) {
147
213
  if (buildJob?.status === HubPlatform.BuildJobDto.Status.Success) {
148
- const msg = `Service ${serviceExistsInPlatform ? 'updated' : 'created'} \u{1F680}`;
149
- stdout(msg);
214
+ stdout(`Service ${serviceExistsInPlatform ? 'updated' : 'created'} \u{1F680}`);
150
215
  }
151
216
  else if (buildJob?.status === HubPlatform.BuildJobDto.Status.Failure) {
152
217
  const { validationResult } = buildJob;
@@ -159,4 +224,57 @@ export const upCommand = new Command('up')
159
224
  else {
160
225
  error('Still pending, please check the platform UI to determine the status');
161
226
  }
227
+ }
228
+ const EXAMPLES = `
229
+ Examples:
230
+ $ qhubctl up
231
+ $ qhubctl up --no-save-id
232
+ $ qhubctl up --name "my-custom-service-name"
233
+ $ qhubctl up --name "test-service" --no-save-id
234
+ $ qhubctl up --image ghcr.io/acme/my-service:1.2.3`;
235
+ export const upCommand = new Command('up')
236
+ .description('Creates or updates a service')
237
+ .option('--silent', 'Suppresses all outputs, helpful when executed in a CI/CD pipeline.', false)
238
+ .option('--no-save-id', 'Prevents storing the created service ID in qhub.json file.')
239
+ .option('--name <value>', 'Override the service name from qhub.json with a custom name.')
240
+ .option('--image <ref>', 'Deploy a pre-built image from a container registry, given as registry/repo:tag (the tag defaults to "latest").')
241
+ .option('--registry-username <value>', 'Username to pull a private registry image. Falls back to the QHUB_REGISTRY_USERNAME environment variable.')
242
+ .option('--registry-password <value>', 'Password to pull a private registry image. Falls back to the QHUB_REGISTRY_PASSWORD environment variable.')
243
+ .addHelpText('after', EXAMPLES)
244
+ .action(async (opts) => {
245
+ const userConfig = await requireAuth();
246
+ const qhubService = new QuantumHubService(userConfig);
247
+ const silentMode = opts.silent;
248
+ // Commander inverts --no-save-id into opts.saveId = false
249
+ const noSaveId = !opts.saveId;
250
+ const serviceConfig = readServiceConfig(process.cwd());
251
+ warnAboutDeprecations(serviceConfig);
252
+ const serviceExistsInPlatform = await resolveServiceExists(qhubService, serviceConfig);
253
+ const source = await resolveServiceSource(serviceConfig, opts, silentMode);
254
+ // Metadata (openapi + description) is independent of the source kind.
255
+ const metadata = await getServiceMetadata();
256
+ // Only a file source ships local code; a registry source is pre-built.
257
+ const sourceCode = source.type === 'file' ? await zipProjectFolder('./') : undefined;
258
+ // The platform has no rename operation, so --name only takes effect on create.
259
+ if (serviceExistsInPlatform && opts.name && !silentMode) {
260
+ warn('Ignoring --name: the service name cannot be changed on update.');
261
+ }
262
+ let service;
263
+ try {
264
+ service = serviceExistsInPlatform
265
+ ? await updateService(qhubService, serviceConfig, source, metadata, sourceCode, silentMode)
266
+ : await createService(qhubService, serviceConfig, source, metadata, sourceCode, silentMode, noSaveId, opts.name);
267
+ }
268
+ catch (err) {
269
+ fail(err);
270
+ }
271
+ // A registry source is pre-built — the platform runs no build job, so
272
+ // success is reported immediately. A file source must wait for the build.
273
+ if (source.type !== 'file') {
274
+ if (!silentMode)
275
+ stdout(`Service ${serviceExistsInPlatform ? 'updated' : 'created'} \u{1F680}`);
276
+ return;
277
+ }
278
+ const buildJob = await buildServiceJob(qhubService, service, silentMode);
279
+ reportBuildResult(buildJob, serviceExistsInPlatform);
162
280
  });
@@ -3,13 +3,15 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { promisify } from 'node:util';
5
5
  const readFile = promisify(fs.readFile);
6
+ // Always excluded from the upload archive. Added before user patterns so a
7
+ // user can re-include any of them with a negation (e.g. `!node_modules`).
8
+ const DEFAULT_IGNORES = ['.git', 'node_modules', '.venv', '__pycache__', 'service.zip'];
6
9
  export async function createIgnoreFileChecker(filePath = process.cwd()) {
7
- const ignoreFilePath = path.join(filePath, '.dockerignore');
8
- if (!fs.existsSync(ignoreFilePath)) {
9
- return () => false;
10
+ const ig = ignore().add(DEFAULT_IGNORES);
11
+ const ignoreFilePath = path.join(filePath, '.qhubignore');
12
+ if (fs.existsSync(ignoreFilePath)) {
13
+ const ignoreFileContents = await readFile(ignoreFilePath, 'utf8');
14
+ ig.add(ignoreFileContents.split('\n'));
10
15
  }
11
- const ignoreFileContents = await readFile(ignoreFilePath, 'utf8');
12
- const ig = ignore().add(ignoreFileContents.split('\n'));
13
- ig.add('service.zip');
14
16
  return (fullPath) => ig.ignores(fullPath);
15
17
  }
@@ -0,0 +1,8 @@
1
+ export interface RequiredFilesResult {
2
+ skipped: boolean;
3
+ runtimeMissing: boolean;
4
+ missing: string[];
5
+ excluded: string[];
6
+ }
7
+ export declare function evaluateRequiredFiles(workingDirectory: string): Promise<RequiredFilesResult>;
8
+ export declare function enforceRequiredFiles(workingDirectory: string): Promise<void>;
@@ -0,0 +1,48 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { Runtime } from '../../model/managed-service-config.js';
4
+ import { readServiceConfig } from '../../service/service-config-service.js';
5
+ import { createIgnoreFileChecker } from './ignore.js';
6
+ import { warn } from '../output.js';
7
+ const REQUIRED_FILES = {
8
+ [Runtime.DOCKER]: ['Dockerfile'],
9
+ [Runtime.PYTHON_TEMPLATE]: ['requirements.txt', 'src/program.py'],
10
+ };
11
+ export async function evaluateRequiredFiles(workingDirectory) {
12
+ const result = { skipped: false, runtimeMissing: false, missing: [], excluded: [] };
13
+ if (!fs.existsSync(path.join(workingDirectory, 'qhub.json'))) {
14
+ result.skipped = true;
15
+ return result;
16
+ }
17
+ const { runtime } = readServiceConfig(workingDirectory);
18
+ if (!runtime) {
19
+ result.runtimeMissing = true;
20
+ return result;
21
+ }
22
+ const required = REQUIRED_FILES[runtime] ?? [];
23
+ const isIgnored = await createIgnoreFileChecker(workingDirectory);
24
+ for (const file of required) {
25
+ const onDisk = fs.existsSync(path.join(workingDirectory, file));
26
+ if (onDisk && isIgnored(file)) {
27
+ result.excluded.push(file);
28
+ }
29
+ else if (!onDisk) {
30
+ result.missing.push(file);
31
+ }
32
+ }
33
+ return result;
34
+ }
35
+ export async function enforceRequiredFiles(workingDirectory) {
36
+ const { runtimeMissing, missing, excluded } = await evaluateRequiredFiles(workingDirectory);
37
+ if (runtimeMissing) {
38
+ warn('No "runtime" set in qhub.json; skipping required-file validation.');
39
+ return;
40
+ }
41
+ if (missing.length > 0) {
42
+ warn(`Required file(s) missing for the configured runtime: ${missing.join(', ')}`);
43
+ }
44
+ if (excluded.length > 0) {
45
+ throw new Error(`Required file(s) are present but excluded by .qhubignore: ${excluded.join(', ')}. ` +
46
+ `Re-include them with a negation (e.g. "!Dockerfile").`);
47
+ }
48
+ }
@@ -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
+ }
@@ -5,10 +5,15 @@ 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",
11
- PYTHON = "PYTHON",
12
17
  PYTHON_TEMPLATE = "PYTHON_TEMPLATE"
13
18
  }
14
19
  export interface ResourceConfiguration {
@@ -1,7 +1,6 @@
1
1
  export var Runtime;
2
2
  (function (Runtime) {
3
3
  Runtime["DOCKER"] = "DOCKER";
4
- Runtime["PYTHON"] = "PYTHON";
5
4
  Runtime["PYTHON_TEMPLATE"] = "PYTHON_TEMPLATE";
6
5
  })(Runtime || (Runtime = {}));
7
6
  export var GpuType;
@@ -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, sourceCode: Blob, apiDefinition?: Blob): Promise<HubPlatform.ServiceDto>;
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, sourceCode, apiDefinition) {
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: serviceConfig.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: { type: 'file' },
49
+ source,
48
50
  summary: serviceConfig.summary,
49
51
  });
50
- await this.uploadSourceCode(service.id, sourceCode);
51
- if (apiDefinition) {
52
- await this.apiClient.services.updateServiceOpenApi(service.id, { file: apiDefinition });
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quantum-hub/qhubctl",
3
- "version": "1.19.2",
3
+ "version": "2.1.0",
4
4
  "description": "Kipu Quantum Hub CLI",
5
5
  "author": "Kipu Quantum GmbH",
6
6
  "contributors": [