kub-cli 0.2.0__py3-none-any.whl

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.
kub_cli/runtime.py ADDED
@@ -0,0 +1,463 @@
1
+ # SPDX-FileCopyrightText: 2026 University of Strasbourg
2
+ # SPDX-FileContributor: Christophe Prud'homme
3
+ # SPDX-FileContributor: Cemosis
4
+ # SPDX-License-Identifier: Apache-2.0
5
+
6
+ """Container runtime resolution, command construction, and process execution."""
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ import os
12
+ from pathlib import Path
13
+ import shutil
14
+ import subprocess
15
+ from typing import Literal, Sequence
16
+
17
+ from .config import (
18
+ DEFAULT_APPTAINER_IMAGE,
19
+ DEFAULT_DOCKER_IMAGE,
20
+ KubConfig,
21
+ SUPPORTED_RUNTIMES,
22
+ looksLikeContainerReference,
23
+ )
24
+ from .errors import ImageNotFoundError, KubCliError, RunnerNotFoundError, RuntimeSelectionError
25
+ from .logging_utils import LOGGER, formatCommand
26
+
27
+
28
+ ResolvedRuntime = Literal["apptainer", "docker"]
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class RuntimeResolution:
33
+ """Concrete runtime and image selected for command execution."""
34
+
35
+ runtime: ResolvedRuntime
36
+ runnerPath: str
37
+ imageReference: str
38
+
39
+
40
+ def deriveApptainerOrasReference(dockerImageReference: str) -> str:
41
+ """Derive Apptainer ORAS source from a Docker image reference.
42
+
43
+ Example:
44
+ `ghcr.io/org/app:master` -> `oras://ghcr.io/org/app:master-sif`
45
+ """
46
+
47
+ normalized = dockerImageReference.strip()
48
+ if not normalized:
49
+ raise KubCliError("Docker image reference cannot be empty.")
50
+
51
+ if "@" in normalized:
52
+ raise KubCliError(
53
+ "Cannot derive Apptainer ORAS reference from digest-based Docker image. "
54
+ "Provide a tag-based Docker image reference."
55
+ )
56
+
57
+ if normalized.startswith("oras://"):
58
+ return normalized
59
+
60
+ if "://" in normalized:
61
+ raise KubCliError(
62
+ "Docker image reference for ORAS derivation must not include a URI scheme. "
63
+ "Use format like ghcr.io/org/image:tag"
64
+ )
65
+
66
+ lastSlash = normalized.rfind("/")
67
+ lastColon = normalized.rfind(":")
68
+
69
+ if lastColon > lastSlash:
70
+ repository = normalized[:lastColon]
71
+ tag = normalized[lastColon + 1 :]
72
+ else:
73
+ repository = normalized
74
+ tag = "latest"
75
+
76
+ if not repository or not tag:
77
+ raise KubCliError(
78
+ "Invalid Docker image reference for ORAS derivation. "
79
+ "Expected format like ghcr.io/org/image:tag"
80
+ )
81
+
82
+ return f"oras://{repository}:{tag}-sif"
83
+
84
+
85
+ def getRuntimeCandidateImage(config: KubConfig, runtime: ResolvedRuntime) -> str | None:
86
+ """Resolve configured image by runtime-specific precedence."""
87
+
88
+ if runtime == "docker":
89
+ candidates = [
90
+ config.imageOverride,
91
+ config.imageDocker,
92
+ config.image,
93
+ DEFAULT_DOCKER_IMAGE,
94
+ ]
95
+ else:
96
+ candidates = [
97
+ config.imageOverride,
98
+ config.imageApptainer,
99
+ config.image,
100
+ DEFAULT_APPTAINER_IMAGE,
101
+ ]
102
+
103
+ for candidate in candidates:
104
+ if candidate is None:
105
+ continue
106
+ normalized = candidate.strip()
107
+ if normalized:
108
+ return normalized
109
+
110
+ return None
111
+
112
+
113
+ def getRunnerValue(config: KubConfig, runtime: ResolvedRuntime) -> str:
114
+ """Resolve configured runner name/path by runtime."""
115
+
116
+ if config.runner is not None and config.runner.strip():
117
+ return config.runner.strip()
118
+
119
+ if runtime == "apptainer":
120
+ return config.apptainerRunner.strip()
121
+
122
+ return config.dockerRunner.strip()
123
+
124
+
125
+ def resolveRunnerExecutable(runnerValue: str, *, runtimeName: str) -> str:
126
+ """Resolve runner executable path and validate it is runnable."""
127
+
128
+ normalized = runnerValue.strip()
129
+ if not normalized:
130
+ raise RunnerNotFoundError(
131
+ f"{runtimeName.capitalize()} runner is empty. Set --runner or runtime-specific runner config."
132
+ )
133
+
134
+ runnerPath = Path(normalized).expanduser()
135
+ hasPathSeparator = runnerPath.parent != Path(".")
136
+
137
+ if runnerPath.is_absolute() or hasPathSeparator:
138
+ if runnerPath.exists() and os.access(runnerPath, os.X_OK):
139
+ return str(runnerPath)
140
+
141
+ raise RunnerNotFoundError(
142
+ f"{runtimeName.capitalize()} runner not executable: '{runnerPath}'."
143
+ )
144
+
145
+ resolvedRunner = shutil.which(normalized)
146
+ if resolvedRunner is None:
147
+ if runtimeName == "apptainer":
148
+ installHint = (
149
+ "Install Apptainer: "
150
+ "https://apptainer.org/docs/admin/main/installation.html"
151
+ )
152
+ elif runtimeName == "docker":
153
+ installHint = (
154
+ "Install Docker Engine: "
155
+ "https://docs.docker.com/engine/install/"
156
+ )
157
+ else:
158
+ installHint = "Install the selected runtime executable."
159
+
160
+ raise RunnerNotFoundError(
161
+ f"Unable to find {runtimeName} runner in PATH. "
162
+ f"Set --runner/KUB_APP_RUNNER or install it. {installHint}"
163
+ )
164
+
165
+ return resolvedRunner
166
+
167
+
168
+ def tryResolveRunnerExecutable(runnerValue: str) -> str | None:
169
+ """Try to resolve runner executable, returning None on failure."""
170
+
171
+ normalized = runnerValue.strip()
172
+ if not normalized:
173
+ return None
174
+
175
+ runnerPath = Path(normalized).expanduser()
176
+ hasPathSeparator = runnerPath.parent != Path(".")
177
+
178
+ if runnerPath.is_absolute() or hasPathSeparator:
179
+ if runnerPath.exists() and os.access(runnerPath, os.X_OK):
180
+ return str(runnerPath)
181
+ return None
182
+
183
+ return shutil.which(normalized)
184
+
185
+
186
+ def resolveApptainerExecutionImage(config: KubConfig) -> str:
187
+ """Resolve Apptainer execution image reference (local path or oras:// URI)."""
188
+
189
+ imageReference = getRuntimeCandidateImage(config, "apptainer")
190
+ if imageReference is None:
191
+ raise ImageNotFoundError(
192
+ "No Apptainer image configured for runtime 'apptainer'. "
193
+ "Set --image, KUB_IMAGE_APPTAINER, or KUB_IMAGE."
194
+ )
195
+
196
+ normalizedReference = imageReference.strip()
197
+
198
+ if normalizedReference.startswith("docker://"):
199
+ raise ImageNotFoundError(
200
+ "Apptainer image reference must use oras:// (or a local .sif path), "
201
+ "not docker://."
202
+ )
203
+
204
+ if normalizedReference.startswith("oras://"):
205
+ return normalizedReference
206
+
207
+ if "://" in normalizedReference:
208
+ raise ImageNotFoundError(
209
+ "Unsupported Apptainer image URI scheme. "
210
+ "Use oras://<registry>/<image>:<tag>-sif or a local .sif path."
211
+ )
212
+
213
+ if looksLikeContainerReference(normalizedReference):
214
+ return f"oras://{normalizedReference}"
215
+
216
+ imagePath = Path(normalizedReference).expanduser()
217
+
218
+ if not imagePath.exists():
219
+ raise ImageNotFoundError(f"Container image not found: '{imagePath}'.")
220
+
221
+ if imagePath.is_dir():
222
+ raise ImageNotFoundError(
223
+ f"Container image must be a file, got directory: '{imagePath}'."
224
+ )
225
+
226
+ return str(imagePath)
227
+
228
+
229
+ def resolveDockerExecutionImage(config: KubConfig) -> str:
230
+ """Resolve Docker image reference used at runtime execution."""
231
+
232
+ imageReference = getRuntimeCandidateImage(config, "docker")
233
+ if imageReference is None:
234
+ raise ImageNotFoundError(
235
+ "No Docker image configured for runtime 'docker'. "
236
+ "Set --image, KUB_IMAGE_DOCKER, or KUB_IMAGE."
237
+ )
238
+
239
+ return imageReference
240
+
241
+
242
+ def resolveRuntimeForExecution(config: KubConfig) -> RuntimeResolution:
243
+ """Resolve runtime backend, executable, and image for application execution."""
244
+
245
+ configuredRuntime = config.runtime.strip().lower()
246
+ if configuredRuntime not in SUPPORTED_RUNTIMES:
247
+ supported = ", ".join(sorted(SUPPORTED_RUNTIMES))
248
+ raise RuntimeSelectionError(
249
+ f"Invalid runtime value '{config.runtime}'. Use one of: {supported}."
250
+ )
251
+
252
+ if configuredRuntime == "apptainer":
253
+ return resolveApptainerRuntime(config)
254
+
255
+ if configuredRuntime == "docker":
256
+ return resolveDockerRuntime(config)
257
+
258
+ return resolveAutoRuntime(config)
259
+
260
+
261
+ def resolveApptainerRuntime(config: KubConfig) -> RuntimeResolution:
262
+ runnerValue = getRunnerValue(config, "apptainer")
263
+ runnerPath = resolveRunnerExecutable(runnerValue, runtimeName="apptainer")
264
+ imageReference = resolveApptainerExecutionImage(config)
265
+ return RuntimeResolution(
266
+ runtime="apptainer",
267
+ runnerPath=runnerPath,
268
+ imageReference=imageReference,
269
+ )
270
+
271
+
272
+ def resolveDockerRuntime(config: KubConfig) -> RuntimeResolution:
273
+ runnerValue = getRunnerValue(config, "docker")
274
+ runnerPath = resolveRunnerExecutable(runnerValue, runtimeName="docker")
275
+ imageReference = resolveDockerExecutionImage(config)
276
+ return RuntimeResolution(
277
+ runtime="docker",
278
+ runnerPath=runnerPath,
279
+ imageReference=imageReference,
280
+ )
281
+
282
+
283
+ def resolveAutoRuntime(config: KubConfig) -> RuntimeResolution:
284
+ """Resolve runtime in auto mode using preference order.
285
+
286
+ Policy:
287
+ 1. Apptainer if configured and available.
288
+ 2. Docker if configured and available.
289
+ 3. Fail with actionable diagnostics.
290
+ """
291
+
292
+ diagnostics: list[str] = []
293
+
294
+ apptainerImage = getRuntimeCandidateImage(config, "apptainer")
295
+ if apptainerImage is not None:
296
+ apptainerRunnerValue = getRunnerValue(config, "apptainer")
297
+ apptainerRunner = tryResolveRunnerExecutable(apptainerRunnerValue)
298
+ if apptainerRunner is not None:
299
+ try:
300
+ imageReference = resolveApptainerExecutionImage(config)
301
+ return RuntimeResolution(
302
+ runtime="apptainer",
303
+ runnerPath=apptainerRunner,
304
+ imageReference=imageReference,
305
+ )
306
+ except KubCliError as error:
307
+ diagnostics.append(f"Apptainer not selected: {error}")
308
+ else:
309
+ diagnostics.append(
310
+ "Apptainer not selected: runner not available in PATH or not executable."
311
+ )
312
+ else:
313
+ diagnostics.append(
314
+ "Apptainer not selected: no Apptainer image configured."
315
+ )
316
+
317
+ dockerImage = getRuntimeCandidateImage(config, "docker")
318
+ if dockerImage is not None:
319
+ dockerRunnerValue = getRunnerValue(config, "docker")
320
+ dockerRunner = tryResolveRunnerExecutable(dockerRunnerValue)
321
+ if dockerRunner is not None:
322
+ return RuntimeResolution(
323
+ runtime="docker",
324
+ runnerPath=dockerRunner,
325
+ imageReference=dockerImage,
326
+ )
327
+ diagnostics.append(
328
+ "Docker not selected: runner not available in PATH or not executable."
329
+ )
330
+ else:
331
+ diagnostics.append(
332
+ "Docker not selected: no Docker image configured."
333
+ )
334
+
335
+ diagnosticText = " ".join(diagnostics)
336
+ raise RuntimeSelectionError(
337
+ "Unable to resolve runtime in auto mode. "
338
+ "Configure a valid runtime/image pair or set --runtime explicitly. "
339
+ "Install Apptainer (https://apptainer.org/docs/admin/main/installation.html) "
340
+ "or Docker Engine (https://docs.docker.com/engine/install/). "
341
+ f"Details: {diagnosticText}"
342
+ )
343
+
344
+
345
+ @dataclass(frozen=True)
346
+ class ApptainerCommandBuilder:
347
+ """Build final `apptainer run` command lines for wrapped apps."""
348
+
349
+ appName: str
350
+ config: KubConfig
351
+
352
+ def resolveRunner(self) -> str:
353
+ runnerValue = getRunnerValue(self.config, "apptainer")
354
+ return resolveRunnerExecutable(runnerValue, runtimeName="apptainer")
355
+
356
+ def resolveImage(self) -> str:
357
+ return resolveApptainerExecutionImage(self.config)
358
+
359
+ def build(self, forwardedArgs: Sequence[str]) -> list[str]:
360
+ runner = self.resolveRunner()
361
+ image = self.resolveImage()
362
+
363
+ command: list[str] = [runner, "run"]
364
+
365
+ if self.config.apptainerFlags:
366
+ command.extend(self.config.apptainerFlags)
367
+
368
+ for bindSpec in self.config.binds:
369
+ command.extend(["--bind", bindSpec])
370
+
371
+ if self.config.workdir:
372
+ command.extend(["--pwd", self.config.workdir])
373
+
374
+ command.extend(["--app", self.appName, image])
375
+ command.extend(forwardedArgs)
376
+
377
+ return command
378
+
379
+
380
+ @dataclass(frozen=True)
381
+ class DockerCommandBuilder:
382
+ """Build final `docker run` command lines for wrapped apps."""
383
+
384
+ appName: str
385
+ config: KubConfig
386
+
387
+ def resolveRunner(self) -> str:
388
+ runnerValue = getRunnerValue(self.config, "docker")
389
+ return resolveRunnerExecutable(runnerValue, runtimeName="docker")
390
+
391
+ def resolveImage(self) -> str:
392
+ return resolveDockerExecutionImage(self.config)
393
+
394
+ def build(self, forwardedArgs: Sequence[str]) -> list[str]:
395
+ runner = self.resolveRunner()
396
+ imageReference = self.resolveImage()
397
+
398
+ command: list[str] = [runner, "run", "--rm"]
399
+
400
+ if self.config.dockerFlags:
401
+ command.extend(self.config.dockerFlags)
402
+
403
+ for bindSpec in self.config.binds:
404
+ command.extend(["--volume", bindSpec])
405
+
406
+ if self.config.workdir:
407
+ command.extend(["--workdir", self.config.workdir])
408
+
409
+ for key, value in self.config.env.items():
410
+ command.extend(["--env", f"{key}={value}"])
411
+
412
+ command.append(imageReference)
413
+ command.append(self.appName)
414
+ command.extend(forwardedArgs)
415
+
416
+ return command
417
+
418
+
419
+ @dataclass(frozen=True)
420
+ class KubAppRunner:
421
+ """Execute wrapped containerized apps using resolved configuration."""
422
+
423
+ config: KubConfig
424
+
425
+ def run(
426
+ self,
427
+ *,
428
+ appName: str,
429
+ forwardedArgs: Sequence[str],
430
+ dryRun: bool = False,
431
+ ) -> int:
432
+ runtimeResolution = resolveRuntimeForExecution(self.config)
433
+
434
+ if runtimeResolution.runtime == "apptainer":
435
+ builder = ApptainerCommandBuilder(appName=appName, config=self.config)
436
+ else:
437
+ builder = DockerCommandBuilder(appName=appName, config=self.config)
438
+
439
+ command = builder.build(forwardedArgs)
440
+
441
+ if self.config.verbose:
442
+ LOGGER.debug(
443
+ "Resolved runtime=%s command: %s",
444
+ runtimeResolution.runtime,
445
+ formatCommand(command),
446
+ )
447
+
448
+ if dryRun:
449
+ print(formatCommand(command))
450
+ return 0
451
+
452
+ executionEnv = dict(os.environ)
453
+ if runtimeResolution.runtime == "apptainer":
454
+ executionEnv.update(self.config.env)
455
+
456
+ try:
457
+ completed = subprocess.run(command, check=False, env=executionEnv)
458
+ except KeyboardInterrupt:
459
+ return 130
460
+ except OSError as error:
461
+ raise KubCliError(f"Unable to execute runtime command: {error}") from error
462
+
463
+ return completed.returncode
kub_cli/versioning.py ADDED
@@ -0,0 +1,175 @@
1
+ # SPDX-FileCopyrightText: 2026 University of Strasbourg
2
+ # SPDX-FileContributor: Christophe Prud'homme
3
+ # SPDX-FileContributor: Cemosis
4
+ # SPDX-License-Identifier: Apache-2.0
5
+
6
+ """Version bump utilities for kub-cli development workflows."""
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from pathlib import Path
12
+ import re
13
+
14
+ from .errors import KubCliError
15
+
16
+
17
+ SEMVER_PATTERN = re.compile(r"^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$")
18
+ PYPROJECT_VERSION_PATTERN = re.compile(
19
+ r"(^version\s*=\s*\")(\d+\.\d+\.\d+)(\"\s*$)",
20
+ re.MULTILINE,
21
+ )
22
+ INIT_FALLBACK_VERSION_PATTERN = re.compile(
23
+ r"(^\s*__version__\s*=\s*\")(\d+\.\d+\.\d+)(\"\s*$)",
24
+ re.MULTILINE,
25
+ )
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class SemanticVersion:
30
+ """Semantic version components."""
31
+
32
+ major: int
33
+ minor: int
34
+ patch: int
35
+
36
+ def toString(self) -> str:
37
+ return f"{self.major}.{self.minor}.{self.patch}"
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class BumpResult:
42
+ """Result of version bump planning/execution."""
43
+
44
+ oldVersion: str
45
+ newVersion: str
46
+ pyprojectPath: Path
47
+ initPath: Path
48
+ changed: bool
49
+
50
+
51
+ def parseSemanticVersion(rawValue: str) -> SemanticVersion:
52
+ normalized = rawValue.strip()
53
+ match = SEMVER_PATTERN.match(normalized)
54
+ if match is None:
55
+ raise KubCliError(
56
+ f"Invalid version '{rawValue}'. Expected semantic version MAJOR.MINOR.PATCH."
57
+ )
58
+
59
+ return SemanticVersion(
60
+ major=int(match.group(1)),
61
+ minor=int(match.group(2)),
62
+ patch=int(match.group(3)),
63
+ )
64
+
65
+
66
+ def bumpSemanticVersion(currentVersion: SemanticVersion, part: str) -> SemanticVersion:
67
+ normalizedPart = part.strip().lower()
68
+
69
+ if normalizedPart == "major":
70
+ return SemanticVersion(
71
+ major=currentVersion.major + 1,
72
+ minor=0,
73
+ patch=0,
74
+ )
75
+
76
+ if normalizedPart == "minor":
77
+ return SemanticVersion(
78
+ major=currentVersion.major,
79
+ minor=currentVersion.minor + 1,
80
+ patch=0,
81
+ )
82
+
83
+ if normalizedPart == "patch":
84
+ return SemanticVersion(
85
+ major=currentVersion.major,
86
+ minor=currentVersion.minor,
87
+ patch=currentVersion.patch + 1,
88
+ )
89
+
90
+ raise KubCliError(
91
+ f"Invalid bump part '{part}'. Use one of: major, minor, patch."
92
+ )
93
+
94
+
95
+ def bumpProjectVersion(
96
+ *,
97
+ projectRoot: Path,
98
+ part: str,
99
+ toVersion: str | None,
100
+ dryRun: bool,
101
+ ) -> BumpResult:
102
+ pyprojectPath = projectRoot / "pyproject.toml"
103
+ initPath = projectRoot / "src" / "kub_cli" / "__init__.py"
104
+
105
+ oldVersion = readPyprojectVersion(pyprojectPath)
106
+ oldSemanticVersion = parseSemanticVersion(oldVersion)
107
+
108
+ if toVersion is not None:
109
+ newVersion = parseSemanticVersion(toVersion).toString()
110
+ else:
111
+ newVersion = bumpSemanticVersion(oldSemanticVersion, part).toString()
112
+
113
+ changed = oldVersion != newVersion
114
+
115
+ if not dryRun and changed:
116
+ replaceVersionInFile(
117
+ filePath=pyprojectPath,
118
+ pattern=PYPROJECT_VERSION_PATTERN,
119
+ newVersion=newVersion,
120
+ valueLabel="pyproject version",
121
+ )
122
+ replaceVersionInFile(
123
+ filePath=initPath,
124
+ pattern=INIT_FALLBACK_VERSION_PATTERN,
125
+ newVersion=newVersion,
126
+ valueLabel="__init__ fallback version",
127
+ )
128
+
129
+ return BumpResult(
130
+ oldVersion=oldVersion,
131
+ newVersion=newVersion,
132
+ pyprojectPath=pyprojectPath,
133
+ initPath=initPath,
134
+ changed=changed,
135
+ )
136
+
137
+
138
+ def readPyprojectVersion(pyprojectPath: Path) -> str:
139
+ if not pyprojectPath.exists():
140
+ raise KubCliError(f"pyproject.toml not found at '{pyprojectPath}'.")
141
+
142
+ content = pyprojectPath.read_text(encoding="utf-8")
143
+ match = PYPROJECT_VERSION_PATTERN.search(content)
144
+
145
+ if match is None:
146
+ raise KubCliError(
147
+ f"Unable to locate project version in '{pyprojectPath}'."
148
+ )
149
+
150
+ return match.group(2)
151
+
152
+
153
+ def replaceVersionInFile(
154
+ *,
155
+ filePath: Path,
156
+ pattern: re.Pattern[str],
157
+ newVersion: str,
158
+ valueLabel: str,
159
+ ) -> None:
160
+ if not filePath.exists():
161
+ raise KubCliError(f"Expected file not found: '{filePath}'.")
162
+
163
+ content = filePath.read_text(encoding="utf-8")
164
+
165
+ def replacement(match: re.Match[str]) -> str:
166
+ return f"{match.group(1)}{newVersion}{match.group(3)}"
167
+
168
+ updatedContent, count = pattern.subn(replacement, content, count=1)
169
+
170
+ if count != 1:
171
+ raise KubCliError(
172
+ f"Unable to update {valueLabel} in '{filePath}'."
173
+ )
174
+
175
+ filePath.write_text(updatedContent, encoding="utf-8")