Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ contree run apt-get install -y curl # builds on the previous snapshot
contree session branch experiment # branch the sandbox state
contree run -- make test # experiment freely
contree session checkout main # switch back instantly
contree session rollback 2 # or rewind two steps
contree session rollback -- -2 # or rewind two steps
```

## What is ConTree?
Expand Down Expand Up @@ -116,7 +116,7 @@ contree cp /app/output.log . # download to local machine
contree session branch experiment # create a branch
contree run -- make test # experiment on it
contree session checkout main # switch back
contree session rollback 1 # undo last run
contree session rollback # undo last run (default: back 1 entry)
```

## Interactive Shell
Expand Down Expand Up @@ -151,9 +151,13 @@ The shell provides tab completion for commands, paths, image tags, and operation
| `run [-- CMD]` | `r` | Spawn a sandbox instance, execute command |
| `images [--prefix]` | `i`, `img` | List and import images |
| `tag UUID TAG` | `t` | Tag or untag an image |
| `ps` | | List operations (instances, imports) |
| `kill UUID` | | Cancel an operation (`--all` for all) |
| `ps` | | List operations (shortcut for `operation ls`) |
| `kill UUID [UUID...]` | | Cancel operations (shortcut for `operation cancel`; `--all` for all active) |
| `show UUID` | | Show operation result |
| `operation list` | `op`, `ls` | Same as `ps` (canonical) |
| `operation show UUID...` | `sh` | Multi-UUID inspect |
| `operation wait UUID...` | `w` | Block until each op finishes (or `--all`; `--timeout`) |
| `operation cancel UUID...` | `kill`, `k` | Multi-UUID cancel (or `--all`) |
| `ls [PATH]` | | List files in session image (no VM) |
| `cat PATH` | | Show file content from session image (no VM) |
| `cp PATH DEST` | | Download file from image to local path |
Expand Down Expand Up @@ -237,7 +241,7 @@ contree session # show current state
contree session show # display history DAG
contree session branch feature # create branch from HEAD
contree session checkout feature # switch to it
contree session rollback 3 # go back 3 steps
contree session rollback -- -3 # go back 3 steps (note `--`; bare `3` is absolute id)
contree session use other-session # import image from another session
```

Expand All @@ -259,7 +263,7 @@ Pipe JSON output into `jq`, feed CSV into spreadsheets, or parse programmaticall

### Config file

`~/.config/contree-cli/config.ini`:
`$XDG_CONFIG_HOME/contree/auth.ini` (default: `~/.config/contree/auth.ini`; override via `$CONTREE_HOME`):

```ini
[DEFAULT]
Expand Down
11 changes: 10 additions & 1 deletion contree_cli/__main__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import contextvars
import http.client
import logging
import sys
from collections.abc import Callable
Expand Down Expand Up @@ -98,11 +99,19 @@ def main() -> None:
except ApiError as exc:
log.error("%s", exc)
exit(1)
except ValueError as exc:
# Raised by loader.from_args for malformed user input
# (invalid UUIDs, etc.); the message is already user-facing.
log.error("%s", exc)
exit(1)
except (OSError, http.client.HTTPException) as exc:
log.error("Network error: %s", exc)
exit(1)
except KeyboardInterrupt:
log.error("User interrupted")
exit(1)
finally:
formatter.flush()
formatter.close()

exit(exit_code or 0)

Expand Down
257 changes: 137 additions & 120 deletions contree_cli/agent.md

Large diffs are not rendered by default.

