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
76 changes: 75 additions & 1 deletion contree_cli/agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,28 @@ Tag conventions:
Always search before building:
contree images --prefix=python-dev

More: contree images --help, contree tag --help
Building from a Dockerfile:
When a project already ships a Dockerfile, prefer `contree build`
over hand-running each step. It executes FROM/RUN/COPY/WORKDIR/ENV
/ARG/USER against the API and caches every layer as a branch so
rebuilds are fast.

Layer cache is keyed by abspath(context), shared across invocations:
contree build . build ./Dockerfile, no tag
contree build . --tag myapp:dev build + tag the final image
contree build ./app --dockerfile ./app/Dockerfile.prod --tag svc:prod
contree build . --build-arg VERSION=1.2
contree build . --no-cache force rebuild

Supported directives: FROM, RUN, COPY, ADD (local paths only),
WORKDIR, ENV, ARG, USER. CMD/ENTRYPOINT/LABEL/EXPOSE/VOLUME/etc.
are parsed but skipped with a warning. Multi-stage (AS / --from)
is not yet supported.

.dockerignore is applied to every COPY/ADD walk on top of the
default exclude list (.git, __pycache__, node_modules, etc.).

More: contree build --help, contree images --help, contree tag --help

Files and directories
=====================
Expand Down Expand Up @@ -177,6 +198,32 @@ Pending files are injected into the next non-disposable run.
Explicit --file takes priority over pending files at same path.
Pending files are branch-aware.

Listing uploaded files:
contree file ls list all uploaded files in the project
contree file ls --since 1d narrow by upload time
contree file ls -q uuid + sha256 + source only (quiet)
contree -f json file ls JSON output for jq

Output joins remote files (uuid, sha256, size, created_at) with the
local upload cache. The SOURCE column shows whatever this machine
used to produce the file:
- absolute host path for files uploaded via `run --file` / `COPY`;
- https://... URL for files fetched via `ADD URL`.

IMPORTANT: SOURCE is resolved ONLY for files uploaded from this
specific machine. The mapping lives in the local SQLite cache (per
profile, under $CONTREE_HOME/cli/sessions/<profile>.db) keyed by
path+inode+mtime+size (for host paths) or by the URL itself (for
URL fetches), and is NOT shared between hosts. Rows show empty
SOURCE when:
- the file was uploaded from a different machine or by a teammate;
- the host file has been moved, renamed, or its inode/mtime/size
changed since upload (the cache key no longer matches);
- the upload happened before tracking landed (older entries
backfill on the next match).
An agent must not assume SOURCE is authoritative across hosts;
for cross-machine identity always use the remote UUID or sha256.

More: contree run --help, contree file --help

Execution modes
Expand Down Expand Up @@ -211,10 +258,31 @@ Piped stdin:
Detached mode (-d):
contree run -d -- long-running-task
contree ps check status
contree ps -a -S FAILED --since=1h recent failures
contree show UUID view result
contree session wait block until done
contree session wait UUID1 UUID2 wait for specific ops

Monitoring background operations:
Use the `operation` namespace (alias `op`) when juggling several
detached runs. `op ls` is `ps`; `op show` and `op cancel` accept
multiple UUIDs in one call.

contree op ls list operations (= ps)
contree op ls -a -S EXECUTING filter active runs
contree op show UUID1 UUID2 UUID3 inspect a batch in one call
contree op cancel UUID1 UUID2 cancel selected operations
contree op cancel --all cancel every active op (rare)

Fan-out + join pattern:
A=$(contree run -d -- make -C /work/a build | jq -r .uuid)
B=$(contree run -d -- make -C /work/b build | jq -r .uuid)
contree session wait "$A" "$B"
contree op show "$A" "$B"

Background checks are cheap: terminal results are cached locally,
so repeated `op show` / `show` calls do not re-hit the API.

