605 lines
25 KiB
Python
605 lines
25 KiB
Python
"""Operator commands for the independent KnowledgeFS control-plane."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from collections.abc import Callable
|
|
from datetime import datetime
|
|
from functools import partial
|
|
from pathlib import Path
|
|
|
|
import click
|
|
from pydantic import BaseModel, ValidationError
|
|
|
|
from core.db.session_factory import session_factory
|
|
from services.knowledge_fs.cleanup import (
|
|
CleanupApprovalInput,
|
|
CleanupCompletionEvidenceInput,
|
|
CleanupReadinessEvidenceInput,
|
|
CleanupStartInput,
|
|
KnowledgeFSCleanupError,
|
|
KnowledgeFSCleanupService,
|
|
)
|
|
from services.knowledge_fs.control_space_commands import KnowledgeFSControlSpaceCommandService
|
|
from services.knowledge_fs.control_space_lifecycle import KnowledgeFSControlSpaceLifecycleError
|
|
from services.knowledge_fs.control_space_management import (
|
|
KnowledgeFSControlSpaceManagementService,
|
|
KnowledgeFSControlSpaceRegistration,
|
|
)
|
|
from services.knowledge_fs.cutover import (
|
|
CutoverSmokeResultsInput,
|
|
FinalDeltaInput,
|
|
KnowledgeFSCutoverError,
|
|
KnowledgeFSWorkspaceCutoverService,
|
|
LegacyDependencyInput,
|
|
QuarantineResolutionInput,
|
|
ShadowAuthorizationObservationInput,
|
|
ShadowCompletionInput,
|
|
WorkspaceInventoryInput,
|
|
)
|
|
from services.knowledge_fs.orphan_reconciler import KnowledgeFSOrphanReconciler
|
|
from services.knowledge_fs.remote_registry import get_knowledge_fs_lifecycle_remote
|
|
|
|
|
|
@click.group("knowledge-fs-control-space")
|
|
def knowledge_fs_control_space() -> None:
|
|
"""Inspect and repair Dify-owned KnowledgeFS control-space state."""
|
|
|
|
|
|
@knowledge_fs_control_space.command("dry-run")
|
|
@click.option("--tenant-id", default=None)
|
|
def dry_run(tenant_id: str | None) -> None:
|
|
report = _management_service().dry_run(tenant_id=tenant_id)
|
|
click.echo(json.dumps(report._asdict(), sort_keys=True))
|
|
|
|
|
|
@knowledge_fs_control_space.command("inventory")
|
|
@click.option("--input", "input_path", type=click.Path(path_type=Path, exists=True, dir_okay=False), required=True)
|
|
@click.option("--apply", is_flag=True, default=False, help="Create ledgers; omitted means read-only inventory.")
|
|
def inventory(input_path: Path, apply: bool) -> None:
|
|
"""Validate strict Workspace inventory JSONL and optionally create ledgers."""
|
|
|
|
service = _cutover_service()
|
|
for payload in _read_jsonl(input_path, WorkspaceInventoryInput):
|
|
report = _operator_call(partial(service.inventory, payload, apply=apply))
|
|
_echo_json(report._asdict())
|
|
|
|
|
|
@knowledge_fs_control_space.command("register")
|
|
@click.option("--tenant-id", required=True)
|
|
@click.option("--owner-account-id", required=True)
|
|
@click.option("--provisioning-key", required=True)
|
|
@click.option("--knowledge-space-id", required=True)
|
|
@click.option("--knowledge-space-revision", type=click.IntRange(min=0), required=True)
|
|
def register(
|
|
tenant_id: str,
|
|
owner_account_id: str,
|
|
provisioning_key: str,
|
|
knowledge_space_id: str,
|
|
knowledge_space_revision: int,
|
|
) -> None:
|
|
control_space, replayed = _management_service().register(
|
|
KnowledgeFSControlSpaceRegistration(
|
|
tenant_id,
|
|
owner_account_id,
|
|
provisioning_key,
|
|
knowledge_space_id,
|
|
knowledge_space_revision,
|
|
)
|
|
)
|
|
click.echo(json.dumps({"control_space_id": control_space.id, "replayed": replayed}, sort_keys=True))
|
|
|
|
|
|
@knowledge_fs_control_space.command("backfill")
|
|
@click.option("--input", "input_path", type=click.Path(path_type=Path, exists=True, dir_okay=False), required=True)
|
|
@click.option("--apply", is_flag=True, default=False, help="Persist registrations; omitted means dry-run.")
|
|
def backfill(input_path: Path, apply: bool) -> None:
|
|
"""Backfill strict Workspace inventory JSONL; dry-run unless --apply is explicit."""
|
|
|
|
service = _cutover_service()
|
|
for payload in _read_jsonl(input_path, WorkspaceInventoryInput):
|
|
report = _operator_call(partial(service.backfill, payload, apply=apply))
|
|
_echo_json(report._asdict())
|
|
|
|
|
|
@knowledge_fs_control_space.command("quarantine-resolve")
|
|
@click.option("--input", "input_path", type=click.Path(path_type=Path, exists=True, dir_okay=False), required=True)
|
|
@click.option("--apply", is_flag=True, default=False, help="Persist resolutions; omitted means dry-run.")
|
|
def quarantine_resolve(input_path: Path, apply: bool) -> None:
|
|
"""Resolve strict tenant-scoped quarantine JSONL with immutable operator evidence."""
|
|
|
|
service = _cutover_service()
|
|
for payload in _read_jsonl(input_path, QuarantineResolutionInput):
|
|
report = _operator_call(partial(service.resolve_quarantine, payload, apply=apply))
|
|
_echo_json(report._asdict())
|
|
|
|
|
|
@knowledge_fs_control_space.command("shadow-start")
|
|
@click.option("--tenant-id", required=True)
|
|
@click.option("--expected-cas-version", type=click.IntRange(min=0), required=True)
|
|
@click.option("--at", "started_at", default=None, help="Optional explicit timezone-aware shadow start.")
|
|
def shadow_start(tenant_id: str, expected_cas_version: int, started_at: str | None) -> None:
|
|
service = _cutover_service()
|
|
_operator_call(
|
|
lambda: service.begin_shadow(
|
|
tenant_id=tenant_id,
|
|
expected_cas_version=expected_cas_version,
|
|
started_at=_parse_timestamp(started_at) if started_at is not None else None,
|
|
)
|
|
)
|
|
_echo_json(service.status(tenant_id=tenant_id))
|
|
|
|
|
|
@knowledge_fs_control_space.command("shadow-report")
|
|
@click.option("--input", "input_path", type=click.Path(path_type=Path, exists=True, dir_okay=False), required=True)
|
|
@click.option("--apply", is_flag=True, default=False, help="Persist observations; omitted means dry-run.")
|
|
def shadow_report(input_path: Path, apply: bool) -> None:
|
|
observations = _read_jsonl(input_path, ShadowAuthorizationObservationInput)
|
|
report = _operator_call(lambda: _cutover_service().record_shadow_report(observations, apply=apply))
|
|
_echo_json(report._asdict())
|
|
|
|
|
|
@knowledge_fs_control_space.command("shadow-complete")
|
|
@click.option("--input", "input_path", type=click.Path(path_type=Path, exists=True, dir_okay=False), required=True)
|
|
@click.option("--apply", is_flag=True, default=False, help="Persist completion; omitted means dry-run.")
|
|
def shadow_complete(input_path: Path, apply: bool) -> None:
|
|
payload = _read_one_jsonl(input_path, ShadowCompletionInput)
|
|
report = _operator_call(lambda: _cutover_service().complete_shadow(payload, apply=apply))
|
|
_echo_json(report._asdict())
|
|
|
|
|
|
@knowledge_fs_control_space.command("issue-approve")
|
|
@click.option("--tenant-id", required=True)
|
|
@click.option("--issue-key", required=True)
|
|
@click.option("--account-id", required=True)
|
|
@click.option("--at", "approved_at", required=True)
|
|
def issue_approve(tenant_id: str, issue_key: str, account_id: str, approved_at: str) -> None:
|
|
_operator_call(
|
|
lambda: _cutover_service().approve_issue_fail_closed(
|
|
tenant_id=tenant_id,
|
|
issue_key=issue_key,
|
|
account_id=account_id,
|
|
approved_at=_parse_timestamp(approved_at),
|
|
)
|
|
)
|
|
_echo_json(_cutover_service().status(tenant_id=tenant_id))
|
|
|
|
|
|
@knowledge_fs_control_space.command("issue-resolve")
|
|
@click.option("--tenant-id", required=True)
|
|
@click.option("--issue-key", required=True)
|
|
@click.option("--account-id", required=True)
|
|
@click.option("--at", "resolved_at", required=True)
|
|
def issue_resolve(tenant_id: str, issue_key: str, account_id: str, resolved_at: str) -> None:
|
|
_operator_call(
|
|
lambda: _cutover_service().resolve_issue(
|
|
tenant_id=tenant_id,
|
|
issue_key=issue_key,
|
|
account_id=account_id,
|
|
resolved_at=_parse_timestamp(resolved_at),
|
|
)
|
|
)
|
|
_echo_json(_cutover_service().status(tenant_id=tenant_id))
|
|
|
|
|
|
@knowledge_fs_control_space.command("shadow-approve")
|
|
@click.option("--tenant-id", required=True)
|
|
@click.option("--diff-key", required=True)
|
|
@click.option("--account-id", required=True)
|
|
@click.option("--at", "approved_at", required=True)
|
|
def shadow_approve(tenant_id: str, diff_key: str, account_id: str, approved_at: str) -> None:
|
|
_operator_call(
|
|
lambda: _cutover_service().approve_shadow_diff(
|
|
tenant_id=tenant_id,
|
|
diff_key=diff_key,
|
|
account_id=account_id,
|
|
approved_at=_parse_timestamp(approved_at),
|
|
)
|
|
)
|
|
_echo_json(_cutover_service().status(tenant_id=tenant_id))
|
|
|
|
|
|
@knowledge_fs_control_space.command("shadow-resolve")
|
|
@click.option("--tenant-id", required=True)
|
|
@click.option("--diff-key", required=True)
|
|
@click.option("--account-id", required=True)
|
|
@click.option("--at", "resolved_at", required=True)
|
|
def shadow_resolve(tenant_id: str, diff_key: str, account_id: str, resolved_at: str) -> None:
|
|
_operator_call(
|
|
lambda: _cutover_service().resolve_shadow_diff(
|
|
tenant_id=tenant_id,
|
|
diff_key=diff_key,
|
|
account_id=account_id,
|
|
resolved_at=_parse_timestamp(resolved_at),
|
|
)
|
|
)
|
|
_echo_json(_cutover_service().status(tenant_id=tenant_id))
|
|
|
|
|
|
@knowledge_fs_control_space.command("legacy-dashboard")
|
|
@click.option("--tenant-id", required=True)
|
|
@click.option("--input", "input_path", type=click.Path(path_type=Path, exists=True, dir_okay=False), required=True)
|
|
@click.option("--checked-at", required=True)
|
|
@click.option("--expected-cas-version", type=click.IntRange(min=0), default=None)
|
|
@click.option("--apply", is_flag=True, default=False, help="Persist gate evidence; omitted means read-only report.")
|
|
def legacy_dashboard(
|
|
tenant_id: str,
|
|
input_path: Path,
|
|
checked_at: str,
|
|
expected_cas_version: int | None,
|
|
apply: bool,
|
|
) -> None:
|
|
dependencies = _read_jsonl(input_path, LegacyDependencyInput, allow_empty=True)
|
|
report = _operator_call(
|
|
lambda: _cutover_service().legacy_dependency_dashboard(
|
|
tenant_id=tenant_id,
|
|
dependencies=dependencies,
|
|
expected_cas_version=expected_cas_version,
|
|
checked_at=_parse_timestamp(checked_at),
|
|
apply=apply,
|
|
)
|
|
)
|
|
_echo_json(report._asdict())
|
|
|
|
|
|
@knowledge_fs_control_space.command("legacy-check")
|
|
@click.option("--tenant-id", required=True)
|
|
def legacy_check(tenant_id: str) -> None:
|
|
status_report = _operator_call(lambda: _cutover_service().status(tenant_id=tenant_id))
|
|
passed = (
|
|
bool(status_report["legacy_dependency_ready"])
|
|
and status_report["open_issues"] == 0
|
|
and status_report["unresolved_cutover_quarantine"] == 0
|
|
)
|
|
_echo_json({"tenant_id": tenant_id, "passed": passed, "status": status_report})
|
|
if not passed:
|
|
raise click.exceptions.Exit(1)
|
|
|
|
|
|
@knowledge_fs_control_space.command("freeze")
|
|
@click.option("--tenant-id", required=True)
|
|
@click.option("--expected-cas-version", type=click.IntRange(min=0), required=True)
|
|
@click.option("--at", "freeze_at", required=True)
|
|
def freeze(tenant_id: str, expected_cas_version: int, freeze_at: str) -> None:
|
|
service = _cutover_service()
|
|
_operator_call(
|
|
lambda: service.freeze(
|
|
tenant_id=tenant_id,
|
|
expected_cas_version=expected_cas_version,
|
|
freeze_at=_parse_timestamp(freeze_at),
|
|
)
|
|
)
|
|
_echo_json(service.status(tenant_id=tenant_id))
|
|
|
|
|
|
@knowledge_fs_control_space.command("final-delta")
|
|
@click.option("--input", "input_path", type=click.Path(path_type=Path, exists=True, dir_okay=False), required=True)
|
|
def final_delta(input_path: Path) -> None:
|
|
payload = _read_one_jsonl(input_path, FinalDeltaInput)
|
|
service = _cutover_service()
|
|
_operator_call(lambda: service.apply_final_delta(payload))
|
|
_echo_json(service.status(tenant_id=str(payload.tenant_id)))
|
|
|
|
|
|
@knowledge_fs_control_space.command("cutover")
|
|
@click.option("--tenant-id", required=True)
|
|
@click.option("--expected-cas-version", type=click.IntRange(min=0), required=True)
|
|
@click.option("--at", "cutover_at", required=True)
|
|
@click.option("--rollback-cutoff-at", required=True)
|
|
def cutover(tenant_id: str, expected_cas_version: int, cutover_at: str, rollback_cutoff_at: str) -> None:
|
|
service = _cutover_service()
|
|
_operator_call(
|
|
lambda: service.cutover(
|
|
tenant_id=tenant_id,
|
|
expected_cas_version=expected_cas_version,
|
|
cutover_at=_parse_timestamp(cutover_at),
|
|
rollback_cutoff_at=_parse_timestamp(rollback_cutoff_at),
|
|
)
|
|
)
|
|
_echo_json(service.status(tenant_id=tenant_id))
|
|
|
|
|
|
@knowledge_fs_control_space.command("smoke")
|
|
@click.option("--tenant-id", required=True)
|
|
@click.option("--expected-cas-version", type=click.IntRange(min=0), required=True)
|
|
@click.option("--input", "input_path", type=click.Path(path_type=Path, exists=True, dir_okay=False), required=True)
|
|
def smoke(tenant_id: str, expected_cas_version: int, input_path: Path) -> None:
|
|
results = _read_one_jsonl(input_path, CutoverSmokeResultsInput)
|
|
service = _cutover_service()
|
|
_operator_call(
|
|
lambda: service.record_smoke_results(
|
|
tenant_id=tenant_id,
|
|
expected_cas_version=expected_cas_version,
|
|
results=results,
|
|
)
|
|
)
|
|
_echo_json(service.status(tenant_id=tenant_id))
|
|
|
|
|
|
@knowledge_fs_control_space.command("observe")
|
|
@click.option("--tenant-id", required=True)
|
|
@click.option("--expected-cas-version", type=click.IntRange(min=0), required=True)
|
|
@click.option("--started-at", default=None)
|
|
@click.option("--window-ends-at", default=None)
|
|
@click.option("--maximum-task-expires-at", default=None)
|
|
@click.option("--observed-at", default=None)
|
|
def observe(
|
|
tenant_id: str,
|
|
expected_cas_version: int,
|
|
started_at: str | None,
|
|
window_ends_at: str | None,
|
|
maximum_task_expires_at: str | None,
|
|
observed_at: str | None,
|
|
) -> None:
|
|
service = _cutover_service()
|
|
if observed_at is not None:
|
|
if any(value is not None for value in (started_at, window_ends_at, maximum_task_expires_at)):
|
|
raise click.UsageError("--observed-at cannot be combined with observation start options")
|
|
_operator_call(
|
|
lambda: service.complete_observation(
|
|
tenant_id=tenant_id,
|
|
expected_cas_version=expected_cas_version,
|
|
observed_at=_parse_timestamp(observed_at),
|
|
)
|
|
)
|
|
else:
|
|
if started_at is None or window_ends_at is None or maximum_task_expires_at is None:
|
|
raise click.UsageError(
|
|
"observation start requires --started-at, --window-ends-at, and --maximum-task-expires-at"
|
|
)
|
|
_operator_call(
|
|
lambda: service.begin_observation(
|
|
tenant_id=tenant_id,
|
|
expected_cas_version=expected_cas_version,
|
|
started_at=_parse_timestamp(started_at),
|
|
window_ends_at=_parse_timestamp(window_ends_at),
|
|
maximum_task_expires_at=_parse_timestamp(maximum_task_expires_at),
|
|
)
|
|
)
|
|
_echo_json(service.status(tenant_id=tenant_id))
|
|
|
|
|
|
@knowledge_fs_control_space.command("rollback")
|
|
@click.option("--tenant-id", required=True)
|
|
@click.option("--expected-cas-version", type=click.IntRange(min=0), required=True)
|
|
@click.option("--at", "rolled_back_at", required=True)
|
|
def rollback(tenant_id: str, expected_cas_version: int, rolled_back_at: str) -> None:
|
|
service = _cutover_service()
|
|
_operator_call(
|
|
lambda: service.rollback(
|
|
tenant_id=tenant_id,
|
|
expected_cas_version=expected_cas_version,
|
|
rolled_back_at=_parse_timestamp(rolled_back_at),
|
|
)
|
|
)
|
|
_echo_json(service.status(tenant_id=tenant_id))
|
|
|
|
|
|
@knowledge_fs_control_space.command("status")
|
|
@click.option("--tenant-id", required=True)
|
|
def status(tenant_id: str) -> None:
|
|
_echo_json(_operator_call(lambda: _cutover_service().status(tenant_id=tenant_id)))
|
|
|
|
|
|
@knowledge_fs_control_space.command("cleanup-request")
|
|
@click.option("--input", "input_path", type=click.Path(path_type=Path, exists=True, dir_okay=False), required=True)
|
|
@click.option("--apply", is_flag=True, default=False, help="Persist readiness evidence; omitted means dry-run.")
|
|
def cleanup_request(input_path: Path, apply: bool) -> None:
|
|
payload = _read_one_jsonl(input_path, CleanupReadinessEvidenceInput)
|
|
report = _cleanup_call(lambda: _cleanup_service().request_cleanup(payload, apply=apply))
|
|
_echo_json(report._asdict())
|
|
|
|
|
|
@knowledge_fs_control_space.command("cleanup-approve")
|
|
@click.option("--input", "input_path", type=click.Path(path_type=Path, exists=True, dir_okay=False), required=True)
|
|
@click.option("--apply", is_flag=True, default=False, help="Persist four-eyes approval; omitted means dry-run.")
|
|
def cleanup_approve(input_path: Path, apply: bool) -> None:
|
|
payload = _read_one_jsonl(input_path, CleanupApprovalInput)
|
|
report = _cleanup_call(lambda: _cleanup_service().approve_cleanup(payload, apply=apply))
|
|
_echo_json(report._asdict())
|
|
|
|
|
|
@knowledge_fs_control_space.command("cleanup-start")
|
|
@click.option("--input", "input_path", type=click.Path(path_type=Path, exists=True, dir_okay=False), required=True)
|
|
@click.option("--apply", is_flag=True, default=False, help="Persist the irreversible fence; never runs deletion.")
|
|
@click.option(
|
|
"--acknowledge-irreversible",
|
|
is_flag=True,
|
|
default=False,
|
|
help="Required with --apply; confirms rollback will be permanently closed.",
|
|
)
|
|
def cleanup_start(input_path: Path, apply: bool, acknowledge_irreversible: bool) -> None:
|
|
payload = _read_one_jsonl(input_path, CleanupStartInput)
|
|
if apply and not acknowledge_irreversible:
|
|
raise click.UsageError("--apply requires --acknowledge-irreversible")
|
|
report = _cleanup_call(lambda: _cleanup_service().start_cleanup(payload, apply=apply))
|
|
_echo_json(report._asdict())
|
|
|
|
|
|
@knowledge_fs_control_space.command("cleanup-complete")
|
|
@click.option("--input", "input_path", type=click.Path(path_type=Path, exists=True, dir_okay=False), required=True)
|
|
@click.option("--apply", is_flag=True, default=False, help="Persist externally verified cleanup completion.")
|
|
@click.option(
|
|
"--acknowledge-executed",
|
|
is_flag=True,
|
|
default=False,
|
|
help="Required with --apply; confirms the reviewed destructive bundle already executed.",
|
|
)
|
|
def cleanup_complete(input_path: Path, apply: bool, acknowledge_executed: bool) -> None:
|
|
payload = _read_one_jsonl(input_path, CleanupCompletionEvidenceInput)
|
|
if apply and not acknowledge_executed:
|
|
raise click.UsageError("--apply requires --acknowledge-executed")
|
|
report = _cleanup_call(lambda: _cleanup_service().complete_cleanup(payload, apply=apply))
|
|
_echo_json(report._asdict())
|
|
|
|
|
|
@knowledge_fs_control_space.command("cleanup-status")
|
|
@click.option("--tenant-id", required=True)
|
|
@click.option("--request-id", required=True)
|
|
def cleanup_status(tenant_id: str, request_id: str) -> None:
|
|
_echo_json(_cleanup_call(lambda: _cleanup_service().status(tenant_id=tenant_id, request_id=request_id)))
|
|
|
|
|
|
@knowledge_fs_control_space.command("repair")
|
|
@click.option("--tenant-id", required=True)
|
|
@click.option("--control-space-id", required=True)
|
|
@click.option("--expected-resource-version", type=click.IntRange(min=0), required=True)
|
|
@click.option("--knowledge-space-id", required=True)
|
|
@click.option("--knowledge-space-revision", type=click.IntRange(min=0), required=True)
|
|
def repair(
|
|
tenant_id: str,
|
|
control_space_id: str,
|
|
expected_resource_version: int,
|
|
knowledge_space_id: str,
|
|
knowledge_space_revision: int,
|
|
) -> None:
|
|
control_space = _management_service().repair_registration(
|
|
tenant_id=tenant_id,
|
|
control_space_id=control_space_id,
|
|
expected_resource_version=expected_resource_version,
|
|
knowledge_space_id=knowledge_space_id,
|
|
knowledge_space_revision=knowledge_space_revision,
|
|
)
|
|
click.echo(json.dumps({"control_space_id": control_space.id, "state": control_space.state.value}, sort_keys=True))
|
|
|
|
|
|
@knowledge_fs_control_space.command("orphan-report")
|
|
@click.option("--limit", type=click.IntRange(min=1, max=10_000), default=500, show_default=True)
|
|
def orphan_report(limit: int) -> None:
|
|
report = KnowledgeFSOrphanReconciler(
|
|
session_factory.get_session_maker(),
|
|
get_knowledge_fs_lifecycle_remote(),
|
|
).reconcile(limit=limit, apply_repairs=False)
|
|
click.echo(json.dumps(report._asdict(), sort_keys=True))
|
|
|
|
|
|
@knowledge_fs_control_space.command("workspace-delete-request")
|
|
@click.option("--tenant-id", required=True)
|
|
@click.option("--apply", is_flag=True, default=False, help="Persist durable deletion intents; omitted means dry-run.")
|
|
def workspace_delete_request(tenant_id: str, apply: bool) -> None:
|
|
"""Route every KnowledgeFS Space through the canonical lifecycle deletion path."""
|
|
|
|
if not apply:
|
|
report = _management_service().dry_run(tenant_id=tenant_id)
|
|
_echo_json(
|
|
{
|
|
"apply": False,
|
|
"by_state": report.by_state,
|
|
"tenant_id": tenant_id,
|
|
"total": report.total,
|
|
}
|
|
)
|
|
return
|
|
results = _lifecycle_call(lambda: _lifecycle_service().request_workspace_cleanup(tenant_id=tenant_id))
|
|
_echo_json(
|
|
{
|
|
"apply": True,
|
|
"control_space_ids": [result.control_space.id for result in results],
|
|
"operation_ids": [result.outbox.operation_id for result in results if result.outbox is not None],
|
|
"tenant_id": tenant_id,
|
|
}
|
|
)
|
|
|
|
|
|
@knowledge_fs_control_space.command("workspace-delete-finalize")
|
|
@click.option("--tenant-id", required=True)
|
|
@click.option("--apply", is_flag=True, default=False, help="Purge terminal local control-plane rows.")
|
|
@click.option(
|
|
"--acknowledge-control-plane-purge",
|
|
is_flag=True,
|
|
default=False,
|
|
help="Required with --apply after every remote Space has reached deleted.",
|
|
)
|
|
def workspace_delete_finalize(tenant_id: str, apply: bool, acknowledge_control_plane_purge: bool) -> None:
|
|
"""Release the Workspace FK only after all remote deletions are terminal."""
|
|
|
|
service = _lifecycle_service()
|
|
if not apply:
|
|
_lifecycle_call(lambda: service.assert_workspace_deletion_allowed(tenant_id=tenant_id))
|
|
_echo_json({"apply": False, "ready": True, "tenant_id": tenant_id})
|
|
return
|
|
if not acknowledge_control_plane_purge:
|
|
raise click.UsageError("--apply requires --acknowledge-control-plane-purge")
|
|
deleted = _lifecycle_call(lambda: service.finalize_workspace_deletion(tenant_id=tenant_id))
|
|
_echo_json({"apply": True, "purged_control_spaces": deleted, "tenant_id": tenant_id})
|
|
|
|
|
|
def _read_jsonl[InputT: BaseModel](
|
|
input_path: Path, input_type: type[InputT], *, allow_empty: bool = False
|
|
) -> tuple[InputT, ...]:
|
|
records: list[InputT] = []
|
|
for line_number, line in enumerate(input_path.read_text(encoding="utf-8").splitlines(), start=1):
|
|
if not line.strip():
|
|
continue
|
|
try:
|
|
records.append(input_type.model_validate_json(line))
|
|
except ValidationError as exc:
|
|
raise click.ClickException(f"invalid strict JSONL at line {line_number}: {exc}") from exc
|
|
if not records and not allow_empty:
|
|
raise click.ClickException("strict JSONL input must contain at least one record")
|
|
return tuple(records)
|
|
|
|
|
|
def _read_one_jsonl[InputT: BaseModel](input_path: Path, input_type: type[InputT]) -> InputT:
|
|
records = _read_jsonl(input_path, input_type)
|
|
if len(records) != 1:
|
|
raise click.ClickException("this command requires exactly one JSONL record")
|
|
return records[0]
|
|
|
|
|
|
def _parse_timestamp(value: str) -> datetime:
|
|
try:
|
|
parsed = datetime.fromisoformat(value)
|
|
except ValueError as exc:
|
|
raise click.ClickException(f"invalid ISO-8601 timestamp: {value}") from exc
|
|
if parsed.tzinfo is None:
|
|
raise click.ClickException("operator timestamps must include an explicit timezone")
|
|
return parsed
|
|
|
|
|
|
def _operator_call[ResultT](operation: Callable[[], ResultT]) -> ResultT:
|
|
try:
|
|
return operation()
|
|
except KnowledgeFSCutoverError as exc:
|
|
raise click.ClickException(str(exc)) from exc
|
|
|
|
|
|
def _cleanup_call[ResultT](operation: Callable[[], ResultT]) -> ResultT:
|
|
try:
|
|
return operation()
|
|
except KnowledgeFSCleanupError as exc:
|
|
raise click.ClickException(str(exc)) from exc
|
|
|
|
|
|
def _lifecycle_call[ResultT](operation: Callable[[], ResultT]) -> ResultT:
|
|
try:
|
|
return operation()
|
|
except KnowledgeFSControlSpaceLifecycleError as exc:
|
|
raise click.ClickException(str(exc)) from exc
|
|
|
|
|
|
def _echo_json(payload: object) -> None:
|
|
click.echo(json.dumps(payload, default=str, sort_keys=True))
|
|
|
|
|
|
def _management_service() -> KnowledgeFSControlSpaceManagementService:
|
|
return KnowledgeFSControlSpaceManagementService(session_factory.get_session_maker())
|
|
|
|
|
|
def _cutover_service() -> KnowledgeFSWorkspaceCutoverService:
|
|
return KnowledgeFSWorkspaceCutoverService(
|
|
session_factory.get_session_maker(),
|
|
remote_factory=get_knowledge_fs_lifecycle_remote,
|
|
)
|
|
|
|
|
|
def _cleanup_service() -> KnowledgeFSCleanupService:
|
|
return KnowledgeFSCleanupService(session_factory.get_session_maker())
|
|
|
|
|
|
def _lifecycle_service() -> KnowledgeFSControlSpaceCommandService:
|
|
return KnowledgeFSControlSpaceCommandService(session_factory.get_session_maker())
|
|
|
|
|
|
__all__ = ["knowledge_fs_control_space"]
|