21 changes: 15 additions & 6 deletions contree_cli/arguments.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,10 @@
env,
file,
images,
kill,
ls,
operation,
ps,
run,
session,
show,
skill,
tag,
use,
Expand Down Expand Up @@ -214,9 +211,21 @@ def register(
register("build", "Build image from Dockerfile", build.setup_parser, aliases=["bd"])
register("images", "List and import images", images.setup_parser, aliases=["i", "img"])
register("tag", "Tag an image", tag.setup_parser, aliases=["t"])
register("ps", "List operations/instances", ps.setup_parser)
register("kill", "Cancel an operation", kill.setup_parser)
register("show", "Show operation result", show.setup_parser)
register(
"ps",
"List operations (alias for `operation ls`)",
operation.setup_list_parser,
)
register(
"kill",
"Cancel operations (alias for `operation cancel`)",
operation.setup_cancel_parser,
)
register(
"show",
"Show operation result (alias for `operation show`)",
operation.setup_show_parser,
)
register(
"operation",
"Manage operations (list/show/cancel)",
Expand Down
9 changes: 5 additions & 4 deletions contree_cli/cli/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@
materialised as branches named ``layer:<chain-hash>`` so that
re-running the same Dockerfile reuses prior work.

Supported directives (MVP): FROM, RUN, COPY, ADD (without URL/tar),
WORKDIR, ENV, ARG, USER. Other Dockerfile directives parse cleanly
but are skipped with a warning (CMD, ENTRYPOINT, LABEL, EXPOSE,
VOLUME, STOPSIGNAL, MAINTAINER, HEALTHCHECK, ONBUILD, SHELL).
Supported directives (MVP): FROM, RUN, COPY, ADD (local files/dirs
and http(s) URLs; no tar auto-extraction), WORKDIR, ENV, ARG, USER.
Other Dockerfile directives parse cleanly but are skipped with a
warning (CMD, ENTRYPOINT, LABEL, EXPOSE, VOLUME, STOPSIGNAL,
MAINTAINER, HEALTHCHECK, ONBUILD, SHELL).
"""

from __future__ import annotations
Expand Down
4 changes: 2 additions & 2 deletions contree_cli/cli/env.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@
contree env list session env vars
contree env PATH=/root/.cargo/bin:$PATH set PATH
contree env DEBUG=1 DB_HOST=localhost set multiple
contree env -d PATH unset PATH
contree env -d PATH DEBUG unset multiple
contree env -U PATH unset PATH
contree env -U PATH DEBUG unset multiple
"""


Expand Down
65 changes: 32 additions & 33 deletions contree_cli/cli/file.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,18 @@
ArgumentsProtocol,
SetupResult,
)
from contree_cli.client import ApiError, ContreeClient, resolve_image, stream_response
from contree_cli.client import (
ApiError,
ContreeClient,
PaginatedFetcher,
resolve_image,
stream_response,
)
from contree_cli.config import EDITOR
from contree_cli.session import SessionStore
from contree_cli.session import CONTREE_CONCURRENCY, SessionStore
from contree_cli.types import (
FLAGS,
isoformat_datetime,
parse_datetime,
parse_interval,
positive_int,
)
Expand Down Expand Up @@ -75,7 +80,7 @@ def from_args(cls, ns: argparse.Namespace) -> FileCpArgs:


FILE_LIST_LIMIT_DEFAULT = 1000
FILE_LIST_PAGE_SIZE = 1000
FILE_LIST_PAGE_SIZE = PaginatedFetcher.DEFAULT_PAGE_SIZE


@dataclass(frozen=True)
Expand Down Expand Up @@ -323,17 +328,22 @@ def cmd_file_ls(args: FileListArgs) -> int | None:
if args.until is not None:
params["until"] = isoformat_datetime(args.until)

offset = 0
fetcher = PaginatedFetcher(
client,
"/v1/files",
params,
lambda body: json.loads(body).get("files", []),
limit=args.limit,
concurrency=CONTREE_CONCURRENCY,
)

emitted = 0
while emitted < args.limit:
page_size = min(FILE_LIST_PAGE_SIZE, args.limit - emitted)
page = {**params, "offset": str(offset), "limit": str(page_size)}
resp = client.get("/v1/files", params=page)
data = json.loads(resp.read())
files = data.get("files", [])
if not files:
return None
for entry in files:
hit_limit = False
for page in fetcher:
for entry in page:
if emitted >= args.limit:
hit_limit = True
break
uuid_str = entry.get("uuid")
source = sources.get(uuid_str, "") if isinstance(uuid_str, str) else ""
if args.quiet:
Expand All @@ -342,26 +352,15 @@ def cmd_file_ls(args: FileListArgs) -> int | None:
sha256=entry.get("sha256", ""),
source=source,
)
continue
row: dict[str, object] = {}
for key, value in entry.items():
if isinstance(value, (dict, list)):
continue
if key in {"created_at", "updated_at"} and isinstance(value, str):
value = parse_datetime(value)
row[key] = value
row["source"] = source
formatter(**row)
emitted += len(files)
if len(files) < page_size:
return None
offset += len(files)

