feat: complete production workflow migration
This commit is contained in:
+111
-19
@@ -10,7 +10,7 @@ from pathlib import Path
|
||||
|
||||
from gyxx_flow.catalog import CatalogError, WorkflowCatalog
|
||||
from gyxx_flow.core.config import Settings
|
||||
from gyxx_flow.script_catalog import ScriptCatalog
|
||||
from gyxx_flow.script_catalog import ScriptCatalog, ScriptCatalogError
|
||||
from gyxx_flow.security import scan_repository
|
||||
|
||||
_CHECKLIST = re.compile(r"^\s*-\s*\[([ xX])\]\s+(P\d+\.\d+)\b", re.MULTILINE)
|
||||
@@ -61,38 +61,101 @@ def parse_plan_checklist(path: Path) -> tuple[PlanItem, ...]:
|
||||
|
||||
def build_acceptance_report(settings: Settings) -> AcceptanceReport:
|
||||
project_root = settings.project_root
|
||||
items = parse_plan_checklist(project_root / "plan.md")
|
||||
items = parse_plan_checklist(project_root / "docs" / "plan.md")
|
||||
checks = {
|
||||
"catalog_21_tasks": _catalog_has_21_tasks(project_root),
|
||||
"baseline_21_tasks": _baseline_has_21_tasks(settings.data_root),
|
||||
"catalog_22_tasks": _catalog_has_22_tasks(project_root),
|
||||
"scheduled_graphs_explicit": _scheduled_graphs_are_explicit(project_root),
|
||||
"python_scheduler_only": _python_scheduler_is_the_only_scheduler(project_root),
|
||||
"runtime_service_policy": _runtime_service_policy_is_configured(project_root),
|
||||
"native_entrypoints_local": _native_entrypoints_are_local(project_root),
|
||||
"runtime_sources_decoupled": _runtime_sources_are_decoupled(project_root),
|
||||
"runnable_script_catalog": _runnable_script_catalog_is_complete(),
|
||||
"public_command_registry_complete": _public_command_registry_is_complete(
|
||||
project_root
|
||||
),
|
||||
"source_manifests_verified": _source_manifests_are_verified(project_root),
|
||||
"secret_scan_clean": not scan_repository(project_root),
|
||||
}
|
||||
return AcceptanceReport(items, checks)
|
||||
|
||||
|
||||
def _catalog_has_21_tasks(project_root: Path) -> bool:
|
||||
def _catalog_has_22_tasks(project_root: Path) -> bool:
|
||||
try:
|
||||
catalog = WorkflowCatalog.load(project_root / "config")
|
||||
except Exception:
|
||||
return False
|
||||
scheduled = catalog.scheduled_workflows()
|
||||
return len(scheduled) == 21 and len(catalog.schedules) == 21
|
||||
return (
|
||||
len(scheduled) == 23
|
||||
and len(catalog.schedules) == 23
|
||||
and sum(schedule.enabled for schedule in catalog.schedules) == 23
|
||||
)
|
||||
|
||||
|
||||
def _baseline_has_21_tasks(data_root: Path) -> bool:
|
||||
candidates = sorted((Path(data_root) / "baseline").glob("*/manifest.json"))
|
||||
if not candidates:
|
||||
return False
|
||||
def _scheduled_graphs_are_explicit(project_root: Path) -> bool:
|
||||
try:
|
||||
payload = json.loads(candidates[-1].read_text(encoding="utf-8"))
|
||||
tasks = payload["scheduled_tasks"]
|
||||
return tasks["actual_count"] == 21 and len(tasks["tasks"]) == 21
|
||||
catalog = WorkflowCatalog.load(project_root / "config")
|
||||
except Exception:
|
||||
return False
|
||||
return all(workflow.steps for workflow in catalog.scheduled_workflows())
|
||||
|
||||
|
||||
def _python_scheduler_is_the_only_scheduler(project_root: Path) -> bool:
|
||||
scheduler_service = project_root / "src" / "gyxx_flow" / "scheduler_service.py"
|
||||
retired_windows_scheduler = project_root / "src" / "gyxx_flow" / "scheduler.py"
|
||||
systemd_unit = project_root / "deploy" / "gyxx-flow.service"
|
||||
legacy_installer = project_root / "deploy" / "windows-service" / "install.ps1"
|
||||
try:
|
||||
unit = systemd_unit.read_text(encoding="utf-8").casefold()
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
legacy_is_safe = True
|
||||
if legacy_installer.exists():
|
||||
try:
|
||||
installer = legacy_installer.read_text(encoding="utf-8").casefold()
|
||||
except OSError:
|
||||
return False
|
||||
legacy_is_safe = (
|
||||
"schedule run" in installer
|
||||
and "schtasks" not in installer
|
||||
and "new-scheduledtask" not in installer
|
||||
)
|
||||
|
||||
return (
|
||||
scheduler_service.is_file()
|
||||
and not retired_windows_scheduler.exists()
|
||||
and "execstart=/opt/gyxx-flow/.venv/bin/python -m gyxx_flow schedule run"
|
||||
in unit
|
||||
and "environment=gyxx_data_root=/var/lib/gyxx-flow" in unit
|
||||
and "oncalendar=" not in unit
|
||||
and legacy_is_safe
|
||||
)
|
||||
|
||||
|
||||
def _runtime_service_policy_is_configured(project_root: Path) -> bool:
|
||||
try:
|
||||
payload = json.loads(
|
||||
(project_root / "config" / "runtime-bindings.json").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
)
|
||||
services = payload["services"]
|
||||
except (OSError, json.JSONDecodeError, KeyError, TypeError):
|
||||
return False
|
||||
local_urls = (
|
||||
services.get("hermes_url", ""),
|
||||
services.get("hermes_collector_url", ""),
|
||||
services.get("hermes_analyzer_gateway_url", ""),
|
||||
services.get("hermes_collector_gateway_url", ""),
|
||||
)
|
||||
return (
|
||||
services.get("postgres") == "cloud"
|
||||
and not services.get("postgres_host")
|
||||
and not services.get("postgres_database")
|
||||
and not services.get("postgres_user")
|
||||
and services.get("hermes") == "local"
|
||||
and all(url.startswith("http://127.0.0.1:") for url in local_urls)
|
||||
)
|
||||
|
||||
|
||||
def _native_entrypoints_are_local(project_root: Path) -> bool:
|
||||
@@ -107,7 +170,6 @@ def _native_entrypoints_are_local(project_root: Path) -> bool:
|
||||
/ "gyxx_flow"
|
||||
/ "modules"
|
||||
/ workflow.module
|
||||
/ "runtime"
|
||||
/ workflow.entry
|
||||
).resolve(strict=True)
|
||||
if not target.is_file() or not target.is_relative_to(project_root):
|
||||
@@ -120,12 +182,42 @@ def _native_entrypoints_are_local(project_root: Path) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _runnable_script_catalog_is_complete() -> bool:
|
||||
def _public_command_registry_is_complete(project_root: Path) -> bool:
|
||||
try:
|
||||
scripts = ScriptCatalog.discover_default().scripts
|
||||
except (OSError, ValueError):
|
||||
workflows = WorkflowCatalog.load(project_root / "config").workflows
|
||||
commands = ScriptCatalog.discover_default()
|
||||
except (OSError, ValueError, ScriptCatalogError):
|
||||
return False
|
||||
return len(scripts) >= 100 and len({item.module for item in scripts}) == 4
|
||||
|
||||
runnable = tuple(item for item in workflows if item.trigger != "unavailable")
|
||||
expected_modules = {item.module for item in runnable}
|
||||
scripts = commands.scripts
|
||||
if (
|
||||
not scripts
|
||||
or len(commands.command_ids) != len(set(commands.command_ids))
|
||||
or {item.module for item in scripts} != expected_modules
|
||||
):
|
||||
return False
|
||||
|
||||
resolved_root = project_root.resolve()
|
||||
for script in scripts:
|
||||
if not script.path.is_file() or not script.path.is_relative_to(resolved_root):
|
||||
return False
|
||||
|
||||
try:
|
||||
for workflow in runnable:
|
||||
entries = (
|
||||
tuple(step.entry for step in workflow.steps)
|
||||
if workflow.steps
|
||||
else (workflow.entry,)
|
||||
)
|
||||
for entry in entries:
|
||||
command = commands.get(f"{workflow.module}:{entry}")
|
||||
if command.module != workflow.module or command.entry != entry:
|
||||
return False
|
||||
except ScriptCatalogError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _runtime_sources_are_decoupled(project_root: Path) -> bool:
|
||||
|
||||
Reference in New Issue
Block a user