test: drop ssh-gate suites and shadow-route assertions (PRD 0009)
- Delete tests/unit/test_ssh_gate.py and the fixture_with_ssh helpers. - test_pipelock_yaml: drop the ssh-leak guard (structurally impossible now); the remaining tests switch to fixture_minimal. - test_pipelock_allowlist: rewrite the union/dedup test to exercise an egress.allowlist that duplicates a baked default (the property the ssh-leak assertion was hitching onto). - test_manifest_git: shadow-route assertion becomes a legacy-ssh- dies-with-hint assertion, since bottle.ssh is now parse-fail. - test_orphan_cleanup: drop the SSHGate.stop idempotency check; pipelock equivalent stays. - test_dry_run_plan: drop assertions on the removed ssh_hosts / ssh_gate keys. 52 unit tests pass.
This commit is contained in:
@@ -37,34 +37,6 @@ def fixture_with_egress_dict() -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def fixture_with_ssh_dict() -> dict[str, Any]:
|
|
||||||
"""Bottle has both an IPv4-literal SSH host (CGNAT) and a hostname host,
|
|
||||||
exercising both ssrf.ip_allowlist and trusted_domains code paths. JSON shape."""
|
|
||||||
return {
|
|
||||||
"bottles": {
|
|
||||||
"dev": {
|
|
||||||
"ssh": [
|
|
||||||
{
|
|
||||||
"Host": "tailscale-gitea",
|
|
||||||
"IdentityFile": "/dev/null",
|
|
||||||
"Hostname": "100.78.141.42",
|
|
||||||
"User": "git",
|
|
||||||
"Port": 30009,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"Host": "github",
|
|
||||||
"IdentityFile": "/dev/null",
|
|
||||||
"Hostname": "github.com",
|
|
||||||
"User": "git",
|
|
||||||
"Port": 22,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def fixture_with_git_dict() -> dict[str, Any]:
|
def fixture_with_git_dict() -> dict[str, Any]:
|
||||||
"""Bottle declares a git-gate upstream. JSON shape."""
|
"""Bottle declares a git-gate upstream. JSON shape."""
|
||||||
return {
|
return {
|
||||||
@@ -98,10 +70,6 @@ def fixture_with_egress() -> Manifest:
|
|||||||
return Manifest.from_json_obj(fixture_with_egress_dict())
|
return Manifest.from_json_obj(fixture_with_egress_dict())
|
||||||
|
|
||||||
|
|
||||||
def fixture_with_ssh() -> Manifest:
|
|
||||||
return Manifest.from_json_obj(fixture_with_ssh_dict())
|
|
||||||
|
|
||||||
|
|
||||||
def fixture_with_git() -> Manifest:
|
def fixture_with_git() -> Manifest:
|
||||||
return Manifest.from_json_obj(fixture_with_git_dict())
|
return Manifest.from_json_obj(fixture_with_git_dict())
|
||||||
|
|
||||||
|
|||||||
@@ -79,8 +79,6 @@ class TestDryRunPlan(unittest.TestCase):
|
|||||||
self.assertEqual("runc", plan["runtime"],
|
self.assertEqual("runc", plan["runtime"],
|
||||||
"runsc isn't available on the CI runner")
|
"runsc isn't available on the CI runner")
|
||||||
self.assertEqual([], plan["skills"])
|
self.assertEqual([], plan["skills"])
|
||||||
self.assertEqual([], plan["ssh_hosts"])
|
|
||||||
self.assertEqual([], plan["ssh_gate"])
|
|
||||||
self.assertEqual([], plan["git_remotes"])
|
self.assertEqual([], plan["git_remotes"])
|
||||||
self.assertEqual([], plan["git_gate"])
|
self.assertEqual([], plan["git_gate"])
|
||||||
self.assertEqual(False, plan["remote_control"])
|
self.assertEqual(False, plan["remote_control"])
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"""Integration: the cleanup primitives the start-flow trap depends on
|
"""Integration: the cleanup primitives the start-flow trap depends on
|
||||||
are idempotent. The original orphan-network bug was a trap-ordering
|
are idempotent. The original orphan-network bug was a trap-ordering
|
||||||
issue; the fix moved the install earlier. The trap is only safe if
|
issue; the fix moved the install earlier. The trap is only safe if
|
||||||
network_remove, PipelockProxy.stop, and SSHGate.stop are no-ops
|
network_remove and PipelockProxy.stop are no-ops against missing
|
||||||
against missing resources."""
|
resources."""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -17,10 +17,6 @@ from claude_bottle.backend.docker.pipelock import (
|
|||||||
DockerPipelockProxy,
|
DockerPipelockProxy,
|
||||||
pipelock_container_name,
|
pipelock_container_name,
|
||||||
)
|
)
|
||||||
from claude_bottle.backend.docker.ssh_gate import (
|
|
||||||
DockerSSHGate,
|
|
||||||
ssh_gate_container_name,
|
|
||||||
)
|
|
||||||
from tests._docker import skip_unless_docker
|
from tests._docker import skip_unless_docker
|
||||||
|
|
||||||
|
|
||||||
@@ -79,13 +75,6 @@ class TestOrphanCleanup(unittest.TestCase):
|
|||||||
# Should not raise.
|
# Should not raise.
|
||||||
DockerPipelockProxy().stop(pipelock_container_name(f"missing-{self.slug}"))
|
DockerPipelockProxy().stop(pipelock_container_name(f"missing-{self.slug}"))
|
||||||
|
|
||||||
def test_ssh_gate_stop_missing_sidecar(self):
|
|
||||||
# Same trap-safety requirement for the gate (PRD 0007). The
|
|
||||||
# launch ExitStack calls gate.stop on every error path; if
|
|
||||||
# the container was never created (early failure), stop must
|
|
||||||
# still no-op.
|
|
||||||
DockerSSHGate().stop(ssh_gate_container_name(f"missing-{self.slug}"))
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
@@ -168,11 +168,9 @@ class TestGitEntryCrossValidation(unittest.TestCase):
|
|||||||
"IdentityFile": "/dev/null"},
|
"IdentityFile": "/dev/null"},
|
||||||
]))
|
]))
|
||||||
|
|
||||||
def test_shadow_route_with_ssh_entry_dies(self):
|
def test_legacy_ssh_field_dies_with_hint(self):
|
||||||
# An ssh entry pointing at gitea.dideric.is:30009 AND a git
|
# PRD 0009: bottle.ssh is removed; manifests carrying it must
|
||||||
# entry pointing at ssh://git@gitea.dideric.is:30009/... is a
|
# fail loudly with a hint pointing at bottle.git.
|
||||||
# bypass: agents could route around the gate by using the
|
|
||||||
# ssh-gate. Manifest construction must reject.
|
|
||||||
with self.assertRaises(Die):
|
with self.assertRaises(Die):
|
||||||
Manifest.from_json_obj({
|
Manifest.from_json_obj({
|
||||||
"bottles": {
|
"bottles": {
|
||||||
@@ -184,40 +182,11 @@ class TestGitEntryCrossValidation(unittest.TestCase):
|
|||||||
"User": "git",
|
"User": "git",
|
||||||
"Port": 30009,
|
"Port": 30009,
|
||||||
}],
|
}],
|
||||||
"git": [{
|
|
||||||
"Name": "claude-bottle",
|
|
||||||
"Upstream": "ssh://git@gitea.dideric.is:30009/didericis/claude-bottle.git",
|
|
||||||
"IdentityFile": "/dev/null",
|
|
||||||
}],
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||||
})
|
})
|
||||||
|
|
||||||
def test_independent_ssh_and_git_targets_allowed(self):
|
|
||||||
# Same hostname but different ports are independent targets.
|
|
||||||
m = Manifest.from_json_obj({
|
|
||||||
"bottles": {
|
|
||||||
"dev": {
|
|
||||||
"ssh": [{
|
|
||||||
"Host": "gitea-ssh",
|
|
||||||
"IdentityFile": "/dev/null",
|
|
||||||
"Hostname": "gitea.dideric.is",
|
|
||||||
"User": "git",
|
|
||||||
"Port": 22,
|
|
||||||
}],
|
|
||||||
"git": [{
|
|
||||||
"Name": "claude-bottle",
|
|
||||||
"Upstream": "ssh://git@gitea.dideric.is:30009/didericis/claude-bottle.git",
|
|
||||||
"IdentityFile": "/dev/null",
|
|
||||||
}],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
|
||||||
})
|
|
||||||
self.assertEqual(1, len(m.bottles["dev"].ssh))
|
|
||||||
self.assertEqual(1, len(m.bottles["dev"].git))
|
|
||||||
|
|
||||||
|
|
||||||
class TestEmptyGitField(unittest.TestCase):
|
class TestEmptyGitField(unittest.TestCase):
|
||||||
def test_no_git_field_yields_empty_tuple(self):
|
def test_no_git_field_yields_empty_tuple(self):
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"""Unit: pipelock_effective_allowlist — the union of baked-in defaults
|
"""Unit: pipelock_effective_allowlist — the union of baked-in defaults
|
||||||
and bottle.egress.allowlist. Per PRD 0007, bottle.ssh entries do NOT
|
and bottle.egress.allowlist. Git upstreams declared in bottle.git do not
|
||||||
contribute (SSH traffic goes through the per-agent ssh-gate, not
|
contribute here; they flow through the per-agent git-gate (PRD 0008)."""
|
||||||
pipelock)."""
|
|
||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
@@ -14,25 +13,21 @@ class TestEffectiveAllowlist(unittest.TestCase):
|
|||||||
manifest = Manifest.from_json_obj({
|
manifest = Manifest.from_json_obj({
|
||||||
"bottles": {
|
"bottles": {
|
||||||
"dev": {
|
"dev": {
|
||||||
"egress": {"allowlist": ["registry.npmjs.org"]},
|
"egress": {
|
||||||
"ssh": [
|
"allowlist": [
|
||||||
{"Host": "ts", "IdentityFile": "/dev/null",
|
"registry.npmjs.org",
|
||||||
"Hostname": "100.78.141.42", "User": "git", "Port": 30009},
|
# Duplicate of a baked default; the union
|
||||||
{"Host": "gh", "IdentityFile": "/dev/null",
|
# must dedupe.
|
||||||
"Hostname": "github.com", "User": "git", "Port": 22},
|
"api.anthropic.com",
|
||||||
],
|
],
|
||||||
}
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
||||||
})
|
})
|
||||||
eff = pipelock_effective_allowlist(manifest.bottles["dev"])
|
eff = pipelock_effective_allowlist(manifest.bottles["dev"])
|
||||||
self.assertIn("api.anthropic.com", eff, "baked default present")
|
self.assertIn("api.anthropic.com", eff, "baked default present")
|
||||||
self.assertIn("registry.npmjs.org", eff, "egress.allowlist present")
|
self.assertIn("registry.npmjs.org", eff, "egress.allowlist present")
|
||||||
# PRD 0007: ssh hostnames must not contribute to pipelock's
|
|
||||||
# allowlist anymore — they're routed through the ssh-gate
|
|
||||||
# sidecar, which is on its own egress path.
|
|
||||||
self.assertNotIn("100.78.141.42", eff)
|
|
||||||
self.assertNotIn("github.com", eff)
|
|
||||||
self.assertEqual(len(eff), len(set(eff)), "deduplicated")
|
self.assertEqual(len(eff), len(set(eff)), "deduplicated")
|
||||||
self.assertEqual(eff, sorted(eff), "sorted")
|
self.assertEqual(eff, sorted(eff), "sorted")
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ from claude_bottle.pipelock import (
|
|||||||
pipelock_build_config,
|
pipelock_build_config,
|
||||||
pipelock_render_yaml,
|
pipelock_render_yaml,
|
||||||
)
|
)
|
||||||
from tests.fixtures import fixture_minimal, fixture_with_ssh
|
from tests.fixtures import fixture_minimal
|
||||||
|
|
||||||
|
|
||||||
class TestBuildConfig(unittest.TestCase):
|
class TestBuildConfig(unittest.TestCase):
|
||||||
@@ -38,26 +38,14 @@ class TestBuildConfig(unittest.TestCase):
|
|||||||
# Baked defaults always present.
|
# Baked defaults always present.
|
||||||
self.assertIn("api.anthropic.com", cast(list[str], cfg["api_allowlist"]))
|
self.assertIn("api.anthropic.com", cast(list[str], cfg["api_allowlist"]))
|
||||||
self.assertIn("raw.githubusercontent.com", cast(list[str], cfg["api_allowlist"]))
|
self.assertIn("raw.githubusercontent.com", cast(list[str], cfg["api_allowlist"]))
|
||||||
# PRD 0007: pipelock has no SSH carve-outs at all — neither
|
# pipelock has no SSH carve-outs at all — neither
|
||||||
# trusted_domains nor ssrf are ever emitted from bottle data
|
# trusted_domains nor ssrf are emitted from bottle data.
|
||||||
# in v1.
|
|
||||||
self.assertNotIn("trusted_domains", cfg)
|
self.assertNotIn("trusted_domains", cfg)
|
||||||
self.assertNotIn("ssrf", cfg)
|
self.assertNotIn("ssrf", cfg)
|
||||||
# Without CA paths, the tls_interception block is omitted —
|
# Without CA paths, the tls_interception block is omitted —
|
||||||
# pipelock falls back to its built-in default of `enabled: false`.
|
# pipelock falls back to its built-in default of `enabled: false`.
|
||||||
self.assertNotIn("tls_interception", cfg)
|
self.assertNotIn("tls_interception", cfg)
|
||||||
|
|
||||||
def test_ssh_entries_do_not_leak_into_pipelock(self):
|
|
||||||
# PRD 0007: bottle.ssh routes through the ssh-gate sidecar,
|
|
||||||
# so pipelock's config must not reflect those hostnames or
|
|
||||||
# IPs in any of its blocks.
|
|
||||||
cfg = pipelock_build_config(fixture_with_ssh().bottles["dev"])
|
|
||||||
allow = cast(list[str], cfg["api_allowlist"])
|
|
||||||
self.assertNotIn("github.com", allow)
|
|
||||||
self.assertNotIn("100.78.141.42", allow)
|
|
||||||
self.assertNotIn("trusted_domains", cfg)
|
|
||||||
self.assertNotIn("ssrf", cfg)
|
|
||||||
|
|
||||||
def test_tls_interception_block_emitted_when_paths_supplied(self):
|
def test_tls_interception_block_emitted_when_paths_supplied(self):
|
||||||
# PRD 0006: paths flow in via DockerPipelockProxy's in-container
|
# PRD 0006: paths flow in via DockerPipelockProxy's in-container
|
||||||
# constants; this directly pins the dict shape. passthrough_domains
|
# constants; this directly pins the dict shape. passthrough_domains
|
||||||
@@ -102,7 +90,7 @@ class TestRenderAndWrite(unittest.TestCase):
|
|||||||
"""One render-level smoke check: the serialized YAML is plausibly
|
"""One render-level smoke check: the serialized YAML is plausibly
|
||||||
the shape pipelock expects. We don't grep every key here — that's
|
the shape pipelock expects. We don't grep every key here — that's
|
||||||
what TestBuildConfig is for."""
|
what TestBuildConfig is for."""
|
||||||
cfg = pipelock_build_config(fixture_with_ssh().bottles["dev"])
|
cfg = pipelock_build_config(fixture_minimal().bottles["dev"])
|
||||||
text = pipelock_render_yaml(cfg)
|
text = pipelock_render_yaml(cfg)
|
||||||
for required in (
|
for required in (
|
||||||
"api_allowlist:",
|
"api_allowlist:",
|
||||||
@@ -111,7 +99,7 @@ class TestRenderAndWrite(unittest.TestCase):
|
|||||||
"request_body_scanning:",
|
"request_body_scanning:",
|
||||||
):
|
):
|
||||||
self.assertIn(required, text)
|
self.assertIn(required, text)
|
||||||
# PRD 0007: no ssh carve-outs in the rendered yaml.
|
# No ssh carve-outs in the rendered yaml.
|
||||||
self.assertNotIn("trusted_domains:", text)
|
self.assertNotIn("trusted_domains:", text)
|
||||||
self.assertNotIn("ssrf:", text)
|
self.assertNotIn("ssrf:", text)
|
||||||
|
|
||||||
|
|||||||
@@ -1,137 +0,0 @@
|
|||||||
"""Unit: SSHGate prepare shape + entrypoint render."""
|
|
||||||
|
|
||||||
import os
|
|
||||||
import stat
|
|
||||||
import tempfile
|
|
||||||
import unittest
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
from claude_bottle.manifest import Manifest
|
|
||||||
from claude_bottle.ssh_gate import (
|
|
||||||
SSHGate,
|
|
||||||
SSHGatePlan,
|
|
||||||
SSHGateUpstream,
|
|
||||||
ssh_gate_render_entrypoint,
|
|
||||||
ssh_gate_upstreams_for_bottle,
|
|
||||||
)
|
|
||||||
from tests.fixtures import fixture_minimal, fixture_with_ssh
|
|
||||||
|
|
||||||
|
|
||||||
class _StubGate(SSHGate):
|
|
||||||
"""Concrete subclass for testing the abstract `prepare`. The
|
|
||||||
backend-specific start/stop aren't exercised here."""
|
|
||||||
|
|
||||||
def start(self, plan: SSHGatePlan) -> str:
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
def stop(self, target: str) -> None:
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
class TestUpstreamAssignment(unittest.TestCase):
|
|
||||||
def test_listen_port_matches_upstream_port(self):
|
|
||||||
# Critical: URLs like ssh://git@host:30009/... override the
|
|
||||||
# config Port directive, so the gate must listen on the same
|
|
||||||
# port the URL names.
|
|
||||||
bottle = fixture_with_ssh().bottles["dev"]
|
|
||||||
upstreams = ssh_gate_upstreams_for_bottle(bottle)
|
|
||||||
self.assertEqual(2, len(upstreams))
|
|
||||||
# Fixture: tailscale-gitea -> 100.78.141.42:30009, github -> github.com:22.
|
|
||||||
self.assertEqual(30009, upstreams[0].listen_port)
|
|
||||||
self.assertEqual(22, upstreams[1].listen_port)
|
|
||||||
|
|
||||||
def test_upstream_fields_mirror_ssh_entry(self):
|
|
||||||
bottle = fixture_with_ssh().bottles["dev"]
|
|
||||||
first = ssh_gate_upstreams_for_bottle(bottle)[0]
|
|
||||||
self.assertEqual("tailscale-gitea", first.bottle_host_alias)
|
|
||||||
self.assertEqual("100.78.141.42", first.upstream_host)
|
|
||||||
self.assertEqual("30009", first.upstream_port)
|
|
||||||
|
|
||||||
def test_empty_bottle_yields_empty_upstreams(self):
|
|
||||||
bottle = fixture_minimal().bottles["dev"]
|
|
||||||
self.assertEqual((), ssh_gate_upstreams_for_bottle(bottle))
|
|
||||||
|
|
||||||
def test_duplicate_upstream_port_is_rejected(self):
|
|
||||||
# Two entries on the same upstream port can't both have a
|
|
||||||
# listener — the gate is one container with a flat port
|
|
||||||
# space. Surface as a clear config error.
|
|
||||||
manifest = Manifest.from_json_obj({
|
|
||||||
"bottles": {
|
|
||||||
"dev": {
|
|
||||||
"ssh": [
|
|
||||||
{"Host": "a", "IdentityFile": "/dev/null",
|
|
||||||
"Hostname": "host-a.example", "User": "git", "Port": 22},
|
|
||||||
{"Host": "b", "IdentityFile": "/dev/null",
|
|
||||||
"Hostname": "host-b.example", "User": "git", "Port": 22},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}},
|
|
||||||
})
|
|
||||||
with self.assertRaises(SystemExit):
|
|
||||||
ssh_gate_upstreams_for_bottle(manifest.bottles["dev"])
|
|
||||||
|
|
||||||
|
|
||||||
class TestEntrypointRender(unittest.TestCase):
|
|
||||||
def test_one_socat_line_per_upstream(self):
|
|
||||||
upstreams = (
|
|
||||||
SSHGateUpstream(30009, "gitea.example", "30009", "gitea"),
|
|
||||||
SSHGateUpstream(22, "github.com", "22", "gh"),
|
|
||||||
)
|
|
||||||
script = ssh_gate_render_entrypoint(upstreams)
|
|
||||||
self.assertIn("#!/bin/sh", script)
|
|
||||||
self.assertIn(
|
|
||||||
"socat TCP-LISTEN:30009,reuseaddr,fork TCP:gitea.example:30009 &", script
|
|
||||||
)
|
|
||||||
self.assertIn(
|
|
||||||
"socat TCP-LISTEN:22,reuseaddr,fork TCP:github.com:22 &", script
|
|
||||||
)
|
|
||||||
# wait blocks the entrypoint so PID 1 stays alive while sockets
|
|
||||||
# are open.
|
|
||||||
self.assertTrue(script.rstrip().endswith("wait"))
|
|
||||||
|
|
||||||
def test_empty_upstreams_still_has_wait(self):
|
|
||||||
# Defensive: a no-upstream gate is a no-op, but render must
|
|
||||||
# still produce a valid shell script.
|
|
||||||
script = ssh_gate_render_entrypoint(())
|
|
||||||
self.assertIn("#!/bin/sh", script)
|
|
||||||
self.assertIn("wait", script)
|
|
||||||
|
|
||||||
|
|
||||||
class TestPrepare(unittest.TestCase):
|
|
||||||
def setUp(self):
|
|
||||||
self.stage = Path(tempfile.mkdtemp())
|
|
||||||
|
|
||||||
def tearDown(self):
|
|
||||||
import shutil
|
|
||||||
|
|
||||||
shutil.rmtree(self.stage, ignore_errors=True)
|
|
||||||
|
|
||||||
def test_prepare_writes_entrypoint_mode_600(self):
|
|
||||||
plan = _StubGate().prepare(
|
|
||||||
fixture_with_ssh().bottles["dev"], "demo", self.stage
|
|
||||||
)
|
|
||||||
self.assertEqual(self.stage / "ssh_gate_entrypoint.sh", plan.entrypoint_script)
|
|
||||||
self.assertEqual(0o600, os.stat(plan.entrypoint_script).st_mode & 0o777)
|
|
||||||
|
|
||||||
def test_prepare_plan_carries_upstreams_and_slug(self):
|
|
||||||
plan = _StubGate().prepare(
|
|
||||||
fixture_with_ssh().bottles["dev"], "demo", self.stage
|
|
||||||
)
|
|
||||||
self.assertEqual("demo", plan.slug)
|
|
||||||
self.assertEqual(2, len(plan.upstreams))
|
|
||||||
self.assertEqual("", plan.internal_network)
|
|
||||||
self.assertEqual("", plan.egress_network)
|
|
||||||
|
|
||||||
def test_prepare_with_no_ssh_writes_minimal_script(self):
|
|
||||||
plan = _StubGate().prepare(
|
|
||||||
fixture_minimal().bottles["dev"], "demo", self.stage
|
|
||||||
)
|
|
||||||
self.assertEqual((), plan.upstreams)
|
|
||||||
content = plan.entrypoint_script.read_text()
|
|
||||||
self.assertNotIn("socat", content)
|
|
||||||
self.assertIn("wait", content)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
Reference in New Issue
Block a user