diff --git a/Directory.Packages.props b/Directory.Packages.props
index fd14c62..f67d9bf 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -27,6 +27,7 @@
+
@@ -40,6 +41,7 @@
+
diff --git a/scripts/container_release.py b/scripts/container_release.py
index 8038990..6c2ef96 100644
--- a/scripts/container_release.py
+++ b/scripts/container_release.py
@@ -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:
@@ -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}"
)
@@ -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"},
@@ -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}",
@@ -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")
@@ -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"
diff --git a/scripts/tests/test_container_release.py b/scripts/tests/test_container_release.py
index 009f0b4..4fdc931 100644
--- a/scripts/tests/test_container_release.py
+++ b/scripts/tests/test_container_release.py
@@ -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):
@@ -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()
@@ -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):
@@ -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"):
diff --git a/src/Elsa.Server.Studio.BlazorServer/Elsa.Server.Studio.BlazorServer.csproj b/src/Elsa.Server.Studio.BlazorServer/Elsa.Server.Studio.BlazorServer.csproj
index 76b24c6..c425d07 100644
--- a/src/Elsa.Server.Studio.BlazorServer/Elsa.Server.Studio.BlazorServer.csproj
+++ b/src/Elsa.Server.Studio.BlazorServer/Elsa.Server.Studio.BlazorServer.csproj
@@ -17,6 +17,7 @@
+
@@ -30,6 +31,7 @@
+
diff --git a/src/Elsa.Server.Studio.BlazorServer/Program.cs b/src/Elsa.Server.Studio.BlazorServer/Program.cs
index e98f11e..30132f2 100644
--- a/src/Elsa.Server.Studio.BlazorServer/Program.cs
+++ b/src/Elsa.Server.Studio.BlazorServer/Program.cs
@@ -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;
@@ -41,6 +42,7 @@
runtime.UseEntityFrameworkCore(ef => ConfigureEntityFrameworkCore(ef, configuration));
runtime.UseDistributedRuntime();
})
+ .UseWorkflowRuntimeDashboard()
.UseScheduling()
.UseJavaScript()
.UseLiquid()
@@ -87,6 +89,7 @@
services.AddRemoteBackend(backendApiConfig);
services.AddLoginModule().UseElsaIdentity();
services.AddDashboardModule(backendApiConfig);
+services.AddWorkflowsDashboardModule();
services.AddWorkflowsModule();
services.AddWebhooksModule();
services.AddAgentsModule(backendApiConfig);
diff --git a/src/Elsa.Server.Studio.BlazorWasm.Client/Elsa.Server.Studio.BlazorWasm.Client.csproj b/src/Elsa.Server.Studio.BlazorWasm.Client/Elsa.Server.Studio.BlazorWasm.Client.csproj
index 45bfeaa..e3fd1db 100644
--- a/src/Elsa.Server.Studio.BlazorWasm.Client/Elsa.Server.Studio.BlazorWasm.Client.csproj
+++ b/src/Elsa.Server.Studio.BlazorWasm.Client/Elsa.Server.Studio.BlazorWasm.Client.csproj
@@ -9,6 +9,7 @@
+
diff --git a/src/Elsa.Server.Studio.BlazorWasm.Client/Program.cs b/src/Elsa.Server.Studio.BlazorWasm.Client/Program.cs
index b42db46..994ee53 100644
--- a/src/Elsa.Server.Studio.BlazorWasm.Client/Program.cs
+++ b/src/Elsa.Server.Studio.BlazorWasm.Client/Program.cs
@@ -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;
@@ -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();
diff --git a/src/Elsa.Server.Studio.BlazorWasm/Elsa.Server.Studio.BlazorWasm.csproj b/src/Elsa.Server.Studio.BlazorWasm/Elsa.Server.Studio.BlazorWasm.csproj
index 21b6bab..b65e8ac 100644
--- a/src/Elsa.Server.Studio.BlazorWasm/Elsa.Server.Studio.BlazorWasm.csproj
+++ b/src/Elsa.Server.Studio.BlazorWasm/Elsa.Server.Studio.BlazorWasm.csproj
@@ -12,6 +12,7 @@
+
diff --git a/src/Elsa.Server.Studio.BlazorWasm/Program.cs b/src/Elsa.Server.Studio.BlazorWasm/Program.cs
index 131f19c..fa0356f 100644
--- a/src/Elsa.Server.Studio.BlazorWasm/Program.cs
+++ b/src/Elsa.Server.Studio.BlazorWasm/Program.cs
@@ -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()
diff --git a/src/Elsa.Server/Elsa.Server.csproj b/src/Elsa.Server/Elsa.Server.csproj
index 61eede5..9181a00 100644
--- a/src/Elsa.Server/Elsa.Server.csproj
+++ b/src/Elsa.Server/Elsa.Server.csproj
@@ -10,6 +10,7 @@
+
diff --git a/src/Elsa.Server/Program.cs b/src/Elsa.Server/Program.cs
index 858c48e..1206610 100644
--- a/src/Elsa.Server/Program.cs
+++ b/src/Elsa.Server/Program.cs
@@ -35,6 +35,7 @@
runtime.UseDistributedRuntime();
runtime.WorkflowDispatcherOptions += options => configuration.Bind("Dispatcher", options);
})
+ .UseWorkflowRuntimeDashboard()
.UseIdentity(identity =>
{
identity.TokenOptions = options => configuration.Bind("Identity:Tokens", options);
diff --git a/src/Elsa.Studio.BlazorServer/Bootstrap/Modules.cs b/src/Elsa.Studio.BlazorServer/Bootstrap/Modules.cs
index d2a8880..3823d3e 100644
--- a/src/Elsa.Studio.BlazorServer/Bootstrap/Modules.cs
+++ b/src/Elsa.Studio.BlazorServer/Bootstrap/Modules.cs
@@ -3,6 +3,7 @@
using Elsa.Studio.Extensions;
using Elsa.Studio.Http.Webhooks.Extensions;
using Elsa.Studio.Models;
+using Elsa.Studio.Workflows.Dashboard.Extensions;
using Elsa.Studio.Workflows.Extensions;
using Shared.Constants;
using Shared.Options;
@@ -18,8 +19,11 @@ public static IServiceCollection AddModules(this IServiceCollection services, IC
if(modulesOptions.Features.Agents)
services.AddAgentsModule(backendApiConfig);
- if(modulesOptions.Features.Dashboard)
+ if (modulesOptions.Features.Dashboard)
+ {
services.AddDashboardModule(backendApiConfig);
+ services.AddWorkflowsDashboardModule();
+ }
if (modulesOptions.Features.Webhooks)
services.AddWebhooksModule();
diff --git a/src/Elsa.Studio.BlazorServer/Elsa.Studio.BlazorServer.csproj b/src/Elsa.Studio.BlazorServer/Elsa.Studio.BlazorServer.csproj
index 13dd323..f2bb4f2 100644
--- a/src/Elsa.Studio.BlazorServer/Elsa.Studio.BlazorServer.csproj
+++ b/src/Elsa.Studio.BlazorServer/Elsa.Studio.BlazorServer.csproj
@@ -12,6 +12,7 @@
+
diff --git a/src/Elsa.Studio.BlazorWasm.Client/Bootstrap/Modules.cs b/src/Elsa.Studio.BlazorWasm.Client/Bootstrap/Modules.cs
index 3b5b319..a48bac3 100644
--- a/src/Elsa.Studio.BlazorWasm.Client/Bootstrap/Modules.cs
+++ b/src/Elsa.Studio.BlazorWasm.Client/Bootstrap/Modules.cs
@@ -3,6 +3,7 @@
using Elsa.Studio.Extensions;
using Elsa.Studio.Http.Webhooks.Extensions;
using Elsa.Studio.Models;
+using Elsa.Studio.Workflows.Dashboard.Extensions;
using Elsa.Studio.Workflows.Extensions;
using Shared.Constants;
using Shared.Options;
@@ -18,8 +19,11 @@ public static IServiceCollection AddModules(this IServiceCollection services, IC
if(modulesOptions.Features.Agents)
services.AddAgentsModule(backendApiConfig);
- if(modulesOptions.Features.Dashboard)
+ if (modulesOptions.Features.Dashboard)
+ {
services.AddDashboardModule(backendApiConfig);
+ services.AddWorkflowsDashboardModule();
+ }
if (modulesOptions.Features.Webhooks)
services.AddWebhooksModule();
diff --git a/src/Elsa.Studio.BlazorWasm.Client/Elsa.Studio.BlazorWasm.Client.csproj b/src/Elsa.Studio.BlazorWasm.Client/Elsa.Studio.BlazorWasm.Client.csproj
index ea60faf..142a619 100644
--- a/src/Elsa.Studio.BlazorWasm.Client/Elsa.Studio.BlazorWasm.Client.csproj
+++ b/src/Elsa.Studio.BlazorWasm.Client/Elsa.Studio.BlazorWasm.Client.csproj
@@ -14,6 +14,7 @@
+