Add cached image quickstart
This commit is contained in:
@@ -78,6 +78,9 @@ class BottleSpec:
|
||||
# True when launched via --headless (no TTY, no interactive prompts).
|
||||
# The git-gate host-key preflight uses this to error rather than prompt.
|
||||
headless: bool = False
|
||||
# Image startup policy. "fresh" preserves the normal build path;
|
||||
# "cached" reuses the current local image/artifact without rebuilding.
|
||||
image_policy: str = "fresh"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@@ -180,10 +180,6 @@ def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]:
|
||||
|
||||
service: dict[str, Any] = {
|
||||
"image": SIDECAR_BUNDLE_IMAGE,
|
||||
"build": {
|
||||
"context": _REPO_DIR,
|
||||
"dockerfile": SIDECAR_BUNDLE_DOCKERFILE,
|
||||
},
|
||||
"container_name": sidecar_bundle_container_name(plan.slug),
|
||||
"networks": {
|
||||
"internal": {"aliases": internal_aliases},
|
||||
@@ -192,6 +188,11 @@ def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]:
|
||||
"environment": env,
|
||||
"volumes": volumes,
|
||||
}
|
||||
if plan.spec.image_policy != "cached":
|
||||
service["build"] = {
|
||||
"context": _REPO_DIR,
|
||||
"dockerfile": SIDECAR_BUNDLE_DOCKERFILE,
|
||||
}
|
||||
return service
|
||||
|
||||
|
||||
|
||||
@@ -41,7 +41,8 @@ from ...git_gate import (
|
||||
provision_git_gate_dynamic_keys,
|
||||
revoke_git_gate_provisioned_keys,
|
||||
)
|
||||
from ...log import info, warn
|
||||
from ...image_cache import warn_if_stale
|
||||
from ...log import die, info, warn
|
||||
from . import network as network_mod
|
||||
from . import util as docker_mod
|
||||
from .bottle import DockerBottle
|
||||
@@ -63,6 +64,7 @@ from .compose import (
|
||||
write_compose_file,
|
||||
)
|
||||
from .egress import egress_tls_init
|
||||
from .sidecar_bundle import SIDECAR_BUNDLE_IMAGE
|
||||
|
||||
|
||||
# Where the repo root lives, for `docker build` context. Computed once.
|
||||
@@ -100,12 +102,39 @@ def launch(
|
||||
# Dockerfile. Sidecar images get built lazily by `docker compose
|
||||
# up` via the renderer's `build:` directives.
|
||||
committed = read_committed_image(plan.slug)
|
||||
cached_policy = plan.spec.image_policy == "cached"
|
||||
if committed and docker_mod.image_exists(committed):
|
||||
info(f"using committed image {committed!r}")
|
||||
if cached_policy:
|
||||
warn_if_stale(
|
||||
f"agent image {committed!r}",
|
||||
docker_mod.image_created_at(committed),
|
||||
)
|
||||
plan = dataclasses.replace(
|
||||
plan,
|
||||
agent_provision=dataclasses.replace(plan.agent_provision, image=committed),
|
||||
)
|
||||
elif cached_policy:
|
||||
if not docker_mod.image_exists(plan.image):
|
||||
die(
|
||||
f"cached agent image {plan.image!r} not found; "
|
||||
"run without --cached-images to build it"
|
||||
)
|
||||
if not docker_mod.image_exists(SIDECAR_BUNDLE_IMAGE):
|
||||
die(
|
||||
f"cached sidecar image {SIDECAR_BUNDLE_IMAGE!r} not found; "
|
||||
"run without --cached-images to build it"
|
||||
)
|
||||
info(f"using cached agent image {plan.image!r}")
|
||||
info(f"using cached sidecar image {SIDECAR_BUNDLE_IMAGE!r}")
|
||||
warn_if_stale(
|
||||
f"agent image {plan.image!r}",
|
||||
docker_mod.image_created_at(plan.image),
|
||||
)
|
||||
warn_if_stale(
|
||||
f"sidecar image {SIDECAR_BUNDLE_IMAGE!r}",
|
||||
docker_mod.image_created_at(SIDECAR_BUNDLE_IMAGE),
|
||||
)
|
||||
else:
|
||||
docker_mod.build_image(
|
||||
plan.image, _REPO_DIR,
|
||||
|
||||
@@ -4,6 +4,7 @@ existence, and building images."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
@@ -186,6 +187,45 @@ def image_id(ref: str) -> str:
|
||||
return r.stdout.strip()
|
||||
|
||||
|
||||
def image_created_at(ref: str) -> datetime:
|
||||
"""Return Docker's image Created timestamp as an aware UTC datetime."""
|
||||
r = subprocess.run(
|
||||
["docker", "image", "inspect", "--format", "{{.Created}}", ref],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
die(
|
||||
f"docker image inspect for {ref!r} failed: "
|
||||
f"{(r.stderr or '').strip() or '<no stderr>'}"
|
||||
)
|
||||
raw = r.stdout.strip()
|
||||
try:
|
||||
return _parse_docker_timestamp(raw)
|
||||
except ValueError:
|
||||
die(f"docker image inspect for {ref!r} returned invalid Created timestamp: {raw!r}")
|
||||
|
||||
|
||||
def _parse_docker_timestamp(raw: str) -> datetime:
|
||||
text = raw.strip()
|
||||
if text.endswith("Z"):
|
||||
text = text[:-1] + "+00:00"
|
||||
dot = text.find(".")
|
||||
if dot != -1:
|
||||
tz_plus = text.find("+", dot)
|
||||
tz_minus = text.find("-", dot)
|
||||
tz_candidates = [pos for pos in (tz_plus, tz_minus) if pos != -1]
|
||||
if tz_candidates:
|
||||
tz_pos = min(tz_candidates)
|
||||
frac = text[dot + 1:tz_pos]
|
||||
text = text[:dot + 1] + frac[:6].ljust(6, "0") + text[tz_pos:]
|
||||
dt = datetime.fromisoformat(text)
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def save(ref: str, output: str) -> None:
|
||||
"""`docker save REF -o OUTPUT`. Writes a tarball of the image
|
||||
layers + manifest to the host path. Used by smolmachines
|
||||
|
||||
@@ -45,7 +45,8 @@ from ...git_gate import (
|
||||
provision_git_gate_dynamic_keys,
|
||||
revoke_git_gate_provisioned_keys,
|
||||
)
|
||||
from ...log import info, warn
|
||||
from ...image_cache import warn_if_stale_path
|
||||
from ...log import die, info, warn
|
||||
from ...bottle_state import (
|
||||
egress_state_dir,
|
||||
git_gate_state_dir,
|
||||
@@ -182,10 +183,13 @@ def _start_bundle(
|
||||
plan = _provision_git_gate_keys(plan)
|
||||
bundle_spec = _bundle_launch_spec(plan, network, proxy_host)
|
||||
token_env = _resolve_token_env(plan, dict(os.environ))
|
||||
artifact = _ensure_smolmachine(
|
||||
bundle_spec.image,
|
||||
dockerfile=_bundle.SIDECAR_BUNDLE_DOCKERFILE,
|
||||
)
|
||||
if _image_policy(plan) == "cached":
|
||||
artifact = _cached_smolmachine(bundle_spec.image, label="sidecar")
|
||||
else:
|
||||
artifact = _ensure_smolmachine(
|
||||
bundle_spec.image,
|
||||
dockerfile=_bundle.SIDECAR_BUNDLE_DOCKERFILE,
|
||||
)
|
||||
launch = _bundle.start_bundle_vm(
|
||||
bundle_spec,
|
||||
from_path=artifact,
|
||||
@@ -467,8 +471,13 @@ def _agent_from_path(plan: SmolmachinesBottlePlan) -> Path:
|
||||
committed_path = Path(committed)
|
||||
if committed_path.is_file():
|
||||
info(f"using committed smolmachine {str(committed_path)!r}")
|
||||
if _image_policy(plan) == "cached":
|
||||
warn_if_stale_path("agent smolmachine artifact", committed_path)
|
||||
return committed_path
|
||||
|
||||
if _image_policy(plan) == "cached":
|
||||
return _cached_smolmachine(plan.agent_image, label="agent")
|
||||
|
||||
# Build the agent image and pack it into a `.smolmachine`
|
||||
# artifact (or hit the per-Dockerfile-digest cache). Runs here,
|
||||
# not in prepare, so the docker-build output doesn't garble the
|
||||
@@ -479,6 +488,35 @@ def _agent_from_path(plan: SmolmachinesBottlePlan) -> Path:
|
||||
)
|
||||
|
||||
|
||||
def _image_policy(plan: object) -> str:
|
||||
spec = getattr(plan, "spec", None)
|
||||
return str(getattr(spec, "image_policy", "fresh"))
|
||||
|
||||
|
||||
def _cached_smolmachine(image_ref: str, *, label: str) -> Path:
|
||||
"""Return the cached smolmachine artifact for the current local image.
|
||||
|
||||
This is intentionally buildless: it inspects the existing local Docker
|
||||
image ID and looks for the artifact keyed by that ID. If either side is
|
||||
missing, the caller must use the fresh path to build/pack it.
|
||||
"""
|
||||
if not docker_mod.image_exists(image_ref):
|
||||
die(
|
||||
f"cached {label} image {image_ref!r} not found; "
|
||||
"run without --cached-images to build it"
|
||||
)
|
||||
digest = docker_mod.image_id(image_ref).split(":", 1)[-1][:16]
|
||||
sidecar = _SMOLMACHINE_CACHE_DIR / f"{digest}.smolmachine.smolmachine"
|
||||
if not sidecar.is_file():
|
||||
die(
|
||||
f"cached {label} smolmachine artifact for {image_ref!r} not found; "
|
||||
"run without --cached-images to build it"
|
||||
)
|
||||
info(f"using cached {label} smolmachine {str(sidecar)!r}")
|
||||
warn_if_stale_path(f"{label} smolmachine artifact", sidecar)
|
||||
return sidecar
|
||||
|
||||
|
||||
def _ensure_smolmachine(image_ref: str, *, dockerfile: str = "") -> Path:
|
||||
"""Build the agent docker image and convert it into a
|
||||
`.smolmachine` artifact, caching the result under
|
||||
|
||||
Reference in New Issue
Block a user