probe = {**params, "offset": str(offset), "limit": "1"}
resp = client.get("/v1/files", params=probe)
data = json.loads(resp.read())
if data.get("files"):
else:
formatter(**{**entry, "source": source})
emitted += 1
formatter.flush()
if hit_limit:
fetcher.stop()
break

if hit_limit:
logger.warning(
"Output truncated at --limit=%d files; more results are"
" available. Raise --limit or narrow with --since/--until.",
Expand Down
81 changes: 32 additions & 49 deletions contree_cli/cli/images.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,19 @@
from datetime import datetime

from contree_cli import CLIENT, FORMATTER, ArgumentsProtocol, SetupResult
from contree_cli.client import ApiError
from contree_cli.client import ApiError, PaginatedFetcher
from contree_cli.session import CONTREE_CONCURRENCY
from contree_cli.types import (
FLAGS,
ArgumentsFormatter,
isoformat_datetime,
parse_datetime,
parse_interval,
positive_int,
)

logger = logging.getLogger(__name__)

PAGE_SIZE = 1000
PAGE_SIZE = PaginatedFetcher.DEFAULT_PAGE_SIZE
LIMIT_DEFAULT = 3000
TERMINAL_STATUSES = frozenset({"SUCCESS", "FAILED", "CANCELLED"})
DOCKER_HUB = "docker.io"
Expand Down Expand Up @@ -272,50 +272,30 @@ def cmd_images(args: ImagesArgs) -> None:
if args.until is not None:
base_params["until"] = isoformat_datetime(args.until)

offset = 0
emitted = 0
while emitted < args.limit:
page_size = min(PAGE_SIZE, args.limit - emitted)
params = {
**base_params,
"offset": str(offset),
"limit": str(page_size),
}
resp = client.get("/v1/images", params=params)
data = json.loads(resp.read())
images = data["images"]
if not images:
return
for image in images:
row: dict[str, object] = {}
for key, value in image.items():
if isinstance(value, (dict, list)):
continue
if key == "created_at" and isinstance(value, str):
value = parse_datetime(value)
if key == "tag" and value is None:
value = ""
row[key] = value
formatter(**row)
emitted += len(images)
if len(images) < page_size:
return
offset += len(images)
if emitted < args.limit:
logger.info(
"Fetched %d images so far... (press Ctrl+C to break)",
emitted,
)
fetcher = PaginatedFetcher(
client,
"/v1/images",
base_params,
lambda body: json.loads(body)["images"],
limit=args.limit,
concurrency=CONTREE_CONCURRENCY,
)

# Hit the limit. Probe one extra record (offset=emitted, limit=1) to
# detect truncation without re-fetching a full page.
probe_params = {**base_params, "offset": str(offset), "limit": "1"}
resp = client.get("/v1/images", params=probe_params)
data = json.loads(resp.read())
if data.get("images"):
# Flush buffered output (e.g. TableFormatter) before the warning
# so the truncation note appears AFTER the listing on screen.
emitted = 0
hit_limit = False
for page in fetcher:
for image in page:
if emitted >= args.limit:
hit_limit = True
break
formatter(**image)
emitted += 1
formatter.flush()
if hit_limit:
fetcher.stop()
break

if hit_limit:
logger.warning(
"Output truncated at --limit=%d images; more results are"
" available. Raise --limit or narrow with"
Expand Down Expand Up @@ -353,6 +333,7 @@ def _derive_tag(ref: str) -> str:
def cmd_import(args: ImportArgs) -> int | None:
client = CLIENT.get()
formatter = FORMATTER.get()
formatter.configure(tail=("error",))

# 1. Build credentials (prompt for password when --username given)
credentials: dict[str, str] | None = None
Expand Down Expand Up @@ -411,10 +392,12 @@ def cmd_import(args: ImportArgs) -> int | None:
if op["status"] != "SUCCESS":
failed = True
formatter(
uuid=op_uuids[idx],
status=op["status"],
registry_url=imports[idx][0],
image=(op.get("result") or {}).get("image", ""),
**{
**op,
"uuid": op_uuids[idx],
"registry_url": imports[idx][0],
"image": (op.get("result") or {}).get("image", ""),
}
)
except KeyboardInterrupt:
# Cancel ALL operations on Ctrl+C
Expand Down
Loading
Loading