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
2 changes: 2 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
<PackageVersion Include="Elsa.Scheduling" Version="$(ElsaVersion)"/>
<PackageVersion Include="Elsa.Workflows.Api" Version="$(ElsaVersion)"/>
<PackageVersion Include="Elsa.Workflows.Runtime" Version="$(ElsaVersion)"/>
<PackageVersion Include="Elsa.Workflows.Runtime.Dashboard" Version="$(ElsaVersion)"/>
<PackageVersion Include="Elsa.Workflows.Runtime.Distributed" Version="$(ElsaVersion)"/>
</ItemGroup>

Expand All @@ -40,6 +41,7 @@
<PackageVersion Include="Elsa.Studio.Localization.BlazorWasm" Version="$(ElsaStudioVersion)"/>
<PackageVersion Include="Elsa.Studio.Login.BlazorServer" Version="$(ElsaStudioVersion)"/>
<PackageVersion Include="Elsa.Studio.Login.BlazorWasm" Version="$(ElsaStudioVersion)"/>
<PackageVersion Include="Elsa.Studio.Workflows.Dashboard" Version="$(ElsaStudioVersion)"/>
</ItemGroup>

<ItemGroup>
Expand Down
79 changes: 67 additions & 12 deletions scripts/container_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,19 @@ def image_reference(repository: str, tag: str) -> str:
return f"{repository}:{tag}"


def repository_from_reference(reference: str) -> str:
if not isinstance(reference, str) or not reference:
raise ReleaseError("Image reference must be a non-empty string")
name = reference.split("@", 1)[0]
last_slash = name.rfind("/")
last_colon = name.rfind(":")
if last_colon > last_slash:
name = name[:last_colon]
if not name or name.endswith("/"):
raise ReleaseError(f"Could not resolve repository from image reference {reference!r}")
return name


def verify_manifest(reference: str, *, expected_digest: str | None = None) -> dict[str, Any]:
manifest = inspect_manifest(reference)
if manifest is None:
Expand All @@ -442,24 +455,35 @@ def verify_manifest(reference: str, *, expected_digest: str | None = None) -> di


def verify_labels(reference: str, expected: dict[str, str], platforms: Iterable[str]) -> None:
for platform in platforms:
run_command(["docker", "pull", "--platform", platform, reference])
requested_platforms = tuple(platforms)
if len(requested_platforms) != len(REQUIRED_PLATFORMS) or set(requested_platforms) != set(REQUIRED_PLATFORMS):
raise ReleaseError(f"Label verification requires exactly these platforms: {list(REQUIRED_PLATFORMS)}")

manifest = verify_manifest(reference)
repository = repository_from_reference(reference)
for platform in REQUIRED_PLATFORMS:
platform_reference = f"{repository}@{manifest['platforms'][platform]}"
run_command(["docker", "pull", "--platform", platform, platform_reference])
result = run_command(
["docker", "image", "inspect", "--format", "{{json .Config.Labels}}", reference]
["docker", "image", "inspect", "--format", "{{json .Config.Labels}}", platform_reference]
)
try:
actual = json.loads(result.stdout)
except json.JSONDecodeError as error:
raise ReleaseError(f"Could not read image labels from {reference}: {error}") from error
raise ReleaseError(f"Could not read image labels from {platform_reference}: {error}") from error
if not isinstance(actual, dict):
raise ReleaseError(f"Could not read image labels from {platform_reference}: expected a JSON object")
for key, value in expected.items():
if key == "org.opencontainers.image.ref.name":
allowed_refs = {"refs/heads/main", f"refs/tags/{expected['org.opencontainers.image.version']}"}
if actual.get(key) not in allowed_refs:
raise ReleaseError(f"{reference} has an unapproved publication ref label: {actual.get(key)!r}")
raise ReleaseError(
f"{platform_reference} has an unapproved publication ref label: {actual.get(key)!r}"
)
continue
if actual.get(key) != value:
raise ReleaseError(
f"{reference} label {key!r} is {actual.get(key)!r}; expected {value!r}"
f"{platform_reference} label {key!r} is {actual.get(key)!r}; expected {value!r}"
)


