diff --git a/artifacts.json b/artifacts.json index 226059dc..fd9e69ba 100644 --- a/artifacts.json +++ b/artifacts.json @@ -1,14 +1,31 @@ -[ - { - "path": "src/main.py", - "category": "core" - }, - { - "path": "src/api/routes/clean_release.py", - "category": "core" - }, - { - "path": "docs/installation.md", - "category": "docs" - } -] +{ + "artifacts": [ + { + "id": "artifact-backend-dist", + "path": "backend/dist/package.tar.gz", + "sha256": "deadbeef", + "size": 1024, + "category": "core", + "source_uri": "https://repo.intra.company.local/releases/backend/dist/package.tar.gz", + "source_host": "repo.intra.company.local" + }, + { + "id": "artifact-clean-release-route", + "path": "backend/src/api/routes/clean_release.py", + "sha256": "feedface", + "size": 8192, + "category": "core", + "source_uri": "https://repo.intra.company.local/releases/backend/src/api/routes/clean_release.py", + "source_host": "repo.intra.company.local" + }, + { + "id": "artifact-installation-docs", + "path": "docs/installation.md", + "sha256": "c0ffee00", + "size": 4096, + "category": "docs", + "source_uri": "https://repo.intra.company.local/releases/docs/installation.md", + "source_host": "repo.intra.company.local" + } + ] +} diff --git a/backend/src/scripts/clean_release_tui.py b/backend/src/scripts/clean_release_tui.py index 68a8ff5b..4565f501 100644 --- a/backend/src/scripts/clean_release_tui.py +++ b/backend/src/scripts/clean_release_tui.py @@ -36,6 +36,7 @@ from src.models.clean_release import ( ReleaseCandidateStatus, ) from src.services.clean_release.approval_service import approve_candidate +from src.services.clean_release.artifact_catalog_loader import load_bootstrap_artifacts from src.services.clean_release.compliance_execution_service import ComplianceExecutionService from src.services.clean_release.enums import CandidateStatus from src.services.clean_release.manifest_service import build_manifest_snapshot @@ -270,6 +271,15 @@ class CleanReleaseTUI: status=ReleaseCandidateStatus.DRAFT, ) repository.save_candidate(candidate) + imported_artifacts = load_bootstrap_artifacts( + os.getenv("CLEAN_TUI_ARTIFACTS_JSON", "").strip(), + candidate.id, + ) + for artifact in imported_artifacts: + repository.save_artifact(artifact) + if imported_artifacts: + candidate.transition_to(CandidateStatus.PREPARED) + repository.save_candidate(candidate) registry_id = payload.get("registry_id", "REG-1") entries = [ diff --git a/backend/src/services/clean_release/artifact_catalog_loader.py b/backend/src/services/clean_release/artifact_catalog_loader.py new file mode 100644 index 00000000..cc3b5b80 --- /dev/null +++ b/backend/src/services/clean_release/artifact_catalog_loader.py @@ -0,0 +1,94 @@ +# [DEF:backend.src.services.clean_release.artifact_catalog_loader:Module] +# @TIER: STANDARD +# @SEMANTICS: clean-release, artifacts, bootstrap, json, tui +# @PURPOSE: Load bootstrap artifact catalogs for clean release real-mode flows. +# @LAYER: Domain +# @RELATION: DEPENDS_ON -> backend.src.models.clean_release.CandidateArtifact +# @INVARIANT: Artifact catalog must produce deterministic CandidateArtifact entries with required identity and checksum fields. + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Dict, List + +from ...models.clean_release import CandidateArtifact + + +# [DEF:load_bootstrap_artifacts:Function] +# @PURPOSE: Parse artifact catalog JSON into CandidateArtifact models for TUI/bootstrap flows. +# @PRE: path points to readable JSON file; payload is list[artifact] or {"artifacts": list[artifact]}. +# @POST: Returns non-mutated CandidateArtifact models with required fields populated. +def load_bootstrap_artifacts(path: str, candidate_id: str) -> List[CandidateArtifact]: + if not path or not path.strip(): + return [] + if not candidate_id or not candidate_id.strip(): + raise ValueError("candidate_id must be non-empty for artifact catalog import") + + catalog_path = Path(path) + payload = json.loads(catalog_path.read_text(encoding="utf-8")) + raw_artifacts = payload.get("artifacts") if isinstance(payload, dict) else payload + if not isinstance(raw_artifacts, list): + raise ValueError("artifact catalog must be a list or an object with 'artifacts' list") + + artifacts: List[CandidateArtifact] = [] + for index, raw_artifact in enumerate(raw_artifacts, start=1): + if not isinstance(raw_artifact, dict): + raise ValueError(f"artifact #{index} must be an object") + + artifact_id = str(raw_artifact.get("id", "")).strip() + artifact_path = str(raw_artifact.get("path", "")).strip() + artifact_sha256 = str(raw_artifact.get("sha256", "")).strip() + artifact_size = raw_artifact.get("size") + if not artifact_id: + raise ValueError(f"artifact #{index} missing required field 'id'") + if not artifact_path: + raise ValueError(f"artifact #{index} missing required field 'path'") + if not artifact_sha256: + raise ValueError(f"artifact #{index} missing required field 'sha256'") + if not isinstance(artifact_size, int) or artifact_size < 0: + raise ValueError(f"artifact #{index} field 'size' must be non-negative integer") + + category = str(raw_artifact.get("detected_category") or raw_artifact.get("category") or "").strip() or None + source_uri = str(raw_artifact.get("source_uri", "")).strip() or None + source_host = str(raw_artifact.get("source_host", "")).strip() or None + metadata_json = raw_artifact.get("metadata_json") + if metadata_json is None: + metadata_json = { + key: value + for key, value in raw_artifact.items() + if key + not in { + "id", + "path", + "sha256", + "size", + "category", + "detected_category", + "source_uri", + "source_host", + "metadata_json", + } + } + if not isinstance(metadata_json, dict): + raise ValueError(f"artifact #{index} field 'metadata_json' must be object") + + artifacts.append( + CandidateArtifact( + id=artifact_id, + candidate_id=candidate_id, + path=artifact_path, + sha256=artifact_sha256, + size=artifact_size, + detected_category=category, + declared_category=category, + source_uri=source_uri, + source_host=source_host, + metadata_json=metadata_json, + ) + ) + + return artifacts +# [/DEF:load_bootstrap_artifacts:Function] + +# [/DEF:backend.src.services.clean_release.artifact_catalog_loader:Module] diff --git a/backend/tests/scripts/test_clean_release_tui.py b/backend/tests/scripts/test_clean_release_tui.py index 87a87e55..f8ad731d 100644 --- a/backend/tests/scripts/test_clean_release_tui.py +++ b/backend/tests/scripts/test_clean_release_tui.py @@ -9,6 +9,7 @@ import os import sys import curses +import json from unittest import mock from unittest.mock import MagicMock, patch @@ -152,4 +153,71 @@ def test_tui_clear_history_f7(mock_curses_module, mock_stdscr: MagicMock): assert len(app.checks_progress) == 0 +@patch("src.scripts.clean_release_tui.curses") +def test_tui_real_mode_bootstrap_imports_artifacts_catalog( + mock_curses_module, + mock_stdscr: MagicMock, + tmp_path, +): + """ + @TEST_CONTRACT: bootstrap.json + artifacts.json -> candidate PREPARED with imported artifacts + """ + mock_curses_module.KEY_F10 = curses.KEY_F10 + mock_curses_module.color_pair.side_effect = lambda x: x + mock_curses_module.A_BOLD = 0 + + bootstrap_path = tmp_path / "bootstrap.json" + artifacts_path = tmp_path / "artifacts.json" + bootstrap_path.write_text( + json.dumps( + { + "candidate_id": "real-candidate-1", + "version": "1.0.0", + "source_snapshot_ref": "git:release/1", + "created_by": "operator", + "allowed_hosts": ["repo.intra.company.local"], + } + ), + encoding="utf-8", + ) + artifacts_path.write_text( + json.dumps( + { + "artifacts": [ + { + "id": "artifact-1", + "path": "backend/dist/package.tar.gz", + "sha256": "deadbeef", + "size": 1024, + "category": "core", + "source_uri": "https://repo.intra.company.local/releases/package.tar.gz", + "source_host": "repo.intra.company.local", + } + ] + } + ), + encoding="utf-8", + ) + + with mock.patch.dict( + os.environ, + { + "CLEAN_TUI_MODE": "real", + "CLEAN_TUI_BOOTSTRAP_JSON": str(bootstrap_path), + "CLEAN_TUI_ARTIFACTS_JSON": str(artifacts_path), + }, + clear=False, + ): + app = CleanReleaseTUI(mock_stdscr) + + candidate = app.repo.get_candidate("real-candidate-1") + artifacts = app.repo.get_artifacts_by_candidate("real-candidate-1") + + assert candidate is not None + assert candidate.status == "PREPARED" + assert len(artifacts) == 1 + assert artifacts[0].path == "backend/dist/package.tar.gz" + assert artifacts[0].detected_category == "core" + + # [/DEF:backend.tests.scripts.test_clean_release_tui:Module] diff --git a/frontend/src/components/StartupEnvironmentWizard.svelte b/frontend/src/components/StartupEnvironmentWizard.svelte new file mode 100644 index 00000000..24671077 --- /dev/null +++ b/frontend/src/components/StartupEnvironmentWizard.svelte @@ -0,0 +1,210 @@ + + + +{#if open} +
{$t.nav?.dashboards}
+{$t.dashboard?.setup_intro || "Dashboards need at least one Superset environment. Create it here instead of landing on an empty screen."}
+{$t.dashboard?.setup_card_title || "What happens next"}
+{$t.dashboard?.setup_card_body || "The wizard saves a Superset endpoint, validates login, and immediately makes the environment available in the global selector."}
+{$t.dashboard?.setup_checklist_title || "Prepare these values"}
+{$t.dashboard?.setup_step_title || "Starter flow"}
+1. {$t.dashboard?.setup_step_one || "Create the first environment"}
+2. {$t.dashboard?.setup_step_two || "Select it automatically for the current session"}
+3. {$t.dashboard?.setup_step_three || "Load dashboard inventory for that environment"}
++ {$t.dashboard?.setup_badge || "Initial setup"} +
++ {$t.dashboard?.setup_empty_body || + "Add the first Superset environment to unlock dashboards, datasets, backups, and migrations."} +
+