contree-cli 0.2.3__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.
contree_cli/cli/run.py ADDED
@@ -0,0 +1,438 @@
1
+ """Spawn a container instance from the current session image and execute a command.
2
+
3
+ Uses the image from the active session (set via `contree use IMAGE`).
4
+ Commands are passed after -- separator; without --, the first
5
+ positional arg is the command.
6
+
7
+ By default the CLI polls until the operation reaches a terminal
8
+ status (SUCCESS, FAILED, CANCELLED) and prints stdout/stderr.
9
+ Use -d/--detach to exit immediately after spawning.
10
+
11
+ File attachments:
12
+ Use --file to inject host files into the container before
13
+ execution. Files are uploaded to the API (with SHA256 dedup)
14
+ and mounted at the specified instance path. Ownership and
15
+ permissions default to the host file's stat unless overridden.
16
+
17
+ Note: non-disposable runs persist filesystem changes into a
18
+ new image. Files attached once are already part of that image
19
+ and do not need re-attachment. Use --disposable to discard
20
+ changes after execution.
21
+
22
+ Format: host_path[:instance_path][:uUID][:gGID][:mMODE]
23
+
24
+ host_path all defaults from stat
25
+ host_path:/inst/path point a destination path
26
+ host_path:m0755 override only mode
27
+ host_path:/inst/path:u0:g0:m0755 all explicit
28
+ host_path:uroot:groot uid/gid by name (local)
29
+
30
+ Tagged options (u/g/m) can appear in any order after host_path.
31
+ instance_path is detected by its leading /.
32
+ Note: named uid/gid (e.g. uroot) are resolved locally via
33
+ pwd/grp — use numeric IDs if unsure about host/container mismatch.
34
+ """
35
+ from __future__ import annotations
36
+
37
+ import argparse
38
+ import base64
39
+ import json
40
+ import logging
41
+ import re
42
+ import sys
43
+ import time
44
+ from dataclasses import dataclass, field
45
+ from datetime import timedelta
46
+
47
+ from contree_cli import CLIENT, FORMATTER, SESSION_STORE, ArgumentsProtocol, SetupResult
48
+ from contree_cli.client import ApiError, ContreeClient, decode_stream, resolve_image
49
+ from contree_cli.mapped_file import MappedFile
50
+ from contree_cli.output import (
51
+ DefaultFormatter,
52
+ OutputFormatter,
53
+ )
54
+
55
+ logger = logging.getLogger(__name__)
56
+
57
+ EPILOG = """\
58
+ examples:
59
+ contree use ubuntu && contree run -- uname -a
60
+ contree run --shell -- 'echo hello && ls /'
61
+ contree run -e FOO=bar DEBUG=1 -- ./app
62
+ contree run --file ./app.py:/app.py --disposable -- python /app.py
63
+ contree run -d -- sleep 3600
64
+ """
65
+
66
+ TERMINAL_STATUSES = frozenset({"SUCCESS", "FAILED", "CANCELLED"})
67
+
68
+ # Escape sequences that corrupt the terminal when replayed from captured output.
69
+ # Matches CSI sequences that are NOT SGR (colors end with 'm') — cursor movement,
70
+ # screen clearing, mode set/reset, scroll regions, etc. Also matches RIS (\033c),
71
+ # DEC cursor save/restore (\0337/\0338), and OSC sequences (title setting, etc.).
72
+ _BREAKING_ESC_RE = re.compile(
73
+ r"\033\[\??[0-9;]*[ABCDEFGHJKSTfhlrsu]" # CSI non-SGR
74
+ r"|\033c" # RIS (full reset)
75
+ r"|\0337|\0338" # DEC cursor save/restore
76
+ r"|\033\][^\033\x07]*(?:\033\\|\x07)" # OSC sequences
77
+ )
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class RunArgs(ArgumentsProtocol):
82
+ command_args: list[str] = field(default_factory=list)
83
+ timeout: int | None = None
84
+ env: list[str] = field(default_factory=list)
85
+ hostname: str = "linuxkit"
86
+ disposable: bool = False
87
+ interpreter: bool = False
88
+ shell: bool = False
89
+ file: list[MappedFile] = field(default_factory=list)
90
+ truncate: int = 65536
91
+ detach: bool = False
92
+ cwd: str = ""
93
+
94
+ @classmethod
95
+ def from_args(cls, ns: argparse.Namespace) -> RunArgs:
96
+ raw = ns.command_args
97
+ if raw and raw[0] == "--":
98
+ raw = raw[1:]
99
+ return cls(
100
+ command_args=raw,
101
+ timeout=ns.timeout,
102
+ env=ns.env,
103
+ hostname=ns.hostname,
104
+ disposable=ns.disposable,
105
+ interpreter=ns.interpreter,
106
+ shell=ns.shell,
107
+ file=[MappedFile.parse(f) for f in ns.file],
108
+ truncate=ns.truncate,
109
+ detach=ns.detach,
110
+ cwd=ns.cwd,
111
+ )
112
+
113
+
114
+ def setup_parser(p: argparse.ArgumentParser) -> SetupResult:
115
+ p.add_argument(
116
+ "command_args", nargs=argparse.REMAINDER,
117
+ help="Command and arguments (after --)",
118
+ )
119
+ p.add_argument("-t", "--timeout", type=int, help="Timeout in seconds", default=120)
120
+ p.add_argument(
121
+ "-C", "--cwd", default="",
122
+ help=(
123
+ "Working directory inside container, absolute path "
124
+ "or empty string for use container WORKDIR"
125
+ )
126
+ )
127
+ p.add_argument(
128
+ "-e", "--env", action="append", default=[],
129
+ help="Environment variable KEY=VALUE (repeatable)",
130
+ )
131
+ p.add_argument(
132
+ "-H", "--hostname", default="linuxkit",
133
+ help="Container hostname",
134
+ )
135
+ p.add_argument(
136
+ "-D", "--disposable", action="store_true",
137
+ help="Drop filesystem changes after run",
138
+ )
139
+ p.add_argument(
140
+ "-I", "--interpreter", action="store_true",
141
+ help=(
142
+ "Interpreter (shebang) mode. Read the script file given "
143
+ "as the first argument, strip the #! line, and send the "
144
+ "body as stdin to /bin/sh -s. "
145
+ "Usage: #!/usr/bin/env -S contree run -I"
146
+ ),
147
+ )
148
+ p.add_argument(
149
+ "-s", "--shell", action="store_true",
150
+ help="Join command args into a single shell expression",
151
+ )
152
+ p.add_argument(
153
+ "-F", "--file", action="append", default=[], metavar="FILE",
154
+ help=(
155
+ "Attach file (repeatable). "
156
+ "Format: host[:inst_path][:uUID][:gGID][:mMODE]. "
157
+ "Tagged options (u/g/m) in any order; "
158
+ "uid/gid resolved locally from pwd/grp; defaults"
159
+ " from host stat."
160
+ ),
161
+ )
162
+ p.add_argument(
163
+ "-T", "--truncate", type=int, default=65536,
164
+ help="Truncate output to N bytes",
165
+ )
166
+ p.add_argument(
167
+ "-d", "--detach", "--no-wait",
168
+ action="store_true",
169
+ help="Exit immediately after spawning (do not wait for result)",
170
+ )
171
+ return cmd_run, RunArgs
172
+
173
+
174
+ def _upload_file(
175
+ client: ContreeClient, mf: MappedFile,
176
+ ) -> str:
177
+ """Upload a host file and return its UUID, reusing if already present."""
178
+ try:
179
+ resp = client.get("/v1/files", params={"sha256": mf.sha256()})
180
+ uuid = json.loads(resp.read())["uuid"]
181
+ logger.info("File %s already uploaded (%s)", mf.host_path, uuid)
182
+ return str(uuid)
183
+ except ApiError as exc:
184
+ if exc.status != 404:
185
+ raise
186
+ with open(mf.host_path, "rb") as fh:
187
+ data = fh.read()
188
+ resp = client.request(
189
+ "POST", "/v1/files",
190
+ body=data,
191
+ headers={"Content-Type": "application/octet-stream"},
192
+ )
193
+ uuid = json.loads(resp.read())["uuid"]
194
+ logger.info("Uploaded %s (%s)", mf.host_path, uuid)
195
+ return str(uuid)
196
+
197
+
198
+ def _build_payload(
199
+ args: RunArgs, image_uuid: str, uploaded: dict[str, str],
200
+ ) -> dict[str, object]:
201
+ """Build the JSON payload for POST /v1/instances."""
202
+ if args.shell:
203
+ command = " ".join(args.command_args)
204
+ else:
205
+ parts = args.command_args
206
+ command = parts[0] if parts else ""
207
+
208
+ payload: dict[str, object] = {
209
+ "image": image_uuid,
210
+ "command": command,
211
+ "shell": args.shell,
212
+ "disposable": args.disposable,
213
+ "hostname": args.hostname,
214
+ "truncate_output_at": args.truncate,
215
+ }
216
+
217
+ if not args.shell and len(args.command_args) > 1:
218
+ payload["args"] = args.command_args[1:]
219
+
220
+ if args.timeout is not None:
221
+ payload["timeout"] = args.timeout
222
+
223
+ if args.cwd:
224
+ payload["cwd"] = args.cwd
225
+
226
+ if args.env:
227
+ env_dict: dict[str, str] = {}
228
+ for item in args.env:
229
+ key, _, value = item.partition("=")
230
+ env_dict[key] = value
231
+ payload["env"] = env_dict
232
+
233
+ if uploaded:
234
+ files: dict[str, object] = {}
235
+ for mf in args.file:
236
+ file_uuid = uploaded[mf.host_path]
237
+ files[mf.instance_path] = {
238
+ "uuid": file_uuid,
239
+ "uid": mf.uid,
240
+ "gid": mf.gid,
241
+ "mode": f"{mf.mode:04o}",
242
+ }
243
+ payload["files"] = files
244
+
245
+ return payload
246
+
247
+
248
+ def _display_operation(
249
+ op: dict[str, object], formatter: OutputFormatter,
250
+ ) -> None:
251
+ """Display an operation result using the given formatter."""
252
+ duration_raw = op.get("duration")
253
+ duration = (
254
+ timedelta(seconds=duration_raw) # type: ignore[arg-type]
255
+ if duration_raw is not None
256
+ else None
257
+ )
258
+ result = op.get("result") or {}
259
+ assert isinstance(result, dict)
260
+ metadata = op.get("metadata") or {}
261
+ assert isinstance(metadata, dict)
262
+ instance_result = metadata.get("result") or {}
263
+ assert isinstance(instance_result, dict)
264
+
265
+ exit_code = None
266
+ state = instance_result.get("state") or {}
267
+ assert isinstance(state, dict)
268
+ if state:
269
+ exit_code = state.get("exit_code")
270
+
271
+ if formatter.STREAM:
272
+ formatter(
273
+ uuid=op["uuid"],
274
+ kind=op.get("kind", ""),
275
+ status=op["status"],
276
+ duration=duration,
277
+ exit_code=exit_code,
278
+ error=op.get("error") or "",
279
+ image=result.get("image") or "",
280
+ tag=result.get("tag") or "",
281
+ stdout=decode_stream(instance_result.get("stdout")),
282
+ stderr=decode_stream(instance_result.get("stderr")),
283
+ )
284
+ formatter.flush()
285
+ return
286
+
287
+ if not isinstance(formatter, DefaultFormatter):
288
+ raise RuntimeError(f"Unsupported formatter type: {type(formatter).__name__}, "
289
+ "only json/json-pretty/default are supported")
290
+
291
+ # For DefaultFormatter, just only print stdout/stderr
292
+ stdout = _BREAKING_ESC_RE.sub("", decode_stream(instance_result.get("stdout")))
293
+ stderr = _BREAKING_ESC_RE.sub("", decode_stream(instance_result.get("stderr")))
294
+
295
+ if stdout:
296
+ sys.stdout.write(stdout)
297
+ if not stdout.endswith("\n"):
298
+ sys.stdout.write("\n")
299
+ if stderr:
300
+ sys.stderr.write(stderr)
301
+ if not stderr.endswith("\n"):
302
+ sys.stderr.write("\n")
303
+
304
+
305
+ def cmd_run(args: RunArgs) -> int | None:
306
+ client = CLIENT.get()
307
+ formatter = FORMATTER.get()
308
+ store = SESSION_STORE.get()
309
+
310
+ # 1. Resolve image from active session
311
+ image_uuid = resolve_image(client, store.current_image)
312
+
313
+ # 2. Upload attached files
314
+ uploaded: dict[str, str] = {}
315
+ for mf in args.file:
316
+ uploaded[mf.host_path] = _upload_file(client, mf)
317
+
318
+ # 2b. Include pending files from session store
319
+ pending = store.pending_files()
320
+
321
+ # 3. Build and send spawn request
322
+ payload = _build_payload(args, image_uuid, uploaded)
323
+
324
+ # Merge pending files (explicit --file takes priority)
325
+ if pending:
326
+ files = payload.get("files", {})
327
+ assert isinstance(files, dict)
328
+ for pf in pending:
329
+ if pf.instance_path not in files:
330
+ files[pf.instance_path] = {
331
+ "uuid": pf.file_uuid,
332
+ "uid": pf.uid,
333
+ "gid": pf.gid,
334
+ "mode": pf.mode,
335
+ }
336
+ logger.info(
337
+ "Including pending file %s (%s)",
338
+ pf.instance_path, pf.file_uuid,
339
+ )
340
+ if files:
341
+ payload["files"] = files
342
+
343
+ # Interpreter mode (-I): read script file, strip shebang, send as stdin
344
+ if args.interpreter and args.command_args:
345
+ script_path = args.command_args[0]
346
+ logger.debug("Interpreter mode: reading %s", script_path)
347
+ with open(script_path, "rb") as f:
348
+ script_data = f.read()
349
+ # Strip shebang line
350
+ shebang_line, _, script_body = script_data.partition(b"\n")
351
+ logger.debug("Shebang line: %s", shebang_line.decode(errors="replace"))
352
+ if script_body:
353
+ payload["stdin"] = {
354
+ "value": base64.b64encode(script_body).decode(),
355
+ "encoding": "base64",
356
+ }
357
+ logger.debug("Script body: %d bytes", len(script_body))
358
+ payload["command"] = "/bin/sh"
359
+ payload["shell"] = True
360
+ extra_args = args.command_args[1:]
361
+ if extra_args:
362
+ payload["args"] = ["-s", "--", *extra_args]
363
+ logger.debug("Extra args: %s", extra_args)
364
+ else:
365
+ payload["args"] = ["-s"]
366
+
367
+ # Read piped stdin (skip if shebang already set it)
368
+ if "stdin" not in payload and not sys.stdin.isatty():
369
+ logger.debug("Reading piped stdin")
370
+ stdin_data = sys.stdin.buffer.read()
371
+ if stdin_data:
372
+ payload["stdin"] = {
373
+ "value": base64.b64encode(stdin_data).decode(),
374
+ "encoding": "base64",
375
+ }
376
+ logger.debug("Piped stdin: %d bytes", len(stdin_data))
377
+
378
+ resp = client.post_json("/v1/instances", payload)
379
+ op = json.loads(resp.read())
380
+ op_uuid: str = op["uuid"]
381
+
382
+ logger.info("Spawned operation %s", op_uuid)
383
+ # 4. Detach mode - exit immediately
384
+ if args.detach:
385
+ formatter(uuid=op_uuid, status=op.get("status", "PENDING"))
386
+ formatter.flush()
387
+ return None
388
+
389
+ # 5. Poll until terminal status
390
+ sleep_time = 0.5
391
+ try:
392
+ time.sleep(sleep_time)
393
+ sleep_time += sleep_time
394
+ while True:
395
+ resp = client.get(f"/v1/operations/{op_uuid}")
396
+ op = json.loads(resp.read())
397
+ if op["status"] in TERMINAL_STATUSES:
398
+ break
399
+ time.sleep(sleep_time)
400
+ if sleep_time < 5:
401
+ sleep_time += sleep_time
402
+ except KeyboardInterrupt:
403
+ try:
404
+ client.delete(f"/v1/operations/{op_uuid}")
405
+ logger.info("Cancelled operation %s", op_uuid)
406
+ except (ApiError, KeyboardInterrupt, OSError):
407
+ pass
408
+ raise
409
+
410
+ # 6. Cache terminal operation result
411
+ store.cache[(op_uuid, "operation")] = op
412
+
413
+ # 7. Display result
414
+ _display_operation(op, formatter)
415
+
416
+ result = op.get("result") or {}
417
+ assert isinstance(result, dict)
418
+ new_image = result.get("image")
419
+ if new_image and op["status"] == "SUCCESS":
420
+ logger.debug("New image: %s", new_image)
421
+ if not args.disposable:
422
+ title = " ".join(args.command_args) if args.command_args else ""
423
+ store.set_image(
424
+ str(new_image), kind="run", title=title,
425
+ )
426
+
427
+ metadata = op.get("metadata") or {}
428
+ assert isinstance(metadata, dict)
429
+ instance_result = metadata.get("result") or {}
430
+ assert isinstance(instance_result, dict)
431
+ state = instance_result.get("state") or {}
432
+ assert isinstance(state, dict)
433
+ exit_code = state.get("exit_code")
434
+ if isinstance(exit_code, int):
435
+ return exit_code
436
+ if op["status"] != "SUCCESS":
437
+ return 1
438
+ return None