Expand Down Expand Up @@ -550,11 +574,7 @@ def login_and_probe_api(url: str, username: str, password: str) -> dict[str, Any
)

dashboard_api = probe_bearer_json_api(url, DASHBOARD_API_ENDPOINT, access_token)
dashboard = dashboard_api["body"]
if not isinstance(dashboard.get("runtime"), dict) or not isinstance(dashboard.get("workflowInstances"), dict):
raise ReleaseError(
f"Bearer-authenticated dashboard API request to {DASHBOARD_API_ENDPOINT} returned an unexpected JSON object"
)
dashboard_evidence = validate_dashboard_overview(dashboard_api["body"])

return {
"identityLogin": {"status": login_status, "endpoint": "/elsa/api/identity/login"},
Expand All @@ -567,10 +587,40 @@ def login_and_probe_api(url: str, username: str, password: str) -> dict[str, Any
"status": dashboard_api["status"],
"endpoint": DASHBOARD_API_ENDPOINT,
"contentType": dashboard_api["contentType"],
**dashboard_evidence,
},
}


def validate_dashboard_overview(body: dict[str, Any]) -> dict[str, Any]:
runtime = body.get("runtime")
workflow_instances = body.get("workflowInstances")
if not isinstance(runtime, dict) or not isinstance(workflow_instances, dict):
raise ReleaseError(
f"Bearer-authenticated dashboard API request to {DASHBOARD_API_ENDPOINT} returned an unexpected JSON object"
)

runtime_status = runtime.get("status")
is_accepting_work = runtime.get("isAcceptingWork")
metric_names = ("running", "completed", "faulted", "suspended", "interrupted", "incidentBearing")
metrics = {name: workflow_instances.get(name) for name in metric_names}
if (
runtime_status != "AcceptingWork"
or is_accepting_work is not True
or any(type(value) is not int or value < 0 for value in metrics.values())
):
raise ReleaseError(
f"Bearer-authenticated dashboard API request to {DASHBOARD_API_ENDPOINT} did not report an accepting workflow runtime and metrics"
)

return {
"runtimeStatus": runtime_status,
"isAcceptingWork": is_accepting_work,
"workflowMetricsValid": True,
"running": metrics["running"],
}


