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
82 changes: 53 additions & 29 deletions scripts/container_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
ELSA_VERSION = re.compile(rf"^3\.{_NUM}\.{_NUM}(?:-{_PRERELEASE_ID}(?:\.{_PRERELEASE_ID})*)?$")
REQUIRED_PLATFORMS = ("linux/amd64", "linux/arm64")
APPS_REPOSITORY = "elsa-workflows/elsa-apps"
BEARER_API_ENDPOINT = "/elsa/api/workflow-definitions?page=0&pageSize=1"
EXTENSION_PACKAGES = (
"Elsa.Agents.",
"Elsa.Logging.",
Expand Down Expand Up @@ -479,27 +480,46 @@ def login_and_probe_api(url: str, username: str, password: str) -> dict[str, Any
with urllib.request.urlopen(login_request, timeout=15) as response:
login_status = response.status
login = json.loads(response.read())
except (urllib.error.URLError, urllib.error.HTTPError, json.JSONDecodeError) as error:
raise ReleaseError(f"Synthetic admin login failed: {error}") from error
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError, json.JSONDecodeError) as error:
raise ReleaseError(
f"Synthetic admin login request to /elsa/api/identity/login failed: {error}"
) from error
access_token = login.get("accessToken") or login.get("AccessToken")
if login_status != 200 or not access_token:
raise ReleaseError("Synthetic admin login did not return an access token")

endpoint = BEARER_API_ENDPOINT
probe = urllib.request.Request(
f"{api_root}/identity/me/permissions",
f"{url.rstrip('/')}{endpoint}",
headers={"Authorization": f"Bearer {access_token}"},
)
try:
with urllib.request.urlopen(probe, timeout=15) as response:
api_status = response.status
response.read()
except (urllib.error.URLError, urllib.error.HTTPError) as error:
raise ReleaseError(f"Bearer-authenticated identity API probe failed: {error}") from error
if api_status != 200:
raise ReleaseError(f"Bearer-authenticated identity API returned HTTP {api_status}")
content_type = response.headers.get("Content-Type", "").lower()
result = json.loads(response.read())
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError) as error:
raise ReleaseError(
f"Bearer-authenticated workflow definitions API request to {endpoint} failed: {error}"
) from error
except json.JSONDecodeError as error:
raise ReleaseError(
f"Bearer-authenticated workflow definitions API request to {endpoint} returned invalid JSON"
) from error
items = result.get("items", result.get("Items")) if isinstance(result, dict) else None
total_count = result.get("totalCount", result.get("TotalCount")) if isinstance(result, dict) else None
if (
api_status != 200
or not content_type.startswith("application/json")
or not isinstance(items, list)
or not isinstance(total_count, int)
):
raise ReleaseError(
f"Bearer-authenticated workflow definitions API request to {endpoint} did not return a paged JSON response"
)
return {
"identityLogin": {"status": login_status, "endpoint": "/elsa/api/identity/login"},
"bearerApi": {"status": api_status, "endpoint": "/elsa/api/identity/me/permissions"},
"bearerApi": {"status": api_status, "endpoint": endpoint},
}


