Skip to content

Commit b2ce3ec

Browse files
feat(server): Add CLI task to list organizations
In preparation of #8352, I needed to bootstrap the `tasks` cli group and investigate if it's doable to perform operation without relying on a running backend
1 parent 4fb4062 commit b2ce3ec

14 files changed

Lines changed: 325 additions & 22 deletions

File tree

server/parsec/cli/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from parsec.cli.sequester_create import create_service, generate_service_certificate
1919
from parsec.cli.sequester_list import list_services
2020
from parsec.cli.sequester_revoke import generate_service_revocation_certificate, revoke_service
21+
from parsec.cli.tasks import server_tasks_cmd_group
2122
from parsec.cli.testbed import TESTBED_AVAILABLE, testbed_cmd
2223

2324
__all__ = ("cli",)
@@ -53,6 +54,8 @@ def cli() -> None:
5354
cli.add_command(list_deletable_realms, "list_deletable_realms")
5455
cli.add_command(delete_realm, "delete_realm")
5556
cli.add_command(server_sequester_cmd, "sequester")
57+
cli.add_command(server_tasks_cmd_group)
58+
5659
if TESTBED_AVAILABLE:
5760
cli.add_command(testbed_cmd, "testbed")
5861
# Since `render_email` is only for debugging purpose, we don't expose it
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Parsec Cloud (https://parsec.cloud) Copyright (c) BUSL-1.1 2016-present Scille SAS
2+
3+
import click
4+
5+
from parsec.cli.options import version_option
6+
7+
from . import list_organization
8+
9+
10+
@click.group(name="tasks", short_help="Server tasks collections")
11+
@version_option
12+
def server_tasks_cmd_group() -> None:
13+
pass
14+
15+
16+
server_tasks_cmd_group.add_command(list_organization.cmd)
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
# Parsec Cloud (https://parsec.cloud) Copyright (c) BUSL-1.1 2016-present Scille SAS
2+
from __future__ import annotations
3+
4+
from collections.abc import AsyncGenerator
5+
from contextlib import asynccontextmanager
6+
7+
import click
8+
import pydantic
9+
10+
from parsec._parsec import EmailAddress, OrganizationID, ParsecAddr, SecretKey
11+
from parsec.cli.options import asyncio_run, db_server_options, logging_config_options
12+
from parsec.components.memory.datamodel import MemoryDatamodel
13+
from parsec.components.memory.events import event_bus_factory
14+
from parsec.components.memory.organization import MemoryOrganizationComponent
15+
from parsec.components.organization import BaseOrganizationComponent, OrganizationDump
16+
from parsec.components.postgresql.handler import asyncpg_pool_factory
17+
from parsec.components.postgresql.organization import PGOrganizationComponent
18+
from parsec.config import (
19+
BackendConfig,
20+
BaseDatabaseConfig,
21+
LogLevel,
22+
MockedBlockStoreConfig,
23+
MockedEmailConfig,
24+
PostgreSQLDatabaseConfig,
25+
)
26+
from parsec.webhooks import MockedWebhooksComponent
27+
28+
29+
@pydantic.dataclasses.dataclass()
30+
class OrganizationInfo:
31+
id: OrganizationID
32+
is_bootstrapped: bool
33+
# bootstrapped_on: DateTime | None
34+
is_expired: bool
35+
# expired_on: DateTime | None
36+
# active_users_limit: ActiveUsersLimit
37+
user_profile_outsider_allowed: bool
38+
realm_minimum_archiving_period_before_deletion: int
39+
# tos: TermsOfService | None
40+
41+
@classmethod
42+
def from_dump(cls, dump: OrganizationDump) -> OrganizationInfo:
43+
return OrganizationInfo(
44+
id=dump.organization_id,
45+
is_bootstrapped=dump.is_bootstrapped,
46+
is_expired=dump.is_expired,
47+
user_profile_outsider_allowed=dump.user_profile_outsider_allowed,
48+
realm_minimum_archiving_period_before_deletion=dump.realm_minimum_archiving_period_before_deletion,
49+
)
50+
51+
52+
@click.command(name="list-organizations", short_help="List organization present on the server")
53+
@db_server_options
54+
@logging_config_options(default_log_level="INFO")
55+
@asyncio_run
56+
async def cmd(
57+
db: BaseDatabaseConfig,
58+
db_min_connections: int,
59+
db_max_connections: int,
60+
log_level: LogLevel,
61+
log_format: str,
62+
log_file: str | None,
63+
):
64+
backend_config = BackendConfig(
65+
debug=False,
66+
db_config=db,
67+
blockstore_config=MockedBlockStoreConfig(),
68+
email_config=MockedEmailConfig(sender=EmailAddress("tasks@parsec.local")),
69+
server_addr=ParsecAddr("tasks.parsec.local", None, True),
70+
administration_token="",
71+
fake_account_password_algorithm_seed=SecretKey.generate(),
72+
)
73+
async with organization_component_factory(backend_config) as component:
74+
orgs = await list_organizations(component)
75+
76+
adapter = pydantic.TypeAdapter(
77+
dict[
78+
OrganizationID,
79+
OrganizationInfo,
80+
]
81+
)
82+
click.echo_via_pager(adapter.dump_json(orgs, indent=4).decode())
83+
84+
85+
@asynccontextmanager
86+
async def organization_component_factory(
87+
config: BackendConfig,
88+
) -> AsyncGenerator[BaseOrganizationComponent, None]:
89+
if config.db_config.is_mocked():
90+
data = MemoryDatamodel(
91+
{} if config.backend_mocked_data is None else config.backend_mocked_data
92+
)
93+
async with event_bus_factory() as event_bus:
94+
yield MemoryOrganizationComponent(data, event_bus, MockedWebhooksComponent(), config)
95+
96+
else:
97+
assert isinstance(config.db_config, PostgreSQLDatabaseConfig)
98+
async with asyncpg_pool_factory(
99+
url=config.db_config.url,
100+
min_connections=config.db_config.min_connections,
101+
max_connections=config.db_config.max_connections,
102+
) as pool:
103+
yield PGOrganizationComponent(
104+
pool=pool,
105+
webhooks=MockedWebhooksComponent(),
106+
config=None, # pyright: ignore [reportArgumentType]
107+
)
108+
109+
110+
async def list_organizations(
111+
component: BaseOrganizationComponent,
112+
) -> dict[OrganizationID, OrganizationInfo]:
113+
return {
114+
id: OrganizationInfo.from_dump(dump)
115+
for id, dump in (await component.list_organizations()).items()
116+
}

server/parsec/components/memory/organization.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -402,7 +402,7 @@ async def get_tos(
402402
return org.tos
403403

404404
@override
405-
async def test_dump_organizations(
405+
async def list_organizations(
406406
self, skip_templates: bool = True
407407
) -> dict[OrganizationID, OrganizationDump]:
408408
items = {}

server/parsec/components/organization.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -321,7 +321,7 @@ async def get_tos(
321321
) -> TermsOfService | OrganizationGetTosBadOutcome | None:
322322
raise NotImplementedError
323323

324-
async def test_dump_organizations(
324+
async def list_organizations(
325325
self, skip_templates: bool = True
326326
) -> dict[OrganizationID, OrganizationDump]:
327327
raise NotImplementedError

server/parsec/components/postgresql/organization.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,13 @@
3838
from parsec.components.postgresql.organization_bootstrap import organization_bootstrap
3939
from parsec.components.postgresql.organization_create import organization_create
4040
from parsec.components.postgresql.organization_get_tos import organization_get_tos
41+
from parsec.components.postgresql.organization_list_organizations import (
42+
organization_list_organizations,
43+
)
4144
from parsec.components.postgresql.organization_stats import (
4245
organization_server_stats,
4346
organization_stats,
4447
)
45-
from parsec.components.postgresql.organization_test_dump_organizations import (
46-
organization_test_dump_organizations,
47-
)
4848
from parsec.components.postgresql.organization_test_dump_topics import organization_test_dump_topics
4949
from parsec.components.postgresql.organization_update import organization_update
5050
from parsec.components.postgresql.test_queries import (
@@ -355,10 +355,10 @@ async def get_tos(
355355

356356
@override
357357
@no_transaction
358-
async def test_dump_organizations(
358+
async def list_organizations(
359359
self, conn: AsyncpgConnection, skip_templates: bool = True
360360
) -> dict[OrganizationID, OrganizationDump]:
361-
return await organization_test_dump_organizations(conn, skip_templates)
361+
return await organization_list_organizations(conn, skip_templates)
362362

363363
@override
364364
@no_transaction

server/parsec/components/postgresql/organization_test_dump_organizations.py renamed to server/parsec/components/postgresql/organization_list_organizations.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
""")
3333

3434

35-
async def organization_test_dump_organizations(
35+
async def organization_list_organizations(
3636
conn: AsyncpgConnection, skip_templates: bool = True
3737
) -> dict[OrganizationID, OrganizationDump]:
3838
items = {}

server/parsec/webhooks.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,3 +236,55 @@ async def sequester_service_on_realm_rotate_key(
236236
return SequesterServiceUnavailable(
237237
service_id=service_id,
238238
)
239+
240+
241+
class MockedWebhooksComponent(WebhooksComponent):
242+
"""
243+
Webhooks that does nothing,
244+
245+
Used when creating components that require a webhook class but we do not except that component to use webhook (useful for tasks)"""
246+
247+
def __init__(self) -> None:
248+
pass
249+
250+
async def on_organization_bootstrap(
251+
self,
252+
organization_id: OrganizationID,
253+
device_id: DeviceID,
254+
device_label: DeviceLabel,
255+
human_email: EmailAddress,
256+
human_label: str,
257+
) -> None:
258+
return None
259+
260+
async def sequester_service_on_vlob_create_or_update(
261+
self,
262+
webhook_url: str,
263+
service_id: SequesterServiceID,
264+
organization_id: OrganizationID,
265+
author: DeviceID,
266+
realm_id: VlobID,
267+
vlob_id: VlobID,
268+
key_index: int,
269+
version: int,
270+
timestamp: DateTime,
271+
blob: bytes,
272+
) -> SequesterServiceUnavailable | RejectedBySequesterService | None:
273+
return None
274+
275+
async def sequester_service_on_realm_rotate_key(
276+
self,
277+
webhook_url: str,
278+
service_id: SequesterServiceID,
279+
organization_id: OrganizationID,
280+
keys_bundle: bytes,
281+
keys_bundle_access: bytes,
282+
author: DeviceID,
283+
timestamp: DateTime,
284+
realm_id: VlobID,
285+
key_index: int,
286+
encryption_algorithm: SecretKeyAlgorithm,
287+
hash_algorithm: HashAlgorithm,
288+
key_canary: bytes,
289+
) -> SequesterServiceUnavailable | RejectedBySequesterService | None:
290+
return None

server/src/ids.rs

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@
22

33
use std::str::FromStr;
44

5-
use pyo3::{exceptions::PyValueError, prelude::*, types::PyType};
5+
use pyo3::{
6+
exceptions::PyValueError,
7+
prelude::*,
8+
types::{PyDict, PyList, PyType, PyTypeMethods},
9+
};
610

711
// UUID based type
812

@@ -203,14 +207,55 @@ impl OrganizationID {
203207
Err(err) => Err(PyValueError::new_err(err.to_string())),
204208
}
205209
} else {
206-
Err(PyValueError::new_err("Unimplemented"))
210+
// NOTE: We return `ValueError` instead of `TypeError` to be able to use it with
211+
// pydantic `PlainValidator`
212+
Err(PyValueError::new_err(format!(
213+
"Does not support converting {} to OrganizationID",
214+
organization_id.get_type().name()?.to_str()?
215+
)))
207216
}
208217
}
209218

210219
#[getter]
211220
fn str(&self) -> &str {
212221
self.0.as_ref()
213222
}
223+
224+
#[classmethod]
225+
#[pyo3(name = "__get_pydantic_core_schema__")]
226+
fn get_pydantic_core_schema<'py>(
227+
cls: &Bound<'py, PyType>,
228+
_source_type: &Bound<'_, PyType>,
229+
_handler: &Bound<'_, PyAny>,
230+
py: Python<'py>,
231+
) -> PyResult<Bound<'py, PyAny>> {
232+
let core_schema = py.import("pydantic_core")?.getattr("core_schema")?;
233+
234+
let str_schema = core_schema.call_method0("str_schema")?;
235+
236+
// Indicate to pydantic that it just need to call `str(val)` to serialize the value
237+
let kwargs = PyDict::new(py);
238+
let serialization = core_schema.call_method0("to_string_ser_schema")?;
239+
kwargs.set_item("serialization", serialization)?;
240+
241+
// Create a string schema to load the value from string.
242+
let from_str = core_schema.call_method(
243+
"no_info_after_validator_function",
244+
(cls, str_schema),
245+
Some(&kwargs),
246+
)?;
247+
248+
// Make pydantic accept own class as value
249+
let instance_schema = core_schema.call_method1("is_instance_schema", (cls,))?;
250+
251+
// Build schema that accept both string or instance value.
252+
let union_schema = core_schema.call_method1(
253+
"union_schema",
254+
(PyList::new(py, vec![instance_schema, from_str])?,),
255+
)?;
256+
257+
Ok(union_schema)
258+
}
214259
}
215260

216261
crate::binding_utils::gen_py_wrapper_class_for_id!(

server/tests/administration/test_create_organization.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ async def test_ok(
198198
case tos:
199199
expected_tos = TermsOfService(updated_on=ANY, per_locale_urls=tos)
200200

201-
dump = await backend.organization.test_dump_organizations()
201+
dump = await backend.organization.list_organizations()
202202
assert dump[org_id] == OrganizationDump(
203203
organization_id=org_id,
204204
bootstrap_token=bootstrap_token,
@@ -231,7 +231,7 @@ async def test_overwrite_existing(
231231
assert isinstance(outcome, AccessToken)
232232

233233
# Sanity check
234-
dump = await backend.organization.test_dump_organizations()
234+
dump = await backend.organization.list_organizations()
235235
assert dump[org_id] == OrganizationDump(
236236
organization_id=org_id,
237237
bootstrap_token=bootstrap_token,
@@ -262,7 +262,7 @@ async def test_overwrite_existing(
262262
new_bootstrap_token = ParsecOrganizationBootstrapAddr.from_url(body["bootstrap_url"]).token
263263
assert new_bootstrap_token != bootstrap_token.hex
264264

265-
dump = await backend.organization.test_dump_organizations()
265+
dump = await backend.organization.list_organizations()
266266
assert dump[org_id] == OrganizationDump(
267267
organization_id=org_id,
268268
bootstrap_token=new_bootstrap_token,

0 commit comments

Comments
 (0)