def probe_bearer_json_api(url: str, endpoint: str, access_token: str) -> dict[str, Any]:
request = urllib.request.Request(
f"{url.rstrip('/')}{endpoint}",
Expand Down Expand Up @@ -889,6 +939,11 @@ def validate_fragment_evidence(
or row.get("dashboardApi", {}).get("status") != 200
or row.get("dashboardApi", {}).get("endpoint") != DASHBOARD_API_ENDPOINT
or row.get("dashboardApi", {}).get("contentType", "").split(";", 1)[0] != "application/json"
or row.get("dashboardApi", {}).get("runtimeStatus") != "AcceptingWork"
or row.get("dashboardApi", {}).get("isAcceptingWork") is not True
or row.get("dashboardApi", {}).get("workflowMetricsValid") is not True
or type(row.get("dashboardApi", {}).get("running")) is not int
or row.get("dashboardApi", {}).get("running", -1) < 0
):
raise ReleaseError(f"Image evidence for {image['name']} lacks successful authenticated API smoke")

Expand Down Expand Up @@ -1358,7 +1413,7 @@ def load_superseded_publication(
run_repository = (run.get("repository") or {}).get("full_name")
old_commit = str(run.get("head_sha", ""))
attempt = run.get("run_attempt")
prior_source_ref = prior_publication_source_ref(run, version)
prior_publication_source_ref(run, version)
if (
str(run.get("id", "")) != run_id
or run.get("status") != "completed"
Expand Down
122 changes: 121 additions & 1 deletion scripts/tests/test_container_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,81 @@ def test_correction_plan_ignores_the_existing_version_tag(self):
self.assertEqual([call.args[0] for call in inspect.call_args_list], [candidate_ref, candidate_ref])


class VerifyLabelsTests(unittest.TestCase):
def setUp(self):
self.packages = {family: "3.9.0" for family in ("core", "studio", "extensions")}
self.expected_labels = release.labels_for("3.9.0", COMMIT, "refs/heads/main", self.packages)
self.manifest = {"digest": ROOT_DIGEST, "platforms": PLATFORMS}

def test_pulls_each_platform_child_digest_for_tag_and_root_digest_references(self):
for reference in (
"registry.example:5443/team/elsa-server:3.9.0",
f"registry.example:5443/team/elsa-server@{ROOT_DIGEST}",
):
with self.subTest(reference=reference):
commands = []

def run(command, **_kwargs):
commands.append(command)
return Mock(stdout=json.dumps(self.expected_labels))

with (
patch.object(release, "verify_manifest", return_value=self.manifest),
patch.object(release, "run_command", side_effect=run),
):
release.verify_labels(reference, self.expected_labels, release.REQUIRED_PLATFORMS)

repository = "registry.example:5443/team/elsa-server"
expected_commands = []
for platform in release.REQUIRED_PLATFORMS:
child_reference = f"{repository}@{PLATFORMS[platform]}"
expected_commands.extend((
["docker", "pull", "--platform", platform, child_reference],
["docker", "image", "inspect", "--format", "{{json .Config.Labels}}", child_reference],
))
self.assertEqual(commands, expected_commands)

def test_rejects_a_label_mismatch_from_the_platform_child(self):
actual_labels = dict(self.expected_labels)
actual_labels["org.opencontainers.image.version"] = "3.8.4"
with (
patch.object(release, "verify_manifest", return_value=self.manifest),
patch.object(release, "run_command", return_value=Mock(stdout=json.dumps(actual_labels))) as command,
):
with self.assertRaisesRegex(release.ReleaseError, "org.opencontainers.image.version"):
release.verify_labels(
"elsaworkflows/elsa-server-app:3.9.0",
self.expected_labels,
release.REQUIRED_PLATFORMS,
)

self.assertIn(PLATFORMS["linux/amd64"], command.call_args_list[1].args[0][-1])

def test_rejects_incomplete_manifest_before_pulling_any_platform(self):
incomplete = {"digest": ROOT_DIGEST, "platforms": {"linux/amd64": PLATFORMS["linux/amd64"]}}
with (
patch.object(release, "inspect_manifest", return_value=incomplete),
patch.object(release, "run_command") as command,
):
with self.assertRaisesRegex(release.ReleaseError, "missing required platforms"):
release.verify_labels(
"elsaworkflows/elsa-server-app:3.9.0",
self.expected_labels,
release.REQUIRED_PLATFORMS,
)
command.assert_not_called()

def test_requires_the_complete_supported_platform_set(self):
with patch.object(release, "verify_manifest") as verify_manifest:
with self.assertRaisesRegex(release.ReleaseError, "requires exactly these platforms"):
release.verify_labels(
"elsaworkflows/elsa-server-app:3.9.0",
self.expected_labels,
("linux/amd64",),
)
verify_manifest.assert_not_called()


class SupersededPublicationTests(unittest.TestCase):
def assert_archive_rejected(self, run, artifact, archive, packages, message):
with self.assertRaisesRegex(release.ReleaseError, message):
Expand Down Expand Up @@ -721,6 +796,10 @@ def server_fragment():
"status": 200,
"endpoint": release.DASHBOARD_API_ENDPOINT,
"contentType": "application/json; charset=utf-8",
"runtimeStatus": "AcceptingWork",
"isAcceptingWork": True,
"workflowMetricsValid": True,
"running": 0,
},
}
for platform, digest in PLATFORMS.items()
Expand Down Expand Up @@ -981,7 +1060,9 @@ def read(self):
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}')
dashboard = self.Response(b'{"runtime":{},"workflowInstances":{}}')
dashboard = self.Response(
b'{"runtime":{"status":"AcceptingWork","isAcceptingWork":true},"workflowInstances":{"running":0,"completed":0,"faulted":0,"suspended":0,"interrupted":0,"incidentBearing":0}}'
)
requests = []

