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/__init__.py +27 -0
- contree_cli/__main__.py +62 -0
- contree_cli/arguments.py +141 -0
- contree_cli/cli/__init__.py +0 -0
- contree_cli/cli/auth.py +124 -0
- contree_cli/cli/cat.py +74 -0
- contree_cli/cli/cd.py +49 -0
- contree_cli/cli/cp.py +107 -0
- contree_cli/cli/file.py +179 -0
- contree_cli/cli/images.py +88 -0
- contree_cli/cli/kill.py +86 -0
- contree_cli/cli/ls.py +83 -0
- contree_cli/cli/ps.py +120 -0
- contree_cli/cli/run.py +438 -0
- contree_cli/cli/session.py +282 -0
- contree_cli/cli/show.py +97 -0
- contree_cli/cli/tag.py +50 -0
- contree_cli/cli/use.py +119 -0
- contree_cli/client.py +222 -0
- contree_cli/config.py +116 -0
- contree_cli/log.py +40 -0
- contree_cli/mapped_file.py +112 -0
- contree_cli/output.py +376 -0
- contree_cli/session.py +761 -0
- contree_cli/shell/__init__.py +59 -0
- contree_cli/shell/completer.py +465 -0
- contree_cli/shell/history.py +53 -0
- contree_cli/shell/parser.py +107 -0
- contree_cli/shell/repl.py +486 -0
- contree_cli/shell/trie.py +113 -0
- contree_cli/types.py +87 -0
- contree_cli-0.2.3.dist-info/METADATA +323 -0
- contree_cli-0.2.3.dist-info/RECORD +35 -0
- contree_cli-0.2.3.dist-info/WHEEL +4 -0
- contree_cli-0.2.3.dist-info/entry_points.txt +3 -0
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
|