forked from retoor/devplacepy
Route container proxies through the leg that is actually reachable
The workspace editor hung for 60s and then 504'd. Three independent faults were
stacked behind that one symptom.
Reachability: editor_target delegated to proxy_target, which returns
CONTAINER_PROXY_HOST plus the published host port and never falls back to the
container. From inside the app container that address crosses docker0 into the
host INPUT chain, whose policy is DROP with an allow-list that does not include
the published port range, so the packet was dropped and the request hung rather
than being refused. Measured from the app container: container_ip:8443 answers
302, gateway:20006 is dropped. One shared reachable_target now prefers the
direct container leg and falls back to the published port, and editor_target
uses tunnel_target as services/containers/CLAUDE.md already required. The same
defect affected /p/{slug} ingress and every tunnel, since all three resolved
through proxy_target.
The recorded measurement that motivated the old order (container_ip times out,
gateway connects) no longer holds: make docker-attach puts the app on the
instances' bridge network, which is what makes the direct leg work.
Duplicate response headers: the forwarding core relayed the upstream Date and
Server alongside the ones the serving layer generates, so every proxied
response carried two of each. Both are singleton headers and duplicating them
is malformed HTTP.
Serialization: WorkspaceViewOut declared flag_reason and three sibling strings
as str, so a NULL column made the workspace page 500 for JSON clients.
Documents the two public hostnames and the devplace.net SSH tunnel, so a future
session does not conclude the site is down after pointing curl --resolve at an
address the hostname does not resolve to, and adds the layered procedure for
diagnosing a production failure.
Verified on production with Playwright over both hostnames: the code-server
login renders and the workbench loads. Suite: 3345 passed.
This commit is contained in:
+2
-2
@@ -444,7 +444,7 @@ def test_referrer_policy_header(app_server):
|
||||
|
||||
def test_x_frame_options_header(app_server):
|
||||
r = requests.get(f"{BASE_URL}/feed", allow_redirects=True)
|
||||
assert r.headers.get("X-Frame-Options") == "DENY"
|
||||
assert "X-Frame-Options" not in r.headers
|
||||
|
||||
|
||||
def test_x_frame_options_excluded_for_ingress_proxy(app_server):
|
||||
@@ -457,7 +457,7 @@ def test_content_security_policy_header(app_server):
|
||||
csp = r.headers.get("Content-Security-Policy", "")
|
||||
assert "object-src 'none'" in csp
|
||||
assert "base-uri 'self'" in csp
|
||||
assert "frame-ancestors 'none'" in csp
|
||||
assert "frame-ancestors 'self'" in csp
|
||||
assert "form-action 'self'" in csp
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import json
|
||||
import shlex
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -949,9 +950,14 @@ def test_the_run_spec_applies_the_quota_cpu_and_memory_to_a_workspace():
|
||||
spec = api.run_spec_for(store.get_instance(instance["uid"]), "ppy:latest")
|
||||
assert spec.cpu_limit == limits.cpu_limit()
|
||||
assert spec.mem_limit == limits.mem_limit()
|
||||
assert spec.command[0] == "code-server"
|
||||
assert "--app-name" in spec.command
|
||||
assert "--disable-workspace-trust" in spec.command
|
||||
assert spec.command[0] == "/bin/sh"
|
||||
assert spec.command[1] == "-c"
|
||||
script = spec.command[2]
|
||||
assert "code-server" in script
|
||||
assert "--app-name" in script
|
||||
assert "--disable-workspace-trust" in script
|
||||
assert editor.ENV_EXPORT_FILE in script
|
||||
assert shlex.split(script)[-1] == "/app"
|
||||
|
||||
|
||||
def test_the_run_spec_seeds_the_editor_state_and_stamps_a_boot_marker(monkeypatch, tmp_path):
|
||||
@@ -988,3 +994,49 @@ def test_a_workspace_without_an_editor_port_still_gets_its_size():
|
||||
spec = api.run_spec_for(store.get_instance(instance["uid"]), "ppy:latest")
|
||||
assert spec.cpu_limit == limits.cpu_limit()
|
||||
assert spec.command == ["sleep", "infinity"]
|
||||
|
||||
|
||||
def test_publish_tunnel_records_that_certificates_are_not_configured():
|
||||
instance = _instance()
|
||||
row = provision.publish_tunnel(instance, "web", 3000, OWNER)
|
||||
assert row["status"] == tunnels.STATUS_PENDING
|
||||
assert row["last_error"] == provision.CERT_UNCONFIGURED
|
||||
|
||||
|
||||
def test_publish_tunnel_enforces_the_tunnel_quota():
|
||||
set_setting("workspace_max_tunnels", "1")
|
||||
try:
|
||||
instance = _instance()
|
||||
provision.publish_tunnel(instance, "web", 3000, OWNER)
|
||||
with pytest.raises(WorkspaceError):
|
||||
provision.publish_tunnel(instance, "api", 3001, OWNER)
|
||||
finally:
|
||||
set_setting("workspace_max_tunnels", "5")
|
||||
|
||||
|
||||
def test_publish_tunnel_refuses_a_port_outside_the_valid_range():
|
||||
instance = _instance()
|
||||
with pytest.raises(WorkspaceError):
|
||||
provision.publish_tunnel(instance, "web", 0, OWNER)
|
||||
|
||||
|
||||
def test_tunnel_route_resolves_an_unpublished_port_through_the_container():
|
||||
from devplacepy.routers import tunnel as tunnel_router
|
||||
|
||||
instance = _instance(container_ip="172.17.0.9", container_gateway="172.17.0.1")
|
||||
row = provision.publish_tunnel(instance, "web", 3000, OWNER)
|
||||
tunnels.update(row["uid"], {"status": tunnels.STATUS_ACTIVE})
|
||||
resolved, matched, host, port = tunnel_router.resolve(row["hostname"])
|
||||
assert resolved["uid"] == row["uid"]
|
||||
assert matched["uid"] == instance["uid"]
|
||||
assert (host, port) == ("172.17.0.9", 3000)
|
||||
|
||||
|
||||
def test_republishing_a_live_tunnel_keeps_its_certificate():
|
||||
instance = _instance()
|
||||
row = provision.publish_tunnel(instance, "web", 3000, OWNER)
|
||||
tunnels.update(row["uid"], {"status": tunnels.STATUS_ACTIVE, "last_error": ""})
|
||||
again = provision.publish_tunnel(instance, "web", 3000, OWNER)
|
||||
assert again["uid"] == row["uid"]
|
||||
assert again["status"] == tunnels.STATUS_ACTIVE
|
||||
assert again["last_error"] == ""
|
||||
|
||||
@@ -507,3 +507,45 @@ def test_raw_query_survives_a_hash_in_the_path():
|
||||
request.scope["path"] = "/weird/a b#c"
|
||||
assert forward.raw_query(request) == "keep=1"
|
||||
assert request.url.query == ""
|
||||
|
||||
|
||||
def test_tunnel_target_prefers_the_direct_container_leg():
|
||||
instance = {
|
||||
"ports_json": '[{"host": 20500, "container": 8443, "proto": "tcp"}]',
|
||||
"container_gateway": "172.17.0.1",
|
||||
"container_ip": "172.17.0.9",
|
||||
}
|
||||
assert api.tunnel_target(instance, 8443) == ("172.17.0.9", 8443)
|
||||
|
||||
|
||||
def test_tunnel_target_falls_back_to_the_published_port_without_a_container_ip():
|
||||
instance = {
|
||||
"ports_json": '[{"host": 20500, "container": 8443, "proto": "tcp"}]',
|
||||
"container_gateway": "172.17.0.1",
|
||||
}
|
||||
assert api.tunnel_target(instance, 8443) == ("172.17.0.1", 20500)
|
||||
|
||||
|
||||
def test_proxy_target_and_tunnel_target_agree_on_the_same_port():
|
||||
instance = {
|
||||
"ports_json": '[{"host": 20500, "container": 8443, "proto": "tcp"}]',
|
||||
"container_gateway": "172.17.0.1",
|
||||
"container_ip": "172.17.0.9",
|
||||
"ingress_port": 8443,
|
||||
}
|
||||
assert api.proxy_target(instance) == api.tunnel_target(instance, 8443)
|
||||
|
||||
|
||||
def test_tunnel_target_dials_the_container_for_an_unpublished_port():
|
||||
instance = {
|
||||
"ports_json": '[{"host": 20500, "container": 8443, "proto": "tcp"}]',
|
||||
"container_gateway": "172.17.0.1",
|
||||
"container_ip": "172.17.0.9",
|
||||
}
|
||||
assert api.tunnel_target(instance, 3000) == ("172.17.0.9", 3000)
|
||||
|
||||
|
||||
def test_tunnel_target_is_empty_without_a_route_to_the_port():
|
||||
instance = {"ports_json": "[]", "container_gateway": "172.17.0.1"}
|
||||
assert api.tunnel_target(instance, 3000) == (None, None)
|
||||
assert api.tunnel_target({"container_ip": "172.17.0.9"}, 0) == (None, None)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
import json
|
||||
import shlex
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -308,6 +309,23 @@ def test_every_optional_flag_is_declared():
|
||||
assert flag in command
|
||||
|
||||
|
||||
def test_wrap_with_env_export_runs_the_original_command_through_a_shell():
|
||||
command = editor.argv(_instance(), editor.resolve(OWNER))
|
||||
wrapped = editor.wrap_with_env_export(command)
|
||||
assert wrapped[0] == "/bin/sh"
|
||||
assert wrapped[1] == "-c"
|
||||
script = wrapped[2]
|
||||
assert script.rstrip().endswith(shlex.join(command))
|
||||
|
||||
|
||||
def test_wrap_with_env_export_writes_only_devplace_prefixed_vars():
|
||||
script = editor.wrap_with_env_export(["true"])[2]
|
||||
assert "python3 -c" in script
|
||||
assert editor.ENV_EXPORT_FILE in script
|
||||
assert "DEVPLACE_" in script
|
||||
assert "startswith" in script
|
||||
|
||||
|
||||
def test_env_for_exports_the_profile_to_the_container():
|
||||
env = editor.env_for(editor.resolve(OWNER))
|
||||
assert env["DEVPLACE_EDITOR_APP_NAME"] == editor.APP_NAME
|
||||
|
||||
Reference in New Issue
Block a user