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/img_tools.py ADDED
@@ -0,0 +1,434 @@
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
+ """Image management helpers for the kub-img command."""
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, replace
11
+ from datetime import datetime, timezone
12
+ import json
13
+ import os
14
+ from pathlib import Path
15
+ import subprocess
16
+ from typing import Any, Mapping, Sequence
17
+
18
+ from .config import KubConfig, KubConfigOverrides, SUPPORTED_RUNTIMES, loadKubConfig
19
+ from .errors import ImageNotFoundError, KubCliError
20
+ from .img_integration import (
21
+ buildKubImgInfoRequest,
22
+ buildKubImgPullRequest,
23
+ resolveApptainerLocalImageReference,
24
+ )
25
+ from .logging_utils import LOGGER, formatCommand
26
+ from .runtime import getRunnerValue, resolveRunnerExecutable
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class KubImgManager:
31
+ """Execute runtime-specific image operations using resolved kub-cli config."""
32
+
33
+ config: KubConfig
34
+
35
+ def resolveRuntime(self, runtime: str | None) -> str:
36
+ if runtime is None:
37
+ return self.config.runtime
38
+
39
+ normalized = runtime.strip().lower()
40
+ if normalized not in SUPPORTED_RUNTIMES:
41
+ supported = ", ".join(sorted(SUPPORTED_RUNTIMES))
42
+ raise KubCliError(
43
+ f"Invalid runtime value '{runtime}'. Use one of: {supported}."
44
+ )
45
+
46
+ return normalized
47
+
48
+ def configWithRuntime(self, runtime: str | None) -> KubConfig:
49
+ resolvedRuntime = self.resolveRuntime(runtime)
50
+ return replace(self.config, runtime=resolvedRuntime)
51
+
52
+ def pullImage(
53
+ self,
54
+ *,
55
+ runtime: str | None,
56
+ source: str | None,
57
+ force: bool,
58
+ disableCache: bool,
59
+ apptainerFlags: Sequence[str],
60
+ dockerFlags: Sequence[str],
61
+ dryRun: bool,
62
+ ) -> int:
63
+ runtimeConfig = self.configWithRuntime(runtime)
64
+ request = buildKubImgPullRequest(runtimeConfig)
65
+
66
+ if source is not None:
67
+ normalizedSource = source.strip()
68
+ if not normalizedSource:
69
+ raise KubCliError("Image source cannot be empty.")
70
+ request = replace(request, source=normalizedSource)
71
+
72
+ if request.runtime == "apptainer":
73
+ return self.pullApptainerImage(
74
+ runtimeConfig=runtimeConfig,
75
+ source=request.source,
76
+ destinationImage=request.image,
77
+ force=force,
78
+ disableCache=disableCache,
79
+ apptainerFlags=apptainerFlags,
80
+ dryRun=dryRun,
81
+ )
82
+
83
+ return self.pullDockerImage(
84
+ runtimeConfig=runtimeConfig,
85
+ source=request.source,
86
+ destinationImage=request.image,
87
+ dockerFlags=dockerFlags,
88
+ dryRun=dryRun,
89
+ )
90
+
91
+ def pullApptainerImage(
92
+ self,
93
+ *,
94
+ runtimeConfig: KubConfig,
95
+ source: str,
96
+ destinationImage: str,
97
+ force: bool,
98
+ disableCache: bool,
99
+ apptainerFlags: Sequence[str],
100
+ dryRun: bool,
101
+ ) -> int:
102
+ if source.startswith("docker://"):
103
+ raise KubCliError(
104
+ "Apptainer pull source must use oras:// and not docker://."
105
+ )
106
+
107
+ runner = self.resolveRunner("apptainer", runtimeConfig)
108
+
109
+ command: list[str] = [runner, "pull"]
110
+
111
+ if force:
112
+ command.append("--force")
113
+
114
+ if disableCache:
115
+ command.append("--disable-cache")
116
+
117
+ if runtimeConfig.apptainerFlags:
118
+ command.extend(runtimeConfig.apptainerFlags)
119
+
120
+ if apptainerFlags:
121
+ command.extend(apptainerFlags)
122
+
123
+ command.extend([destinationImage, source])
124
+
125
+ return self.runCommand(command, captureOutput=False, dryRun=dryRun)
126
+
127
+ def pullDockerImage(
128
+ self,
129
+ *,
130
+ runtimeConfig: KubConfig,
131
+ source: str,
132
+ destinationImage: str,
133
+ dockerFlags: Sequence[str],
134
+ dryRun: bool,
135
+ ) -> int:
136
+ runner = self.resolveRunner("docker", runtimeConfig)
137
+
138
+ pullCommand: list[str] = [runner, "pull"]
139
+ if runtimeConfig.dockerFlags:
140
+ pullCommand.extend(runtimeConfig.dockerFlags)
141
+ if dockerFlags:
142
+ pullCommand.extend(dockerFlags)
143
+ pullCommand.append(source)
144
+
145
+ exitCode = self.runCommand(pullCommand, captureOutput=False, dryRun=dryRun)
146
+ if exitCode != 0:
147
+ return exitCode
148
+
149
+ if destinationImage == source:
150
+ return 0
151
+
152
+ tagCommand = [runner, "tag", source, destinationImage]
153
+ return self.runCommand(tagCommand, captureOutput=False, dryRun=dryRun)
154
+
155
+ def collectInfo(self, *, runtime: str | None) -> dict[str, Any]:
156
+ runtimeConfig = self.configWithRuntime(runtime)
157
+ request = buildKubImgInfoRequest(runtimeConfig)
158
+
159
+ if request.runtime == "apptainer":
160
+ return self.collectApptainerInfo(request.image, runtimeConfig)
161
+
162
+ return self.collectDockerInfo(request.image, runtimeConfig)
163
+
164
+ def collectApptainerInfo(self, imagePathRaw: str, runtimeConfig: KubConfig) -> dict[str, Any]:
165
+ imagePath = Path(imagePathRaw).expanduser()
166
+ if not imagePath.exists():
167
+ raise ImageNotFoundError(f"Container image not found: '{imagePath}'.")
168
+
169
+ if imagePath.is_dir():
170
+ raise ImageNotFoundError(
171
+ f"Container image must be a file, got directory: '{imagePath}'."
172
+ )
173
+
174
+ apps = self.inspectApptainerApps(imagePath, runtimeConfig)
175
+ labelsRaw = self.inspectApptainerLabels(imagePath, runtimeConfig)
176
+
177
+ imageStat = imagePath.stat()
178
+
179
+ return {
180
+ "runtime": "apptainer",
181
+ "image": str(imagePath),
182
+ "sizeBytes": imageStat.st_size,
183
+ "modifiedUtc": datetime.fromtimestamp(
184
+ imageStat.st_mtime,
185
+ tz=timezone.utc,
186
+ ).isoformat(),
187
+ "apps": apps,
188
+ "labels": parseLabelOutput(labelsRaw),
189
+ "labelsRaw": labelsRaw,
190
+ }
191
+
192
+ def collectDockerInfo(self, imageReference: str, runtimeConfig: KubConfig) -> dict[str, Any]:
193
+ runner = self.resolveRunner("docker", runtimeConfig)
194
+ command = [runner, "image", "inspect", imageReference]
195
+ completed = self.runCommand(
196
+ command,
197
+ captureOutput=True,
198
+ dryRun=False,
199
+ runtimeConfig=runtimeConfig,
200
+ )
201
+
202
+ if not isinstance(completed, subprocess.CompletedProcess):
203
+ raise KubCliError("Internal error while collecting Docker image information.")
204
+
205
+ if completed.returncode != 0:
206
+ stderrText = completed.stderr.strip() if completed.stderr else ""
207
+ raise KubCliError(
208
+ "Unable to inspect Docker image. "
209
+ f"Command failed with code {completed.returncode}. {stderrText}".strip()
210
+ )
211
+
212
+ payload: Any
213
+ try:
214
+ payload = json.loads(completed.stdout)
215
+ except json.JSONDecodeError as error:
216
+ raise KubCliError("Docker inspect did not return valid JSON output.") from error
217
+
218
+ return {
219
+ "runtime": "docker",
220
+ "image": imageReference,
221
+ "inspect": payload,
222
+ }
223
+
224
+ def inspectApptainerApps(self, imagePath: Path, runtimeConfig: KubConfig) -> list[str]:
225
+ runner = self.resolveRunner("apptainer", runtimeConfig)
226
+ command = [runner, "inspect", "--list-apps", str(imagePath)]
227
+ completed = self.runCommand(
228
+ command,
229
+ captureOutput=True,
230
+ dryRun=False,
231
+ runtimeConfig=runtimeConfig,
232
+ )
233
+
234
+ if not isinstance(completed, subprocess.CompletedProcess):
235
+ raise KubCliError("Internal error while listing Apptainer apps.")
236
+
237
+ if completed.returncode != 0:
238
+ stderrText = completed.stderr.strip() if completed.stderr else ""
239
+ raise KubCliError(
240
+ "Unable to list Apptainer apps from image. "
241
+ f"Command failed with code {completed.returncode}. {stderrText}".strip()
242
+ )
243
+
244
+ return [line.strip() for line in completed.stdout.splitlines() if line.strip()]
245
+
246
+ def inspectApptainerLabels(self, imagePath: Path, runtimeConfig: KubConfig) -> str:
247
+ runner = self.resolveRunner("apptainer", runtimeConfig)
248
+ command = [runner, "inspect", "--labels", str(imagePath)]
249
+ completed = self.runCommand(
250
+ command,
251
+ captureOutput=True,
252
+ dryRun=False,
253
+ runtimeConfig=runtimeConfig,
254
+ )
255
+
256
+ if not isinstance(completed, subprocess.CompletedProcess):
257
+ raise KubCliError("Internal error while inspecting Apptainer labels.")
258
+
259
+ if completed.returncode != 0:
260
+ stderrText = completed.stderr.strip() if completed.stderr else ""
261
+ raise KubCliError(
262
+ "Unable to inspect Apptainer image labels. "
263
+ f"Command failed with code {completed.returncode}. {stderrText}".strip()
264
+ )
265
+
266
+ return completed.stdout.strip()
267
+
268
+ def printInfo(self, *, runtime: str | None, jsonOutput: bool) -> int:
269
+ info = self.collectInfo(runtime=runtime)
270
+
271
+ if jsonOutput:
272
+ print(json.dumps(info, indent=2, sort_keys=True))
273
+ return 0
274
+
275
+ print(f"Runtime: {info['runtime']}")
276
+ print(f"Image: {info['image']}")
277
+
278
+ if info["runtime"] == "apptainer":
279
+ print(f"Size: {info['sizeBytes']} bytes")
280
+ print(f"Modified (UTC): {info['modifiedUtc']}")
281
+
282
+ apps: list[str] = info["apps"]
283
+ if apps:
284
+ print("Apps:")
285
+ for app in apps:
286
+ print(f" - {app}")
287
+ else:
288
+ print("Apps: (none listed)")
289
+
290
+ labels: Mapping[str, str] = info["labels"]
291
+ if labels:
292
+ print("Labels:")
293
+ for key in sorted(labels.keys()):
294
+ print(f" {key}: {labels[key]}")
295
+ else:
296
+ labelsRaw: str = info["labelsRaw"]
297
+ if labelsRaw:
298
+ print("Labels (raw):")
299
+ print(labelsRaw)
300
+ else:
301
+ print("Labels: (none)")
302
+ else:
303
+ inspectData = info["inspect"]
304
+ if isinstance(inspectData, list) and inspectData:
305
+ first = inspectData[0]
306
+ if isinstance(first, Mapping):
307
+ repoTags = first.get("RepoTags")
308
+ if repoTags is not None:
309
+ print(f"RepoTags: {repoTags}")
310
+ imageId = first.get("Id")
311
+ if imageId is not None:
312
+ print(f"Id: {imageId}")
313
+
314
+ return 0
315
+
316
+ def printApps(self, *, runtime: str | None) -> int:
317
+ runtimeConfig = self.configWithRuntime(runtime)
318
+
319
+ if resolveRuntimeString(runtimeConfig.runtime) != "apptainer":
320
+ raise KubCliError(
321
+ "kub-img apps is only available with Apptainer runtime. "
322
+ "Use --runtime apptainer."
323
+ )
324
+
325
+ imagePathRaw = resolveApptainerLocalImageReference(runtimeConfig)
326
+ imagePath = Path(imagePathRaw).expanduser()
327
+ if not imagePath.exists():
328
+ raise ImageNotFoundError(f"Container image not found: '{imagePath}'.")
329
+
330
+ apps = self.inspectApptainerApps(imagePath, runtimeConfig)
331
+
332
+ for app in apps:
333
+ print(app)
334
+
335
+ return 0
336
+
337
+ def printImagePath(self, *, runtime: str | None) -> int:
338
+ runtimeConfig = self.configWithRuntime(runtime)
339
+ resolvedRuntime = resolveRuntimeString(runtimeConfig.runtime)
340
+
341
+ if resolvedRuntime == "docker":
342
+ request = buildKubImgInfoRequest(runtimeConfig)
343
+ print(request.image)
344
+ return 0
345
+
346
+ imagePath = resolveApptainerLocalImageReference(runtimeConfig)
347
+ print(imagePath)
348
+ return 0
349
+
350
+ def resolveRunner(self, runtime: str, runtimeConfig: KubConfig) -> str:
351
+ runnerValue = getRunnerValue(runtimeConfig, runtime) # type: ignore[arg-type]
352
+ return resolveRunnerExecutable(runnerValue, runtimeName=runtime)
353
+
354
+ def runCommand(
355
+ self,
356
+ command: Sequence[str],
357
+ *,
358
+ captureOutput: bool,
359
+ dryRun: bool,
360
+ runtimeConfig: KubConfig | None = None,
361
+ ) -> int | subprocess.CompletedProcess[str]:
362
+ if self.config.verbose or (runtimeConfig is not None and runtimeConfig.verbose):
363
+ LOGGER.debug("Resolved command: %s", formatCommand(command))
364
+
365
+ if dryRun:
366
+ print(formatCommand(command))
367
+ return 0
368
+
369
+ if captureOutput:
370
+ runKwargs: dict[str, Any] = {
371
+ "capture_output": True,
372
+ "text": True,
373
+ }
374
+ else:
375
+ runKwargs = {}
376
+
377
+ try:
378
+ return subprocess.run(
379
+ list(command),
380
+ check=False,
381
+ env=dict(os.environ),
382
+ **runKwargs,
383
+ )
384
+ except KeyboardInterrupt as error:
385
+ raise KubCliError("Execution interrupted by user.", exit_code=130) from error
386
+ except OSError as error:
387
+ raise KubCliError(f"Unable to execute runtime command: {error}") from error
388
+
389
+
390
+ def resolveImgConfig(
391
+ *,
392
+ runtime: str | None,
393
+ image: str | None,
394
+ runner: str | None,
395
+ verbose: bool | None,
396
+ cwd: Path | None = None,
397
+ env: Mapping[str, str] | None = None,
398
+ userConfigPath: Path | None = None,
399
+ ) -> KubConfig:
400
+ overrides = KubConfigOverrides(
401
+ runtime=runtime,
402
+ image=image,
403
+ runner=runner,
404
+ verbose=verbose,
405
+ )
406
+ return loadKubConfig(
407
+ cwd=cwd,
408
+ env=env,
409
+ overrides=overrides,
410
+ userConfigPath=userConfigPath,
411
+ )
412
+
413
+
414
+ def parseLabelOutput(rawText: str) -> dict[str, str]:
415
+ labels: dict[str, str] = {}
416
+
417
+ for line in rawText.splitlines():
418
+ normalized = line.strip()
419
+ if not normalized or ":" not in normalized:
420
+ continue
421
+
422
+ key, value = normalized.split(":", maxsplit=1)
423
+ labels[key.strip()] = value.strip()
424
+
425
+ return labels
426
+
427
+
428
+ def resolveRuntimeString(runtime: str) -> str:
429
+ normalized = runtime.strip().lower()
430
+ if normalized not in SUPPORTED_RUNTIMES:
431
+ raise KubCliError(f"Invalid runtime value: '{runtime}'.")
432
+ if normalized == "auto":
433
+ return "apptainer"
434
+ return normalized
@@ -0,0 +1,31 @@
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
+ """Logging helpers for kub-cli."""
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ import shlex
12
+ from typing import Sequence
13
+
14
+
15
+ LOGGER = logging.getLogger("kub_cli")
16
+
17
+
18
+ def configureLogging(verbose: bool) -> None:
19
+ """Configure process-wide logging according to verbosity."""
20
+ level = logging.DEBUG if verbose else logging.WARNING
21
+ logging.basicConfig(
22
+ level=level,
23
+ format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
24
+ datefmt="%Y-%m-%d %H:%M:%S",
25
+ force=True,
26
+ )
27
+
28
+
29
+ def formatCommand(arguments: Sequence[str]) -> str:
30
+ """Format a subprocess argument list as a shell-style string."""
31
+ return shlex.join([str(item) for item in arguments])