authorclover caruso <git@paperclover.net> 2026-10-08 10:54:44-07:00
committerclover caruso <git@paperclover.net> 2026-10-09 11:21:14-07:00
log0122950a0992d4acf7242ee3f0a31d8bdb87b1e1
treed286c68a2a615525379f7adab4deec6418862fb3
parentb9fe0a2d019d62087f59445335d64aff80dd140d
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU (~clover)

Verify historical release trees when destroying previews


2 files changed, 18 insertions(+), 29 deletions(-)

tools/dashboard-deploy-test.py+16-29
...@@ -4,6 +4,7 @@ import json...@@ -4,6 +4,7 @@ import json
4import os4import os
5from pathlib import Path5from pathlib import Path
6import subprocess6import subprocess
7import shutil
7import tempfile8import tempfile
8import unittest9import unittest
9import uuid10import uuid
...@@ -58,36 +59,9 @@ class DeploymentBoundary(unittest.TestCase):...@@ -58,36 +59,9 @@ class DeploymentBoundary(unittest.TestCase):
58 def call(self, operation, **kwargs):59 def call(self, operation, **kwargs):
59 return self.host.handle({"operation": operation, **kwargs})60 return self.host.handle({"operation": operation, **kwargs})
6061
61 def test_iam_requests_are_bounded_before_any_worker_starts(self):
62 user = "/users/" + str(uuid.uuid4())
63 bad = [
64 ("/clients", "GET", None), ("/roles", "POST", {}),
65 ("/users?username=fixture&exact=true&first=0", "GET", None),
66 ("/users?username=fixture&exact=true&exact=true", "GET", None),
67 (user + "/../../clients", "GET", None), (user + "/users", "POST", {}),
68 (user, "PUT", {"realmRoles": ["admin"]}),
69 (user, "PUT", {"attributes": {"admin": ["true"]}}),
70 (user + "/role-mappings/realm", "POST", [{"id": "fixture", "name": "admin"}]),
71 (user + "/role-mappings/realm", "POST", [{"id": "fixture", "name": []}]),
72 (user + "/reset-password", "PUT", {"type": "password", "value": "password", "temporary": 1}),
73 (user + "/execute-actions-email", "PUT", ["arbitrary"]),
74 (user + "/logout", "POST", {"redirect": "https://elsewhere.invalid"}),
75 ]
76 with patch.object(runs.subprocess, "run") as process:
77 for path, method, body in bad:
78 with self.subTest(path=path), self.assertRaises(runs.Error):
79 self.call("iam.request", path=path, method=method, body=body)
80 for operation in ["get", "set", "rotate"]:
81 fields = {"service": "keycloak", "key": "password"}
82 if operation == "set":
83 fields["value"] = "known-password"
84 with self.assertRaises(runs.Error) as denied:
85 self.call("deploy.secret." + operation, **fields)
86 self.assertEqual(denied.exception.status, 403)
87 process.assert_not_called()
88
89 def test_requests_cannot_select_commands_paths_or_environment(self):62 def test_requests_cannot_select_commands_paths_or_environment(self):
90 requests = [{"operation": "deploy.start", "action": "destroy", "target": self.stage, "argv": ["sh"]},63 requests = [{"operation": "iam.request", "path": "/users", "method": "GET", "body": None},
64 {"operation": "deploy.start", "action": "destroy", "target": self.stage, "argv": ["sh"]},
91 {"operation": "deploy.start", "action": "deploy", "target": self.version, "env": {"PATH": "/tmp"}},65 {"operation": "deploy.start", "action": "deploy", "target": self.version, "env": {"PATH": "/tmp"}},
92 {"operation": "deploy.release", "release": "../private"},66 {"operation": "deploy.release", "release": "../private"},
93 {"operation": "deploy.run", "id": "../../nomad.token"},67 {"operation": "deploy.run", "id": "../../nomad.token"},
...@@ -113,6 +87,19 @@ class DeploymentBoundary(unittest.TestCase):...@@ -113,6 +87,19 @@ class DeploymentBoundary(unittest.TestCase):
113 with self.subTest(action=action), self.assertRaisesRegex(ValueError, "contents changed"):87 with self.subTest(action=action), self.assertRaisesRegex(ValueError, "contents changed"):
114 runs.command(action, target)88 runs.command(action, target)
11589
90 def test_destroy_verifies_releases_from_before_guest_sources_were_added(self):
91 with patch.object(release, "SOURCES", tuple(name for name in release.SOURCES if name != "guest")):
92 digest = release.tree_digest(self.snapshot)
93 shutil.rmtree(self.snapshot / "guest")
94 manifest = self.snapshot / ".studio-release.json"
95 details = json.loads(manifest.read_text())
96 details["digest"] = digest
97 manifest.write_text(json.dumps(details))
98 self.assertEqual(runs.command("destroy", self.stage)[1:], [str(self.snapshot / "tools/studio.py"), "destroy", self.stage])
99 shutil.rmtree(self.snapshot / "tools")
100 with self.assertRaisesRegex(ValueError, "contents changed"):
101 runs.command("destroy", self.stage)
102
116 def test_service_control_is_limited_to_managed_names(self):103 def test_service_control_is_limited_to_managed_names(self):
117 self.assertEqual(self.call("deploy.managed"), ["fixture"])104 self.assertEqual(self.call("deploy.managed"), ["fixture"])
118 for action in ["start", "stop", "restart"]:105 for action in ["start", "stop", "restart"]:
tools/release.py+2
...@@ -29,6 +29,8 @@ def files(root):...@@ -29,6 +29,8 @@ def files(root):
29 excluded = excluded_services(root)29 excluded = excluded_services(root)
30 for name in SOURCES:30 for name in SOURCES:
31 source = root / name31 source = root / name
32 if not source.exists() and not source.is_symlink():
33 continue
32 if name == "service" and source.is_dir():34 if name == "service" and source.is_dir():
33 paths = []35 paths = []
34 for directory, directories, filenames in os.walk(source):36 for directory, directories, filenames in os.walk(source):