PRD 0044: print parity across backends #147
@@ -32,15 +32,21 @@ manifest does not carry a backend field; the host picks.
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from abc import ABC, abstractmethod
|
||||
from contextlib import AbstractContextManager
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Generic, Sequence, TypeVar
|
||||
|
||||
from ..log import die
|
||||
from ..agent_provider import AgentProvisionPlan
|
||||
from ..egress import EgressPlan
|
||||
from ..git_gate import GitGatePlan
|
||||
from ..log import die, info
|
||||
from ..manifest import GitEntry, Manifest
|
||||
from ..supervise import SupervisePlan
|
||||
from ..util import expand_tilde
|
||||
from .print_util import print_multi, visible_agent_env_names
|
||||
from .util import host_skill_dir
|
||||
|
||||
|
||||
@@ -65,15 +71,56 @@ class BottleSpec:
|
||||
@dataclass(frozen=True)
|
||||
class BottlePlan(ABC):
|
||||
"""Base output of a backend's prepare step. Concrete subclasses
|
||||
(e.g. DockerBottlePlan) add backend-specific resolved fields and
|
||||
implement `print`."""
|
||||
(e.g. DockerBottlePlan) add backend-specific resolved fields."""
|
||||
|
||||
spec: BottleSpec
|
||||
stage_dir: Path
|
||||
git_gate_plan: GitGatePlan
|
||||
egress_plan: EgressPlan
|
||||
supervise_plan: SupervisePlan | None
|
||||
agent_provision: AgentProvisionPlan
|
||||
|
|
||||
|
||||
@abstractmethod
|
||||
def print(self, *, remote_control: bool) -> None:
|
||||
"""Render the y/N preflight summary to stderr."""
|
||||
del remote_control
|
||||
spec = self.spec
|
||||
manifest = spec.manifest
|
||||
agent = manifest.agents[spec.agent_name]
|
||||
bottle = manifest.bottle_for(spec.agent_name)
|
||||
|
||||
env_names = visible_agent_env_names(
|
||||
sorted(
|
||||
set(bottle.env.keys())
|
||||
| set(self.agent_provision.guest_env.keys())
|
||||
),
|
||||
hidden_env_names=self.agent_provision.hidden_env_names,
|
||||
)
|
||||
|
||||
print(file=sys.stderr)
|
||||
info(f"agent : {spec.agent_name}")
|
||||
info(f"provider : {self.agent_provision.template}")
|
||||
print_multi("env ", env_names)
|
||||
print_multi("skills ", list(agent.skills))
|
||||
info(f"bottle : {agent.bottle}")
|
||||
|
||||
identity = manifest.git_identity_summary(spec.agent_name)
|
||||
if identity:
|
||||
info(f" git identity : {identity}")
|
||||
|
||||
git_lines = [
|
||||
f"{u.name} → {u.upstream_host}:{u.upstream_port}"
|
||||
for u in self.git_gate_plan.upstreams
|
||||
]
|
||||
if git_lines:
|
||||
print_multi(" git gate ", git_lines)
|
||||
|
||||
if self.egress_plan.routes:
|
||||
egress_lines = []
|
||||
for r in self.egress_plan.routes:
|
||||
auth = f" [auth:{r.auth_scheme}]" if r.auth_scheme else ""
|
||||
egress_lines.append(f"{r.host}{auth}")
|
||||
print_multi(" egress ", egress_lines)
|
||||
print(file=sys.stderr)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@@ -2,30 +2,25 @@
|
||||
|
||||
Carries the Docker-specific resolved fields produced by
|
||||
DockerBottleBackend.prepare. The launch step consumes it without
|
||||
further resolution; show_plan-style rendering is the `print` method.
|
||||
further resolution; preflight rendering is inherited from BottlePlan.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
from ...agent_provider import AgentProvisionPlan, PromptMode
|
||||
from ...egress import EgressPlan
|
||||
from ...git_gate import GitGatePlan
|
||||
from ...log import info
|
||||
from ...agent_provider import PromptMode
|
||||
from ...pipelock import PipelockProxyPlan
|
||||
from ...supervise import SupervisePlan
|
||||
from .. import BottlePlan
|
||||
from ..print_util import print_multi, visible_agent_env_names
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DockerBottlePlan(BottlePlan):
|
||||
"""Docker-specific resolved fields produced by
|
||||
DockerBottleBackend.prepare. Inherits `spec` and `stage_dir` from
|
||||
BottlePlan."""
|
||||
DockerBottleBackend.prepare. Inherits `spec`, `stage_dir`,
|
||||
`git_gate_plan`, `egress_plan`, `supervise_plan`, and
|
||||
`agent_provision` from BottlePlan."""
|
||||
|
||||
slug: str
|
||||
container_name: str
|
||||
@@ -46,13 +41,7 @@ class DockerBottlePlan(BottlePlan):
|
||||
forwarded_env: dict[str, str] = field(repr=False)
|
||||
prompt_file: Path
|
||||
proxy_plan: PipelockProxyPlan
|
||||
git_gate_plan: GitGatePlan
|
||||
egress_plan: EgressPlan
|
||||
# None when bottle.supervise is False. PRD 0013 supervise sidecar
|
||||
# is opt-in via the manifest's bottle.supervise field.
|
||||
supervise_plan: SupervisePlan | None
|
||||
use_runsc: bool
|
||||
agent_provision: AgentProvisionPlan
|
||||
|
||||
@property
|
||||
def agent_command(self) -> str:
|
||||
@@ -65,55 +54,3 @@ class DockerBottlePlan(BottlePlan):
|
||||
@property
|
||||
def agent_provider_template(self) -> str:
|
||||
return self.agent_provision.template
|
||||
|
||||
def print(self, *, remote_control: bool) -> None:
|
||||
"""Render the y/N preflight summary to stderr — compact form
|
||||
intended to fit on screen without scrolling. The full
|
||||
structured shape (image, container, runtime, etc.) lives on
|
||||
this dataclass for tooling that wants to introspect it."""
|
||||
del remote_control # not surfaced in the compact summary
|
||||
spec = self.spec
|
||||
manifest = spec.manifest
|
||||
agent = manifest.agents[spec.agent_name]
|
||||
bottle = manifest.bottle_for(spec.agent_name)
|
||||
# The agent sees the union of literal env names (rendered into
|
||||
# --env-file) and forwarded env names (`-e NAME` with the
|
||||
# value arriving via subprocess env). The forwarded set holds
|
||||
# the OAuth token (CLAUDE_CODE_OAUTH_TOKEN) and any host-env
|
||||
# interpolations from the manifest; egress holds
|
||||
# upstream tokens in its own environ, so no token forwarding
|
||||
# from the agent to the proxy is needed.
|
||||
env_names = visible_agent_env_names(
|
||||
sorted(
|
||||
set(bottle.env.keys())
|
||||
| set(self.forwarded_env.keys())
|
||||
| set(self.agent_provision.guest_env.keys())
|
||||
),
|
||||
hidden_env_names=self.agent_provision.hidden_env_names,
|
||||
)
|
||||
|
||||
print(file=sys.stderr)
|
||||
info(f"agent : {spec.agent_name}")
|
||||
info(f"provider : {self.agent_provider_template}")
|
||||
print_multi("env ", env_names)
|
||||
print_multi("skills ", list(agent.skills))
|
||||
info(f"bottle : {agent.bottle}")
|
||||
|
||||
identity = manifest.git_identity_summary(spec.agent_name)
|
||||
if identity:
|
||||
info(f" git identity : {identity}")
|
||||
|
||||
git_lines = [
|
||||
f"{u.upstream_host}:{u.upstream_port}"
|
||||
for u in self.git_gate_plan.upstreams
|
||||
]
|
||||
if git_lines:
|
||||
print_multi(" git gate ", git_lines)
|
||||
|
||||
if self.egress_plan.routes:
|
||||
egress_lines = []
|
||||
for r in self.egress_plan.routes:
|
||||
auth = f" [auth:{r.auth_scheme}]" if r.auth_scheme else ""
|
||||
egress_lines.append(f"{r.host}{auth}")
|
||||
print_multi(" egress ", egress_lines)
|
||||
print(file=sys.stderr)
|
||||
|
||||
@@ -8,25 +8,20 @@ in chunk 4."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from ...agent_provider import AgentProvisionPlan, PromptMode
|
||||
from ...egress import EgressPlan
|
||||
from ...git_gate import GitGatePlan
|
||||
from ...log import info
|
||||
from ...agent_provider import PromptMode
|
||||
from ...pipelock import PipelockProxyPlan
|
||||
from ...supervise import SupervisePlan
|
||||
from .. import BottlePlan
|
||||
from ..print_util import print_multi, visible_agent_env_names
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SmolmachinesBottlePlan(BottlePlan):
|
||||
"""Resolved fields the launch step needs to bring up the bottle.
|
||||
|
||||
Inherits `spec` and `stage_dir` from BottlePlan."""
|
||||
Inherits `spec`, `stage_dir`, `git_gate_plan`, `egress_plan`,
|
||||
`supervise_plan`, and `agent_provision` from BottlePlan."""
|
||||
|
||||
slug: str
|
||||
# Per-bottle docker subnet for the sidecar bundle container.
|
||||
@@ -77,12 +72,6 @@ class SmolmachinesBottlePlan(BottlePlan):
|
||||
# per-bottle bridge with a pinned IP. The unused fields stay
|
||||
# at their dataclass defaults.
|
||||
proxy_plan: PipelockProxyPlan
|
||||
git_gate_plan: GitGatePlan
|
||||
egress_plan: EgressPlan
|
||||
# None when bottle.supervise is False, matching the docker
|
||||
# backend's convention.
|
||||
supervise_plan: SupervisePlan | None
|
||||
agent_provision: AgentProvisionPlan
|
||||
# Agent-side endpoints. On Docker Desktop the docker bridge
|
||||
# IPs aren't reachable from the smolvm guest (TSI uses macOS
|
||||
# networking; docker container IPs live in the daemon's VM),
|
||||
@@ -110,42 +99,3 @@ class SmolmachinesBottlePlan(BottlePlan):
|
||||
@property
|
||||
def agent_dockerfile_path(self) -> str:
|
||||
return self.agent_provision.dockerfile
|
||||
|
||||
def print(self, *, remote_control: bool) -> None:
|
||||
"""Compact y/N preflight. Same shape as the Docker
|
||||
backend's so operators see one format across backends."""
|
||||
del remote_control # not surfaced in the compact summary
|
||||
spec = self.spec
|
||||
manifest = spec.manifest
|
||||
agent = manifest.agents[spec.agent_name]
|
||||
bottle = manifest.bottle_for(spec.agent_name)
|
||||
|
||||
env_names = visible_agent_env_names(
|
||||
sorted(
|
||||
set(bottle.env.keys())
|
||||
| set(self.agent_provision.guest_env.keys())
|
||||
),
|
||||
hidden_env_names=self.agent_provision.hidden_env_names,
|
||||
)
|
||||
upstreams = [
|
||||
f"{g.Name} → {g.Upstream}" for g in bottle.git
|
||||
]
|
||||
# Use the resolved egress_plan (lowercase `host` on the
|
||||
# plan-level EgressRoute) rather than `bottle.egress.routes`,
|
||||
# which is the manifest's capitalized-attr form.
|
||||
routes = [r.host for r in self.egress_plan.routes]
|
||||
|
||||
print(file=sys.stderr)
|
||||
info(f"agent : {spec.agent_name}")
|
||||
info(f"provider : {self.agent_provider_template}")
|
||||
print_multi("env ", env_names)
|
||||
print_multi("skills ", list(agent.skills))
|
||||
info(f"bottle : {agent.bottle}")
|
||||
identity = manifest.git_identity_summary(spec.agent_name)
|
||||
if identity:
|
||||
info(f" git identity : {identity}")
|
||||
if upstreams:
|
||||
print_multi(" git gate ", upstreams)
|
||||
if routes:
|
||||
print_multi(" egress ", routes)
|
||||
print(file=sys.stderr)
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
# PRD 0044: Print Parity Across Backends
|
||||
|
||||
- **Status:** Active
|
||||
- **Author:** didericis-claude
|
||||
- **Created:** 2026-06-02
|
||||
- **Issue:** #96
|
||||
|
||||
## Summary
|
||||
|
||||
Hoist `git_gate_plan`, `egress_plan`, `agent_provision`, and `supervise_plan`
|
||||
from the concrete `BottlePlan` subclasses up to `BottlePlan`, and implement
|
||||
`print` concretely there. This eliminates the two per-backend output divergences
|
||||
and ensures any future backend gets correct preflight rendering for free.
|
||||
|
||||
## Problem
|
||||
|
||||
`BottlePlan.print` is `@abstractmethod`, so each backend provides its own
|
||||
implementation. The two current implementations have drifted:
|
||||
|
||||
| Field | Docker | smolmachines |
|
||||
|---|---|---|
|
||||
| git gate lines | `upstream_host:upstream_port` from resolved `git_gate_plan.upstreams` | `Name → Upstream` from manifest `bottle.git` |
|
||||
| egress lines | `host [auth:scheme]` | `host` only (auth dropped) |
|
||||
|
||||
The smolmachines docstring says "same shape as the Docker backend's so operators
|
||||
see one format across backends" — that intent is real but nothing enforces it.
|
||||
|
||||
The env_names divergence previously noted in this issue was resolved by PRD 0038
|
||||
(smolmachines env contract): `resolved.forwarded` is now merged into
|
||||
`agent_provision.guest_env` at prepare time on both backends, so displayed env
|
||||
names are equivalent.
|
||||
|
||||
## Goals / Success Criteria
|
||||
|
||||
- `BottlePlan` carries `git_gate_plan`, `egress_plan`, `agent_provision`, and
|
||||
`supervise_plan` as concrete fields; subclasses no longer declare them
|
||||
independently.
|
||||
- `BottlePlan.print` is a concrete method; subclasses have no `print`
|
||||
implementation of their own.
|
||||
- Both backends render git gate lines as `name → upstream_host:upstream_port`
|
||||
(using `git_gate_plan.upstreams`), not the manifest-level URL.
|
||||
- Both backends render egress lines as `host [auth:scheme]` (dropping the
|
||||
annotation only when `auth_scheme` is empty).
|
||||
- Unit tests assert the unified output for both backends from a single shared
|
||||
test helper.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- No changes to the Docker or smolmachines launch, prepare, or cleanup paths.
|
||||
- No changes to how env values are resolved or injected (that is PRD 0038).
|
||||
- No changes to the manifest schema or `GitEntry`.
|
||||
- No new CLI flags or dashboard changes.
|
||||
|
||||
## Scope
|
||||
|
||||
In scope:
|
||||
|
||||
- `bot_bottle/backend/__init__.py` — add `git_gate_plan`, `egress_plan`,
|
||||
`agent_provision`, and `supervise_plan` fields to `BottlePlan`; replace
|
||||
`@abstractmethod print` with a concrete implementation.
|
||||
- `bot_bottle/backend/docker/bottle_plan.py` — remove the four hoisted fields
|
||||
and the `print` method.
|
||||
- `bot_bottle/backend/smolmachines/bottle_plan.py` — remove the four hoisted
|
||||
fields and the `print` method.
|
||||
- `tests/unit/` — add or update tests asserting unified preflight output; a
|
||||
shared helper can build a minimal plan fixture for each backend and assert
|
||||
the same lines appear.
|
||||
|
||||
Out of scope:
|
||||
|
||||
- Changes to `bot_bottle/backend/print_util.py` beyond what the new `print`
|
||||
implementation requires.
|
||||
- Changes to `BottleCleanupPlan.print` or any other print method.
|
||||
- Integration tests that launch a real bottle.
|
||||
|
||||
## Design
|
||||
|
||||
Move the four fields that both concrete subclasses already declare —
|
||||
`git_gate_plan: GitGatePlan`, `egress_plan: EgressPlan`,
|
||||
`agent_provision: AgentProvisionPlan`, `supervise_plan: SupervisePlan | None`
|
||||
— up to `BottlePlan`. Both backends' `prepare` paths already produce these with
|
||||
the same types, so no prepare-time changes are needed.
|
||||
|
||||
Replace the `@abstractmethod` `print` with a concrete implementation on
|
||||
`BottlePlan` that:
|
||||
|
||||
1. Builds `env_names` from `bottle.env.keys() | agent_provision.guest_env.keys()`
|
||||
filtered through `agent_provision.hidden_env_names`.
|
||||
2. Builds git gate lines from `git_gate_plan.upstreams` as
|
||||
`f"{u.name} → {u.upstream_host}:{u.upstream_port}"`.
|
||||
3. Builds egress lines from `egress_plan.routes` as
|
||||
`f"{r.host} [auth:{r.auth_scheme}]"` when `r.auth_scheme` is non-empty,
|
||||
else `r.host`.
|
||||
4. Renders the standard two-column preflight block (leading blank line, agent,
|
||||
provider, env, skills, bottle, git identity, git gate, egress, trailing blank
|
||||
line).
|
||||
|
||||
Docker's `forwarded_env` keys are already merged into `agent_provision.guest_env`
|
||||
via the `agent_provision_plan` builder, so no special handling is needed for
|
||||
env_names.
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
- Add a shared fixture builder (e.g. `make_plan(backend)`) in a new or existing
|
||||
unit test module that constructs a minimal `DockerBottlePlan` and
|
||||
`SmolmachinesBottlePlan` from the same spec and plan fields.
|
||||
- Assert that `plan.print(remote_control=False)` produces identical git gate and
|
||||
egress lines for both backends given the same `git_gate_plan` and
|
||||
`egress_plan`.
|
||||
- Test the `auth_scheme` annotation: present when non-empty, absent otherwise.
|
||||
- Test git gate rendering: `name → host:port` format.
|
||||
|
||||
Run:
|
||||
|
||||
- `python3 -m unittest discover -s tests/unit`
|
||||
|
||||
## Open Questions
|
||||
|
||||
None.
|
||||
@@ -0,0 +1,246 @@
|
||||
"""Unit: BottlePlan.print parity across Docker and smolmachines (PRD 0044).
|
||||
|
||||
Both backends inherit a single concrete print() from BottlePlan. These
|
||||
tests verify that identical git_gate_plan and egress_plan inputs produce
|
||||
identical preflight output regardless of backend-specific fields.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from bot_bottle.agent_provider import AgentProvisionPlan
|
||||
from bot_bottle.backend import BottleSpec
|
||||
from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
|
||||
from bot_bottle.backend.smolmachines.bottle_plan import SmolmachinesBottlePlan
|
||||
from bot_bottle.egress import EgressPlan, EgressRoute
|
||||
from bot_bottle.git_gate import GitGatePlan, GitGateUpstream
|
||||
from bot_bottle.manifest import Manifest
|
||||
from bot_bottle.pipelock import PipelockProxyPlan
|
||||
|
||||
|
||||
def _manifest() -> Manifest:
|
||||
return Manifest.from_json_obj({
|
||||
"bottles": {"dev": {}},
|
||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||
})
|
||||
|
||||
|
||||
def _spec(manifest: Manifest, tmp: str) -> BottleSpec:
|
||||
return BottleSpec(
|
||||
manifest=manifest,
|
||||
agent_name="demo",
|
||||
copy_cwd=False,
|
||||
user_cwd=tmp,
|
||||
identity="test-00001",
|
||||
)
|
||||
|
||||
|
||||
def _git_gate_plan(tmp: str) -> GitGatePlan:
|
||||
stage = Path(tmp)
|
||||
return GitGatePlan(
|
||||
slug="test-00001",
|
||||
entrypoint_script=stage / "entrypoint.sh",
|
||||
hook_script=stage / "hook.sh",
|
||||
access_hook_script=stage / "access-hook.sh",
|
||||
upstreams=(
|
||||
GitGateUpstream(
|
||||
name="myrepo",
|
||||
upstream_url="ssh://git@gitea.example.com:30009/org/myrepo.git",
|
||||
upstream_host="gitea.example.com",
|
||||
upstream_port="30009",
|
||||
identity_file="/dev/null",
|
||||
known_host_key="ssh-ed25519 AAAA...",
|
||||
extra_hosts={},
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _egress_plan(tmp: str) -> EgressPlan:
|
||||
return EgressPlan(
|
||||
slug="test-00001",
|
||||
routes_path=Path(tmp) / "egress.yaml",
|
||||
routes=(
|
||||
EgressRoute(
|
||||
host="api.example.com",
|
||||
path_allowlist=("/v1/",),
|
||||
auth_scheme="bearer",
|
||||
token_env="EGRESS_TOKEN_0",
|
||||
token_ref="TOKEN",
|
||||
),
|
||||
EgressRoute(
|
||||
host="static.example.com",
|
||||
path_allowlist=("/",),
|
||||
),
|
||||
),
|
||||
token_env_map={"EGRESS_TOKEN_0": "TOKEN"},
|
||||
)
|
||||
|
||||
|
||||
def _agent_provision() -> AgentProvisionPlan:
|
||||
return AgentProvisionPlan(
|
||||
template="claude",
|
||||
command="claude",
|
||||
prompt_mode="append_file",
|
||||
image="",
|
||||
dockerfile="",
|
||||
guest_env={"HTTPS_PROXY": "http://127.0.0.1:9999"},
|
||||
)
|
||||
|
||||
|
||||
def _proxy_plan(tmp: str) -> PipelockProxyPlan:
|
||||
return PipelockProxyPlan(
|
||||
yaml_path=Path(tmp) / "pipelock.yaml",
|
||||
slug="test-00001",
|
||||
)
|
||||
|
||||
|
||||
def _docker_plan(spec: BottleSpec, tmp: str) -> DockerBottlePlan:
|
||||
stage = Path(tmp)
|
||||
return DockerBottlePlan(
|
||||
spec=spec,
|
||||
stage_dir=stage,
|
||||
git_gate_plan=_git_gate_plan(tmp),
|
||||
egress_plan=_egress_plan(tmp),
|
||||
supervise_plan=None,
|
||||
agent_provision=_agent_provision(),
|
||||
slug="test-00001",
|
||||
container_name="bot-bottle-test-00001",
|
||||
container_name_pinned=False,
|
||||
image="bot-bottle-claude:latest",
|
||||
derived_image="",
|
||||
runtime_image="bot-bottle-claude:latest",
|
||||
dockerfile_path="",
|
||||
env_file=stage / "env",
|
||||
forwarded_env={},
|
||||
prompt_file=stage / "prompt.txt",
|
||||
proxy_plan=_proxy_plan(tmp),
|
||||
use_runsc=False,
|
||||
)
|
||||
|
||||
|
||||
def _smolmachines_plan(spec: BottleSpec, tmp: str) -> SmolmachinesBottlePlan:
|
||||
stage = Path(tmp)
|
||||
return SmolmachinesBottlePlan(
|
||||
spec=spec,
|
||||
stage_dir=stage,
|
||||
git_gate_plan=_git_gate_plan(tmp),
|
||||
egress_plan=_egress_plan(tmp),
|
||||
supervise_plan=None,
|
||||
agent_provision=_agent_provision(),
|
||||
slug="test-00001",
|
||||
bundle_subnet="10.99.0.0/24",
|
||||
bundle_gateway="10.99.0.1",
|
||||
bundle_ip="10.99.0.2",
|
||||
machine_name="bot-bottle-test-00001",
|
||||
agent_image_ref="bot-bottle-claude:latest",
|
||||
guest_env={"HTTPS_PROXY": "http://127.0.0.1:9999"},
|
||||
prompt_file=stage / "prompt.txt",
|
||||
proxy_plan=_proxy_plan(tmp),
|
||||
)
|
||||
|
||||
|
||||
def _capture_print(plan: DockerBottlePlan | SmolmachinesBottlePlan) -> list[str]:
|
||||
buf = io.StringIO()
|
||||
orig = sys.stderr
|
||||
sys.stderr = buf
|
||||
try:
|
||||
plan.print(remote_control=False)
|
||||
finally:
|
||||
sys.stderr = orig
|
||||
return buf.getvalue().splitlines()
|
||||
|
||||
|
||||
class TestGitGatePrintParity(unittest.TestCase):
|
||||
"""Both backends render git gate entries as 'name → host:port'."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.mkdtemp(prefix="plan-print-parity-")
|
||||
manifest = _manifest()
|
||||
spec = _spec(manifest, self._tmp)
|
||||
self._docker_lines = _capture_print(_docker_plan(spec, self._tmp))
|
||||
self._smol_lines = _capture_print(_smolmachines_plan(spec, self._tmp))
|
||||
|
||||
def _git_gate_lines(self, lines: list[str]) -> list[str]:
|
||||
return [ln for ln in lines if "git gate" in ln]
|
||||
|
||||
def test_docker_renders_name_arrow_host_port(self) -> None:
|
||||
git_lines = self._git_gate_lines(self._docker_lines)
|
||||
self.assertEqual(1, len(git_lines))
|
||||
self.assertIn("myrepo → gitea.example.com:30009", git_lines[0])
|
||||
|
||||
def test_smolmachines_renders_name_arrow_host_port(self) -> None:
|
||||
git_lines = self._git_gate_lines(self._smol_lines)
|
||||
self.assertEqual(1, len(git_lines))
|
||||
self.assertIn("myrepo → gitea.example.com:30009", git_lines[0])
|
||||
|
||||
def test_git_gate_lines_match_across_backends(self) -> None:
|
||||
self.assertEqual(
|
||||
self._git_gate_lines(self._docker_lines),
|
||||
self._git_gate_lines(self._smol_lines),
|
||||
)
|
||||
|
||||
|
||||
class TestEgressPrintParity(unittest.TestCase):
|
||||
"""Both backends render egress with auth annotation where present."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.mkdtemp(prefix="plan-print-parity-")
|
||||
manifest = _manifest()
|
||||
spec = _spec(manifest, self._tmp)
|
||||
self._docker_lines = _capture_print(_docker_plan(spec, self._tmp))
|
||||
self._smol_lines = _capture_print(_smolmachines_plan(spec, self._tmp))
|
||||
|
||||
def _egress_section(self, lines: list[str]) -> list[str]:
|
||||
"""Return lines from the egress label through the last route entry.
|
||||
|
||||
print_multi renders the first route on the label line and
|
||||
aligns additional routes as indented continuation lines
|
||||
(no repeated label). Collect the label line plus every
|
||||
non-blank, non-labelled line that follows before the next
|
||||
top-level section begins."""
|
||||
result: list[str] = []
|
||||
collecting = False
|
||||
indent_prefix = None
|
||||
for ln in lines:
|
||||
stripped = ln.lstrip()
|
||||
if "egress" in stripped and ":" in stripped:
|
||||
collecting = True
|
||||
# Determine the continuation indent from this line's prefix.
|
||||
idx = ln.index("egress")
|
||||
indent_prefix = ln[:idx]
|
||||
result.append(ln)
|
||||
elif collecting:
|
||||
if ln.startswith(indent_prefix) and "egress" not in ln and ":" not in ln.lstrip()[:20]:
|
||||
result.append(ln)
|
||||
else:
|
||||
break
|
||||
return result
|
||||
|
||||
def test_docker_includes_auth_annotation(self) -> None:
|
||||
combined = "\n".join(self._egress_section(self._docker_lines))
|
||||
self.assertIn("api.example.com [auth:bearer]", combined)
|
||||
|
||||
def test_smolmachines_includes_auth_annotation(self) -> None:
|
||||
combined = "\n".join(self._egress_section(self._smol_lines))
|
||||
self.assertIn("api.example.com [auth:bearer]", combined)
|
||||
|
||||
def test_unauthenticated_route_has_no_annotation(self) -> None:
|
||||
full = "\n".join(self._docker_lines)
|
||||
self.assertIn("static.example.com", full)
|
||||
self.assertNotIn("static.example.com [auth:", full)
|
||||
|
||||
def test_egress_lines_match_across_backends(self) -> None:
|
||||
self.assertEqual(
|
||||
self._egress_section(self._docker_lines),
|
||||
self._egress_section(self._smol_lines),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user
nit, name should also end in
plan(agent_provision_plan)