- Apply migration 0008_audio_source_import (additive schema for source import) - Apply migration 0009_translation_import_runs (ledger table for import runs) - Wire PostgresAudioImportStore into HTTP commit path with org from node credential - Commit path uses PG store with org from node, no payload org - Bundle hash 21ce5687… remains frozen until v2; no authority transferred
656 lines
25 KiB
Python
656 lines
25 KiB
Python
# Copyright 2026 Umbra Tools (umbratools.com)
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
|
|
"""Hidden-prompt operator CLI for the first local administrator.
|
|
|
|
Installed as the ``umbratrans-bootstrap-admin`` console script and also
|
|
reachable as ``python -m umbratrans_web bootstrap-admin``.
|
|
|
|
Guarantees:
|
|
|
|
* The one-time password is read only from a TTY hidden prompt
|
|
(:func:`getpass.getpass`). It is never read from an argument, an environment
|
|
variable, a file, or stdin, and it is never echoed, logged, or audited.
|
|
* The command refuses to run when ``UMBRA_ENV`` selects production, and it
|
|
writes only to a local development repository with restrictive file
|
|
permissions.
|
|
* Only the account identifier, role, status, and ``must_change_password`` state
|
|
are printed. The command prints nothing that could help recover the secret.
|
|
|
|
Note on the bootstrap account: the default username is the documented,
|
|
non-secret bootstrap identifier ``Umbraadmin``. The password is supplied
|
|
interactively by the operator at run time; this repository intentionally
|
|
contains no such value.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import getpass
|
|
import hmac
|
|
import json
|
|
import os
|
|
import stat
|
|
import sys
|
|
import tempfile
|
|
from collections.abc import Callable, Sequence
|
|
from dataclasses import asdict, replace
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any, Protocol, TextIO
|
|
from uuid import UUID, uuid4
|
|
|
|
from .config import (
|
|
DEVELOPMENT_DATA_ROOT,
|
|
DEVELOPMENT_REPOSITORY_RELATIVE_PATH,
|
|
Settings,
|
|
load_settings,
|
|
)
|
|
from .errors import InputError, OperatorRefusalError, PasswordPolicyError, UmbraWebError
|
|
from .models import AccountView, RoleName
|
|
from .service import AccountService, OperatorContext
|
|
|
|
BOOTSTRAP_USERNAME = "Umbraadmin"
|
|
BOOTSTRAP_DISPLAY_NAME = "Umbra Administrator"
|
|
EXIT_OK = 0
|
|
EXIT_ERROR = 1
|
|
EXIT_VALIDATION_FAILED = 2
|
|
EXIT_REFUSED_PRODUCTION = 3
|
|
EXIT_NOT_INTERACTIVE = 4
|
|
EXIT_PASSWORD_REJECTED = 5
|
|
|
|
#: Development-only location for the audio-import ledger. The ledger is the
|
|
#: stand-in for the PostgreSQL store until the source-import migration is
|
|
#: applied; it is never written in production.
|
|
AUDIO_IMPORT_LEDGER_NAME = "audio-import-ledger.json"
|
|
LEDGER_DIRECTORY_MODE = 0o700
|
|
LEDGER_FILE_MODE = 0o600
|
|
|
|
|
|
class _Tty(Protocol):
|
|
def isatty(self) -> bool: ...
|
|
|
|
|
|
class _InteractiveTerminalUnavailable(OperatorRefusalError):
|
|
pass
|
|
|
|
|
|
def _default_repository_path() -> Path:
|
|
return (DEVELOPMENT_DATA_ROOT / DEVELOPMENT_REPOSITORY_RELATIVE_PATH).resolve()
|
|
|
|
|
|
def _require_interactive(stdin: TextIO) -> None:
|
|
"""Refuse to run without a terminal.
|
|
|
|
A hidden prompt needs a real TTY. Without one, the command fails instead
|
|
of falling back to a value that could land in a shell history file.
|
|
"""
|
|
|
|
try:
|
|
interactive = bool(stdin.isatty())
|
|
except (AttributeError, ValueError, OSError) as error:
|
|
raise _InteractiveTerminalUnavailable(
|
|
"terminal_unavailable", "A hidden prompt requires an interactive terminal."
|
|
) from error
|
|
if not interactive:
|
|
raise _InteractiveTerminalUnavailable(
|
|
"not_interactive",
|
|
"A hidden prompt requires an interactive terminal; refusing to read a password from a pipe or file.",
|
|
)
|
|
|
|
|
|
def _prompt_hidden_password(prompt: str) -> str:
|
|
return getpass.getpass(prompt)
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
"""Build the argument parser for the bootstrap command."""
|
|
|
|
parser = argparse.ArgumentParser(
|
|
prog="umbratrans-bootstrap-admin",
|
|
description=(
|
|
"Provision the first local development administrator. The one-time password is read from a hidden "
|
|
"prompt and is never stored in plaintext. Production mode is refused."
|
|
),
|
|
)
|
|
parser.add_argument("--username", default=BOOTSTRAP_USERNAME, help="non-secret bootstrap username")
|
|
parser.add_argument("--display-name", default=BOOTSTRAP_DISPLAY_NAME, help="display name for the account")
|
|
parser.add_argument(
|
|
"--email",
|
|
required=True,
|
|
help="contact email address for the account (required: users.email is NOT NULL)",
|
|
)
|
|
parser.add_argument(
|
|
"--organization-id",
|
|
default=None,
|
|
help="optional organization identifier the account belongs to",
|
|
)
|
|
parser.add_argument(
|
|
"--role",
|
|
default=str(RoleName.PLATFORM_ADMIN),
|
|
choices=[str(role) for role in RoleName],
|
|
help="role for the bootstrap account",
|
|
)
|
|
parser.add_argument(
|
|
"--repository-path",
|
|
default=None,
|
|
help=f"local development repository file (default: {_default_repository_path()})",
|
|
)
|
|
return parser
|
|
|
|
|
|
def bootstrap_admin(
|
|
argv: Sequence[str] | None = None,
|
|
*,
|
|
stdin: TextIO | None = None,
|
|
stdout: TextIO | None = None,
|
|
stderr: TextIO | None = None,
|
|
prompt: Callable[[str], str] = _prompt_hidden_password,
|
|
) -> int:
|
|
"""Create the bootstrap administrator. Returns a process exit code."""
|
|
|
|
parser = build_parser()
|
|
args = parser.parse_args(list(argv) if argv is not None else None)
|
|
out: TextIO = stdout or sys.stdout
|
|
err: TextIO = stderr or sys.stderr
|
|
stream: TextIO = stdin or sys.stdin
|
|
|
|
if _environment_is_production():
|
|
print(
|
|
"Refusing to run: the bootstrap command is development-only. Production provisioning is blocked until "
|
|
"the identity migration, backup, and role gates are approved.",
|
|
file=err,
|
|
)
|
|
return EXIT_REFUSED_PRODUCTION
|
|
|
|
try:
|
|
settings = _bootstrap_settings(args.repository_path)
|
|
except OperatorRefusalError as refusal:
|
|
print(f"Refusing to run: {refusal.message}", file=err)
|
|
return EXIT_REFUSED_PRODUCTION
|
|
except UmbraWebError as error:
|
|
print(f"Refusing to run: {error.message}", file=err)
|
|
return EXIT_ERROR
|
|
|
|
if settings.is_production:
|
|
print(
|
|
"Refusing to run: the bootstrap command is development-only. Production provisioning is blocked until "
|
|
"the identity migration, backup, and role gates are approved.",
|
|
file=err,
|
|
)
|
|
return EXIT_REFUSED_PRODUCTION
|
|
|
|
# Checked before the password prompt, not after. Every role except
|
|
# platform_admin is organization-scoped, so such a request cannot be
|
|
# satisfied. Reporting it afterwards would mean the operator types a
|
|
# one-time password and only then learns it was never going to be used.
|
|
refusal = _unsatisfiable_assignment(args)
|
|
if refusal is not None:
|
|
print(f"Refusing to run: {refusal}", file=err)
|
|
return EXIT_ERROR
|
|
|
|
try:
|
|
_require_interactive(stream)
|
|
except OperatorRefusalError as refusal_error:
|
|
print(f"Refusing to run: {refusal_error.message}", file=err)
|
|
return EXIT_NOT_INTERACTIVE
|
|
|
|
password = _read_confirmed_password(prompt, err=err)
|
|
if password is None:
|
|
return EXIT_PASSWORD_REJECTED
|
|
|
|
try:
|
|
account = _provision(settings, args=args, password=password)
|
|
except PasswordPolicyError as error:
|
|
requirements = ", ".join(error.unmet_requirements) or "policy"
|
|
print(f"Refusing to run: the password does not meet the policy ({requirements}).", file=err)
|
|
return EXIT_PASSWORD_REJECTED
|
|
except (UmbraWebError, OSError) as error:
|
|
print(f"Failed to provision the bootstrap account: {_safe_error_text(error)}", file=err)
|
|
return EXIT_ERROR
|
|
|
|
# Drop the reference as soon as the hash exists. Python cannot wipe an
|
|
# immutable string reliably, which is exactly why the prompt is the only
|
|
# place the value ever exists and why it is never written anywhere.
|
|
password = ""
|
|
|
|
repository_path = settings.repository_path
|
|
mode = ""
|
|
if repository_path is not None and repository_path.exists():
|
|
mode = oct(stat.S_IMODE(repository_path.stat().st_mode))
|
|
print("Bootstrap account created.", file=out)
|
|
print(f" username: {account.username}", file=out)
|
|
print(f" account id: {account.id}", file=out)
|
|
print(f" role: {account.role}", file=out)
|
|
print(f" status: {account.status}", file=out)
|
|
print(f" must_change_password: {str(account.credential.must_change_password).lower()}", file=out)
|
|
if repository_path is not None:
|
|
print(f" development store: {repository_path} (mode {mode or 'created on first write'})", file=out)
|
|
print(
|
|
" The one-time password was not stored. The first login must change it before any other call succeeds.",
|
|
file=out,
|
|
)
|
|
return EXIT_OK
|
|
|
|
|
|
def _environment_is_production(environ: dict[str, str] | None = None) -> bool:
|
|
"""Report whether the environment selects production mode.
|
|
|
|
Checked before any settings are built so that a production environment
|
|
always produces the dedicated refusal exit code, even when the remaining
|
|
production configuration is incomplete.
|
|
"""
|
|
|
|
env = os.environ if environ is None else environ
|
|
return env.get("UMBRA_ENV", "").strip().lower() in {"production", "prod"}
|
|
|
|
|
|
def _bootstrap_settings(repository_path: str | None) -> Settings:
|
|
"""Load settings for the bootstrap run, forcing the development repository."""
|
|
|
|
settings = load_settings()
|
|
if settings.is_production:
|
|
raise OperatorRefusalError(
|
|
"production_refused",
|
|
"the bootstrap command must not run with production configuration",
|
|
)
|
|
path = Path(repository_path).expanduser() if repository_path else _default_repository_path()
|
|
return replace(settings, repository_kind="json", repository_path=path)
|
|
|
|
|
|
def _read_confirmed_password(prompt: Callable[[str], str], *, err: TextIO) -> str | None:
|
|
"""Prompt twice and require an exact match. Nothing is echoed."""
|
|
|
|
first = prompt("One-time password (input hidden): ")
|
|
second = prompt("Repeat one-time password: ")
|
|
if not first or not hmac.compare_digest(first, second):
|
|
print("Refusing to run: the two entries did not match.", file=err)
|
|
return None
|
|
return first
|
|
|
|
|
|
def _unsatisfiable_assignment(args: argparse.Namespace) -> str | None:
|
|
"""Return why the requested role and organization cannot both be honoured, if so.
|
|
|
|
``user_roles_check`` in the control plane makes an organization-scoped
|
|
assignment without an organization impossible, and every role other than
|
|
``platform_admin`` is organization-scoped. Naming the mismatch here keeps the
|
|
reason in front of the operator instead of buried in a domain error.
|
|
"""
|
|
|
|
if args.role == str(RoleName.PLATFORM_ADMIN) or args.organization_id:
|
|
return None
|
|
return (
|
|
f"role {args.role!r} is scoped to an organization, so --organization-id is required. "
|
|
"Only a platform administrator can be created without one."
|
|
)
|
|
|
|
|
|
def _provision(settings: Settings, *, args: argparse.Namespace, password: str) -> AccountView:
|
|
"""Create the account in the local development repository."""
|
|
|
|
from .dev_repository import JsonDevelopmentRepository
|
|
|
|
repository = JsonDevelopmentRepository(settings)
|
|
service = AccountService(repository, settings)
|
|
organization_id = UUID(args.organization_id) if args.organization_id else None
|
|
return service.create_account(
|
|
enrollment_mode="temporary_password",
|
|
username=args.username,
|
|
display_name=args.display_name,
|
|
role=args.role,
|
|
email=args.email,
|
|
organization_id=organization_id,
|
|
password=password,
|
|
operator=OperatorContext(source="operator-cli", interactive=True),
|
|
)
|
|
|
|
|
|
def _safe_error_text(error: BaseException) -> str:
|
|
"""Render an error without echoing any submitted value."""
|
|
|
|
if isinstance(error, InputError):
|
|
return error.message
|
|
return type(error).__name__
|
|
|
|
|
|
LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"})
|
|
|
|
|
|
def _is_private_lan_address(host: str) -> bool:
|
|
"""Whether an address is private and non-loopback.
|
|
|
|
Checked with the standard library's own parser rather than by string
|
|
matching, so an address that merely looks private, such as ``0.0.0.0`` or a
|
|
hostname, is rejected. A hostname is rejected outright because resolving it
|
|
here would mean a name could point at a public address at bind time and
|
|
somewhere else later.
|
|
"""
|
|
|
|
import ipaddress
|
|
|
|
try:
|
|
address = ipaddress.ip_address(host)
|
|
except ValueError:
|
|
return False
|
|
return address.is_private and not address.is_loopback and not address.is_unspecified
|
|
|
|
|
|
def serve(
|
|
argv: Sequence[str] | None = None,
|
|
*,
|
|
stdout: TextIO | None = None,
|
|
stderr: TextIO | None = None,
|
|
) -> int:
|
|
"""Serve the application with uvicorn.
|
|
|
|
This is both the development command and the deployment command. It serves the
|
|
API and the frontend from one origin, so there is no second server to configure
|
|
and no CORS to get wrong. The defaults are development ones: loopback, an
|
|
in-memory repository, and a refusal to run with production configuration.
|
|
Passing the two opt-in flags below makes it a deployment.
|
|
"""
|
|
|
|
parser = argparse.ArgumentParser(
|
|
prog="umbratrans-web-serve",
|
|
description=(
|
|
"Serve the UmbraTrans account-management API and frontend. Defaults are for local "
|
|
"development; a deployment behind a TLS terminating reverse proxy needs "
|
|
"--allow-production and --allow-lan-bind."
|
|
),
|
|
)
|
|
parser.add_argument("--host", default="127.0.0.1", help="bind address (default: loopback)")
|
|
parser.add_argument("--port", type=int, default=8000, help="bind port (default: 8000)")
|
|
parser.add_argument(
|
|
"--repository",
|
|
default="memory",
|
|
choices=["memory", "json", "postgres"],
|
|
help=(
|
|
"development repository. 'postgres' runs against the authoritative store for testing, "
|
|
"and requires UMBRATRANS_DATABASE_DSN"
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--allow-production",
|
|
action="store_true",
|
|
help=(
|
|
"permit running with UMBRA_ENV=production. Required by name; the configuration gate "
|
|
"still refuses a deployment without a real database, secure cookies, CSRF, and working mail."
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--allow-lan-bind",
|
|
action="store_true",
|
|
help=(
|
|
"permit binding a private LAN address so a reverse proxy on another host can reach the "
|
|
"application. Refused for a public address, and refused by default."
|
|
),
|
|
)
|
|
args = parser.parse_args(list(argv) if argv is not None else None)
|
|
|
|
if os.environ.get("UMBRA_ENV", "").strip().lower() in {"production", "prod"} and not args.allow_production:
|
|
# The configuration gate is the real control: production cannot be
|
|
# configured without a PostgreSQL DSN, Secure cookies, CSRF, an absolute
|
|
# verification base URL, and a mail transport, and it refuses a
|
|
# file-backed repository outright. This flag is an acknowledgement on top
|
|
# of that, so serving real data is always something an operator asked for
|
|
# by name rather than something that happened because a variable was set.
|
|
print(
|
|
"Refusing to serve: this command must be started with --allow-production to run with "
|
|
"production configuration. Local development needs no such flag.",
|
|
file=stderr or sys.stderr,
|
|
)
|
|
return EXIT_REFUSED_PRODUCTION
|
|
if args.host not in LOOPBACK_HOSTS:
|
|
# A reverse proxy reaches the application from another host, so the
|
|
# loopback-only default cannot serve a real deployment. It is still the
|
|
# default, and this branch is the only way past it, which keeps an
|
|
# accidental `--host 0.0.0.0` from quietly exposing the application to
|
|
# every interface on the machine.
|
|
#
|
|
# A specific private address is required rather than a wildcard: naming
|
|
# the interface the proxy actually uses is the whole authorisation, and
|
|
# `0.0.0.0` would also bind the Docker bridges present on this host.
|
|
if not args.allow_lan_bind:
|
|
print(
|
|
"Refusing to serve: bind to the loopback interface unless --allow-lan-bind is given. "
|
|
"The development default is loopback; a deployment behind a reverse proxy on another "
|
|
"host must opt in explicitly and name the address it binds.",
|
|
file=stderr or sys.stderr,
|
|
)
|
|
return EXIT_REFUSED_PRODUCTION
|
|
if not _is_private_lan_address(args.host):
|
|
print(
|
|
f"Refusing to serve: {args.host!r} is not a private LAN address. This flag is for a "
|
|
"reverse proxy on the local network, not for a public interface. Use a TLS "
|
|
"terminating proxy for anything reachable from the internet.",
|
|
file=stderr or sys.stderr,
|
|
)
|
|
return EXIT_REFUSED_PRODUCTION
|
|
print(
|
|
f"Binding {args.host}:{args.port}, a private LAN address. Traffic to this port is "
|
|
"unencrypted and unauthenticated: put a TLS terminating reverse proxy in front of it.",
|
|
file=stderr or sys.stderr,
|
|
)
|
|
|
|
import uvicorn
|
|
|
|
from .app import create_app
|
|
|
|
settings = load_settings({**os.environ, "UMBRATRANS_REPOSITORY": args.repository})
|
|
if args.repository == "json":
|
|
settings = settings.with_development_path_defaults()
|
|
uvicorn.run(
|
|
create_app(settings=settings),
|
|
host=args.host,
|
|
port=args.port,
|
|
log_level="info",
|
|
)
|
|
return EXIT_OK
|
|
|
|
|
|
def _default_ledger_path() -> Path:
|
|
return (DEVELOPMENT_DATA_ROOT / AUDIO_IMPORT_LEDGER_NAME).resolve()
|
|
|
|
|
|
class _JsonAudioImportLedger:
|
|
"""Development stand-in for the PostgreSQL audio-import store.
|
|
|
|
It exists so the file-based import can be exercised end to end, including
|
|
idempotency, before the source-import migration is applied and a database
|
|
adapter exists. It is deliberately not suitable for anything else: it refuses
|
|
production, writes with mode ``0600`` inside a ``0700`` directory, and writes
|
|
atomically. It stores a summary of what it committed - the project, the
|
|
counts, and the warnings - so a reviewer can read what an import did without
|
|
a database and without a nine-megabyte file per book.
|
|
"""
|
|
|
|
def __init__(self, path: Path) -> None:
|
|
self.path = path
|
|
|
|
def _load(self) -> dict[str, Any]:
|
|
if not self.path.exists():
|
|
return {"schema_version": 1, "imports": [], "plans": {}}
|
|
try:
|
|
document = json.loads(self.path.read_text(encoding="utf-8"))
|
|
except (OSError, json.JSONDecodeError):
|
|
return {"schema_version": 1, "imports": [], "plans": {}}
|
|
if not isinstance(document, dict):
|
|
return {"schema_version": 1, "imports": [], "plans": {}}
|
|
document.setdefault("imports", [])
|
|
document.setdefault("plans", {})
|
|
return document
|
|
|
|
def last_content_hash(self, external_id: str) -> str | None:
|
|
record = self._load()["plans"].get(external_id)
|
|
return record.get("content_hash") if isinstance(record, dict) else None
|
|
|
|
def commit(self, plan, *, imported_at: datetime): # type: ignore[no-untyped-def]
|
|
from .audio_import import ImportResult
|
|
|
|
document = self._load()
|
|
result = ImportResult(
|
|
project_id=uuid4(),
|
|
import_id=uuid4(),
|
|
external_id=plan.project.external_id,
|
|
content_hash=plan.project.content_hash,
|
|
counts=plan.counts(),
|
|
imported_at=imported_at,
|
|
)
|
|
document["imports"].append(
|
|
{
|
|
"import_id": str(result.import_id),
|
|
"project_id": str(result.project_id),
|
|
"external_id": result.external_id,
|
|
"content_hash": result.content_hash,
|
|
"counts": result.counts,
|
|
"imported_at": imported_at.isoformat(),
|
|
}
|
|
)
|
|
document["plans"][plan.project.external_id] = {
|
|
"content_hash": plan.project.content_hash,
|
|
"imported_at": imported_at.isoformat(),
|
|
"project": json.loads(json.dumps(asdict(plan.project), default=str)),
|
|
"counts": plan.counts(),
|
|
"warnings": [asdict(warning) for warning in plan.warnings],
|
|
}
|
|
self._write(document)
|
|
return result
|
|
|
|
def _write(self, document: dict[str, Any]) -> None:
|
|
directory = self.path.parent
|
|
directory.mkdir(parents=True, exist_ok=True, mode=LEDGER_DIRECTORY_MODE)
|
|
payload = json.dumps(document, indent=2, sort_keys=True, default=str)
|
|
handle = tempfile.NamedTemporaryFile(
|
|
"w", dir=directory, prefix=".audio-ledger-", delete=False, encoding="utf-8"
|
|
)
|
|
try:
|
|
handle.write(payload)
|
|
handle.flush()
|
|
os.fsync(handle.fileno())
|
|
finally:
|
|
handle.close()
|
|
os.chmod(handle.name, LEDGER_FILE_MODE)
|
|
os.replace(handle.name, self.path)
|
|
|
|
|
|
def import_audio(
|
|
argv: Sequence[str] | None = None,
|
|
*,
|
|
stdout: TextIO | None = None,
|
|
stderr: TextIO | None = None,
|
|
) -> int:
|
|
"""Plan, and optionally commit, a file-based audio-bundle import.
|
|
|
|
The default is a dry run: it validates the bundle and prints exactly what it
|
|
would write. A commit requires ``--confirm`` carrying the bundle's content
|
|
hash, because a bare ``--commit`` is too easy to leave in a shell history or a
|
|
script. Nothing is written without that confirmation, and production is
|
|
refused.
|
|
"""
|
|
|
|
from .audio_import import BundleError, import_bundle, read_bundle, validate_bundle
|
|
|
|
parser = argparse.ArgumentParser(
|
|
prog="umbratrans-import-audio",
|
|
description=(
|
|
"Import an UmbraAudio metadata bundle. Defaults to a dry run: it validates the bundle and "
|
|
"prints the plan. A commit needs --organization-id and --confirm <content_hash>."
|
|
),
|
|
)
|
|
parser.add_argument("--bundle", required=True, help="path to the bundle directory (bundle.json + manifest.jsonl)")
|
|
parser.add_argument("--organization-id", default=None, help="organization the project belongs to (UUID)")
|
|
parser.add_argument("--project-name", default=None, help="override the imported project name")
|
|
parser.add_argument("--project-slug", default=None, help="override the imported project slug")
|
|
parser.add_argument("--node-id", default=None, help="override the source node id")
|
|
parser.add_argument(
|
|
"--commit",
|
|
action="store_true",
|
|
help="write the plan instead of only reporting it (requires --organization-id and --confirm)",
|
|
)
|
|
parser.add_argument(
|
|
"--confirm",
|
|
default=None,
|
|
help="the bundle's content hash, echoing what is being imported. Required to commit.",
|
|
)
|
|
parser.add_argument(
|
|
"--ledger",
|
|
default=None,
|
|
help=f"development ledger file (default: {_default_ledger_path()})",
|
|
)
|
|
args = parser.parse_args(list(argv) if argv is not None else None)
|
|
out: TextIO = stdout or sys.stdout
|
|
err: TextIO = stderr or sys.stderr
|
|
|
|
if _environment_is_production():
|
|
print("Refusing to run: the audio import is development-only until the source-import migration is applied.", file=err)
|
|
return EXIT_REFUSED_PRODUCTION
|
|
|
|
try:
|
|
bundle = read_bundle(args.bundle)
|
|
except BundleError as error:
|
|
print(f"Cannot read bundle: {error.message}", file=err)
|
|
return EXIT_ERROR
|
|
|
|
problems = validate_bundle(bundle)
|
|
if problems:
|
|
print(_render_validation_failure(problems, args.bundle), file=err)
|
|
return EXIT_VALIDATION_FAILED
|
|
|
|
if args.commit and not args.organization_id:
|
|
print("Refusing to commit: --organization-id is required.", file=err)
|
|
return EXIT_ERROR
|
|
|
|
organization_id = UUID(args.organization_id) if args.organization_id else uuid4()
|
|
ledger = _JsonAudioImportLedger(Path(args.ledger).expanduser() if args.ledger else _default_ledger_path())
|
|
|
|
try:
|
|
outcome = import_bundle(
|
|
bundle,
|
|
ledger,
|
|
organization_id=organization_id,
|
|
project_name=args.project_name,
|
|
project_slug=args.project_slug,
|
|
node_id=args.node_id,
|
|
dry_run=not args.commit,
|
|
confirm=args.confirm,
|
|
)
|
|
except BundleError as error:
|
|
print(f"Refusing to run: {error.message}", file=err)
|
|
return EXIT_ERROR
|
|
|
|
print(outcome.report, file=out)
|
|
if outcome.status == "imported" and outcome.result is not None:
|
|
print(f"\nimported: import_id={outcome.result.import_id} project_id={outcome.result.project_id}", file=out)
|
|
elif outcome.status == "dry_run":
|
|
print("\ndry run: nothing written. Re-run with --commit --confirm <content_hash> to import.", file=out)
|
|
return EXIT_OK
|
|
|
|
|
|
def _render_validation_failure(problems: list[str], bundle_path: str) -> str:
|
|
lines = [f"VALIDATION FAILED for {bundle_path}: {len(problems)} problem(s)"]
|
|
lines.extend(f" - {problem}" for problem in problems)
|
|
return "\n".join(lines)
|
|
|
|
|
|
def main(argv: Sequence[str] | None = None) -> int:
|
|
"""Console-script entry point for ``umbratrans-bootstrap-admin``."""
|
|
|
|
return bootstrap_admin(argv)
|
|
|
|
|
|
__all__ = [
|
|
"BOOTSTRAP_DISPLAY_NAME",
|
|
"BOOTSTRAP_USERNAME",
|
|
"EXIT_NOT_INTERACTIVE",
|
|
"EXIT_OK",
|
|
"EXIT_PASSWORD_REJECTED",
|
|
"EXIT_REFUSED_PRODUCTION",
|
|
"EXIT_VALIDATION_FAILED",
|
|
"bootstrap_admin",
|
|
"build_parser",
|
|
"import_audio",
|
|
"main",
|
|
"serve",
|
|
]
|