feat: Implement console session management with tenant and profile handling

- Add ConsoleSessionStore for managing console session state including tenants, profile, and token information.
- Create OperatorContextService to manage operator context for orchestrator actions.
- Implement OperatorMetadataInterceptor to enrich HTTP requests with operator context metadata.
- Develop ConsoleProfileComponent to display user profile and session details, including tenant information and access tokens.
- Add corresponding HTML and SCSS for ConsoleProfileComponent to enhance UI presentation.
- Write unit tests for ConsoleProfileComponent to ensure correct rendering and functionality.
This commit is contained in:
2025-10-28 09:58:55 +02:00
parent 4d932cc1ba
commit 4e3e575db5
501 changed files with 51904 additions and 6663 deletions

View File

@@ -19,6 +19,7 @@
> Blocked: waiting on CLI verifier command and Concelier/Excititor guard endpoints to land (CLI-AOC-19-002, CONCELIER-WEB-AOC-19-004, EXCITITOR-WEB-AOC-19-004).
| DEVOPS-AOC-19-003 | BLOCKED (2025-10-26) | DevOps Guild, QA Guild | CONCELIER-WEB-AOC-19-003, EXCITITOR-WEB-AOC-19-003 | Enforce unit test coverage thresholds for AOC guard suites and ensure coverage exported to dashboards. | Coverage report includes guard projects, threshold gate passes/fails as expected, dashboards refreshed with new metrics. |
> Blocked: guard coverage suites and exporter hooks pending in Concelier/Excititor (CONCELIER-WEB-AOC-19-003, EXCITITOR-WEB-AOC-19-003).
| DEVOPS-AOC-19-101 | TODO (2025-10-28) | DevOps Guild, Concelier Storage Guild | CONCELIER-STORE-AOC-19-002 | Draft supersedes backfill rollout (freeze window, dry-run steps, rollback) once advisory_raw idempotency index passes staging verification. | Runbook committed in `docs/deploy/containers.md` + Offline Kit notes, staging rehearsal scheduled with dependencies captured in SPRINTS. |
| DEVOPS-OBS-50-001 | DONE (2025-10-26) | DevOps Guild, Observability Guild | TELEMETRY-OBS-50-001 | Deliver default OpenTelemetry collector deployment (Compose/Helm manifests), OTLP ingestion endpoints, and secure pipeline (authN, mTLS, tenant partitioning). Provide smoke test verifying traces/logs/metrics ingestion. | Collector manifests committed; smoke test green; docs updated; imposed rule banner reminder noted. |
| DEVOPS-OBS-50-002 | DOING (2025-10-26) | DevOps Guild, Security Guild | DEVOPS-OBS-50-001, TELEMETRY-OBS-51-002 | Stand up multi-tenant storage backends (Prometheus, Tempo/Jaeger, Loki) with retention policies, tenant isolation, and redaction guard rails. Integrate with Authority scopes for read paths. | Storage stack deployed with auth; retention configured; integration tests verify tenant isolation; runbook drafted. |
> Coordination started with Observability Guild (2025-10-26) to schedule staging rollout and provision service accounts. Staging bootstrap commands and secret names documented in `docs/ops/telemetry-storage.md`.
@@ -114,8 +115,8 @@
## Export Center
| ID | Status | Owner(s) | Depends on | Description | Exit Criteria |
|----|--------|----------|------------|-------------|---------------|
| DEVOPS-EXPORT-35-001 | TODO | DevOps Guild, Exporter Service Guild | EXPORT-SVC-35-001..006 | Establish exporter CI pipeline (lint/test/perf smoke), configure object storage fixtures, seed Grafana dashboards, and document bootstrap steps. | CI pipeline running; smoke export job seeded; dashboards live; runbook updated. |
| DEVOPS-EXPORT-36-001 | TODO | DevOps Guild, Exporter Service Guild | DEVOPS-EXPORT-35-001, EXPORT-SVC-36-001..004 | Integrate Trivy compatibility validation, OCI push smoke tests, and throughput/error dashboards. | CI executes Trivy validation; OCI push smoke passes; dashboards/alerts configured. |
| DEVOPS-EXPORT-35-001 | BLOCKED (2025-10-29) | DevOps Guild, Exporter Service Guild | EXPORT-SVC-35-001..006 | Establish exporter CI pipeline (lint/test/perf smoke), configure object storage fixtures, seed Grafana dashboards, and document bootstrap steps. | CI pipeline running; smoke export job seeded; dashboards live; runbook updated. |
| DEVOPS-EXPORT-36-001 | TODO | DevOps Guild, Exporter Service Guild | DEVOPS-EXPORT-35-001, EXPORT-SVC-36-001..004 | Integrate Trivy compatibility validation, cosign signature checks, `trivy module db import` smoke tests, OCI distribution verification, and throughput/error dashboards. | CI executes cosign + Trivy import validation; OCI push smoke passes; dashboards/alerts configured. |
| DEVOPS-EXPORT-37-001 | TODO | DevOps Guild, Exporter Service Guild | DEVOPS-EXPORT-36-001, EXPORT-SVC-37-001..004 | Finalize exporter monitoring (failure alerts, verify metrics, retention jobs) and chaos/latency tests ahead of GA. | Alerts tuned; chaos tests documented; retention monitoring active; runbook updated. |
## CLI Parity & Task Packs
@@ -124,7 +125,10 @@
|----|--------|----------|------------|-------------|---------------|
| DEVOPS-CLI-41-001 | TODO | DevOps Guild, DevEx/CLI Guild | CLI-CORE-41-001 | Establish CLI build pipeline (multi-platform binaries, SBOM, checksums), parity matrix CI enforcement, and release artifact signing. | Build pipeline operational; SBOM/checksums published; parity gate failing on drift; docs updated. |
| DEVOPS-CLI-42-001 | TODO | DevOps Guild | DEVOPS-CLI-41-001, CLI-PARITY-41-001 | Add CLI golden output tests, parity diff automation, pack run CI harness, and artifact cache for remote mode. | Golden tests running; parity diff automation in CI; pack run harness executes sample packs; documentation updated. |
| DEVOPS-CLI-43-001 | TODO | DevOps Guild | DEVOPS-CLI-42-001, TASKRUN-42-001 | Finalize multi-platform release automation, SBOM signing, parity gate enforcement, and Task Pack chaos tests. | Release automation verified; SBOM signed; parity gate enforced; chaos tests documented. |
| DEVOPS-CLI-43-001 | DOING (2025-10-27) | DevOps Guild | DEVOPS-CLI-42-001, TASKRUN-42-001 | Finalize multi-platform release automation, SBOM signing, parity gate enforcement, and Task Pack chaos tests. | Release automation verified; SBOM signed; parity gate enforced; chaos tests documented. |
> 2025-10-27: Release pipeline now packages CLI multi-platform artefacts with SBOM/signature coverage and enforces the CLI parity gate (`ops/devops/check_cli_parity.py`). Task Pack chaos smoke still pending CLI pack command delivery.
| DEVOPS-CLI-43-002 | TODO | DevOps Guild, Task Runner Guild | CLI-PACKS-43-001, TASKRUN-43-001 | Implement Task Pack chaos smoke in CI (random failure injection, resume, sealed-mode toggle) and publish evidence bundles for review. | Chaos smoke job runs nightly; failures alert Slack; evidence stored in `out/pack-chaos`; runbook updated. |
| DEVOPS-CLI-43-003 | TODO | DevOps Guild, DevEx/CLI Guild | CLI-PARITY-41-001, CLI-PACKS-42-001 | Integrate CLI golden output/parity diff automation into release gating; export parity report artifact consumed by Console Downloads workspace. | `check_cli_parity.py` wired to compare parity matrix and CLI outputs; artifact uploaded; release fails on regressions.
## Containerized Distribution (Epic 13)