Disposable mode (-D) — no image checkpoint:
contree run -D -- rm -rf /tmp/*
contree run -D -- cat /etc/passwd
Expand Down Expand Up @@ -352,18 +420,24 @@ All commands

use [IMAGE] Set or show session image (aliases: ci)
run [-- CMD] Spawn sandbox instance (aliases: r)
build [CONTEXT] Build image from Dockerfile (aliases: bd)
images List/import images (aliases: i, img)
tag [IMAGE] TAG Tag image (aliases: t)
ps List operations
kill UUID Cancel operation
show UUID Show operation result
operation list List operations (aliases: op ls)
operation show UUID... Show one or more operation results (aliases: op)
operation cancel UUID...
Cancel one or more operations (or --all)
ls [PATH] List files in image (no VM)
cat PATH Show file content (no VM)
cp PATH DEST Download file from image
cd [PATH] Change session working directory
env [KEY=VALUE ...] Session env vars (-d to unset)
file edit PATH Edit remote file via $EDITOR
file cp SRC DEST Stage local file for next run
file ls [-q] List uploaded files + local path (aliases: list)
session list List sessions (aliases: ls)
session branch [NAME] Create/list branches (aliases: br)
session checkout BRANCH Switch branch (aliases: co)
Expand Down
12 changes: 12 additions & 0 deletions contree_cli/arguments.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from contree_cli.cli import (
agent,
auth,
build,
cat,
cd,
cp,
Expand All @@ -14,6 +15,7 @@
images,
kill,
ls,
operation,
ps,
run,
session,
Expand Down Expand Up @@ -44,6 +46,9 @@
contree run --file ./src:/app/src -- make -C /app/src
contree images --prefix=ubuntu
contree ps -q
contree op ls same as `contree ps`
contree op show UUID1 UUID2 multi-UUID show
contree op cancel UUID1 UUID2 multi-UUID cancel (or --all)
contree show OPERATION_UUID
contree tag IMAGE_UUID latest
contree ls /etc list files in session image
Expand Down Expand Up @@ -206,11 +211,18 @@ def register(

register("use", "Set or show current session image", use.setup_parser, aliases=["ci"])
register("run", "Spawn a sandbox instance", run.setup_parser, aliases=["r"])
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(
"operation",
"Manage operations (list/show/cancel)",
operation.setup_parser,
aliases=["op"],
)
register("ls", "List files in image", ls.setup_parser)
register("cat", "Show file content from image", cat.setup_parser)
register("cp", "Copy file from image to local path", cp.setup_parser)
Expand Down
224 changes: 224 additions & 0 deletions contree_cli/cli/build.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
"""Build an image from a Dockerfile.

Reads the Dockerfile at the given path (default ``<CONTEXT>/Dockerfile``)
and applies each directive against an isolated build session keyed by
the absolute path of the context directory. Successful layers are
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).
"""

from __future__ import annotations

import argparse
import hashlib
import logging
from dataclasses import dataclass, field
from pathlib import Path

from contree_cli import (
CLIENT,
FORMATTER,
PROFILE,
SESSION_STORE,
ArgumentsProtocol,
SetupResult,
)
from contree_cli.docker import (
ArgKeyword,
BuildContext,
DockerKeyword,
FromKeyword,
LocalContext,
RunKeyword,
parse_dockerfile,
)
from contree_cli.docker.context import BUILD_TIMEOUT_DEFAULT
from contree_cli.session import SessionStore
from contree_cli.types import FLAGS

logger = logging.getLogger(__name__)

EPILOG = """\
examples:
contree build .
contree build . --tag myimage:latest
contree build --dockerfile ./Dockerfile.test ./app
contree build --build-arg VERSION=1.2 .
contree build --no-cache .

for coding agents:
mutating command, may create operations against the API
layer cache is per-context (session keyed by abspath(context))
use --no-cache to bypass cached layers and rebuild from scratch
"""


@dataclass(frozen=True)
class BuildArgs(ArgumentsProtocol):
context: str = "."
dockerfile: str = ""
tag: str = ""
build_args: tuple[str, ...] = field(default_factory=tuple)
no_cache: bool = False
timeout: int = BUILD_TIMEOUT_DEFAULT

@classmethod
def from_args(cls, ns: argparse.Namespace) -> BuildArgs:
return cls(
context=ns.context or ".",
dockerfile=ns.dockerfile or "",
tag=ns.tag or "",
build_args=tuple(ns.build_arg or ()),
no_cache=bool(ns.no_cache),
timeout=ns.timeout,
)


def setup_parser(p: argparse.ArgumentParser) -> SetupResult:
p.add_argument(
"context",
nargs="?",
default=".",
help="Build context directory",
)
p.add_argument(
*FLAGS["dockerfile"],
default="",
metavar="PATH",
help="Dockerfile path (default: <context>/Dockerfile)",
)
p.add_argument(
*FLAGS["tag_name"],
default="",
metavar="NAME[:TAG]",
help="Tag the final image",
)
p.add_argument(
*FLAGS["build_arg"],
action="append",
default=[],
metavar="KEY=VALUE",
help="Build-time variable (repeatable)",
)
p.add_argument(
*FLAGS["no_cache"],
action="store_true",
help="Ignore cached layers and rebuild",
)
p.add_argument(
*FLAGS["timeout"],
type=int,
default=BUILD_TIMEOUT_DEFAULT,
help="Timeout in seconds for each RUN step",
)
return cmd_build, BuildArgs


def cmd_build(args: BuildArgs) -> int | None:
context_dir = Path(args.context).expanduser().resolve()
if not context_dir.is_dir():
logger.error("context %s is not a directory", context_dir)
return 1

dockerfile_path = (
Path(args.dockerfile).expanduser()
if args.dockerfile
else context_dir / "Dockerfile"
)
if not dockerfile_path.is_file():
logger.error("Dockerfile %s not found", dockerfile_path)
return 1

text = dockerfile_path.read_text()
try:
directives = parse_dockerfile(text)
except ValueError as exc:
logger.error("Dockerfile parse error: %s", exc)
return 1

if not validate_first_directive(directives):
logger.error("Dockerfile must contain a FROM directive")
return 1

build_args = parse_build_args(args.build_args)

profile = PROFILE.get()
client = CLIENT.get()
session_key = make_session_key(context_dir)
store = SessionStore(profile.session_db_path, session_key)
SESSION_STORE.set(store)

ctx = BuildContext(
client=client,
store=store,
local=LocalContext.from_dir(context_dir),
build_args=build_args,
no_cache=args.no_cache,
timeout=args.timeout,
)

try:
for kw in directives:
kw.execute(ctx)
finalize_pending(ctx)
except Exception as exc:
logger.error("build failed: %s", exc)
return 1

if not ctx.last_image:
logger.error("build produced no image")
return 1

if args.tag:
client.patch_json(
f"/v1/images/{ctx.last_image}/tag",
{"tag": args.tag},
)
logger.info("tagged %s as %s", ctx.last_image, args.tag)

formatter = FORMATTER.get()
formatter(
image=ctx.last_image,
tag=args.tag,
session=session_key,
)
formatter.flush()
return None


def validate_first_directive(directives: list[DockerKeyword]) -> bool:
for d in directives:
if isinstance(d, FromKeyword):
return True
if isinstance(d, ArgKeyword):
continue
return False
return False


def parse_build_args(items: tuple[str, ...]) -> dict[str, str]:
out: dict[str, str] = {}
for item in items:
if "=" not in item:
raise ValueError(f"--build-arg expected KEY=VALUE, got {item!r}")
k, _, v = item.partition("=")
out[k] = v
return out


def make_session_key(context_dir: Path) -> str:
digest = hashlib.sha256(str(context_dir).encode()).hexdigest()
return f"build:{digest[:16]}"


def finalize_pending(ctx: BuildContext) -> None:
"""If COPY/ADD left files pending, commit them via a trivial RUN."""
if not ctx.pending:
return
closer = RunKeyword(parts=(":",), shell_form=True)
closer.execute(ctx)
Loading
Loading