Prepare GovOPlaN self-hosted release workflow
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable, Mapping, Sequence
|
||||
from collections.abc import Callable, Iterable, Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from typing import Literal, Protocol, cast, runtime_checkable
|
||||
@@ -24,9 +24,13 @@ CAPABILITY_ACCESS_ADMINISTRATION = "access.administration"
|
||||
CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER = "access.governanceMaterializer"
|
||||
CAPABILITY_TENANCY_TENANT_RESOLVER = "tenancy.tenantResolver"
|
||||
CAPABILITY_AUDIT_SINK = "audit.sink"
|
||||
CAPABILITY_AUDIT_RECORDER = "audit.recorder"
|
||||
CAPABILITY_AUDIT_RETENTION = "audit.retention"
|
||||
|
||||
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER = "auth.apiPrincipalProvider"
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER = "auth.principalResolver"
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR = "auth.permissionEvaluator"
|
||||
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER = "auth.tenantContextSwitcher"
|
||||
|
||||
ACCESS_CAPABILITY_NAMES = frozenset(
|
||||
{
|
||||
@@ -41,15 +45,21 @@ ACCESS_CAPABILITY_NAMES = frozenset(
|
||||
CAPABILITY_ACCESS_GOVERNANCE_MATERIALIZER,
|
||||
CAPABILITY_TENANCY_TENANT_RESOLVER,
|
||||
CAPABILITY_AUDIT_SINK,
|
||||
CAPABILITY_AUDIT_RECORDER,
|
||||
CAPABILITY_AUDIT_RETENTION,
|
||||
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER,
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER,
|
||||
}
|
||||
)
|
||||
|
||||
AUTH_CAPABILITY_NAMES = frozenset(
|
||||
{
|
||||
CAPABILITY_AUTH_API_PRINCIPAL_PROVIDER,
|
||||
CAPABILITY_AUTH_PRINCIPAL_RESOLVER,
|
||||
CAPABILITY_AUTH_PERMISSION_EVALUATOR,
|
||||
CAPABILITY_AUTH_TENANT_CONTEXT_SWITCHER,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -302,6 +312,26 @@ class TenantOwnerCandidateRef:
|
||||
display_name: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CreatedApiKeyRef:
|
||||
id: str
|
||||
secret: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DevelopmentBootstrapRef:
|
||||
user: UserRef
|
||||
created_api_key: CreatedApiKeyRef | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TenantContextSwitchRef:
|
||||
account_id: str
|
||||
membership_id: str
|
||||
tenant_id: str
|
||||
session_id: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GovernanceTemplateMaterialization:
|
||||
template_id: str
|
||||
@@ -321,7 +351,10 @@ class AuditEvent:
|
||||
action: str
|
||||
outcome: AuditOutcome = "unknown"
|
||||
actor: PrincipalRef | None = None
|
||||
scope: Literal["tenant", "system"] = "tenant"
|
||||
tenant_id: str | None = None
|
||||
user_id: str | None = None
|
||||
api_key_id: str | None = None
|
||||
resource_type: str | None = None
|
||||
resource_id: str | None = None
|
||||
occurred_at: datetime | None = None
|
||||
@@ -330,12 +363,45 @@ class AuditEvent:
|
||||
details: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AuditRecordRef:
|
||||
id: str
|
||||
scope: str
|
||||
tenant_id: str | None
|
||||
user_id: str | None
|
||||
api_key_id: str | None
|
||||
action: str
|
||||
object_type: str | None = None
|
||||
object_id: str | None = None
|
||||
details: Mapping[str, object] = field(default_factory=dict)
|
||||
created_at: datetime | None = None
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PrincipalResolver(Protocol):
|
||||
def resolve_request(self, request: object, *, session: object | None = None) -> PrincipalRef:
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class ApiPrincipalProvider(Protocol):
|
||||
def resolve_api_principal(
|
||||
self,
|
||||
request: object,
|
||||
session: object,
|
||||
*,
|
||||
authorization: str | None = None,
|
||||
x_api_key: str | None = None,
|
||||
) -> object:
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class TenantContextSwitcher(Protocol):
|
||||
def switch_tenant_context(self, session: object, *, principal: object, tenant_id: str) -> TenantContextSwitchRef:
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class TenantResolver(Protocol):
|
||||
def get_tenant(self, tenant_id: str) -> TenantRef | None:
|
||||
@@ -467,9 +533,24 @@ class TenantAccessProvisioner(Protocol):
|
||||
) -> str:
|
||||
...
|
||||
|
||||
def ensure_development_admin(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
tenant: object,
|
||||
user_email: str,
|
||||
user_password: str,
|
||||
api_key_secret: str | None,
|
||||
scopes: Sequence[str],
|
||||
) -> DevelopmentBootstrapRef:
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class AccessAdministration(Protocol):
|
||||
def tenant_counts(self, session: object, tenant_id: str) -> Mapping[str, int]:
|
||||
...
|
||||
|
||||
def system_account_count(self, session: object) -> int:
|
||||
...
|
||||
|
||||
@@ -485,6 +566,32 @@ class AccessAdministration(Protocol):
|
||||
def user_ids_for_actor_filter(self, session: object, *, operator: str, value: str) -> Sequence[str]:
|
||||
...
|
||||
|
||||
def user_settings(self, session: object, user_id: str, *, tenant_id: str) -> Mapping[str, object] | None:
|
||||
...
|
||||
|
||||
def set_user_settings(
|
||||
self,
|
||||
session: object,
|
||||
user_id: str,
|
||||
*,
|
||||
tenant_id: str,
|
||||
settings: Mapping[str, object],
|
||||
) -> Mapping[str, object] | None:
|
||||
...
|
||||
|
||||
def group_settings(self, session: object, group_id: str, *, tenant_id: str) -> Mapping[str, object] | None:
|
||||
...
|
||||
|
||||
def set_group_settings(
|
||||
self,
|
||||
session: object,
|
||||
group_id: str,
|
||||
*,
|
||||
tenant_id: str,
|
||||
settings: Mapping[str, object],
|
||||
) -> Mapping[str, object] | None:
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class AccessGovernanceMaterializer(Protocol):
|
||||
@@ -499,3 +606,22 @@ class AccessGovernanceMaterializer(Protocol):
|
||||
class AuditSink(Protocol):
|
||||
def record(self, event: AuditEvent) -> None:
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class AuditRecorder(Protocol):
|
||||
def record_event(self, session: object, event: AuditEvent) -> AuditRecordRef:
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class AuditRetentionProvider(Protocol):
|
||||
def apply_detail_retention(
|
||||
self,
|
||||
session: object,
|
||||
*,
|
||||
dry_run: bool,
|
||||
now: datetime,
|
||||
policy_for_record: Callable[[AuditRecordRef], object],
|
||||
) -> Mapping[str, int]:
|
||||
...
|
||||
|
||||
@@ -205,7 +205,21 @@ _CONFIGURATION_FIELD_SAFETY: tuple[ConfigurationFieldSafety, ...] = (
|
||||
policy_explanation_required=True,
|
||||
audit_event="organizations.model.updated",
|
||||
rollback_history_required=True,
|
||||
notes="Organization meta-model, concrete units, relations, functions, and assignments can be governed per tenant.",
|
||||
notes="Organization meta-model, concrete units, relations, and function definitions can be governed per tenant.",
|
||||
),
|
||||
ConfigurationFieldSafety(
|
||||
key="idm.organization_assignments",
|
||||
label="IDM organization function assignments",
|
||||
owner_module="idm",
|
||||
scope="tenant",
|
||||
storage="module_settings",
|
||||
ui_managed=True,
|
||||
risk="high",
|
||||
dry_run_required=True,
|
||||
policy_explanation_required=True,
|
||||
audit_event="idm.organization_assignment.updated",
|
||||
rollback_history_required=True,
|
||||
notes="Identity-to-organization-function assignments can grant role-derived access when access consumes IDM assignment links.",
|
||||
),
|
||||
ConfigurationFieldSafety(
|
||||
key="mail_profiles.credentials",
|
||||
|
||||
52
src/govoplan_core/core/idm.py
Normal file
52
src/govoplan_core/core/idm.py
Normal file
@@ -0,0 +1,52 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Literal, Protocol, runtime_checkable
|
||||
|
||||
|
||||
IDM_MODULE_ID = "idm"
|
||||
CAPABILITY_IDM_DIRECTORY = "idm.directory"
|
||||
|
||||
IdmStatus = Literal["active", "inactive", "suspended"]
|
||||
OrganizationFunctionAssignmentSource = Literal["direct", "delegated", "acting_for", "directory", "governance", "system"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OrganizationFunctionAssignmentRef:
|
||||
id: str
|
||||
tenant_id: str
|
||||
identity_id: str
|
||||
function_id: str
|
||||
organization_unit_id: str
|
||||
account_id: str | None = None
|
||||
applies_to_subunits: bool = False
|
||||
source: OrganizationFunctionAssignmentSource = "direct"
|
||||
delegated_from_assignment_id: str | None = None
|
||||
acting_for_account_id: str | None = None
|
||||
valid_from: datetime | None = None
|
||||
valid_until: datetime | None = None
|
||||
status: IdmStatus = "active"
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class IdmDirectory(Protocol):
|
||||
def get_organization_function_assignment(self, assignment_id: str) -> OrganizationFunctionAssignmentRef | None:
|
||||
...
|
||||
|
||||
def organization_function_assignments_for_identity(
|
||||
self,
|
||||
identity_id: str,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
) -> Sequence[OrganizationFunctionAssignmentRef]:
|
||||
...
|
||||
|
||||
def organization_function_assignments_for_account(
|
||||
self,
|
||||
account_id: str,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
) -> Sequence[OrganizationFunctionAssignmentRef]:
|
||||
...
|
||||
314
src/govoplan_core/core/install_config.py
Normal file
314
src/govoplan_core/core/install_config.py
Normal file
@@ -0,0 +1,314 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Literal
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
from sqlalchemy.engine import make_url
|
||||
|
||||
|
||||
ConfigIssueLevel = Literal["error", "warning"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConfigIssue:
|
||||
level: ConfigIssueLevel
|
||||
key: str
|
||||
message: str
|
||||
action: str
|
||||
|
||||
def to_dict(self) -> dict[str, str]:
|
||||
return {
|
||||
"level": self.level,
|
||||
"key": self.key,
|
||||
"message": self.message,
|
||||
"action": self.action,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConfigValidationResult:
|
||||
profile: str
|
||||
issues: tuple[ConfigIssue, ...]
|
||||
|
||||
@property
|
||||
def errors(self) -> tuple[ConfigIssue, ...]:
|
||||
return tuple(issue for issue in self.issues if issue.level == "error")
|
||||
|
||||
@property
|
||||
def warnings(self) -> tuple[ConfigIssue, ...]:
|
||||
return tuple(issue for issue in self.issues if issue.level == "warning")
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return not self.errors
|
||||
|
||||
def to_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"profile": self.profile,
|
||||
"ok": self.ok,
|
||||
"errors": [issue.to_dict() for issue in self.errors],
|
||||
"warnings": [issue.to_dict() for issue in self.warnings],
|
||||
}
|
||||
|
||||
def to_json(self) -> str:
|
||||
return json.dumps(self.to_dict(), indent=2, sort_keys=True)
|
||||
|
||||
def to_text(self) -> str:
|
||||
lines = [
|
||||
f"GovOPlaN configuration validation: {'OK' if self.ok else 'FAILED'}",
|
||||
f"Profile: {self.profile}",
|
||||
]
|
||||
if not self.issues:
|
||||
lines.append("No configuration issues found.")
|
||||
return "\n".join(lines)
|
||||
for issue in self.issues:
|
||||
lines.append(f"- {issue.level.upper()} {issue.key}: {issue.message}")
|
||||
lines.append(f" Action: {issue.action}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
_LOCAL_PROFILES = {"dev", "local", "local-dev", "test"}
|
||||
_PRODUCTION_PROFILES = {"prod", "production", "self-hosted"}
|
||||
_PRODUCTION_LIKE_PROFILES = {"staging", "production-like", "production-like-dev"}
|
||||
_DEFAULT_LOCAL_CORS = {
|
||||
"http://localhost:5173",
|
||||
"http://127.0.0.1:5173",
|
||||
"http://localhost:8080",
|
||||
}
|
||||
|
||||
|
||||
def normalize_install_profile(value: str | None) -> str:
|
||||
profile = (value or "").strip().lower().replace("_", "-")
|
||||
if profile in {"prod", "production"}:
|
||||
return "production"
|
||||
if profile in {"selfhosted", "self-hosted"}:
|
||||
return "self-hosted"
|
||||
if profile in {"productionlike", "production-like"}:
|
||||
return "production-like"
|
||||
if profile in {"local", "local-dev"}:
|
||||
return "local-dev"
|
||||
return profile or "local-dev"
|
||||
|
||||
|
||||
def generate_master_key() -> str:
|
||||
return Fernet.generate_key().decode("ascii")
|
||||
|
||||
|
||||
def env_template(*, profile: str = "self-hosted", generate_secrets: bool = False) -> str:
|
||||
clean_profile = normalize_install_profile(profile)
|
||||
master_key = generate_master_key() if generate_secrets else "<generate-with-govoplan-config-env-template-generate-secrets>"
|
||||
if clean_profile == "production-like":
|
||||
return _production_like_env_template(master_key)
|
||||
return _self_hosted_env_template(master_key)
|
||||
|
||||
|
||||
def validate_runtime_configuration(
|
||||
environ: Mapping[str, str] | None = None,
|
||||
*,
|
||||
profile: str | None = None,
|
||||
strict: bool = False,
|
||||
) -> ConfigValidationResult:
|
||||
env = dict(os.environ if environ is None else environ)
|
||||
clean_profile = normalize_install_profile(profile or env.get("GOVOPLAN_INSTALL_PROFILE") or env.get("APP_ENV"))
|
||||
issues: list[ConfigIssue] = []
|
||||
|
||||
production = clean_profile in _PRODUCTION_PROFILES or env.get("APP_ENV", "").strip().lower() in {"prod", "production"}
|
||||
production_like = production or clean_profile in _PRODUCTION_LIKE_PROFILES
|
||||
local = clean_profile in _LOCAL_PROFILES and not production_like
|
||||
|
||||
def issue(level: ConfigIssueLevel, key: str, message: str, action: str) -> None:
|
||||
if strict and level == "warning":
|
||||
level = "error"
|
||||
issues.append(ConfigIssue(level=level, key=key, message=message, action=action))
|
||||
|
||||
app_env = _clean(env.get("APP_ENV"))
|
||||
if not app_env and production_like:
|
||||
issue("error", "APP_ENV", "APP_ENV is missing for a production-like install.", "Set APP_ENV=staging, APP_ENV=production, or another explicit deployment profile.")
|
||||
elif app_env.lower() in {"dev", "test", "local"} and production_like:
|
||||
issue("error", "APP_ENV", f"APP_ENV={app_env!r} is not valid for profile {clean_profile!r}.", "Use APP_ENV=staging for production-like testing or APP_ENV=production for a real deployment.")
|
||||
|
||||
database_url = _clean(env.get("DATABASE_URL"))
|
||||
if not database_url:
|
||||
issue("error", "DATABASE_URL", "DATABASE_URL is missing.", "Set DATABASE_URL to the PostgreSQL SQLAlchemy URL used by the API and modules.")
|
||||
else:
|
||||
backend = _database_backend(database_url)
|
||||
if backend is None:
|
||||
issue("error", "DATABASE_URL", "DATABASE_URL is not a valid SQLAlchemy URL.", "Use a value like postgresql+psycopg://user:password@host:5432/database.")
|
||||
elif backend == "sqlite" and production_like:
|
||||
issue("error", "DATABASE_URL", "SQLite is only supported for disposable local development.", "Use PostgreSQL for production-like and self-hosted installs.")
|
||||
elif backend != "postgresql" and production:
|
||||
issue("warning", "DATABASE_URL", f"Database backend {backend!r} is not the preferred production target.", "Use PostgreSQL unless this deployment has an explicit support decision.")
|
||||
if backend == "postgresql" and not _clean(env.get("GOVOPLAN_DATABASE_URL_PGTOOLS")):
|
||||
issue("warning", "GOVOPLAN_DATABASE_URL_PGTOOLS", "PostgreSQL backup/restore tools URL is missing.", "Set GOVOPLAN_DATABASE_URL_PGTOOLS to the same database without the SQLAlchemy driver marker, for example postgresql://user:password@host:5432/database.")
|
||||
|
||||
master_key = _clean(env.get("MASTER_KEY_B64"))
|
||||
if not master_key and not local:
|
||||
issue("error", "MASTER_KEY_B64", "MASTER_KEY_B64 is required outside local dev/test.", "Generate a Fernet key with `govoplan-config env-template --generate-secrets` or `python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())'` and store it in deployment secrets.")
|
||||
elif master_key:
|
||||
error = _master_key_error(master_key)
|
||||
if error:
|
||||
issue("error", "MASTER_KEY_B64", error, "Replace MASTER_KEY_B64 with a Fernet key or base64-encoded 32-byte key.")
|
||||
elif "change-me" in master_key.lower() or "generate" in master_key.lower():
|
||||
issue("error", "MASTER_KEY_B64", "MASTER_KEY_B64 still looks like a placeholder.", "Generate a real deployment key and store it outside git.")
|
||||
|
||||
enabled_modules = _csv(env.get("ENABLED_MODULES"))
|
||||
if not enabled_modules and production_like:
|
||||
issue("error", "ENABLED_MODULES", "ENABLED_MODULES is missing.", "Set ENABLED_MODULES explicitly so startup module composition is intentional.")
|
||||
elif "access" not in enabled_modules and production_like:
|
||||
issue("error", "ENABLED_MODULES", "The access module is not enabled.", "Include `access` unless this deployment has a replacement auth/principal provider.")
|
||||
elif enabled_modules and "admin" not in enabled_modules and production_like:
|
||||
issue("warning", "ENABLED_MODULES", "The admin module is not enabled.", "Keep `admin` enabled for operator UI unless this is a deliberately headless install.")
|
||||
|
||||
celery_enabled = _truthy(env.get("CELERY_ENABLED"))
|
||||
if celery_enabled and not _clean(env.get("REDIS_URL")):
|
||||
issue("error", "REDIS_URL", "CELERY_ENABLED=true but REDIS_URL is missing.", "Set REDIS_URL to the Redis broker/result backend used by workers.")
|
||||
|
||||
if production and _truthy(env.get("DEV_BOOTSTRAP_ENABLED")):
|
||||
issue("error", "DEV_BOOTSTRAP_ENABLED", "Development bootstrap is enabled in production.", "Set DEV_BOOTSTRAP_ENABLED=false and create first administrators through the controlled bootstrap path.")
|
||||
|
||||
if production and not _truthy(env.get("AUTH_COOKIE_SECURE")):
|
||||
issue("error", "AUTH_COOKIE_SECURE", "Secure auth cookies are disabled for production.", "Set AUTH_COOKIE_SECURE=true behind HTTPS.")
|
||||
|
||||
cors_origins = _csv(env.get("CORS_ORIGINS"))
|
||||
if production_like and not cors_origins:
|
||||
issue("error", "CORS_ORIGINS", "CORS_ORIGINS is missing.", "Set CORS_ORIGINS to the exact WebUI origin or origins.")
|
||||
elif "*" in cors_origins and production_like:
|
||||
issue("error", "CORS_ORIGINS", "Wildcard CORS is not allowed for production-like installs.", "Replace `*` with exact HTTPS/WebUI origins.")
|
||||
elif production and set(cors_origins) <= _DEFAULT_LOCAL_CORS:
|
||||
issue("warning", "CORS_ORIGINS", "CORS_ORIGINS still contains only local development origins.", "Set CORS_ORIGINS to the deployed WebUI origin.")
|
||||
|
||||
storage_backend = _clean(env.get("FILE_STORAGE_BACKEND")) or "local"
|
||||
if storage_backend == "local":
|
||||
if not _clean(env.get("FILE_STORAGE_LOCAL_ROOT")) and production_like:
|
||||
issue("error", "FILE_STORAGE_LOCAL_ROOT", "Local file storage root is missing.", "Set FILE_STORAGE_LOCAL_ROOT to a durable, backed-up path.")
|
||||
elif production:
|
||||
issue("warning", "FILE_STORAGE_BACKEND", "Production is configured for local file storage.", "Confirm the path is durable and backed up, or use object storage once the deployment needs independent file scaling.")
|
||||
elif storage_backend == "s3":
|
||||
for key in ("FILE_STORAGE_S3_ENDPOINT_URL", "FILE_STORAGE_S3_REGION", "FILE_STORAGE_S3_ACCESS_KEY_ID", "FILE_STORAGE_S3_SECRET_ACCESS_KEY", "FILE_STORAGE_S3_BUCKET"):
|
||||
if not _clean(env.get(key)):
|
||||
issue("error", key, f"{key} is required when FILE_STORAGE_BACKEND=s3.", "Configure all FILE_STORAGE_S3_* settings through deployment secrets.")
|
||||
else:
|
||||
issue("error", "FILE_STORAGE_BACKEND", f"Unsupported FILE_STORAGE_BACKEND={storage_backend!r}.", "Use `local` or `s3`.")
|
||||
|
||||
catalog_source = _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_URL")) or _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG"))
|
||||
if production and catalog_source:
|
||||
if not _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE")):
|
||||
issue("error", "GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE", "A module catalog source is configured without a trusted keyring file.", "Pin the published GovOPlaN catalog keyring locally and set GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE.")
|
||||
if not _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL")):
|
||||
issue("error", "GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL", "A module catalog source is configured without an approved release channel.", "Set GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL=stable or another approved deployment channel.")
|
||||
|
||||
return ConfigValidationResult(profile=clean_profile, issues=tuple(issues))
|
||||
|
||||
|
||||
def _self_hosted_env_template(master_key: str) -> str:
|
||||
return f"""# GovOPlaN self-hosted install configuration.
|
||||
# Copy to a deployment-local .env or secret store. Do not commit populated secrets.
|
||||
|
||||
APP_ENV=production
|
||||
GOVOPLAN_INSTALL_PROFILE=self-hosted
|
||||
MASTER_KEY_B64={master_key}
|
||||
|
||||
DATABASE_URL=postgresql+psycopg://govoplan:change-me@127.0.0.1:5432/govoplan
|
||||
GOVOPLAN_DATABASE_URL_PGTOOLS=postgresql://govoplan:change-me@127.0.0.1:5432/govoplan
|
||||
|
||||
ENABLED_MODULES=tenancy,organizations,identity,access,admin,dashboard,policy,audit,files,mail,campaigns,calendar,docs,ops
|
||||
|
||||
CELERY_ENABLED=true
|
||||
REDIS_URL=redis://127.0.0.1:6379/0
|
||||
CELERY_QUEUES=send_email,append_sent,default
|
||||
|
||||
CORS_ORIGINS=https://govoplan.example.org
|
||||
AUTH_COOKIE_SECURE=true
|
||||
AUTH_COOKIE_SAMESITE=lax
|
||||
AUTH_COOKIE_DOMAIN=
|
||||
|
||||
FILE_STORAGE_BACKEND=local
|
||||
FILE_STORAGE_LOCAL_ROOT=/var/lib/govoplan/files
|
||||
FILE_STORAGE_LOCAL_FALLBACK_ROOTS=
|
||||
|
||||
DEV_AUTO_MIGRATE_ENABLED=false
|
||||
DEV_BOOTSTRAP_ENABLED=false
|
||||
DEV_MAILBOX_API_ENABLED=false
|
||||
|
||||
GOVOPLAN_MODULE_PACKAGE_CATALOG_URL=https://govoplan.add-ideas.de/catalogs/v1/channels/stable.json
|
||||
GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE=/etc/govoplan/catalog-keyring.json
|
||||
GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL=stable
|
||||
"""
|
||||
|
||||
|
||||
def _production_like_env_template(master_key: str) -> str:
|
||||
return f"""# GovOPlaN production-like development profile.
|
||||
# Copy to dev/production-like/.env for local overrides.
|
||||
|
||||
APP_ENV=staging
|
||||
GOVOPLAN_INSTALL_PROFILE=production-like
|
||||
MASTER_KEY_B64={master_key}
|
||||
|
||||
GOVOPLAN_PRODUCTION_LIKE_POSTGRES_DB=govoplan
|
||||
GOVOPLAN_PRODUCTION_LIKE_POSTGRES_USER=govoplan
|
||||
GOVOPLAN_PRODUCTION_LIKE_POSTGRES_PASSWORD=govoplan-dev
|
||||
GOVOPLAN_PRODUCTION_LIKE_POSTGRES_PORT=55433
|
||||
GOVOPLAN_PRODUCTION_LIKE_REDIS_PORT=56379
|
||||
|
||||
GOVOPLAN_PRODUCTION_LIKE_DATABASE_URL=postgresql+psycopg://govoplan:govoplan-dev@127.0.0.1:55433/govoplan
|
||||
GOVOPLAN_PRODUCTION_LIKE_DATABASE_URL_PGTOOLS=postgresql://govoplan:govoplan-dev@127.0.0.1:55433/govoplan
|
||||
GOVOPLAN_PRODUCTION_LIKE_REDIS_URL=redis://127.0.0.1:56379/0
|
||||
|
||||
DATABASE_URL=postgresql+psycopg://govoplan:govoplan-dev@127.0.0.1:55433/govoplan
|
||||
GOVOPLAN_DATABASE_URL_PGTOOLS=postgresql://govoplan:govoplan-dev@127.0.0.1:55433/govoplan
|
||||
REDIS_URL=redis://127.0.0.1:56379/0
|
||||
CELERY_ENABLED=true
|
||||
CELERY_QUEUES=send_email,append_sent,default
|
||||
|
||||
ENABLED_MODULES=tenancy,organizations,identity,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,docs,ops
|
||||
CORS_ORIGINS=http://127.0.0.1:5173,http://localhost:5173
|
||||
AUTH_COOKIE_SECURE=false
|
||||
FILE_STORAGE_BACKEND=local
|
||||
FILE_STORAGE_LOCAL_ROOT=runtime/production-like/files
|
||||
DEV_AUTO_MIGRATE_ENABLED=false
|
||||
DEV_BOOTSTRAP_ENABLED=true
|
||||
"""
|
||||
|
||||
|
||||
def _clean(value: str | None) -> str:
|
||||
return str(value or "").strip()
|
||||
|
||||
|
||||
def _csv(value: str | None) -> tuple[str, ...]:
|
||||
return tuple(dict.fromkeys(item.strip() for item in str(value or "").split(",") if item.strip()))
|
||||
|
||||
|
||||
def _truthy(value: str | None) -> bool:
|
||||
return _clean(value).lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _database_backend(database_url: str) -> str | None:
|
||||
try:
|
||||
return make_url(database_url).get_backend_name()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _master_key_error(value: str) -> str | None:
|
||||
candidate = value.strip().encode("utf-8")
|
||||
try:
|
||||
Fernet(candidate)
|
||||
return None
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
raw = base64.b64decode(candidate)
|
||||
except Exception:
|
||||
return "MASTER_KEY_B64 must be a Fernet key or base64-encoded 32-byte key."
|
||||
if len(raw) != 32:
|
||||
return "MASTER_KEY_B64 must decode to exactly 32 bytes."
|
||||
try:
|
||||
Fernet(base64.urlsafe_b64encode(raw))
|
||||
except Exception:
|
||||
return "MASTER_KEY_B64 is not usable as a Fernet key."
|
||||
return None
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
from collections.abc import Iterable, Mapping
|
||||
from contextlib import AbstractContextManager, closing
|
||||
from dataclasses import dataclass
|
||||
@@ -35,6 +36,7 @@ from govoplan_core.core.module_management import (
|
||||
save_module_install_plan,
|
||||
)
|
||||
from govoplan_core.core.modules import ModuleManifest, MigrationRetirementPlan
|
||||
from govoplan_core.core.versioning import compare_versions, format_version_range, version_satisfies_range
|
||||
|
||||
IssueSeverity = Literal["blocker", "warning", "info"]
|
||||
ChecklistStatus = Literal["done", "pending", "blocked", "warning"]
|
||||
@@ -162,7 +164,9 @@ def module_install_preflight(
|
||||
if not maintenance_mode:
|
||||
issues.append(ModuleInstallerIssue("blocker", "maintenance_required", "Package changes require maintenance mode."))
|
||||
|
||||
issues.extend(module_manifest_compatibility_issues(available))
|
||||
activation_candidates = desired_modules_after_package_plan(desired, plan)
|
||||
issues.extend(module_manifest_compatibility_issues(available, module_ids=activation_candidates))
|
||||
issues.extend(_package_catalog_preflight_issues(plan, available))
|
||||
|
||||
frontend_rebuild_required = False
|
||||
for item in planned_items:
|
||||
@@ -965,10 +969,15 @@ def _module_verify_command(plan: ModuleInstallPlan) -> dict[str, Any] | None:
|
||||
return _structured_command(argv)
|
||||
|
||||
|
||||
def module_manifest_compatibility_issues(available: Mapping[str, ModuleManifest]) -> tuple[ModuleInstallerIssue, ...]:
|
||||
def module_manifest_compatibility_issues(
|
||||
available: Mapping[str, ModuleManifest],
|
||||
*,
|
||||
module_ids: Iterable[str] | None = None,
|
||||
) -> tuple[ModuleInstallerIssue, ...]:
|
||||
current_core_version = _current_core_version()
|
||||
issues: list[ModuleInstallerIssue] = []
|
||||
for manifest in available.values():
|
||||
manifests = _selected_manifests(available, module_ids)
|
||||
for manifest in manifests.values():
|
||||
compatibility = manifest.compatibility
|
||||
if compatibility.manifest_contract_version != SUPPORTED_MANIFEST_CONTRACT_VERSION:
|
||||
issues.append(ModuleInstallerIssue(
|
||||
@@ -980,20 +989,21 @@ def module_manifest_compatibility_issues(available: Mapping[str, ModuleManifest]
|
||||
),
|
||||
manifest.id,
|
||||
))
|
||||
if compatibility.core_version_min and _compare_versions(current_core_version, compatibility.core_version_min) < 0:
|
||||
if compatibility.core_version_min and compare_versions(current_core_version, compatibility.core_version_min) < 0:
|
||||
issues.append(ModuleInstallerIssue(
|
||||
"blocker",
|
||||
"core_version_too_low",
|
||||
f"Module requires core >= {compatibility.core_version_min}; current core is {current_core_version}.",
|
||||
manifest.id,
|
||||
))
|
||||
if compatibility.core_version_max and _compare_versions(current_core_version, compatibility.core_version_max) > 0:
|
||||
if compatibility.core_version_max and compare_versions(current_core_version, compatibility.core_version_max) > 0:
|
||||
issues.append(ModuleInstallerIssue(
|
||||
"blocker",
|
||||
"core_version_too_high",
|
||||
f"Module supports core <= {compatibility.core_version_max}; current core is {current_core_version}.",
|
||||
manifest.id,
|
||||
))
|
||||
issues.extend(_interface_contract_issues(manifests))
|
||||
return tuple(issues)
|
||||
|
||||
|
||||
@@ -1013,24 +1023,210 @@ def _current_core_version() -> str:
|
||||
return "0.0.0"
|
||||
|
||||
|
||||
def _compare_versions(left: str, right: str) -> int:
|
||||
left_parts = _version_tuple(left)
|
||||
right_parts = _version_tuple(right)
|
||||
max_length = max(len(left_parts), len(right_parts))
|
||||
padded_left = left_parts + (0,) * (max_length - len(left_parts))
|
||||
padded_right = right_parts + (0,) * (max_length - len(right_parts))
|
||||
if padded_left < padded_right:
|
||||
return -1
|
||||
if padded_left > padded_right:
|
||||
return 1
|
||||
return 0
|
||||
def _selected_manifests(
|
||||
available: Mapping[str, ModuleManifest],
|
||||
module_ids: Iterable[str] | None,
|
||||
) -> dict[str, ModuleManifest]:
|
||||
if module_ids is None:
|
||||
return dict(available)
|
||||
selected: dict[str, ModuleManifest] = {}
|
||||
for module_id in module_ids:
|
||||
manifest = available.get(module_id)
|
||||
if manifest is not None:
|
||||
selected[module_id] = manifest
|
||||
return selected
|
||||
|
||||
|
||||
def _version_tuple(value: str) -> tuple[int, ...]:
|
||||
match = re.match(r"^\s*(\d+(?:\.\d+)*)", value)
|
||||
if not match:
|
||||
return (0,)
|
||||
return tuple(int(part) for part in match.group(1).split("."))
|
||||
def _interface_contract_issues(available: Mapping[str, ModuleManifest]) -> tuple[ModuleInstallerIssue, ...]:
|
||||
providers: dict[str, list[tuple[str, str]]] = defaultdict(list)
|
||||
for manifest in available.values():
|
||||
for provided in manifest.provides_interfaces:
|
||||
providers[provided.name].append((manifest.id, provided.version))
|
||||
|
||||
issues: list[ModuleInstallerIssue] = []
|
||||
for manifest in available.values():
|
||||
for requirement in manifest.requires_interfaces:
|
||||
available_versions = providers.get(requirement.name, [])
|
||||
matching = [
|
||||
(module_id, version)
|
||||
for module_id, version in available_versions
|
||||
if version_satisfies_range(
|
||||
version,
|
||||
version_min=requirement.version_min,
|
||||
version_max_exclusive=requirement.version_max_exclusive,
|
||||
)
|
||||
]
|
||||
if matching:
|
||||
continue
|
||||
version_range = format_version_range(
|
||||
version_min=requirement.version_min,
|
||||
version_max_exclusive=requirement.version_max_exclusive,
|
||||
)
|
||||
if not available_versions:
|
||||
if requirement.optional:
|
||||
continue
|
||||
issues.append(ModuleInstallerIssue(
|
||||
"blocker",
|
||||
"required_interface_missing",
|
||||
f"Module requires interface {requirement.name!r} ({version_range}), but no provider is installed.",
|
||||
manifest.id,
|
||||
))
|
||||
continue
|
||||
provider_summary = ", ".join(f"{module_id}@{version}" for module_id, version in available_versions)
|
||||
issues.append(ModuleInstallerIssue(
|
||||
"blocker",
|
||||
"interface_version_mismatch",
|
||||
(
|
||||
f"Module requires interface {requirement.name!r} ({version_range}), "
|
||||
f"but installed providers are {provider_summary}."
|
||||
),
|
||||
manifest.id,
|
||||
))
|
||||
return tuple(issues)
|
||||
|
||||
|
||||
def _package_catalog_preflight_issues(
|
||||
plan: ModuleInstallPlan,
|
||||
available: Mapping[str, ModuleManifest],
|
||||
) -> tuple[ModuleInstallerIssue, ...]:
|
||||
install_items = tuple(item for item in plan.items if item.status == "planned" and item.action == "install")
|
||||
if not install_items:
|
||||
return ()
|
||||
catalog_items = tuple(item for item in install_items if item.source == "catalog")
|
||||
try:
|
||||
from govoplan_core.core.module_package_catalog import validate_module_package_catalog
|
||||
|
||||
result = validate_module_package_catalog()
|
||||
except Exception as exc:
|
||||
severity: IssueSeverity = "blocker" if catalog_items else "warning"
|
||||
return (ModuleInstallerIssue(
|
||||
severity,
|
||||
"catalog_validation_failed",
|
||||
f"Package catalog could not be validated for this install plan: {exc}",
|
||||
),)
|
||||
if not result.get("configured"):
|
||||
if catalog_items:
|
||||
return (ModuleInstallerIssue(
|
||||
"blocker",
|
||||
"catalog_required",
|
||||
"Catalog-sourced package installs require a configured package catalog before activation.",
|
||||
),)
|
||||
return ()
|
||||
issues: list[ModuleInstallerIssue] = []
|
||||
if result.get("valid") is not True:
|
||||
severity = "blocker" if catalog_items else "warning"
|
||||
issues.append(ModuleInstallerIssue(
|
||||
severity,
|
||||
"catalog_validation_failed",
|
||||
str(result.get("error") or "Module package catalog is invalid."),
|
||||
))
|
||||
return tuple(issues)
|
||||
warnings = result.get("warnings")
|
||||
if isinstance(warnings, list):
|
||||
issues.extend(
|
||||
ModuleInstallerIssue("warning", "catalog_warning", str(warning))
|
||||
for warning in warnings
|
||||
)
|
||||
if catalog_items:
|
||||
issues.extend(_selected_catalog_interface_issues(catalog_items, result, available))
|
||||
return tuple(issues)
|
||||
|
||||
|
||||
def _selected_catalog_interface_issues(
|
||||
catalog_items: tuple[ModuleInstallPlanItem, ...],
|
||||
validation: Mapping[str, object],
|
||||
available: Mapping[str, ModuleManifest],
|
||||
) -> tuple[ModuleInstallerIssue, ...]:
|
||||
modules = {
|
||||
str(item.get("module_id")): item
|
||||
for item in validation.get("modules", [])
|
||||
if isinstance(item, Mapping) and item.get("module_id")
|
||||
}
|
||||
catalog_provider_versions: dict[str, list[tuple[str, str]]] = defaultdict(list)
|
||||
for module_id, module in modules.items():
|
||||
for provided in _catalog_interface_items(module.get("provides_interfaces")):
|
||||
name = provided.get("name") if isinstance(provided.get("name"), str) else None
|
||||
version = provided.get("version") if isinstance(provided.get("version"), str) else None
|
||||
if name and version:
|
||||
catalog_provider_versions[name].append((module_id, version))
|
||||
installed_provider_versions: dict[str, list[tuple[str, str]]] = defaultdict(list)
|
||||
for manifest in available.values():
|
||||
for provided in manifest.provides_interfaces:
|
||||
installed_provider_versions[provided.name].append((manifest.id, provided.version))
|
||||
|
||||
issues: list[ModuleInstallerIssue] = []
|
||||
for item in catalog_items:
|
||||
module = modules.get(item.module_id)
|
||||
if module is None:
|
||||
issues.append(ModuleInstallerIssue(
|
||||
"blocker",
|
||||
"catalog_entry_missing",
|
||||
f"Catalog-sourced install plan item {item.module_id!r} is no longer present in the package catalog.",
|
||||
item.module_id,
|
||||
))
|
||||
continue
|
||||
for required in _catalog_interface_items(module.get("requires_interfaces")):
|
||||
name = required.get("name") if isinstance(required.get("name"), str) else None
|
||||
if not name:
|
||||
continue
|
||||
version_min = required.get("version_min") if isinstance(required.get("version_min"), str) else None
|
||||
version_max_exclusive = (
|
||||
required.get("version_max_exclusive")
|
||||
if isinstance(required.get("version_max_exclusive"), str)
|
||||
else None
|
||||
)
|
||||
optional = required.get("optional") is True
|
||||
providers = [
|
||||
*catalog_provider_versions.get(name, ()),
|
||||
*installed_provider_versions.get(name, ()),
|
||||
]
|
||||
matching = [
|
||||
(provider_module_id, version)
|
||||
for provider_module_id, version in providers
|
||||
if version_satisfies_range(
|
||||
version,
|
||||
version_min=version_min,
|
||||
version_max_exclusive=version_max_exclusive,
|
||||
)
|
||||
]
|
||||
if matching:
|
||||
continue
|
||||
version_range = format_version_range(
|
||||
version_min=version_min,
|
||||
version_max_exclusive=version_max_exclusive,
|
||||
)
|
||||
if not providers:
|
||||
if optional:
|
||||
continue
|
||||
issues.append(ModuleInstallerIssue(
|
||||
"blocker",
|
||||
"catalog_required_interface_missing",
|
||||
(
|
||||
f"Catalog-sourced module {item.module_id!r} requires interface "
|
||||
f"{name!r} ({version_range}), but neither the package catalog nor "
|
||||
"installed modules provide it."
|
||||
),
|
||||
item.module_id,
|
||||
))
|
||||
continue
|
||||
provider_summary = ", ".join(f"{provider_module_id}@{version}" for provider_module_id, version in providers)
|
||||
severity: IssueSeverity = "warning" if optional else "blocker"
|
||||
issues.append(ModuleInstallerIssue(
|
||||
severity,
|
||||
"catalog_interface_version_mismatch",
|
||||
(
|
||||
f"Catalog-sourced module {item.module_id!r} requires interface "
|
||||
f"{name!r} ({version_range}), but available providers are {provider_summary}."
|
||||
),
|
||||
item.module_id,
|
||||
))
|
||||
return tuple(issues)
|
||||
|
||||
|
||||
def _catalog_interface_items(value: object) -> tuple[Mapping[str, object], ...]:
|
||||
if not isinstance(value, list):
|
||||
return ()
|
||||
return tuple(item for item in value if isinstance(item, Mapping))
|
||||
|
||||
|
||||
def _post_install_checklist(
|
||||
|
||||
@@ -21,6 +21,7 @@ REQUIRED_PLATFORM_MODULES = ("access",)
|
||||
PROTECTED_MODULES = (*REQUIRED_PLATFORM_MODULES, "admin")
|
||||
INSTALL_PLAN_ACTIONS = ("install", "uninstall")
|
||||
INSTALL_PLAN_STATUSES = ("planned", "applied", "blocked")
|
||||
INSTALL_PLAN_SOURCES = ("manual", "catalog")
|
||||
LOCAL_DEPENDENCY_REF_PREFIXES = ("file:", "path:", "workspace:", "link:")
|
||||
|
||||
|
||||
@@ -38,6 +39,8 @@ class ModuleStatePlan:
|
||||
class ModuleInstallPlanItem:
|
||||
module_id: str
|
||||
action: str
|
||||
source: str = "manual"
|
||||
catalog: Mapping[str, object] | None = None
|
||||
python_package: str | None = None
|
||||
python_ref: str | None = None
|
||||
webui_package: str | None = None
|
||||
@@ -51,10 +54,13 @@ class ModuleInstallPlanItem:
|
||||
payload: dict[str, object] = {
|
||||
"module_id": self.module_id,
|
||||
"action": self.action,
|
||||
"source": self.source,
|
||||
"status": self.status,
|
||||
}
|
||||
if self.destroy_data:
|
||||
payload["destroy_data"] = True
|
||||
if self.catalog:
|
||||
payload["catalog"] = dict(self.catalog)
|
||||
for key in ("python_package", "python_ref", "webui_package", "webui_ref", "notes"):
|
||||
value = getattr(self, key)
|
||||
if value:
|
||||
@@ -291,6 +297,8 @@ def normalize_module_install_plan_item(
|
||||
|
||||
module_id = _required_string(raw.get("module_id"), "module_id")
|
||||
action = _required_string(raw.get("action"), "action")
|
||||
source = _clean_optional_string(raw.get("source")) or "manual"
|
||||
catalog = _clean_optional_mapping(raw.get("catalog"), field="catalog", module_id=module_id)
|
||||
status = _clean_optional_string(raw.get("status")) or "planned"
|
||||
python_package = _clean_optional_string(raw.get("python_package"))
|
||||
python_ref = _clean_optional_string(raw.get("python_ref"))
|
||||
@@ -304,6 +312,8 @@ def normalize_module_install_plan_item(
|
||||
raise ModuleManagementError(f"Unsupported install plan action for {module_id!r}: {action!r}.")
|
||||
if status not in INSTALL_PLAN_STATUSES:
|
||||
raise ModuleManagementError(f"Unsupported install plan status for {module_id!r}: {status!r}.")
|
||||
if source not in INSTALL_PLAN_SOURCES:
|
||||
raise ModuleManagementError(f"Unsupported install plan source for {module_id!r}: {source!r}.")
|
||||
if action == "install" and not python_ref:
|
||||
raise ModuleManagementError(f"Install plan item {module_id!r} needs a Python package reference.")
|
||||
if action == "uninstall" and not python_package:
|
||||
@@ -322,6 +332,8 @@ def normalize_module_install_plan_item(
|
||||
return ModuleInstallPlanItem(
|
||||
module_id=module_id,
|
||||
action=action,
|
||||
source=source,
|
||||
catalog=catalog,
|
||||
python_package=python_package,
|
||||
python_ref=python_ref,
|
||||
webui_package=webui_package,
|
||||
|
||||
@@ -2,10 +2,12 @@ from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
from collections import defaultdict
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from typing import Any
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
@@ -14,6 +16,10 @@ from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
|
||||
|
||||
from govoplan_core.core.versioning import format_version_range, version_range_is_valid, version_satisfies_range
|
||||
|
||||
_INTERFACE_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$")
|
||||
|
||||
|
||||
def module_package_catalog(
|
||||
path: Path | str | None = None,
|
||||
@@ -193,6 +199,7 @@ def validate_module_package_catalog(
|
||||
)
|
||||
warnings.extend(str(item) for item in freshness.get("warnings", ()) if item)
|
||||
warnings.extend(str(item) for item in replay.get("warnings", ()) if item)
|
||||
warnings.extend(_catalog_interface_warnings(modules))
|
||||
if not signature_state["signed"]:
|
||||
warnings.append("Catalog is unsigned; use only for local development unless signature enforcement is disabled intentionally.")
|
||||
elif not signature_state["trusted"]:
|
||||
@@ -303,7 +310,10 @@ def _configured_enforce_sequence() -> bool:
|
||||
|
||||
|
||||
def _configured_require_signature() -> bool:
|
||||
return os.getenv("GOVOPLAN_MODULE_PACKAGE_CATALOG_REQUIRE_SIGNATURE", "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
value = os.getenv("GOVOPLAN_MODULE_PACKAGE_CATALOG_REQUIRE_SIGNATURE")
|
||||
if value is not None and value.strip():
|
||||
return value.strip().lower() in {"1", "true", "yes", "on"}
|
||||
return os.getenv("APP_ENV", "").strip().lower() in {"prod", "production"}
|
||||
|
||||
|
||||
def _configured_approved_channels() -> tuple[str, ...]:
|
||||
@@ -601,6 +611,8 @@ def _normalize_catalog_item(value: Any) -> dict[str, object]:
|
||||
"webui_package": _optional_str(value, "webui_package"),
|
||||
"webui_ref": _optional_str(value, "webui_ref"),
|
||||
"license_features": _string_list(value.get("license_features")),
|
||||
"provides_interfaces": _normalize_catalog_interface_providers(value.get("provides_interfaces"), module_id=module_id),
|
||||
"requires_interfaces": _normalize_catalog_interface_requirements(value.get("requires_interfaces"), module_id=module_id),
|
||||
"notes": _optional_str(value, "notes"),
|
||||
"tags": _string_list(value.get("tags")),
|
||||
}
|
||||
@@ -633,6 +645,134 @@ def _string_list(value: Any) -> list[str]:
|
||||
return [str(item).strip() for item in value if str(item).strip()]
|
||||
|
||||
|
||||
def _normalize_catalog_interface_providers(value: Any, *, module_id: str) -> list[dict[str, object]]:
|
||||
if value is None:
|
||||
return []
|
||||
if not isinstance(value, list):
|
||||
raise ValueError(f"Module package catalog provides_interfaces for {module_id!r} must be a list.")
|
||||
normalized: list[dict[str, object]] = []
|
||||
seen: set[str] = set()
|
||||
for raw in value:
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError(f"Module package catalog provides_interfaces entries for {module_id!r} must be objects.")
|
||||
name = _catalog_interface_required_str(raw, "name", module_id=module_id, field="provides_interfaces")
|
||||
version = _catalog_interface_required_str(raw, "version", module_id=module_id, field="provides_interfaces")
|
||||
_validate_catalog_interface_name(name, module_id=module_id)
|
||||
if name in seen:
|
||||
raise ValueError(f"Module package catalog entry {module_id!r} provides interface {name!r} more than once.")
|
||||
seen.add(name)
|
||||
normalized.append({"name": name, "version": version})
|
||||
return normalized
|
||||
|
||||
|
||||
def _normalize_catalog_interface_requirements(value: Any, *, module_id: str) -> list[dict[str, object]]:
|
||||
if value is None:
|
||||
return []
|
||||
if not isinstance(value, list):
|
||||
raise ValueError(f"Module package catalog requires_interfaces for {module_id!r} must be a list.")
|
||||
normalized: list[dict[str, object]] = []
|
||||
seen: set[str] = set()
|
||||
for raw in value:
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError(f"Module package catalog requires_interfaces entries for {module_id!r} must be objects.")
|
||||
name = _catalog_interface_required_str(raw, "name", module_id=module_id, field="requires_interfaces")
|
||||
_validate_catalog_interface_name(name, module_id=module_id)
|
||||
if name in seen:
|
||||
raise ValueError(f"Module package catalog entry {module_id!r} requires interface {name!r} more than once.")
|
||||
seen.add(name)
|
||||
version_min = _optional_str(raw, "version_min")
|
||||
version_max_exclusive = _optional_str(raw, "version_max_exclusive")
|
||||
if not version_range_is_valid(version_min=version_min, version_max_exclusive=version_max_exclusive):
|
||||
version_range = format_version_range(version_min=version_min, version_max_exclusive=version_max_exclusive)
|
||||
raise ValueError(f"Module package catalog entry {module_id!r} has invalid interface range {version_range!r}.")
|
||||
item: dict[str, object] = {
|
||||
"name": name,
|
||||
"optional": _optional_bool(raw, "optional"),
|
||||
}
|
||||
if version_min is not None:
|
||||
item["version_min"] = version_min
|
||||
if version_max_exclusive is not None:
|
||||
item["version_max_exclusive"] = version_max_exclusive
|
||||
normalized.append(item)
|
||||
return normalized
|
||||
|
||||
|
||||
def _catalog_interface_warnings(modules: tuple[dict[str, object], ...]) -> list[str]:
|
||||
providers: dict[str, list[tuple[str, str]]] = defaultdict(list)
|
||||
for module in modules:
|
||||
module_id = str(module.get("module_id") or "")
|
||||
for raw in _catalog_interface_list(module.get("provides_interfaces")):
|
||||
name = str(raw.get("name") or "")
|
||||
version = str(raw.get("version") or "")
|
||||
providers[name].append((module_id, version))
|
||||
|
||||
warnings: list[str] = []
|
||||
for module in modules:
|
||||
module_id = str(module.get("module_id") or "")
|
||||
for raw in _catalog_interface_list(module.get("requires_interfaces")):
|
||||
name = str(raw.get("name") or "")
|
||||
version_min = raw.get("version_min") if isinstance(raw.get("version_min"), str) else None
|
||||
version_max_exclusive = raw.get("version_max_exclusive") if isinstance(raw.get("version_max_exclusive"), str) else None
|
||||
optional = raw.get("optional") is True
|
||||
available = providers.get(name, [])
|
||||
matching = [
|
||||
(provider_module_id, version)
|
||||
for provider_module_id, version in available
|
||||
if version_satisfies_range(
|
||||
version,
|
||||
version_min=version_min,
|
||||
version_max_exclusive=version_max_exclusive,
|
||||
)
|
||||
]
|
||||
if matching:
|
||||
continue
|
||||
version_range = format_version_range(
|
||||
version_min=version_min,
|
||||
version_max_exclusive=version_max_exclusive,
|
||||
)
|
||||
if not available:
|
||||
if optional:
|
||||
continue
|
||||
warnings.append(
|
||||
f"Catalog module {module_id!r} requires interface {name!r} ({version_range}), "
|
||||
"but no catalog entry provides it; activation may still succeed if an installed module provides it."
|
||||
)
|
||||
continue
|
||||
provider_summary = ", ".join(f"{provider_module_id}@{version}" for provider_module_id, version in available)
|
||||
warnings.append(
|
||||
f"Catalog module {module_id!r} requires interface {name!r} ({version_range}), "
|
||||
f"but catalog providers are {provider_summary}."
|
||||
)
|
||||
return warnings
|
||||
|
||||
|
||||
def _catalog_interface_list(value: object) -> list[dict[str, object]]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
return [item for item in value if isinstance(item, dict)]
|
||||
|
||||
|
||||
def _catalog_interface_required_str(value: dict[str, Any], key: str, *, module_id: str, field: str) -> str:
|
||||
item = _optional_str(value, key)
|
||||
if not item:
|
||||
raise ValueError(f"Module package catalog {field} entry for {module_id!r} is missing {key!r}.")
|
||||
return item
|
||||
|
||||
|
||||
def _validate_catalog_interface_name(name: str, *, module_id: str) -> None:
|
||||
if not _INTERFACE_NAME_RE.match(name):
|
||||
raise ValueError(f"Module package catalog entry {module_id!r} has invalid interface name {name!r}.")
|
||||
|
||||
|
||||
def _optional_bool(value: dict[str, Any], key: str) -> bool:
|
||||
item = value.get(key)
|
||||
if item is None:
|
||||
return False
|
||||
if not isinstance(item, bool):
|
||||
raise ValueError(f"Module package catalog boolean field {key!r} must be true or false.")
|
||||
return item
|
||||
|
||||
|
||||
def _normalize_artifact_integrity(value: Any) -> dict[str, object]:
|
||||
if value is None:
|
||||
return {}
|
||||
|
||||
@@ -108,6 +108,20 @@ class ModuleCompatibility:
|
||||
manifest_contract_version: str = "1"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ModuleInterfaceProvider:
|
||||
name: str
|
||||
version: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ModuleInterfaceRequirement:
|
||||
name: str
|
||||
version_min: str | None = None
|
||||
version_max_exclusive: str | None = None
|
||||
optional: bool = False
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ModuleUninstallGuardResult:
|
||||
severity: Literal["blocker", "warning", "info"]
|
||||
@@ -217,6 +231,8 @@ class ModuleManifest:
|
||||
optional_dependencies: tuple[str, ...] = ()
|
||||
required_capabilities: tuple[str, ...] = ()
|
||||
optional_capabilities: tuple[str, ...] = ()
|
||||
provides_interfaces: tuple[ModuleInterfaceProvider, ...] = ()
|
||||
requires_interfaces: tuple[ModuleInterfaceRequirement, ...] = ()
|
||||
permissions: tuple[PermissionDefinition, ...] = ()
|
||||
role_templates: tuple[RoleTemplate, ...] = ()
|
||||
route_factory: RouteFactory | None = None
|
||||
|
||||
@@ -2,7 +2,6 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Literal, Protocol, runtime_checkable
|
||||
|
||||
|
||||
@@ -10,7 +9,6 @@ ORGANIZATIONS_MODULE_ID = "organizations"
|
||||
CAPABILITY_ORGANIZATION_DIRECTORY = "organizations.directory"
|
||||
|
||||
OrganizationStatus = Literal["active", "inactive", "suspended"]
|
||||
FunctionAssignmentSource = Literal["direct", "delegated", "acting_for", "directory", "governance", "system"]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -39,23 +37,6 @@ class OrganizationFunctionRef:
|
||||
status: OrganizationStatus = "active"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OrganizationFunctionAssignmentRef:
|
||||
id: str
|
||||
tenant_id: str
|
||||
identity_id: str
|
||||
function_id: str
|
||||
organization_unit_id: str
|
||||
account_id: str | None = None
|
||||
applies_to_subunits: bool = False
|
||||
source: FunctionAssignmentSource = "direct"
|
||||
delegated_from_assignment_id: str | None = None
|
||||
acting_for_account_id: str | None = None
|
||||
valid_from: datetime | None = None
|
||||
valid_until: datetime | None = None
|
||||
status: OrganizationStatus = "active"
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class OrganizationDirectory(Protocol):
|
||||
def get_organization_unit(self, organization_unit_id: str) -> OrganizationUnitRef | None:
|
||||
@@ -74,22 +55,3 @@ class OrganizationDirectory(Protocol):
|
||||
include_subunits: bool = False,
|
||||
) -> Sequence[OrganizationFunctionRef]:
|
||||
...
|
||||
|
||||
def get_function_assignment(self, assignment_id: str) -> OrganizationFunctionAssignmentRef | None:
|
||||
...
|
||||
|
||||
def function_assignments_for_identity(
|
||||
self,
|
||||
identity_id: str,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
) -> Sequence[OrganizationFunctionAssignmentRef]:
|
||||
...
|
||||
|
||||
def function_assignments_for_account(
|
||||
self,
|
||||
account_id: str,
|
||||
*,
|
||||
tenant_id: str | None = None,
|
||||
) -> Sequence[OrganizationFunctionAssignmentRef]:
|
||||
...
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Iterable, Literal, Mapping, cast
|
||||
from typing import Any, Iterable, Literal, Mapping, Protocol, cast, runtime_checkable
|
||||
from urllib.parse import quote, unquote
|
||||
|
||||
PolicyScopeType = Literal["system", "tenant", "user", "group", "campaign"]
|
||||
|
||||
CAPABILITY_POLICY_PRIVACY_RETENTION = "policy.privacyRetention"
|
||||
|
||||
POLICY_SCOPE_TYPES: tuple[PolicyScopeType, ...] = ("system", "tenant", "user", "group", "campaign")
|
||||
|
||||
|
||||
@@ -122,3 +124,39 @@ class PolicyDecision:
|
||||
"requirements": list(self.requirements),
|
||||
"details": dict(self.details),
|
||||
}
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PrivacyRetentionService(Protocol):
|
||||
def privacy_policy_from_settings(self, *args: Any, **kwargs: Any) -> Any:
|
||||
...
|
||||
|
||||
def privacy_policy_from_session(self, *args: Any, **kwargs: Any) -> Any:
|
||||
...
|
||||
|
||||
def set_privacy_policy(self, *args: Any, **kwargs: Any) -> Any:
|
||||
...
|
||||
|
||||
def effective_privacy_policy(self, *args: Any, **kwargs: Any) -> Any:
|
||||
...
|
||||
|
||||
def parent_privacy_policy(self, *args: Any, **kwargs: Any) -> Any:
|
||||
...
|
||||
|
||||
def effective_privacy_policy_sources(self, *args: Any, **kwargs: Any) -> Any:
|
||||
...
|
||||
|
||||
def parent_privacy_policy_sources(self, *args: Any, **kwargs: Any) -> Any:
|
||||
...
|
||||
|
||||
def get_privacy_policy_for_scope(self, *args: Any, **kwargs: Any) -> Any:
|
||||
...
|
||||
|
||||
def set_privacy_policy_for_scope(self, *args: Any, **kwargs: Any) -> Any:
|
||||
...
|
||||
|
||||
def sanitize_audit_details_for_policy(self, *args: Any, **kwargs: Any) -> Any:
|
||||
...
|
||||
|
||||
def apply_retention_policy(self, *args: Any, **kwargs: Any) -> Any:
|
||||
...
|
||||
|
||||
@@ -8,6 +8,8 @@ from dataclasses import dataclass
|
||||
from govoplan_core.core.modules import (
|
||||
CapabilityFactory,
|
||||
DeleteVetoProvider,
|
||||
ModuleInterfaceProvider,
|
||||
ModuleInterfaceRequirement,
|
||||
ModuleContext,
|
||||
ModuleManifest,
|
||||
NavItem,
|
||||
@@ -18,11 +20,13 @@ from govoplan_core.core.modules import (
|
||||
SUPPORTED_MANIFEST_CONTRACT_VERSION,
|
||||
TenantSummaryProvider,
|
||||
)
|
||||
from govoplan_core.core.versioning import format_version_range, version_range_is_valid, version_satisfies_range
|
||||
|
||||
_MODULE_ID_RE = re.compile(r"^[a-z][a-z0-9_]*$")
|
||||
_NPM_PACKAGE_RE = re.compile(r"^(?:@[a-z0-9][a-z0-9_.-]*/)?[a-z0-9][a-z0-9_.-]*$")
|
||||
_SCOPE_RE = re.compile(r"^[a-z][a-z0-9_]*:[a-z][a-z0-9_]*:[a-z][a-z0-9_]*$")
|
||||
_WILDCARD_RE = re.compile(r"^([a-z][a-z0-9_]*|\*):\*$|^[a-z][a-z0-9_]*:[a-z][a-z0-9_]*:\*$")
|
||||
_INTERFACE_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$")
|
||||
|
||||
|
||||
class RegistryError(ValueError):
|
||||
@@ -177,6 +181,8 @@ class PlatformRegistry:
|
||||
raise RegistryError(f"Duplicate permission scope: {permission.scope}")
|
||||
seen_permissions[permission.scope] = permission
|
||||
|
||||
_validate_interface_closure(ordered)
|
||||
|
||||
known_scopes = set(seen_permissions)
|
||||
for manifest in ordered:
|
||||
for template in manifest.role_templates:
|
||||
@@ -236,6 +242,8 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
|
||||
_validate_dependency_list(manifest.id, "optional_dependencies", manifest.optional_dependencies)
|
||||
_validate_capability_list(manifest.id, "required_capabilities", manifest.required_capabilities)
|
||||
_validate_capability_list(manifest.id, "optional_capabilities", manifest.optional_capabilities)
|
||||
_validate_interface_providers(manifest.id, manifest.provides_interfaces)
|
||||
_validate_interface_requirements(manifest.id, manifest.requires_interfaces)
|
||||
overlap = set(manifest.dependencies) & set(manifest.optional_dependencies)
|
||||
if overlap:
|
||||
joined = ", ".join(sorted(overlap))
|
||||
@@ -275,6 +283,47 @@ def _validate_manifest_shape(manifest: ModuleManifest) -> None:
|
||||
_validate_nav_item(manifest.id, item)
|
||||
|
||||
|
||||
def _validate_interface_closure(manifests: tuple[ModuleManifest, ...]) -> None:
|
||||
providers: dict[str, list[tuple[ModuleManifest, ModuleInterfaceProvider]]] = defaultdict(list)
|
||||
for manifest in manifests:
|
||||
for provided in manifest.provides_interfaces:
|
||||
providers[provided.name].append((manifest, provided))
|
||||
|
||||
for manifest in manifests:
|
||||
for requirement in manifest.requires_interfaces:
|
||||
available = providers.get(requirement.name, [])
|
||||
matching = [
|
||||
(provider_manifest, provided)
|
||||
for provider_manifest, provided in available
|
||||
if version_satisfies_range(
|
||||
provided.version,
|
||||
version_min=requirement.version_min,
|
||||
version_max_exclusive=requirement.version_max_exclusive,
|
||||
)
|
||||
]
|
||||
if matching:
|
||||
continue
|
||||
version_range = format_version_range(
|
||||
version_min=requirement.version_min,
|
||||
version_max_exclusive=requirement.version_max_exclusive,
|
||||
)
|
||||
if not available:
|
||||
if requirement.optional:
|
||||
continue
|
||||
raise RegistryError(
|
||||
f"Module {manifest.id!r} requires unavailable interface "
|
||||
f"{requirement.name!r} ({version_range})"
|
||||
)
|
||||
available_versions = ", ".join(
|
||||
f"{provider_manifest.id}@{provided.version}"
|
||||
for provider_manifest, provided in available
|
||||
)
|
||||
raise RegistryError(
|
||||
f"Module {manifest.id!r} requires interface {requirement.name!r} "
|
||||
f"({version_range}) but available providers are {available_versions}"
|
||||
)
|
||||
|
||||
|
||||
def _validate_dependency_list(module_id: str, field_name: str, dependencies: tuple[str, ...]) -> None:
|
||||
if len(dependencies) != len(set(dependencies)):
|
||||
raise RegistryError(f"Module {module_id!r} has duplicate {field_name}")
|
||||
@@ -293,6 +342,42 @@ def _validate_capability_list(module_id: str, field_name: str, capabilities: tup
|
||||
raise RegistryError(f"Module {module_id!r} has invalid capability name {capability!r}")
|
||||
|
||||
|
||||
def _validate_interface_providers(module_id: str, providers: tuple[ModuleInterfaceProvider, ...]) -> None:
|
||||
names = [provider.name for provider in providers]
|
||||
if len(names) != len(set(names)):
|
||||
raise RegistryError(f"Module {module_id!r} has duplicate provided interface names")
|
||||
for provider in providers:
|
||||
_validate_interface_name(module_id, provider.name)
|
||||
if not provider.version.strip():
|
||||
raise RegistryError(f"Module {module_id!r} provides interface {provider.name!r} without a version")
|
||||
|
||||
|
||||
def _validate_interface_requirements(module_id: str, requirements: tuple[ModuleInterfaceRequirement, ...]) -> None:
|
||||
names = [requirement.name for requirement in requirements]
|
||||
if len(names) != len(set(names)):
|
||||
raise RegistryError(f"Module {module_id!r} has duplicate required interface names")
|
||||
for requirement in requirements:
|
||||
_validate_interface_name(module_id, requirement.name)
|
||||
if requirement.version_min is not None and not requirement.version_min.strip():
|
||||
raise RegistryError(f"Module {module_id!r} has blank minimum version for interface {requirement.name!r}")
|
||||
if requirement.version_max_exclusive is not None and not requirement.version_max_exclusive.strip():
|
||||
raise RegistryError(f"Module {module_id!r} has blank maximum version for interface {requirement.name!r}")
|
||||
if not version_range_is_valid(
|
||||
version_min=requirement.version_min,
|
||||
version_max_exclusive=requirement.version_max_exclusive,
|
||||
):
|
||||
version_range = format_version_range(
|
||||
version_min=requirement.version_min,
|
||||
version_max_exclusive=requirement.version_max_exclusive,
|
||||
)
|
||||
raise RegistryError(f"Module {module_id!r} has invalid interface range {version_range!r}")
|
||||
|
||||
|
||||
def _validate_interface_name(module_id: str, name: str) -> None:
|
||||
if not _INTERFACE_NAME_RE.match(name):
|
||||
raise RegistryError(f"Module {module_id!r} has invalid interface name {name!r}")
|
||||
|
||||
|
||||
def _validate_nav_item(module_id: str, item: NavItem) -> None:
|
||||
if not item.path.startswith("/"):
|
||||
raise RegistryError(f"Navigation item for module {module_id!r} must start with '/': {item.path!r}")
|
||||
|
||||
@@ -10,10 +10,14 @@ def configure_runtime(context: ModuleContext) -> None:
|
||||
_context = context
|
||||
|
||||
|
||||
def clear_runtime() -> None:
|
||||
global _context
|
||||
_context = None
|
||||
|
||||
|
||||
def get_runtime_context() -> ModuleContext | None:
|
||||
return _context
|
||||
|
||||
|
||||
def get_registry() -> object | None:
|
||||
return _context.registry if _context is not None else None
|
||||
|
||||
|
||||
59
src/govoplan_core/core/versioning.py
Normal file
59
src/govoplan_core/core/versioning.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
|
||||
|
||||
def compare_versions(left: str, right: str) -> int:
|
||||
left_parts = version_tuple(left)
|
||||
right_parts = version_tuple(right)
|
||||
max_length = max(len(left_parts), len(right_parts))
|
||||
padded_left = left_parts + (0,) * (max_length - len(left_parts))
|
||||
padded_right = right_parts + (0,) * (max_length - len(right_parts))
|
||||
if padded_left < padded_right:
|
||||
return -1
|
||||
if padded_left > padded_right:
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def version_satisfies_range(
|
||||
version: str,
|
||||
*,
|
||||
version_min: str | None = None,
|
||||
version_max_exclusive: str | None = None,
|
||||
) -> bool:
|
||||
if version_min is not None and compare_versions(version, version_min) < 0:
|
||||
return False
|
||||
if version_max_exclusive is not None and compare_versions(version, version_max_exclusive) >= 0:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def version_range_is_valid(
|
||||
*,
|
||||
version_min: str | None = None,
|
||||
version_max_exclusive: str | None = None,
|
||||
) -> bool:
|
||||
if version_min is None or version_max_exclusive is None:
|
||||
return True
|
||||
return compare_versions(version_min, version_max_exclusive) < 0
|
||||
|
||||
|
||||
def format_version_range(
|
||||
*,
|
||||
version_min: str | None = None,
|
||||
version_max_exclusive: str | None = None,
|
||||
) -> str:
|
||||
parts: list[str] = []
|
||||
if version_min is not None:
|
||||
parts.append(f">= {version_min}")
|
||||
if version_max_exclusive is not None:
|
||||
parts.append(f"< {version_max_exclusive}")
|
||||
return ", ".join(parts) if parts else "any version"
|
||||
|
||||
|
||||
def version_tuple(value: str) -> tuple[int, ...]:
|
||||
match = re.match(r"^\s*v?(\d+(?:\.\d+)*)", value)
|
||||
if not match:
|
||||
return (0,)
|
||||
return tuple(int(part) for part in match.group(1).split("."))
|
||||
Reference in New Issue
Block a user