View File

@@ -0,0 +1,53 @@
#!/usr/bin/env python3
"""Ensure CLI parity matrix contains no outstanding blockers before release."""
from __future__ import annotations
import pathlib
import re
import sys
REPO_ROOT = pathlib.Path(__file__).resolve().parents[2]
PARITY_DOC = REPO_ROOT / "docs/cli-vs-ui-parity.md"
BLOCKERS = {
"🟥": "blocking gap",
"": "missing feature",
"🚫": "unsupported",
}
WARNINGS = {
"🟡": "partial support",
"⚠️": "warning",
}
def main() -> int:
if not PARITY_DOC.exists():
print(f"❌ Parity matrix not found at {PARITY_DOC}", file=sys.stderr)
return 1
text = PARITY_DOC.read_text(encoding="utf-8")
blockers: list[str] = []
warnings: list[str] = []
for line in text.splitlines():
for symbol, label in BLOCKERS.items():
if symbol in line:
blockers.append(f"{label}: {line.strip()}")
for symbol, label in WARNINGS.items():
if symbol in line:
warnings.append(f"{label}: {line.strip()}")
if blockers:
print("❌ CLI parity gate failed — blocking items present:", file=sys.stderr)
for item in blockers:
print(f" - {item}", file=sys.stderr)
return 1
if warnings:
print("⚠️ CLI parity gate warnings detected:", file=sys.stderr)
for item in warnings:
print(f" - {item}", file=sys.stderr)
print("Treat warnings as failures until parity matrix is fully green.", file=sys.stderr)
return 1
print("✅ CLI parity matrix has no blocking or warning entries.")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -23,11 +23,13 @@ import pathlib
import re
import shlex
import shutil
import stat
import subprocess
import sys
import tarfile
import tempfile
import uuid
import zipfile
from collections import OrderedDict
from typing import Any, Dict, Iterable, List, Mapping, MutableMapping, Optional, Sequence, Tuple
@@ -190,6 +192,8 @@ class ReleaseBuilder:
self.metadata_dir = ensure_directory(self.artifacts_dir / "metadata")
self.debug_dir = ensure_directory(self.output_dir / "debug")
self.debug_store_dir = ensure_directory(self.debug_dir / ".build-id")
self.cli_config = config.get("cli")
self.cli_output_dir = ensure_directory(self.output_dir / "cli") if self.cli_config else None
self.temp_dir = pathlib.Path(tempfile.mkdtemp(prefix="stellaops-release-"))
self.skip_signing = skip_signing
self.tlog_upload = tlog_upload
@@ -220,7 +224,8 @@ class ReleaseBuilder:
helm_meta = self._package_helm()
compose_meta = self._digest_compose_files()
debug_meta = self._collect_debug_store(components_result)
manifest = self._compose_manifest(components_result, helm_meta, compose_meta, debug_meta)
cli_meta = self._build_cli_artifacts()
manifest = self._compose_manifest(components_result, helm_meta, compose_meta, debug_meta, cli_meta)
return manifest
def _prime_buildx_plugin(self) -> None:
@@ -262,6 +267,12 @@ class ReleaseBuilder:
def _component_ref(self, repo: str, digest: str) -> str:
return f"{self.registry}/{repo}@{digest}"
def _relative_path(self, path: pathlib.Path) -> str:
try:
return str(path.relative_to(self.output_dir.parent))
except ValueError:
return str(path)
def _build_component(self, component: Mapping[str, Any]) -> Mapping[str, Any]:
name = component["name"]
repo = component.get("repository", name)
@@ -601,6 +612,165 @@ class ReleaseBuilder:
("directory", store_rel),
))
# ----------------
# CLI packaging
# ----------------
def _build_cli_artifacts(self) -> List[Mapping[str, Any]]:
if not self.cli_config or self.dry_run:
return []
project_rel = self.cli_config.get("project")
if not project_rel:
return []
project_path = (self.repo_root / project_rel).resolve()
if not project_path.exists():
raise FileNotFoundError(f"CLI project not found at {project_path}")
runtimes: Sequence[str] = self.cli_config.get("runtimes", [])
if not runtimes:
runtimes = ("linux-x64",)
package_prefix = self.cli_config.get("packagePrefix", "stella")
ensure_directory(self.cli_output_dir or (self.output_dir / "cli"))
cli_entries: List[Mapping[str, Any]] = []
for runtime in runtimes:
entry = self._build_cli_for_runtime(project_path, runtime, package_prefix)
cli_entries.append(entry)
return cli_entries
def _build_cli_for_runtime(
self,
project_path: pathlib.Path,
runtime: str,
package_prefix: str,
) -> Mapping[str, Any]:
publish_dir = ensure_directory(self.temp_dir / f"cli-publish-{runtime}")
publish_cmd = [
"dotnet",
"publish",
str(project_path),
"--configuration",
"Release",
"--runtime",
runtime,
"--self-contained",
"true",
"/p:PublishSingleFile=true",
"/p:IncludeNativeLibrariesForSelfExtract=true",
"/p:EnableCompressionInSingleFile=true",
"/p:InvariantGlobalization=true",
"--output",
str(publish_dir),
]
run(publish_cmd, cwd=self.repo_root)
original_name = "StellaOps.Cli"
if runtime.startswith("win"):
source = publish_dir / f"{original_name}.exe"
target = publish_dir / "stella.exe"
else:
source = publish_dir / original_name
target = publish_dir / "stella"
if source.exists():
if target.exists():
target.unlink()
source.rename(target)
if not runtime.startswith("win"):
target.chmod(target.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
package_dir = self.cli_output_dir or (self.output_dir / "cli")
ensure_directory(package_dir)
archive_name = f"{package_prefix}-{self.version}-{runtime}"
if runtime.startswith("win"):
package_path = package_dir / f"{archive_name}.zip"
self._archive_zip(publish_dir, package_path)
else:
package_path = package_dir / f"{archive_name}.tar.gz"
self._archive_tar(publish_dir, package_path)
digest = compute_sha256(package_path)
sha_path = package_path.with_suffix(package_path.suffix + ".sha256")
sha_path.write_text(f"{digest} {package_path.name}\n", encoding="utf-8")
archive_info = OrderedDict((
("path", self._relative_path(package_path)),
("sha256", digest),
))
signature_info = self._sign_file(package_path)
if signature_info:
archive_info["signature"] = signature_info
sbom_info = self._generate_cli_sbom(runtime, publish_dir)
entry = OrderedDict((
("runtime", runtime),
("archive", archive_info),
))
if sbom_info:
entry["sbom"] = sbom_info
return entry
def _archive_tar(self, source_dir: pathlib.Path, archive_path: pathlib.Path) -> None:
with tarfile.open(archive_path, "w:gz") as tar:
for item in sorted(source_dir.rglob("*")):
arcname = item.relative_to(source_dir)
tar.add(item, arcname=arcname)
def _archive_zip(self, source_dir: pathlib.Path, archive_path: pathlib.Path) -> None:
with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED) as zipf:
for item in sorted(source_dir.rglob("*")):
if item.is_dir():
continue
arcname = item.relative_to(source_dir).as_posix()
zip_info = zipfile.ZipInfo(arcname)
zip_info.external_attr = (item.stat().st_mode & 0xFFFF) << 16
with item.open("rb") as handle:
zipf.writestr(zip_info, handle.read())
def _generate_cli_sbom(self, runtime: str, publish_dir: pathlib.Path) -> Optional[Mapping[str, Any]]:
if self.dry_run:
return None
sbom_dir = ensure_directory(self.sboms_dir / "cli")
sbom_path = sbom_dir / f"cli-{runtime}.cyclonedx.json"
run([
"syft",
f"dir:{publish_dir}",
"--output",
f"cyclonedx-json={sbom_path}",
])
entry = OrderedDict((
("path", self._relative_path(sbom_path)),
("sha256", compute_sha256(sbom_path)),
))
signature_info = self._sign_file(sbom_path)
if signature_info:
entry["signature"] = signature_info
return entry
def _sign_file(self, path: pathlib.Path) -> Optional[Mapping[str, Any]]:
if self.skip_signing:
return None
if not (self.cosign_key_ref or self.cosign_identity_token):
raise ValueError(
"Signing requested but no cosign key or identity token provided. Use --skip-signing to bypass."
)
signature_path = path.with_suffix(path.suffix + ".sig")
sha_path = path.with_suffix(path.suffix + ".sha256")
digest = compute_sha256(path)
sha_path.write_text(f"{digest} {path.name}\n", encoding="utf-8")
cmd = ["cosign", "sign-blob", "--yes", str(path)]
if self.cosign_key_ref:
cmd.extend(["--key", self.cosign_key_ref])
if self.cosign_identity_token:
cmd.extend(["--identity-token", self.cosign_identity_token])
if not self.tlog_upload:
cmd.append("--tlog-upload=false")
signature_data = run(cmd, env=self.cosign_env).strip()
signature_path.write_text(signature_data + "\n", encoding="utf-8")
return OrderedDict((
("path", self._relative_path(signature_path)),
("sha256", compute_sha256(signature_path)),
("tlogUploaded", self.tlog_upload),
))
def _extract_debug_entries(self, component_name: str, image_ref: str) -> List[OrderedDict[str, Any]]:
if self.dry_run:
return []
@@ -832,6 +1002,7 @@ class ReleaseBuilder:
helm_meta: Optional[Mapping[str, Any]],
compose_meta: List[Mapping[str, Any]],
debug_meta: Optional[Mapping[str, Any]],
cli_meta: Sequence[Mapping[str, Any]],
) -> Dict[str, Any]:
manifest = OrderedDict()
manifest["release"] = OrderedDict((
@@ -847,6 +1018,8 @@ class ReleaseBuilder:
manifest["compose"] = compose_meta
if debug_meta:
manifest["debugStore"] = debug_meta
if cli_meta:
manifest["cli"] = list(cli_meta)
return manifest

View File

@@ -80,6 +80,18 @@
"dockerfile": "ops/devops/release/docker/Dockerfile.angular-ui"
}
],
"cli": {
"project": "src/StellaOps.Cli/StellaOps.Cli.csproj",
"runtimes": [
"linux-x64",
"linux-arm64",
"osx-x64",
"osx-arm64",
"win-x64"
],
"packagePrefix": "stella",
"outputDir": "out/release/cli"
},
"helm": {
"chartPath": "deploy/helm/stellaops",
"outputDir": "out/release/helm"

View File

@@ -238,6 +238,60 @@ def verify_debug_store(manifest: Mapping[str, Any], release_dir: pathlib.Path, e
f"(recorded {artefact_sha}, computed {actual_sha})"
)
def verify_signature(signature: Mapping[str, Any], release_dir: pathlib.Path, label: str, component_name: str, errors: list[str]) -> None:
sig_path_value = signature.get("path")
if not sig_path_value:
errors.append(f"{component_name}: {label} signature missing path.")
return
sig_path = resolve_path(str(sig_path_value), release_dir)
if not sig_path.exists():
errors.append(f"{component_name}: {label} signature missing → {sig_path}")
return
recorded_sha = signature.get("sha256")
if recorded_sha:
actual_sha = compute_sha256(sig_path)
if actual_sha != recorded_sha:
errors.append(
f"{component_name}: {label} signature SHA mismatch for {sig_path} "
f"(recorded {recorded_sha}, computed {actual_sha})"
)
def verify_cli_entries(manifest: Mapping[str, Any], release_dir: pathlib.Path, errors: list[str]) -> None:
cli_entries = manifest.get("cli")
if not cli_entries:
return
if not isinstance(cli_entries, list):
errors.append("CLI manifest section must be a list.")
return
for entry in cli_entries:
if not isinstance(entry, Mapping):
errors.append("CLI entry must be a mapping.")
continue
runtime = entry.get("runtime", "<unknown>")
component_name = f"cli[{runtime}]"
archive = entry.get("archive")
if not isinstance(archive, Mapping):
errors.append(f"{component_name}: archive metadata missing or invalid.")
else:
verify_artifact_entry(archive, release_dir, "archive", component_name, errors)
signature = archive.get("signature")
if isinstance(signature, Mapping):
verify_signature(signature, release_dir, "archive", component_name, errors)
elif signature is not None:
errors.append(f"{component_name}: archive signature must be an object.")
sbom = entry.get("sbom")
if sbom:
if not isinstance(sbom, Mapping):
errors.append(f"{component_name}: sbom entry must be a mapping.")
else:
verify_artifact_entry(sbom, release_dir, "sbom", component_name, errors)
signature = sbom.get("signature")
if isinstance(signature, Mapping):
verify_signature(signature, release_dir, "sbom", component_name, errors)
elif signature is not None:
errors.append(f"{component_name}: sbom signature must be an object.")
def verify_release(release_dir: pathlib.Path) -> None:
if not release_dir.exists():
@@ -246,6 +300,7 @@ def verify_release(release_dir: pathlib.Path) -> None:
errors: list[str] = []
verify_manifest_hashes(manifest, release_dir, errors)
verify_components(manifest, release_dir, errors)
verify_cli_entries(manifest, release_dir, errors)
verify_collections(manifest, release_dir, errors)
verify_debug_store(manifest, release_dir, errors)
if errors:

View File

@@ -0,0 +1,77 @@
/**
* Aggregation helper that surfaces advisory_raw duplicate candidates prior to enabling the
* idempotency unique index. Intended for staging/offline snapshots.
*
* Usage:
* mongo concelier ops/devops/scripts/check-advisory-raw-duplicates.js
*
* Environment variables:
* LIMIT - optional cap on number of duplicate groups to print (default 50).
*/
(function () {
function toInt(value, fallback) {
var parsed = parseInt(value, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
var limit = typeof LIMIT !== "undefined" ? toInt(LIMIT, 50) : 50;
var database = db.getName ? db.getSiblingDB(db.getName()) : db;
if (!database) {
throw new Error("Unable to resolve database handle");
}
print("");
print("== advisory_raw duplicate audit ==");
print("Database: " + database.getName());
print("Limit : " + limit);
print("");
var pipeline = [
{
$group: {
_id: {
vendor: "$source.vendor",
upstreamId: "$upstream.upstream_id",
contentHash: "$upstream.content_hash",
tenant: "$tenant"
},
ids: { $addToSet: "$_id" },
count: { $sum: 1 }
}
},
{ $match: { count: { $gt: 1 } } },
{
$project: {
_id: 0,
vendor: "$_id.vendor",
upstreamId: "$_id.upstreamId",
contentHash: "$_id.contentHash",
tenant: "$_id.tenant",
count: 1,
ids: 1
}
},
{ $sort: { count: -1, vendor: 1, upstreamId: 1 } },
{ $limit: limit }
];
var cursor = database.getCollection("advisory_raw").aggregate(pipeline, { allowDiskUse: true });
var any = false;
while (cursor.hasNext()) {
var doc = cursor.next();
any = true;
print("---");
print("vendor : " + doc.vendor);
print("upstream_id : " + doc.upstreamId);
print("tenant : " + doc.tenant);
print("content_hash: " + doc.contentHash);
print("count : " + doc.count);
print("ids : " + doc.ids.join(", "));
}
if (!any) {
print("No duplicate advisory_raw documents detected.");
}
print("");
})();