def open_url(request, timeout):
Expand Down Expand Up @@ -1037,6 +1118,45 @@ def test_rejects_dashboard_json_without_dashboard_overview_shape(self):
with self.assertRaisesRegex(release.ReleaseError, "dashboard/overview.*unexpected JSON object"):
release.login_and_probe_api("http://127.0.0.1:1234", "smoke", "secret")

def test_rejects_dashboard_without_runtime_contributor_or_metrics(self):
login = self.Response(b'{"accessToken":"synthetic-token"}')
definitions = self.Response(b'{"items":[],"totalCount":0}')
unavailable = self.Response(
b'{"runtime":{"status":"Unavailable","isAcceptingWork":false},"workflowInstances":{"running":0,"completed":0,"faulted":0,"suspended":0,"interrupted":0,"incidentBearing":0}}'
)
with patch.object(release.urllib.request, "urlopen", side_effect=[login, definitions, unavailable]):
with self.assertRaisesRegex(release.ReleaseError, "did not report an accepting workflow runtime"):
release.login_and_probe_api("http://127.0.0.1:1234", "smoke", "secret")

accepting_without_metrics = {
"runtime": {"status": "AcceptingWork", "isAcceptingWork": True},
"workflowInstances": {
"running": 0,
"completed": 0,
"faulted": 0,
"suspended": 0,
"interrupted": 0,
"incidentBearing": "0",
},
}
with self.assertRaisesRegex(release.ReleaseError, "did not report an accepting workflow runtime"):
release.validate_dashboard_overview(accepting_without_metrics)

def test_dashboard_fragment_requires_runtime_contributor_evidence(self):
image = release.parse_image_selection("server")[0]
fragment = server_fragment()
row = fragment["smoke"]["platforms"][0]
row["dashboardApi"].pop("runtimeStatus")
with self.assertRaisesRegex(release.ReleaseError, "lacks successful authenticated API smoke"):
release.validate_fragment_evidence(
[fragment],
[image],
"3.9.0",
COMMIT,
{"core": "3.9.0", "studio": "3.9.0", "extensions": "3.9.0"},
"published",
)

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"):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
<PackageReference Include="Elsa.Scheduling"/>
<PackageReference Include="Elsa.Workflows.Api"/>
<PackageReference Include="Elsa.Workflows.Runtime"/>
<PackageReference Include="Elsa.Workflows.Runtime.Dashboard"/>
<PackageReference Include="Elsa.Workflows.Runtime.Distributed"/>
</ItemGroup>

Expand All @@ -30,6 +31,7 @@
<PackageReference Include="Elsa.Studio.Login.BlazorServer"/>
<PackageReference Include="Elsa.Studio.Http.Webhooks"/>
<PackageReference Include="Elsa.Studio.WorkflowContexts"/>
<PackageReference Include="Elsa.Studio.Workflows.Dashboard"/>
</ItemGroup>