Expand All @@ -511,8 +531,8 @@ def verify_smoke_assets(url: str, asset_paths: Iterable[str]) -> list[dict[str,
body = response.read()
status = response.status
content_type = response.headers.get("Content-Type", "").lower()
except (urllib.error.URLError, urllib.error.HTTPError) as error:
raise ReleaseError(f"Browser framework asset {path} is unavailable: {error}") from error
except (urllib.error.URLError, urllib.error.HTTPError, TimeoutError) as error:
raise ReleaseError(f"Browser framework asset request for {path} failed: {error}") from error
if status != 200 or len(body) < 100:
raise ReleaseError(f"Browser framework asset {path} returned HTTP {status} or an empty body")
if "javascript" not in content_type and "ecmascript" not in content_type:
Expand Down Expand Up @@ -550,23 +570,24 @@ def smoke_image(
if auth_enabled
else []
)
run = run_command(
[
"docker",
"run",
"--detach",
*platform_args,
"--publish",
f"127.0.0.1::{port}",
"--env",
"ASPNETCORE_ENVIRONMENT=Production",
*auth_args,
reference,
],
env=run_env,
)
container_id = run.stdout.strip()
container_id = ""
try:
run = run_command(
[
"docker",
"run",
"--detach",
*platform_args,
"--publish",
f"127.0.0.1::{port}",
"--env",
"ASPNETCORE_ENVIRONMENT=Production",
*auth_args,
reference,
],
env=run_env,
)
container_id = run.stdout.strip()
url, status = wait_for_http(container_id, port)
image_digest = image_digest or (reference.rsplit("@", 1)[-1] if "@" in reference else reference)
result = {
Expand All @@ -580,8 +601,11 @@ def smoke_image(
if auth_enabled:
result.update(login_and_probe_api(url, username, password))
return result
except ReleaseError as error:
raise ReleaseError(f"Smoke test failed for {reference} on {platform}: {error}") from error
finally:
run_command(["docker", "rm", "--force", container_id], check=False)
if container_id:
run_command(["docker", "rm", "--force", container_id], check=False)


def verify_registry_image(
Expand Down Expand Up @@ -777,7 +801,7 @@ def validate_fragment_evidence(
row.get("identityLogin", {}).get("status") != 200
or row.get("bearerApi", {}).get("status") != 200
or row.get("identityLogin", {}).get("endpoint") != "/elsa/api/identity/login"
or row.get("bearerApi", {}).get("endpoint") != "/elsa/api/identity/me/permissions"
or row.get("bearerApi", {}).get("endpoint") != BEARER_API_ENDPOINT
):
raise ReleaseError(f"Image evidence for {image['name']} lacks successful authenticated API smoke")

Expand Down
95 changes: 94 additions & 1 deletion scripts/tests/test_container_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,7 @@ def server_fragment():
"httpStatus": 200,
"browserAssets": [],
"identityLogin": {"status": 200, "endpoint": "/elsa/api/identity/login"},
"bearerApi": {"status": 200, "endpoint": "/elsa/api/identity/me/permissions"},
"bearerApi": {"status": 200, "endpoint": "/elsa/api/workflow-definitions?page=0&pageSize=1"},
}
for platform, digest in PLATFORMS.items()
]
Expand Down Expand Up @@ -390,6 +390,79 @@ def test_rejects_html_fallback_for_missing_javascript_asset(self):
release.verify_smoke_assets("http://127.0.0.1:1234", ["/_framework/dotnet.js"])


class AuthenticatedApiSmokeTests(unittest.TestCase):
class Response:
status = 200

def __init__(self, body, content_type="application/json"):
self.body = body
self.headers = {"Content-Type": content_type}

def __enter__(self):
return self

def __exit__(self, *_):
return False

def read(self):
return self.body

def test_uses_bearer_protected_workflow_definitions_api_and_validates_paged_json(self):
login = self.Response(b'{"accessToken":"synthetic-token"}')
definitions = self.Response(b'{"items":[],"totalCount":0}')
requests = []

def open_url(request, timeout):
requests.append(request)
return (login, definitions)[len(requests) - 1]

with patch.object(release.urllib.request, "urlopen", side_effect=open_url):
result = release.login_and_probe_api("http://127.0.0.1:1234/", "smoke", "secret")

self.assertEqual(requests[0].full_url, "http://127.0.0.1:1234/elsa/api/identity/login")
self.assertEqual(requests[0].get_method(), "POST")
self.assertEqual(
requests[1].full_url,
"http://127.0.0.1:1234/elsa/api/workflow-definitions?page=0&pageSize=1",
)
self.assertEqual(requests[1].get_method(), "GET")
self.assertEqual(requests[1].get_header("Authorization"), "Bearer synthetic-token")
self.assertEqual(result["bearerApi"]["status"], 200)

def test_rejects_html_fallback_even_when_it_returns_http_200(self):
login = self.Response(b'{"accessToken":"synthetic-token"}')
fallback = self.Response(b'<!doctype html><html>Studio</html>', "text/html; charset=utf-8")
with patch.object(release.urllib.request, "urlopen", side_effect=[login, fallback]):
with self.assertRaisesRegex(release.ReleaseError, "invalid JSON"):
release.login_and_probe_api("http://127.0.0.1:1234", "smoke", "secret")

def test_rejects_non_paged_json_response(self):
login = self.Response(b'{"accessToken":"synthetic-token"}')
invalid = self.Response(b'{"message":"not the workflow list"}')
with patch.object(release.urllib.request, "urlopen", side_effect=[login, invalid]):
with self.assertRaisesRegex(release.ReleaseError, "paged JSON response"):
release.login_and_probe_api("http://127.0.0.1:1234", "smoke", "secret")

def test_login_and_api_timeouts_name_the_failed_endpoint(self):
with patch.object(release.urllib.request, "urlopen", side_effect=TimeoutError("timed out")):
with self.assertRaisesRegex(release.ReleaseError, "identity/login failed"):
release.login_and_probe_api("http://127.0.0.1:1234", "smoke", "secret")

login = self.Response(b'{"accessToken":"synthetic-token"}')
with patch.object(release.urllib.request, "urlopen", side_effect=[login, TimeoutError("timed out")]):
with self.assertRaisesRegex(release.ReleaseError, "workflow definitions API request.*failed"):
release.login_and_probe_api("http://127.0.0.1:1234", "smoke", "secret")

def test_asset_timeout_names_the_failed_asset(self):
with patch.object(
release.urllib.request,
"urlopen",
side_effect=TimeoutError("timed out"),
):
with self.assertRaisesRegex(release.ReleaseError, "asset request for /_framework/dotnet.js failed"):
release.verify_smoke_assets("http://127.0.0.1:1234", ["/_framework/dotnet.js"])


class SmokeCredentialTests(unittest.TestCase):
def test_ephemeral_credentials_are_shared_with_login_without_command_line_exposure(self):
with (
Expand All @@ -411,6 +484,26 @@ def test_ephemeral_credentials_are_shared_with_login_without_command_line_exposu
self.assertEqual(len(passwords), 2)
self.assertNotEqual(*passwords)

def test_smoke_errors_include_image_and_platform_without_credentials(self):
with (
patch.object(release.secrets, "token_urlsafe", return_value="known-smoke-password"),
patch.object(release, "run_command", return_value=Mock(stdout="test-container")),
patch.object(release, "wait_for_http", return_value=("http://127.0.0.1:1234/", 200)),
patch.object(release, "login_and_probe_api", side_effect=release.ReleaseError("API request timed out")),
):
with self.assertRaisesRegex(
release.ReleaseError,
r"registry/image:3\.8\.4 on linux/arm64: API request timed out",
) as error:
release.smoke_image(
"registry/image:3.8.4",
"linux/arm64",
8080,
auth_enabled=True,
)
self.assertNotIn("known-smoke-password", str(error.exception))
self.assertNotIn("container-smoke", str(error.exception))


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