Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,6 @@ docker_container/
AWSCLIV2.pkg
.python-version
uv.lock

Agent Artifacts
docs/
1 change: 1 addition & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ def test_deploy_runs_ec2_preflight_for_ec2_config(

ensure_grpc.assert_called_once_with(os.getcwd())
preflight.assert_called_once_with(config, os.getcwd())
controller.run.assert_called_once_with()

@patch("ventis.cli._ensure_grpc_stubs_importable")
@patch("ventis.cli._require_docker_for_ec2")
Expand Down
124 changes: 124 additions & 0 deletions tests/test_deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ class _FakeRedis:
def __init__(self):
self.store = {}
self.ttls = {}
self.hashes = {}

def set(self, key, value):
self.store[key] = value
Expand All @@ -29,6 +30,12 @@ def expire(self, key, seconds):
if key in self.store:
self.ttls[key] = seconds

def hset_multiple(self, name, mapping):
self.hashes.setdefault(name, {}).update(mapping)

def hgetall(self, name):
return dict(self.hashes.get(name, {}))


class _SyncThread:
"""Runs the target synchronously instead of on a real thread. None of
Expand Down Expand Up @@ -338,5 +345,122 @@ def test_skips_the_fallback_when_the_database_is_not_configured(self):
mock_get.assert_not_called()


class DeployLiveIdentityTests(unittest.TestCase):
"""Bug E: VENTIS_PROJECT_ID/VENTIS_DATABASE_URL are Docker env vars frozen at container
launch. A GlobalController reload (SIGHUP) publishes the current project/database identity
to Redis (controller:identity); this container must read that fresh on every request
instead of trusting the env vars it booted with, or a project switch leaves it creating
session rows under the *old* project indefinitely."""

def setUp(self):
os.environ["VENTIS_DATABASE_URL"] = "postgresql://example/old-db"
os.environ["VENTIS_PROJECT_ID"] = "11111111-1111-1111-1111-111111111111"

def tearDown(self):
os.environ.pop("VENTIS_DATABASE_URL", None)
os.environ.pop("VENTIS_PROJECT_ID", None)

def test_a_value_already_in_redis_at_boot_overrides_the_env_var(self):
with patch.object(deploy_module, "upsert_session") as mock_upsert, \
_deployed_app() as app:
app.fake_redis.hset_multiple(
deploy_module.IDENTITY_KEY,
{
"project_id": "22222222-2222-2222-2222-222222222222",
"database_url": "postgresql://example/new-db",
},
)
client = app.test_client()
client.post("/_noop_workflow", json={"x": 2})

first_call_args = mock_upsert.call_args_list[0].args
self.assertEqual(first_call_args[0], "postgresql://example/new-db")
self.assertEqual(first_call_args[1], "22222222-2222-2222-2222-222222222222")

def test_a_switch_between_two_requests_is_picked_up_by_the_second_one(self):
"""Directly reproduces the live incident: request A lands under the project that
was current when it was submitted; a reload happens (simulated here by the
controller writing a new value to the same Redis key a real SIGHUP would update);
request B, with no restart of this container in between, must land under the new
project -- not the one baked into this process's env vars at boot."""
with patch.object(deploy_module, "upsert_session") as mock_upsert, \
_deployed_app() as app:
client = app.test_client()

client.post("/_noop_workflow", json={"x": 1})
project_after_a = mock_upsert.call_args_list[0].args[1]

app.fake_redis.hset_multiple(
deploy_module.IDENTITY_KEY,
{
"project_id": "22222222-2222-2222-2222-222222222222",
"database_url": "postgresql://example/new-db",
},
)

client.post("/_noop_workflow", json={"x": 2})
project_after_b = mock_upsert.call_args_list[-1].args[1]

self.assertEqual(project_after_a, "11111111-1111-1111-1111-111111111111")
self.assertEqual(project_after_b, "22222222-2222-2222-2222-222222222222")

def test_completion_and_failure_writes_also_use_the_live_value_not_the_boot_one(self):
"""The running/completed/failed transitions are three separate call sites in
deploy.py -- a switch mid-request must not leave the later ones (written from the
background thread, after the switch) tagging with the value the request started
under."""
with patch.object(deploy_module, "upsert_session") as mock_upsert, \
_deployed_app(workflow_fn=_failing_workflow) as app:
app.fake_redis.hset_multiple(
deploy_module.IDENTITY_KEY,
{
"project_id": "22222222-2222-2222-2222-222222222222",
"database_url": "postgresql://example/new-db",
},
)
client = app.test_client()
client.post("/_failing_workflow", json={"x": 2})

projects_used = [call.args[1] for call in mock_upsert.call_args_list]
self.assertEqual(
projects_used,
[
"22222222-2222-2222-2222-222222222222",
"22222222-2222-2222-2222-222222222222",
],
)

def test_falls_back_to_the_boot_time_env_var_when_redis_has_no_identity_yet(self):
"""A request racing the controller's own startup write must still get a value,
not silently skip the session upsert."""
with patch.object(deploy_module, "upsert_session") as mock_upsert, \
_deployed_app() as app:
client = app.test_client()
client.post("/_noop_workflow", json={"x": 2})

first_call_args = mock_upsert.call_args_list[0].args
self.assertEqual(first_call_args[0], "postgresql://example/old-db")
self.assertEqual(first_call_args[1], "11111111-1111-1111-1111-111111111111")

def test_status_lookup_after_expiry_also_uses_the_live_value(self):
with patch.object(deploy_module, "get_session") as mock_get, \
_deployed_app() as app:
app.fake_redis.hset_multiple(
deploy_module.IDENTITY_KEY,
{
"project_id": "22222222-2222-2222-2222-222222222222",
"database_url": "postgresql://example/new-db",
},
)
mock_get.return_value = None
app.test_client().get("/status/expired-id")

mock_get.assert_called_once_with(
"postgresql://example/new-db",
"22222222-2222-2222-2222-222222222222",
"expired-id",
)


if __name__ == "__main__":
unittest.main()
Loading
Loading