<ItemGroup>
Expand Down
3 changes: 3 additions & 0 deletions src/Elsa.Server.Studio.BlazorServer/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
using Elsa.Studio.Models;
using Elsa.Studio.Shell.Extensions;
using Elsa.Studio.Http.Webhooks.Extensions;
using Elsa.Studio.Workflows.Dashboard.Extensions;
using Elsa.Studio.Workflows.Designer.Extensions;
using Elsa.Studio.Workflows.Extensions;
using Elsa.Workflows.Runtime.Distributed.Extensions;
Expand All @@ -41,6 +42,7 @@
runtime.UseEntityFrameworkCore(ef => ConfigureEntityFrameworkCore(ef, configuration));
runtime.UseDistributedRuntime();
})
.UseWorkflowRuntimeDashboard()
.UseScheduling()
.UseJavaScript()
.UseLiquid()
Expand Down Expand Up @@ -87,6 +89,7 @@
services.AddRemoteBackend(backendApiConfig);
services.AddLoginModule().UseElsaIdentity();
services.AddDashboardModule(backendApiConfig);
services.AddWorkflowsDashboardModule();
services.AddWorkflowsModule();
services.AddWebhooksModule();
services.AddAgentsModule(backendApiConfig);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
<PackageReference Include="Elsa.Studio.Login.BlazorWasm"/>
<PackageReference Include="Elsa.Studio.Http.Webhooks"/>
<PackageReference Include="Elsa.Studio.WorkflowContexts"/>
<PackageReference Include="Elsa.Studio.Workflows.Dashboard"/>
</ItemGroup>

<ItemGroup>
Expand Down
2 changes: 2 additions & 0 deletions src/Elsa.Server.Studio.BlazorWasm.Client/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using Elsa.Studio.Core.BlazorWasm.Extensions;
using Elsa.Studio.Dashboard.Extensions;
using Elsa.Studio.Extensions;
using Elsa.Studio.Workflows.Dashboard.Extensions;
using Elsa.Studio.Localization.Time;
using Elsa.Studio.Localization.Time.Providers;
using Elsa.Studio.Login.BlazorWasm.Extensions;
Expand Down Expand Up @@ -41,6 +42,7 @@
builder.Services.AddRemoteBackend(backendApiConfig);
builder.Services.AddLoginModule().UseElsaIdentity();
builder.Services.AddDashboardModule(backendApiConfig);
builder.Services.AddWorkflowsDashboardModule();
builder.Services.AddWorkflowsModule();
builder.Services.AddWorkflowContextsModule();
builder.Services.AddScoped<ITimeZoneProvider, LocalTimeZoneProvider>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
<PackageReference Include="Elsa.Expressions.Liquid"/>
<PackageReference Include="Elsa.Scheduling"/>
<PackageReference Include="Elsa.Workflows.Api"/>
<PackageReference Include="Elsa.Workflows.Runtime.Dashboard"/>
</ItemGroup>

<ItemGroup>
Expand Down
1 change: 1 addition & 0 deletions src/Elsa.Server.Studio.BlazorWasm/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
.UseDefaultAuthentication()
.UseWorkflowManagement(management => management.UseEntityFrameworkCore(ef => ConfigureEntityFrameworkCore(ef, configuration)))
.UseWorkflowRuntime(runtime => runtime.UseEntityFrameworkCore(ef => ConfigureEntityFrameworkCore(ef, configuration)))
.UseWorkflowRuntimeDashboard()
.UseScheduling()
.UseJavaScript()
.UseLiquid()
Expand Down
1 change: 1 addition & 0 deletions src/Elsa.Server/Elsa.Server.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
<PackageReference Include="Elsa.Expressions.Liquid"/>
<PackageReference Include="Elsa.Scheduling"/>
<PackageReference Include="Elsa.Workflows.Api"/>
<PackageReference Include="Elsa.Workflows.Runtime.Dashboard"/>
<PackageReference Include="Elsa.Workflows.Runtime.Distributed"/>
</ItemGroup>

Expand Down
1 change: 1 addition & 0 deletions src/Elsa.Server/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
runtime.UseDistributedRuntime();
runtime.WorkflowDispatcherOptions += options => configuration.Bind("Dispatcher", options);
})
.UseWorkflowRuntimeDashboard()
.UseIdentity(identity =>
{
identity.TokenOptions = options => configuration.Bind("Identity:Tokens", options);
Expand Down
Loading
Loading