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/file.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Manage files in the session image.
|
|
2
|
+
|
|
3
|
+
Subcommands:
|
|
4
|
+
edit (e) Download a file from the session image, open it in $EDITOR
|
|
5
|
+
(or vi), and upload the modified version as a pending file
|
|
6
|
+
attachment. The change takes effect on the next `run`.
|
|
7
|
+
|
|
8
|
+
cp Copy a local file into the session image as a pending file
|
|
9
|
+
attachment. The file is uploaded immediately but injected
|
|
10
|
+
into the container on the next `run`.
|
|
11
|
+
|
|
12
|
+
Pending files are branch-aware — switching branches changes which
|
|
13
|
+
files are visible.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import hashlib
|
|
19
|
+
import json
|
|
20
|
+
import logging
|
|
21
|
+
import os
|
|
22
|
+
import shlex
|
|
23
|
+
import subprocess
|
|
24
|
+
import tempfile
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
from contree_cli import CLIENT, SESSION_STORE, ArgumentsProtocol, SetupResult
|
|
29
|
+
from contree_cli.client import ApiError, ContreeClient, resolve_image, stream_response
|
|
30
|
+
from contree_cli.session import SessionStore
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger(__name__)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class FileEditArgs(ArgumentsProtocol):
|
|
37
|
+
path: str
|
|
38
|
+
editor: str | None = None
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def from_args(cls, ns: argparse.Namespace) -> FileEditArgs:
|
|
42
|
+
return cls(path=ns.path, editor=getattr(ns, "editor", None))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class FileCpArgs(ArgumentsProtocol):
|
|
47
|
+
src: str
|
|
48
|
+
dest: str
|
|
49
|
+
|
|
50
|
+
@classmethod
|
|
51
|
+
def from_args(cls, ns: argparse.Namespace) -> FileCpArgs:
|
|
52
|
+
return cls(src=ns.src, dest=ns.dest)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def setup_parser(p: argparse.ArgumentParser) -> SetupResult:
|
|
56
|
+
sub = p.add_subparsers(dest="file_action", required=True)
|
|
57
|
+
edit_p = sub.add_parser(
|
|
58
|
+
"edit", aliases=["e"],
|
|
59
|
+
help="Edit a file in the session image",
|
|
60
|
+
)
|
|
61
|
+
edit_p.add_argument(
|
|
62
|
+
"-e", "--editor", help="Editor command (default: $EDITOR or vi)",
|
|
63
|
+
)
|
|
64
|
+
edit_p.add_argument("path", help="Path inside image")
|
|
65
|
+
edit_p.set_defaults(handler=cmd_file_edit, load_args=FileEditArgs)
|
|
66
|
+
|
|
67
|
+
cp_p = sub.add_parser(
|
|
68
|
+
"cp", help="Copy a local file into the session image",
|
|
69
|
+
)
|
|
70
|
+
cp_p.add_argument("src", help="Local file path")
|
|
71
|
+
cp_p.add_argument("dest", help="Destination path inside image")
|
|
72
|
+
cp_p.set_defaults(handler=cmd_file_cp, load_args=FileCpArgs)
|
|
73
|
+
|
|
74
|
+
return cmd_file_edit, FileEditArgs
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _file_sha256(path: Path) -> str:
|
|
78
|
+
h = hashlib.sha256()
|
|
79
|
+
with path.open("rb") as f:
|
|
80
|
+
while chunk := f.read(256 * 1024):
|
|
81
|
+
h.update(chunk)
|
|
82
|
+
return h.hexdigest()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _upload_and_record(
|
|
86
|
+
client: ContreeClient,
|
|
87
|
+
store: SessionStore,
|
|
88
|
+
local_path: Path,
|
|
89
|
+
instance_path: str,
|
|
90
|
+
title: str,
|
|
91
|
+
) -> str:
|
|
92
|
+
"""Upload a local file (with dedup) and record as pending."""
|
|
93
|
+
sha = _file_sha256(local_path)
|
|
94
|
+
try:
|
|
95
|
+
resp = client.get("/v1/files", params={"sha256": sha})
|
|
96
|
+
file_uuid = json.loads(resp.read())["uuid"]
|
|
97
|
+
logger.info("File already exists on server (%s)", file_uuid)
|
|
98
|
+
except ApiError as exc:
|
|
99
|
+
if exc.status != 404:
|
|
100
|
+
raise
|
|
101
|
+
data = local_path.read_bytes()
|
|
102
|
+
resp = client.request(
|
|
103
|
+
"POST", "/v1/files",
|
|
104
|
+
body=data,
|
|
105
|
+
headers={"Content-Type": "application/octet-stream"},
|
|
106
|
+
)
|
|
107
|
+
file_uuid = json.loads(resp.read())["uuid"]
|
|
108
|
+
logger.info("Uploaded %s (%s)", instance_path, file_uuid)
|
|
109
|
+
|
|
110
|
+
history_id = store.set_image(
|
|
111
|
+
store.current_image, kind="file", title=title,
|
|
112
|
+
)
|
|
113
|
+
store.add_pending_file(history_id, instance_path, str(file_uuid))
|
|
114
|
+
logger.info(
|
|
115
|
+
"Pending: %s -> %s (will be included in next run)",
|
|
116
|
+
instance_path, file_uuid,
|
|
117
|
+
)
|
|
118
|
+
return str(file_uuid)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def cmd_file_edit(args: FileEditArgs) -> int | None:
|
|
122
|
+
client = CLIENT.get()
|
|
123
|
+
store = SESSION_STORE.get()
|
|
124
|
+
image_uuid = resolve_image(client, store.current_image)
|
|
125
|
+
|
|
126
|
+
# 1. Download to temp file (or create empty)
|
|
127
|
+
tmp_dir = Path(tempfile.mkdtemp(prefix="contree-"))
|
|
128
|
+
filename = Path(args.path).name or "file"
|
|
129
|
+
tmp_file = tmp_dir / filename
|
|
130
|
+
try:
|
|
131
|
+
resp = client.get(
|
|
132
|
+
f"/v1/inspect/{image_uuid}/download",
|
|
133
|
+
params={"path": args.path},
|
|
134
|
+
)
|
|
135
|
+
with tmp_file.open("wb") as f:
|
|
136
|
+
for chunk in stream_response(resp):
|
|
137
|
+
f.write(chunk)
|
|
138
|
+
logger.info("Downloaded %s to %s", args.path, tmp_file)
|
|
139
|
+
except ApiError as exc:
|
|
140
|
+
if exc.status != 404:
|
|
141
|
+
raise
|
|
142
|
+
tmp_file.write_bytes(b"")
|
|
143
|
+
logger.info("File %s not found, creating empty file", args.path)
|
|
144
|
+
|
|
145
|
+
# 2. Record original hash, open editor
|
|
146
|
+
original_hash = _file_sha256(tmp_file)
|
|
147
|
+
editor = args.editor or os.environ.get("EDITOR", "vi")
|
|
148
|
+
logger.info("Opening %s in %s", tmp_file, editor)
|
|
149
|
+
rc = subprocess.call(f"{editor} {shlex.quote(str(tmp_file))}", shell=True)
|
|
150
|
+
if rc != 0:
|
|
151
|
+
logger.error("Editor exited with code %d", rc)
|
|
152
|
+
return 1
|
|
153
|
+
|
|
154
|
+
# 3. Check for changes
|
|
155
|
+
new_hash = _file_sha256(tmp_file)
|
|
156
|
+
if new_hash == original_hash:
|
|
157
|
+
logger.info("No changes detected, skipping upload")
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
# 4+5. Upload (with dedup) and record pending file
|
|
161
|
+
_upload_and_record(
|
|
162
|
+
client, store, tmp_file, args.path,
|
|
163
|
+
title=f"Change file {args.path}",
|
|
164
|
+
)
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def cmd_file_cp(args: FileCpArgs) -> int | None:
|
|
169
|
+
client = CLIENT.get()
|
|
170
|
+
store = SESSION_STORE.get()
|
|
171
|
+
local_path = Path(args.src)
|
|
172
|
+
if not local_path.is_file():
|
|
173
|
+
logger.error("File not found: %s", args.src)
|
|
174
|
+
return 1
|
|
175
|
+
_upload_and_record(
|
|
176
|
+
client, store, local_path, args.dest,
|
|
177
|
+
title=f"Change file {args.dest}",
|
|
178
|
+
)
|
|
179
|
+
return None
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""List container images.
|
|
2
|
+
|
|
3
|
+
Queries the Contree API for available images and displays them with
|
|
4
|
+
their UUID, tag, and creation time. Supports filtering by tag prefix,
|
|
5
|
+
UUID, date range, and tagged-only mode.
|
|
6
|
+
|
|
7
|
+
Results are paginated automatically. Use --format to control output
|
|
8
|
+
(default table, json, csv, etc.).
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from contree_cli import CLIENT, FORMATTER, ArgumentsProtocol, SetupResult
|
|
17
|
+
from contree_cli.types import parse_datetime
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
PAGE_SIZE = 100
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class ImagesArgs(ArgumentsProtocol):
|
|
26
|
+
prefix: str | None = None
|
|
27
|
+
uuid: str | None = None
|
|
28
|
+
tagged: bool = False
|
|
29
|
+
since: str | None = None
|
|
30
|
+
until: str | None = None
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def from_args(cls, ns: argparse.Namespace) -> ImagesArgs:
|
|
34
|
+
return cls(
|
|
35
|
+
prefix=ns.prefix,
|
|
36
|
+
uuid=ns.uuid,
|
|
37
|
+
tagged=ns.tagged,
|
|
38
|
+
since=ns.since,
|
|
39
|
+
until=ns.until,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def setup_parser(p: argparse.ArgumentParser) -> SetupResult:
|
|
44
|
+
p.add_argument("--prefix", help="Filter by tag prefix")
|
|
45
|
+
p.add_argument("--uuid", help="Filter by image UUID")
|
|
46
|
+
p.add_argument(
|
|
47
|
+
"--tagged", action="store_true",
|
|
48
|
+
help="Only show tagged images",
|
|
49
|
+
)
|
|
50
|
+
p.add_argument("--since", help="Show images after date/time or interval")
|
|
51
|
+
p.add_argument("--until", help="Show images before date/time or interval")
|
|
52
|
+
return cmd_images, ImagesArgs
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def cmd_images(args: ImagesArgs) -> None:
|
|
56
|
+
client = CLIENT.get()
|
|
57
|
+
formatter = FORMATTER.get()
|
|
58
|
+
|
|
59
|
+
base_params: dict[str, str] = {"limit": str(PAGE_SIZE)}
|
|
60
|
+
if args.prefix is not None:
|
|
61
|
+
base_params["tag"] = args.prefix
|
|
62
|
+
if args.uuid is not None:
|
|
63
|
+
base_params["uuid"] = args.uuid
|
|
64
|
+
if args.tagged:
|
|
65
|
+
base_params["tagged"] = "1"
|
|
66
|
+
if args.since is not None:
|
|
67
|
+
base_params["since"] = args.since
|
|
68
|
+
if args.until is not None:
|
|
69
|
+
base_params["until"] = args.until
|
|
70
|
+
|
|
71
|
+
offset = 0
|
|
72
|
+
while True:
|
|
73
|
+
params = {**base_params, "offset": str(offset)}
|
|
74
|
+
resp = client.get("/v1/images", params=params)
|
|
75
|
+
data = json.loads(resp.read())
|
|
76
|
+
images = data["images"]
|
|
77
|
+
if not images:
|
|
78
|
+
break
|
|
79
|
+
for image in images:
|
|
80
|
+
created_at = parse_datetime(image["created_at"])
|
|
81
|
+
formatter(
|
|
82
|
+
uuid=image["uuid"],
|
|
83
|
+
created_at=created_at,
|
|
84
|
+
tag=image.get("tag") or "",
|
|
85
|
+
)
|
|
86
|
+
if len(images) < PAGE_SIZE:
|
|
87
|
+
break
|
|
88
|
+
offset += len(images)
|
contree_cli/cli/kill.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Cancel a running operation.
|
|
2
|
+
|
|
3
|
+
Sends a DELETE request to stop the specified operation. Use --all to
|
|
4
|
+
cancel every active operation (PENDING, ASSIGNED, EXECUTING) in one go.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from contree_cli import CLIENT, ArgumentsProtocol, SetupResult
|
|
14
|
+
from contree_cli.client import ApiError
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
PAGE_SIZE = 100
|
|
19
|
+
ACTIVE_STATUSES = ("PENDING", "ASSIGNED", "EXECUTING")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class KillArgs(ArgumentsProtocol):
|
|
24
|
+
uuid: str | None = None
|
|
25
|
+
all: bool = False
|
|
26
|
+
|
|
27
|
+
@classmethod
|
|
28
|
+
def from_args(cls, ns: argparse.Namespace) -> KillArgs:
|
|
29
|
+
return cls(uuid=ns.uuid, all=ns.all)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def setup_parser(p: argparse.ArgumentParser) -> SetupResult:
|
|
33
|
+
target = p.add_mutually_exclusive_group(required=True)
|
|
34
|
+
target.add_argument("uuid", nargs="?", help="Operation UUID")
|
|
35
|
+
target.add_argument(
|
|
36
|
+
"--all", action="store_true",
|
|
37
|
+
help="Cancel all active operations",
|
|
38
|
+
)
|
|
39
|
+
return cmd_kill, KillArgs
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _list_active(client: object) -> list[str]:
|
|
43
|
+
"""Collect UUIDs of all active operations."""
|
|
44
|
+
from contree_cli.client import ContreeClient
|
|
45
|
+
assert isinstance(client, ContreeClient)
|
|
46
|
+
uuids: list[str] = []
|
|
47
|
+
for status in ACTIVE_STATUSES:
|
|
48
|
+
offset = 0
|
|
49
|
+
while True:
|
|
50
|
+
params = {
|
|
51
|
+
"status": status,
|
|
52
|
+
"limit": str(PAGE_SIZE),
|
|
53
|
+
"offset": str(offset),
|
|
54
|
+
}
|
|
55
|
+
resp = client.get("/v1/operations", params=params)
|
|
56
|
+
operations = json.loads(resp.read())
|
|
57
|
+
if not operations:
|
|
58
|
+
break
|
|
59
|
+
uuids.extend(op["uuid"] for op in operations)
|
|
60
|
+
if len(operations) < PAGE_SIZE:
|
|
61
|
+
break
|
|
62
|
+
offset += len(operations)
|
|
63
|
+
return uuids
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def cmd_kill(args: KillArgs) -> int | None:
|
|
67
|
+
client = CLIENT.get()
|
|
68
|
+
|
|
69
|
+
if args.all:
|
|
70
|
+
uuids = _list_active(client)
|
|
71
|
+
if not uuids:
|
|
72
|
+
logger.info("No active operations to cancel")
|
|
73
|
+
return None
|
|
74
|
+
failed = 0
|
|
75
|
+
for uuid in uuids:
|
|
76
|
+
try:
|
|
77
|
+
client.delete(f"/v1/operations/{uuid}")
|
|
78
|
+
logger.info("Cancelled operation %s", uuid)
|
|
79
|
+
except ApiError as exc:
|
|
80
|
+
logger.error("Failed to cancel %s: %s", uuid, exc)
|
|
81
|
+
failed += 1
|
|
82
|
+
return 1 if failed else None
|
|
83
|
+
|
|
84
|
+
client.delete(f"/v1/operations/{args.uuid}")
|
|
85
|
+
logger.info("Cancelled operation %s", args.uuid)
|
|
86
|
+
return None
|
contree_cli/cli/ls.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""List files in the session image.
|
|
2
|
+
|
|
3
|
+
Uses the /inspect/ API to list directory contents without spawning an
|
|
4
|
+
instance. Defaults to the session working directory (set via `cd`).
|
|
5
|
+
|
|
6
|
+
In default format, the API returns a pre-formatted text listing. In
|
|
7
|
+
structured formats (json, csv, etc.) the response is cached per
|
|
8
|
+
(image, path) for instant repeat queries.
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import json
|
|
14
|
+
import sys
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from datetime import datetime, timezone
|
|
17
|
+
from typing import Any, cast
|
|
18
|
+
|
|
19
|
+
from contree_cli import CLIENT, FORMATTER, SESSION_STORE, ArgumentsProtocol, SetupResult
|
|
20
|
+
from contree_cli.client import resolve_image
|
|
21
|
+
from contree_cli.output import DefaultFormatter
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def setup_parser(p: argparse.ArgumentParser) -> SetupResult:
|
|
25
|
+
p.add_argument(
|
|
26
|
+
"path", nargs="?", default=None,
|
|
27
|
+
help="Path inside image (defaults to session cwd)",
|
|
28
|
+
)
|
|
29
|
+
return cmd_ls, LsArgs
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True)
|
|
33
|
+
class LsArgs(ArgumentsProtocol):
|
|
34
|
+
path: str | None
|
|
35
|
+
|
|
36
|
+
@classmethod
|
|
37
|
+
def from_args(cls, ns: argparse.Namespace) -> LsArgs:
|
|
38
|
+
return cls(path=ns.path)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def cmd_ls(args: LsArgs) -> None:
|
|
42
|
+
client = CLIENT.get()
|
|
43
|
+
formatter = FORMATTER.get()
|
|
44
|
+
store = SESSION_STORE.get()
|
|
45
|
+
image = store.current_image
|
|
46
|
+
uuid = resolve_image(client, image)
|
|
47
|
+
if args.path is not None:
|
|
48
|
+
path = store.resolve_path(args.path)
|
|
49
|
+
else:
|
|
50
|
+
path = store.get_cwd() or "/"
|
|
51
|
+
|
|
52
|
+
if isinstance(formatter, DefaultFormatter):
|
|
53
|
+
resp = client.get(
|
|
54
|
+
f"/v1/inspect/{uuid}/list",
|
|
55
|
+
params={"path": path, "text": "1"},
|
|
56
|
+
)
|
|
57
|
+
sys.stdout.write(resp.read().decode())
|
|
58
|
+
return
|
|
59
|
+
|
|
60
|
+
cache_key = (uuid, f"list:{path}")
|
|
61
|
+
cached = store.cache.get(cache_key)
|
|
62
|
+
if cached is not None:
|
|
63
|
+
data = cast(dict[str, Any], cached)
|
|
64
|
+
else:
|
|
65
|
+
resp = client.get(f"/v1/inspect/{uuid}/list", params={"path": path})
|
|
66
|
+
data = json.loads(resp.read())
|
|
67
|
+
store.cache[cache_key] = data
|
|
68
|
+
for f in data["files"]:
|
|
69
|
+
if f.get("is_dir"):
|
|
70
|
+
ftype = "d"
|
|
71
|
+
elif f.get("is_symlink"):
|
|
72
|
+
ftype = "l"
|
|
73
|
+
else:
|
|
74
|
+
ftype = "-"
|
|
75
|
+
formatter(
|
|
76
|
+
path=f["path"],
|
|
77
|
+
size=f["size"],
|
|
78
|
+
mode=format(f["mode"], "o"),
|
|
79
|
+
owner=f.get("owner", f.get("uid", "")),
|
|
80
|
+
group=f.get("group", f.get("gid", "")),
|
|
81
|
+
mtime=datetime.fromtimestamp(f["mtime"], tz=timezone.utc),
|
|
82
|
+
type=ftype,
|
|
83
|
+
)
|
contree_cli/cli/ps.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""List operations (running and completed instances, image imports).
|
|
2
|
+
|
|
3
|
+
By default shows only active operations (PENDING, ASSIGNED, EXECUTING).
|
|
4
|
+
Use -a/--all to include completed ones, or -S/--status to filter by a
|
|
5
|
+
specific status. Use -K/--kind to filter by operation type.
|
|
6
|
+
|
|
7
|
+
Use -q/--quiet to print only UUIDs, useful for scripting.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from datetime import timedelta
|
|
16
|
+
|
|
17
|
+
from contree_cli import CLIENT, FORMATTER, ArgumentsProtocol, SetupResult
|
|
18
|
+
from contree_cli.types import parse_datetime
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger(__name__)
|
|
21
|
+
|
|
22
|
+
PAGE_SIZE = 100
|
|
23
|
+
ACTIVE_STATUSES = frozenset({"PENDING", "ASSIGNED", "EXECUTING"})
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class PsArgs(ArgumentsProtocol):
|
|
28
|
+
quiet: bool = False
|
|
29
|
+
all: bool = False
|
|
30
|
+
status: str | None = None
|
|
31
|
+
kind: str | None = None
|
|
32
|
+
since: str | None = None
|
|
33
|
+
until: str | None = None
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def from_args(cls, ns: argparse.Namespace) -> PsArgs:
|
|
37
|
+
return cls(
|
|
38
|
+
quiet=ns.quiet,
|
|
39
|
+
all=getattr(ns, "all", False),
|
|
40
|
+
status=ns.status,
|
|
41
|
+
kind=ns.kind,
|
|
42
|
+
since=ns.since,
|
|
43
|
+
until=ns.until,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def setup_parser(p: argparse.ArgumentParser) -> SetupResult:
|
|
48
|
+
p.add_argument(
|
|
49
|
+
"-q", "--quiet", action="store_true",
|
|
50
|
+
help="Only show UUIDs, useful for scripting",
|
|
51
|
+
)
|
|
52
|
+
p.add_argument(
|
|
53
|
+
"-a", "--all", action="store_true",
|
|
54
|
+
help="Show all operations (default: active only)",
|
|
55
|
+
)
|
|
56
|
+
p.add_argument(
|
|
57
|
+
"-S", "--status",
|
|
58
|
+
choices=(
|
|
59
|
+
"PENDING", "ASSIGNED", "EXECUTING",
|
|
60
|
+
"SUCCESS", "FAILED", "CANCELLED",
|
|
61
|
+
),
|
|
62
|
+
help="Filter by status",
|
|
63
|
+
)
|
|
64
|
+
p.add_argument(
|
|
65
|
+
"-K", "--kind",
|
|
66
|
+
choices=("image_import", "instance"),
|
|
67
|
+
help="Filter by operation kind",
|
|
68
|
+
)
|
|
69
|
+
p.add_argument("--since", help="Show operations after date/time or interval")
|
|
70
|
+
p.add_argument("--until", help="Show operations before date/time or interval")
|
|
71
|
+
return cmd_ps, PsArgs
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def cmd_ps(args: PsArgs) -> None:
|
|
75
|
+
client = CLIENT.get()
|
|
76
|
+
formatter = FORMATTER.get()
|
|
77
|
+
|
|
78
|
+
# When neither --all nor --status is given, show only active operations.
|
|
79
|
+
filter_active = not args.all and args.status is None
|
|
80
|
+
|
|
81
|
+
base_params: dict[str, str] = {"limit": str(PAGE_SIZE)}
|
|
82
|
+
if args.status is not None:
|
|
83
|
+
base_params["status"] = args.status
|
|
84
|
+
if args.kind is not None:
|
|
85
|
+
base_params["kind"] = args.kind
|
|
86
|
+
if args.since is not None:
|
|
87
|
+
base_params["since"] = args.since
|
|
88
|
+
if args.until is not None:
|
|
89
|
+
base_params["until"] = args.until
|
|
90
|
+
|
|
91
|
+
offset = 0
|
|
92
|
+
while True:
|
|
93
|
+
params = {**base_params, "offset": str(offset)}
|
|
94
|
+
resp = client.get("/v1/operations", params=params)
|
|
95
|
+
operations = json.loads(resp.read())
|
|
96
|
+
if not operations:
|
|
97
|
+
break
|
|
98
|
+
for op in operations:
|
|
99
|
+
if filter_active and op["status"] not in ACTIVE_STATUSES:
|
|
100
|
+
continue
|
|
101
|
+
if args.quiet:
|
|
102
|
+
print(op["uuid"])
|
|
103
|
+
else:
|
|
104
|
+
created_at = parse_datetime(op["created_at"])
|
|
105
|
+
duration = (
|
|
106
|
+
timedelta(seconds=op["duration"])
|
|
107
|
+
if op.get("duration") is not None
|
|
108
|
+
else None
|
|
109
|
+
)
|
|
110
|
+
formatter(
|
|
111
|
+
uuid=op["uuid"],
|
|
112
|
+
status=op["status"],
|
|
113
|
+
kind=op["kind"],
|
|
114
|
+
created_at=created_at,
|
|
115
|
+
duration=duration,
|
|
116
|
+
error=op.get("error") or "",
|
|
117
|
+
)
|
|
118
|
+
if len(operations) < PAGE_SIZE:
|
|
119
|
+
break
|
|
120
|
+
offset += len(operations)
|