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_cli.py ADDED
@@ -0,0 +1,306 @@
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
+ """Typer CLI for kub-img image management."""
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+
12
+ import typer
13
+
14
+ from . import __version__
15
+ from .errors import KubCliError
16
+ from .logging_utils import configureLogging
17
+ from .img_tools import KubImgManager, resolveImgConfig
18
+
19
+
20
+ imgApp = typer.Typer(
21
+ add_completion=False,
22
+ help="Manage kub runtime images for Apptainer and Docker.",
23
+ )
24
+
25
+
26
+ def buildImgManager(
27
+ *,
28
+ runtime: str | None,
29
+ image: str | None,
30
+ runner: str | None,
31
+ verbose: bool | None,
32
+ showConfig: bool,
33
+ ) -> KubImgManager:
34
+ config = resolveImgConfig(
35
+ runtime=runtime,
36
+ image=image,
37
+ runner=runner,
38
+ verbose=verbose,
39
+ )
40
+ configureLogging(config.verbose)
41
+
42
+ if showConfig:
43
+ print(json.dumps(config.toDict(), indent=2, sort_keys=True))
44
+
45
+ return KubImgManager(config=config)
46
+
47
+
48
+ def exitOnError(error: KubCliError) -> None:
49
+ typer.secho(str(error), fg=typer.colors.RED, err=True)
50
+ raise typer.Exit(code=error.exit_code) from error
51
+
52
+
53
+ @imgApp.callback(invoke_without_command=True)
54
+ def root(
55
+ ctx: typer.Context,
56
+ version: bool = typer.Option(
57
+ False,
58
+ "--version",
59
+ is_eager=True,
60
+ help="Show kub-cli version and exit.",
61
+ ),
62
+ ) -> None:
63
+ if version:
64
+ typer.echo(f"kub-cli {__version__}")
65
+ raise typer.Exit(code=0)
66
+
67
+ if ctx.invoked_subcommand is None:
68
+ typer.echo(ctx.get_help())
69
+ raise typer.Exit(code=0)
70
+
71
+
72
+ @imgApp.command("pull")
73
+ def pullImageCommand(
74
+ source: str | None = typer.Argument(
75
+ None,
76
+ metavar="SOURCE",
77
+ help=(
78
+ "Optional pull source. "
79
+ "For Apptainer use oras://... (never docker://). "
80
+ "If omitted, source is derived from configuration."
81
+ ),
82
+ ),
83
+ runtime: str | None = typer.Option(
84
+ None,
85
+ "--runtime",
86
+ metavar="{auto,apptainer,docker}",
87
+ help="Container runtime selection.",
88
+ ),
89
+ image: str | None = typer.Option(
90
+ None,
91
+ "--image",
92
+ metavar="IMAGE",
93
+ help="Runtime image path/reference (destination for pull).",
94
+ ),
95
+ runner: str | None = typer.Option(
96
+ None,
97
+ "--runner",
98
+ metavar="PATH",
99
+ help="Runtime executable path or command name.",
100
+ ),
101
+ force: bool = typer.Option(
102
+ False,
103
+ "--force",
104
+ help="Overwrite destination image when runtime is Apptainer.",
105
+ ),
106
+ disableCache: bool = typer.Option(
107
+ False,
108
+ "--disable-cache",
109
+ help="Disable cache when runtime is Apptainer.",
110
+ ),
111
+ apptainerFlags: list[str] | None = typer.Option(
112
+ None,
113
+ "--apptainer-flag",
114
+ metavar="FLAG",
115
+ help="Additional apptainer pull flags (repeatable).",
116
+ ),
117
+ dockerFlags: list[str] | None = typer.Option(
118
+ None,
119
+ "--docker-flag",
120
+ metavar="FLAG",
121
+ help="Additional docker pull flags (repeatable).",
122
+ ),
123
+ dryRun: bool = typer.Option(
124
+ False,
125
+ "--dry-run",
126
+ help="Print the pull command without executing it.",
127
+ ),
128
+ verbose: bool | None = typer.Option(
129
+ None,
130
+ "--verbose/--no-verbose",
131
+ help="Enable or disable verbose logging.",
132
+ ),
133
+ showConfig: bool = typer.Option(
134
+ False,
135
+ "--show-config",
136
+ help="Print effective kub-cli configuration as JSON.",
137
+ ),
138
+ ) -> None:
139
+ try:
140
+ manager = buildImgManager(
141
+ runtime=runtime,
142
+ image=image,
143
+ runner=runner,
144
+ verbose=verbose,
145
+ showConfig=showConfig,
146
+ )
147
+ exitCode = manager.pullImage(
148
+ runtime=runtime,
149
+ source=source,
150
+ force=force,
151
+ disableCache=disableCache,
152
+ apptainerFlags=apptainerFlags or [],
153
+ dockerFlags=dockerFlags or [],
154
+ dryRun=dryRun,
155
+ )
156
+ except KubCliError as error:
157
+ exitOnError(error)
158
+
159
+ raise typer.Exit(code=exitCode)
160
+
161
+
162
+ @imgApp.command("info")
163
+ def infoCommand(
164
+ runtime: str | None = typer.Option(
165
+ None,
166
+ "--runtime",
167
+ metavar="{auto,apptainer,docker}",
168
+ help="Container runtime selection.",
169
+ ),
170
+ image: str | None = typer.Option(
171
+ None,
172
+ "--image",
173
+ metavar="IMAGE",
174
+ help="Runtime image path/reference.",
175
+ ),
176
+ runner: str | None = typer.Option(
177
+ None,
178
+ "--runner",
179
+ metavar="PATH",
180
+ help="Runtime executable path or command name.",
181
+ ),
182
+ jsonOutput: bool = typer.Option(
183
+ False,
184
+ "--json",
185
+ help="Emit image info as JSON.",
186
+ ),
187
+ verbose: bool | None = typer.Option(
188
+ None,
189
+ "--verbose/--no-verbose",
190
+ help="Enable or disable verbose logging.",
191
+ ),
192
+ showConfig: bool = typer.Option(
193
+ False,
194
+ "--show-config",
195
+ help="Print effective kub-cli configuration as JSON.",
196
+ ),
197
+ ) -> None:
198
+ try:
199
+ manager = buildImgManager(
200
+ runtime=runtime,
201
+ image=image,
202
+ runner=runner,
203
+ verbose=verbose,
204
+ showConfig=showConfig,
205
+ )
206
+ exitCode = manager.printInfo(runtime=runtime, jsonOutput=jsonOutput)
207
+ except KubCliError as error:
208
+ exitOnError(error)
209
+
210
+ raise typer.Exit(code=exitCode)
211
+
212
+
213
+ @imgApp.command("apps")
214
+ def appsCommand(
215
+ runtime: str | None = typer.Option(
216
+ "apptainer",
217
+ "--runtime",
218
+ metavar="{apptainer}",
219
+ help="Runtime selection for app listing (Apptainer only).",
220
+ ),
221
+ image: str | None = typer.Option(
222
+ None,
223
+ "--image",
224
+ metavar="IMAGE",
225
+ help="Apptainer image path.",
226
+ ),
227
+ runner: str | None = typer.Option(
228
+ None,
229
+ "--runner",
230
+ metavar="PATH",
231
+ help="Apptainer executable path or command name.",
232
+ ),
233
+ verbose: bool | None = typer.Option(
234
+ None,
235
+ "--verbose/--no-verbose",
236
+ help="Enable or disable verbose logging.",
237
+ ),
238
+ showConfig: bool = typer.Option(
239
+ False,
240
+ "--show-config",
241
+ help="Print effective kub-cli configuration as JSON.",
242
+ ),
243
+ ) -> None:
244
+ try:
245
+ manager = buildImgManager(
246
+ runtime=runtime,
247
+ image=image,
248
+ runner=runner,
249
+ verbose=verbose,
250
+ showConfig=showConfig,
251
+ )
252
+ exitCode = manager.printApps(runtime=runtime)
253
+ except KubCliError as error:
254
+ exitOnError(error)
255
+
256
+ raise typer.Exit(code=exitCode)
257
+
258
+
259
+ @imgApp.command("path")
260
+ def pathCommand(
261
+ runtime: str | None = typer.Option(
262
+ None,
263
+ "--runtime",
264
+ metavar="{auto,apptainer,docker}",
265
+ help="Runtime selection for resolved image path/reference.",
266
+ ),
267
+ image: str | None = typer.Option(
268
+ None,
269
+ "--image",
270
+ metavar="IMAGE",
271
+ help="Image path/reference override.",
272
+ ),
273
+ runner: str | None = typer.Option(
274
+ None,
275
+ "--runner",
276
+ metavar="PATH",
277
+ help="Runtime executable path or command name.",
278
+ ),
279
+ verbose: bool | None = typer.Option(
280
+ None,
281
+ "--verbose/--no-verbose",
282
+ help="Enable or disable verbose logging.",
283
+ ),
284
+ showConfig: bool = typer.Option(
285
+ False,
286
+ "--show-config",
287
+ help="Print effective kub-cli configuration as JSON.",
288
+ ),
289
+ ) -> None:
290
+ try:
291
+ manager = buildImgManager(
292
+ runtime=runtime,
293
+ image=image,
294
+ runner=runner,
295
+ verbose=verbose,
296
+ showConfig=showConfig,
297
+ )
298
+ exitCode = manager.printImagePath(runtime=runtime)
299
+ except KubCliError as error:
300
+ exitOnError(error)
301
+
302
+ raise typer.Exit(code=exitCode)
303
+
304
+
305
+ def imgMain() -> None:
306
+ imgApp(prog_name="kub-img")
@@ -0,0 +1,265 @@
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
+ """Internal helpers to invoke the kub-img utility via subprocess."""
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ import json
12
+ import os
13
+ from pathlib import Path
14
+ import shutil
15
+ import subprocess
16
+ from typing import Any, Literal
17
+
18
+ from .config import KubConfig, SUPPORTED_RUNTIMES
19
+ from .errors import KubCliError, RuntimeSelectionError
20
+ from .logging_utils import LOGGER, formatCommand
21
+ from .runtime import (
22
+ deriveApptainerOrasReference,
23
+ getRuntimeCandidateImage,
24
+ getRunnerValue,
25
+ tryResolveRunnerExecutable,
26
+ )
27
+
28
+
29
+ ImageRuntime = Literal["apptainer", "docker"]
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class KubImgPullRequest:
34
+ """Resolved arguments for a kub-img pull operation."""
35
+
36
+ runtime: ImageRuntime
37
+ image: str
38
+ source: str
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class KubImgInfoRequest:
43
+ """Resolved arguments for a kub-img info operation."""
44
+
45
+ runtime: ImageRuntime
46
+ image: str
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class KubImgCommandRunner:
51
+ """Invoke `kub-img` as a subprocess safely."""
52
+
53
+ executable: str = "kub-img"
54
+ verbose: bool = False
55
+
56
+ def resolveExecutable(self) -> str:
57
+ executablePath = Path(self.executable).expanduser()
58
+ hasPathSeparator = executablePath.parent != Path(".")
59
+
60
+ if executablePath.is_absolute() or hasPathSeparator:
61
+ if executablePath.exists() and os.access(executablePath, os.X_OK):
62
+ return str(executablePath)
63
+ raise KubCliError(f"kub-img executable not found or not executable: '{executablePath}'")
64
+
65
+ resolved = shutil.which(self.executable)
66
+ if resolved is None:
67
+ raise KubCliError(
68
+ "kub-img executable not found in PATH. Install kub-cli with the kub-img entrypoint."
69
+ )
70
+
71
+ return resolved
72
+
73
+ def pullImage(self, request: KubImgPullRequest, *, dryRun: bool = False) -> int:
74
+ executable = self.resolveExecutable()
75
+ command = [
76
+ executable,
77
+ "pull",
78
+ "--runtime",
79
+ request.runtime,
80
+ "--image",
81
+ request.image,
82
+ request.source,
83
+ ]
84
+
85
+ if self.verbose:
86
+ command.append("--verbose")
87
+ LOGGER.debug("Running kub-img pull command: %s", formatCommand(command))
88
+
89
+ if dryRun:
90
+ print(formatCommand(command))
91
+ return 0
92
+
93
+ try:
94
+ completed = subprocess.run(command, check=False)
95
+ except OSError as error:
96
+ raise KubCliError(f"Unable to execute kub-img pull command: {error}") from error
97
+
98
+ if completed.returncode != 0:
99
+ raise KubCliError(
100
+ f"kub-img pull failed with exit code {completed.returncode}.",
101
+ exit_code=completed.returncode,
102
+ )
103
+
104
+ return 0
105
+
106
+ def inspectImageInfo(self, request: KubImgInfoRequest) -> dict[str, Any]:
107
+ executable = self.resolveExecutable()
108
+ command = [
109
+ executable,
110
+ "info",
111
+ "--runtime",
112
+ request.runtime,
113
+ "--image",
114
+ request.image,
115
+ "--json",
116
+ ]
117
+
118
+ if self.verbose:
119
+ command.append("--verbose")
120
+ LOGGER.debug("Running kub-img info command: %s", formatCommand(command))
121
+
122
+ try:
123
+ completed = subprocess.run(
124
+ command,
125
+ check=False,
126
+ capture_output=True,
127
+ text=True,
128
+ )
129
+ except OSError as error:
130
+ raise KubCliError(f"Unable to execute kub-img info command: {error}") from error
131
+
132
+ if completed.returncode != 0:
133
+ stderrText = completed.stderr.strip()
134
+ message = (
135
+ f"kub-img info failed with exit code {completed.returncode}. "
136
+ f"{stderrText}".strip()
137
+ )
138
+ raise KubCliError(message, exit_code=completed.returncode)
139
+
140
+ try:
141
+ payload = json.loads(completed.stdout)
142
+ except json.JSONDecodeError as error:
143
+ raise KubCliError(
144
+ "kub-img info returned non-JSON output while --json was requested."
145
+ ) from error
146
+
147
+ if not isinstance(payload, dict):
148
+ raise KubCliError("kub-img info JSON output must be an object.")
149
+
150
+ return payload
151
+
152
+
153
+ def resolveImageRuntime(config: KubConfig) -> ImageRuntime:
154
+ """Resolve runtime for image pull/info operations."""
155
+
156
+ configured = config.runtime.strip().lower()
157
+ if configured not in SUPPORTED_RUNTIMES:
158
+ supported = ", ".join(sorted(SUPPORTED_RUNTIMES))
159
+ raise RuntimeSelectionError(
160
+ f"Invalid runtime value '{config.runtime}'. Use one of: {supported}."
161
+ )
162
+
163
+ if configured in {"apptainer", "docker"}:
164
+ return configured
165
+
166
+ apptainerRunnerValue = getRunnerValue(config, "apptainer")
167
+ apptainerRunner = tryResolveRunnerExecutable(apptainerRunnerValue)
168
+ if apptainerRunner is not None:
169
+ try:
170
+ resolveApptainerLocalImageReference(config)
171
+ return "apptainer"
172
+ except KubCliError:
173
+ pass
174
+
175
+ dockerRunnerValue = getRunnerValue(config, "docker")
176
+ dockerRunner = tryResolveRunnerExecutable(dockerRunnerValue)
177
+ dockerImage = getRuntimeCandidateImage(config, "docker")
178
+ if dockerRunner is not None and dockerImage is not None:
179
+ return "docker"
180
+
181
+ raise RuntimeSelectionError(
182
+ "Unable to resolve runtime in auto mode for image operations. "
183
+ "Neither Apptainer nor Docker runner is available."
184
+ )
185
+
186
+
187
+ def buildKubImgInfoRequest(config: KubConfig) -> KubImgInfoRequest:
188
+ runtime = resolveImageRuntime(config)
189
+
190
+ if runtime == "docker":
191
+ dockerImage = getRuntimeCandidateImage(config, "docker")
192
+ if dockerImage is None:
193
+ raise KubCliError(
194
+ "No Docker image configured for image info. "
195
+ "Set --image, KUB_IMAGE_DOCKER, or KUB_IMAGE."
196
+ )
197
+ return KubImgInfoRequest(runtime="docker", image=dockerImage)
198
+
199
+ apptainerImage = resolveApptainerLocalImageReference(config)
200
+ return KubImgInfoRequest(runtime="apptainer", image=apptainerImage)
201
+
202
+
203
+ def buildKubImgPullRequest(config: KubConfig) -> KubImgPullRequest:
204
+ runtime = resolveImageRuntime(config)
205
+
206
+ if runtime == "docker":
207
+ dockerImage = getRuntimeCandidateImage(config, "docker")
208
+ if dockerImage is None:
209
+ raise KubCliError(
210
+ "No Docker image configured for pull. "
211
+ "Set --image, KUB_IMAGE_DOCKER, or KUB_IMAGE."
212
+ )
213
+
214
+ return KubImgPullRequest(
215
+ runtime="docker",
216
+ image=dockerImage,
217
+ source=dockerImage,
218
+ )
219
+
220
+ destinationImage = resolveApptainerLocalImageReference(config)
221
+
222
+ explicitApptainerReference = config.imageApptainer
223
+ if explicitApptainerReference is not None and explicitApptainerReference.startswith("oras://"):
224
+ source = explicitApptainerReference
225
+ else:
226
+ dockerImage = getRuntimeCandidateImage(config, "docker")
227
+ if dockerImage is None:
228
+ raise KubCliError(
229
+ "Unable to derive Apptainer ORAS source: no Docker image is configured. "
230
+ "Set KUB_IMAGE_DOCKER (or runtime image config)."
231
+ )
232
+ source = deriveApptainerOrasReference(dockerImage)
233
+
234
+ if source.startswith("docker://"):
235
+ raise KubCliError(
236
+ "Apptainer image pull source must use oras://, not docker://."
237
+ )
238
+
239
+ return KubImgPullRequest(
240
+ runtime="apptainer",
241
+ image=destinationImage,
242
+ source=source,
243
+ )
244
+
245
+
246
+ def resolveApptainerLocalImageReference(config: KubConfig) -> str:
247
+ candidates = [config.imageOverride, config.imageApptainer, config.image]
248
+
249
+ for candidate in candidates:
250
+ if candidate is None:
251
+ continue
252
+
253
+ normalized = candidate.strip()
254
+ if not normalized:
255
+ continue
256
+
257
+ if "://" in normalized:
258
+ continue
259
+
260
+ return normalized
261
+
262
+ raise KubCliError(
263
+ "No local Apptainer image path configured. "
264
+ "Set --image or KUB_IMAGE_APPTAINER to a .sif file path."
265
+ )