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
|
||||
|
||||
@@ -63,6 +63,14 @@ def cmd_start(argv: list[str]) -> int:
|
||||
"skip all prompts. For orchestrators, CI, and webhooks."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--cached-images",
|
||||
action="store_true",
|
||||
help=(
|
||||
"quickstart with existing local agent and sidecar images; "
|
||||
"only valid with --headless"
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--bottle",
|
||||
action="append",
|
||||
@@ -95,6 +103,8 @@ def cmd_start(argv: list[str]) -> int:
|
||||
help="agent name defined in bot-bottle.json (omit to pick interactively)",
|
||||
)
|
||||
args = parser.parse_args(argv)
|
||||
if args.cached_images and not args.headless:
|
||||
die("--cached-images is only supported with --headless")
|
||||
|
||||
dry_run = args.dry_run or os.environ.get("BOT_BOTTLE_DRY_RUN") == "1"
|
||||
|
||||
@@ -142,6 +152,10 @@ def cmd_start(argv: list[str]) -> int:
|
||||
label, color = tui.name_color_modal(default_label=agent_name)
|
||||
label, color = _resolve_unique_label(label, color)
|
||||
|
||||
image_policy = _select_image_policy()
|
||||
if image_policy is None:
|
||||
return 0
|
||||
|
||||
spec = BottleSpec(
|
||||
manifest=manifest,
|
||||
agent_name=agent_name,
|
||||
@@ -150,6 +164,7 @@ def cmd_start(argv: list[str]) -> int:
|
||||
label=label,
|
||||
color=color,
|
||||
bottle_names=bottle_names,
|
||||
image_policy=image_policy,
|
||||
)
|
||||
return _launch_bottle(
|
||||
spec,
|
||||
@@ -210,6 +225,7 @@ def _start_headless(
|
||||
color=args.color or "",
|
||||
bottle_names=bottle_names,
|
||||
headless=True,
|
||||
image_policy="cached" if args.cached_images else "fresh",
|
||||
)
|
||||
return _launch_bottle(
|
||||
spec,
|
||||
@@ -390,6 +406,13 @@ def _text_prompt_yes() -> bool:
|
||||
return reply in ("y", "Y", "yes", "YES")
|
||||
|
||||
|
||||
def _select_image_policy() -> str | None:
|
||||
return tui.filter_select(
|
||||
["fresh", "cached"],
|
||||
title="Select image startup mode",
|
||||
)
|
||||
|
||||
|
||||
def _text_render_preflight():
|
||||
def _render(plan: DockerBottlePlan, backend_name: str) -> None:
|
||||
print(file=sys.stderr)
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
"""SQLite-backed bot-bottle configuration store."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from .db_store import DbStore
|
||||
from .migrations import TableMigrations
|
||||
from .supervise_types import host_db_path
|
||||
except ImportError:
|
||||
from db_store import DbStore # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
|
||||
from migrations import TableMigrations # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
|
||||
from supervise_types import host_db_path # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
|
||||
|
||||
|
||||
CACHED_IMAGE_STALE_WARNING_DAYS = "cached_image_stale_warning_days"
|
||||
DEFAULT_CACHED_IMAGE_STALE_WARNING_DAYS = 1
|
||||
|
||||
|
||||
class ConfigStore(DbStore):
|
||||
"""SQLite key/value configuration for host-side bot-bottle settings."""
|
||||
|
||||
def __init__(self, db_path: Path | None = None) -> None:
|
||||
migrations = TableMigrations("config_store", [
|
||||
# v1 — generic string key/value settings
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS bot_bottle_config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
)
|
||||
""",
|
||||
])
|
||||
super().__init__(db_path or host_db_path(), migrations)
|
||||
|
||||
def get(self, key: str) -> str | None:
|
||||
if not self.db_path.is_file():
|
||||
return None
|
||||
with self._connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT value FROM bot_bottle_config WHERE key = ?",
|
||||
(key,),
|
||||
).fetchone()
|
||||
return str(row["value"]) if row is not None else None
|
||||
|
||||
def set(self, key: str, value: str) -> Path:
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO bot_bottle_config (key, value)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
||||
""",
|
||||
(key, value),
|
||||
)
|
||||
self._chmod()
|
||||
return self.db_path
|
||||
|
||||
def get_int(self, key: str, default: int) -> int:
|
||||
raw = self.get(key)
|
||||
if raw is None:
|
||||
return default
|
||||
try:
|
||||
return int(raw)
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
def cached_image_stale_warning_days(self) -> int:
|
||||
return self.get_int(
|
||||
CACHED_IMAGE_STALE_WARNING_DAYS,
|
||||
DEFAULT_CACHED_IMAGE_STALE_WARNING_DAYS,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CACHED_IMAGE_STALE_WARNING_DAYS",
|
||||
"DEFAULT_CACHED_IMAGE_STALE_WARNING_DAYS",
|
||||
"ConfigStore",
|
||||
]
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Shared helpers for cached-image quickstart warnings."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from .config_store import ConfigStore
|
||||
from .log import warn
|
||||
except ImportError:
|
||||
from config_store import ConfigStore # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
|
||||
from log import warn # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
|
||||
|
||||
|
||||
def warn_if_stale(label: str, created_at: datetime) -> None:
|
||||
threshold_days = ConfigStore().cached_image_stale_warning_days()
|
||||
if threshold_days < 0:
|
||||
return
|
||||
now = datetime.now(timezone.utc)
|
||||
created = created_at.astimezone(timezone.utc)
|
||||
age = now - created
|
||||
if age.total_seconds() <= threshold_days * 86400:
|
||||
return
|
||||
warn(
|
||||
f"cached {label} is {age.days} day(s) old; "
|
||||
"quickstart does not verify it matches the current Dockerfile/context"
|
||||
)
|
||||
|
||||
|
||||
def warn_if_stale_path(label: str, path: Path) -> None:
|
||||
warn_if_stale(
|
||||
label,
|
||||
datetime.fromtimestamp(path.stat().st_mtime, tz=timezone.utc),
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["warn_if_stale", "warn_if_stale_path"]
|
||||
@@ -6,9 +6,11 @@ from pathlib import Path
|
||||
|
||||
try:
|
||||
from .audit_store import AuditStore
|
||||
from .config_store import ConfigStore
|
||||
from .queue_store import QueueStore
|
||||
except ImportError:
|
||||
from audit_store import AuditStore # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
|
||||
from config_store import ConfigStore # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
|
||||
from queue_store import QueueStore # type: ignore[import-not-found] # pylint: disable=import-error,no-name-in-module
|
||||
|
||||
_instance: StoreManager | None = None
|
||||
@@ -50,11 +52,13 @@ class StoreManager:
|
||||
return (
|
||||
QueueStore("", self.db_path).is_migrated()
|
||||
and AuditStore(self.db_path).is_migrated()
|
||||
and ConfigStore(self.db_path).is_migrated()
|
||||
)
|
||||
|
||||
def migrate(self) -> None:
|
||||
QueueStore("", self.db_path).migrate()
|
||||
AuditStore(self.db_path).migrate()
|
||||
ConfigStore(self.db_path).migrate()
|
||||
|
||||
|
||||
__all__ = ["StoreManager"]
|
||||
|
||||
Reference in New Issue
Block a user