bazaar-contrib-server-computer 0.3.0rc1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. bazaar_contrib_server_computer/__init__.py +187 -0
  2. bazaar_contrib_server_computer/calls.py +37 -0
  3. bazaar_contrib_server_computer/contracts.py +98 -0
  4. bazaar_contrib_server_computer/hosts.py +96 -0
  5. bazaar_contrib_server_computer/memory.py +122 -0
  6. bazaar_contrib_server_computer/pages.py +393 -0
  7. bazaar_contrib_server_computer/py.typed +0 -0
  8. bazaar_contrib_server_computer/resources/locales/en.toml +65 -0
  9. bazaar_contrib_server_computer/resources/locales/zh-CN.toml +65 -0
  10. bazaar_contrib_server_computer/resources/static/computers.css +187 -0
  11. bazaar_contrib_server_computer/resources/templates/computers/detail.html +45 -0
  12. bazaar_contrib_server_computer/resources/templates/computers/enrol.html +54 -0
  13. bazaar_contrib_server_computer/resources/templates/computers/icons/check.svg +1 -0
  14. bazaar_contrib_server_computer/resources/templates/computers/icons/chevron-right.svg +1 -0
  15. bazaar_contrib_server_computer/resources/templates/computers/icons/copy.svg +1 -0
  16. bazaar_contrib_server_computer/resources/templates/computers/icons/cpu.svg +1 -0
  17. bazaar_contrib_server_computer/resources/templates/computers/icons/ellipsis.svg +1 -0
  18. bazaar_contrib_server_computer/resources/templates/computers/icons/hard-drive.svg +1 -0
  19. bazaar_contrib_server_computer/resources/templates/computers/icons/key-round.svg +1 -0
  20. bazaar_contrib_server_computer/resources/templates/computers/icons/memory-stick.svg +1 -0
  21. bazaar_contrib_server_computer/resources/templates/computers/icons/monitor.svg +1 -0
  22. bazaar_contrib_server_computer/resources/templates/computers/icons/os-apple.svg +1 -0
  23. bazaar_contrib_server_computer/resources/templates/computers/icons/os-linux.svg +1 -0
  24. bazaar_contrib_server_computer/resources/templates/computers/icons/os-windows.svg +1 -0
  25. bazaar_contrib_server_computer/resources/templates/computers/icons/pencil.svg +1 -0
  26. bazaar_contrib_server_computer/resources/templates/computers/icons/plus.svg +1 -0
  27. bazaar_contrib_server_computer/resources/templates/computers/icons/share-2.svg +1 -0
  28. bazaar_contrib_server_computer/resources/templates/computers/icons/terminal.svg +1 -0
  29. bazaar_contrib_server_computer/resources/templates/computers/icons/trash-2.svg +1 -0
  30. bazaar_contrib_server_computer/resources/templates/computers/icons/x.svg +1 -0
  31. bazaar_contrib_server_computer/resources/templates/computers/list.html +12 -0
  32. bazaar_contrib_server_computer/resources/templates/computers/macros.html +32 -0
  33. bazaar_contrib_server_computer/resources/templates/computers/remove.html +13 -0
  34. bazaar_contrib_server_computer/resources/templates/computers/rows.html +37 -0
  35. bazaar_contrib_server_computer/resources/templates/computers/share.html +8 -0
  36. bazaar_contrib_server_computer/resources/templates/computers/stall.html +24 -0
  37. bazaar_contrib_server_computer/resources/templates/computers/table.html +14 -0
  38. bazaar_contrib_server_computer/schema.py +27 -0
  39. bazaar_contrib_server_computer/starlink.py +169 -0
  40. bazaar_contrib_server_computer/store.py +209 -0
  41. bazaar_contrib_server_computer-0.3.0rc1.dist-info/METADATA +40 -0
  42. bazaar_contrib_server_computer-0.3.0rc1.dist-info/RECORD +45 -0
  43. bazaar_contrib_server_computer-0.3.0rc1.dist-info/WHEEL +4 -0
  44. bazaar_contrib_server_computer-0.3.0rc1.dist-info/entry_points.txt +4 -0
  45. bazaar_contrib_server_computer-0.3.0rc1.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,187 @@
1
+ """The computers of the Bazaar Compute server: enrolling them, where their
2
+ starlink connects, calling what they expose, and their pages; and the relay
3
+ between the server's replicas, kept in memory for a server with one."""
4
+
5
+ from __future__ import annotations
6
+
7
+ from contextlib import asynccontextmanager
8
+ lazy import asyncio
9
+ lazy from collections.abc import AsyncIterator
10
+ lazy from importlib.resources import files
11
+ lazy from pathlib import Path
12
+
13
+ from bazaar_contrib_http import HTTP
14
+ from bazaar_contrib_server import SERVER, PermissionItem, SidebarItem, Topic
15
+ from bazaar_contrib_server_access import ACCESS, Shared
16
+ from bazaar_contrib_server_database import SERVER_DB, SERVER_SCHEMA
17
+ from bazaar_contrib_server_migrations import SERVER_MIGRATIONS
18
+ from bazaar_contrib_server_overview import OVERVIEW, OverviewItem
19
+ from bazaar_contrib_timer import TIMER
20
+ from bazaar_core import Context, Contrib
21
+
22
+ from .contracts import (
23
+ ANSWER_MS,
24
+ CALLS_AT_MOST,
25
+ COMPUTERS,
26
+ HOLD_MS,
27
+ RELAY,
28
+ REPORTED,
29
+ Call,
30
+ Computers,
31
+ Relay,
32
+ Reported,
33
+ )
34
+ lazy from .calls import Calls
35
+ lazy from .hosts import Hosts
36
+ lazy from .memory import MemoryRelay
37
+ lazy from .pages import CREATE, DELETE, SHARE, UPDATE, VIEW, Views
38
+ lazy from .schema import MODULE
39
+ lazy from .starlink import Starlink
40
+ lazy from .store import Store
41
+
42
+ __all__ = [
43
+ "ANSWER_MS",
44
+ "CALLS_AT_MOST",
45
+ "COMPUTERS",
46
+ "HOLD_MS",
47
+ "RELAY",
48
+ "REPORTED",
49
+ "Call",
50
+ "Computers",
51
+ "Relay",
52
+ "Reported",
53
+ "computer",
54
+ "relay",
55
+ ]
56
+
57
+
58
+ @asynccontextmanager
59
+ async def _computer(ctx: Context) -> AsyncIterator[None]:
60
+ server = ctx.accept(SERVER)
61
+ access = ctx.accept(ACCESS)
62
+ http = ctx.accept(HTTP)
63
+ relay = ctx.accept(RELAY)
64
+ resources = Path(str(files(__name__))) / "resources"
65
+ monitor = await asyncio.to_thread(
66
+ (resources / "templates" / "computers" / "icons" / "monitor.svg").read_text,
67
+ encoding="utf-8",
68
+ )
69
+ async with (
70
+ ctx.accept(SERVER_SCHEMA).register(MODULE),
71
+ asyncio.TaskGroup() as tasks,
72
+ ):
73
+ calls = Calls(relay)
74
+ hosts = Hosts(calls, tasks)
75
+ store = Store(ctx.accept(SERVER_DB), access)
76
+ starlink = Starlink(
77
+ store, relay, hosts, lambda reported: ctx.announce(REPORTED, reported)
78
+ )
79
+ pages = await server.pages("computers", __name__)
80
+ views = Views(server, pages, access, store, relay, hosts)
81
+ async with (
82
+ server.module(
83
+ "computers",
84
+ ui=pages.application(views.routes),
85
+ static=resources / "static",
86
+ locales=resources / "locales",
87
+ ),
88
+ server.permission(
89
+ PermissionItem(point=VIEW, description="computers.permission.view")
90
+ ),
91
+ server.permission(
92
+ PermissionItem(point=CREATE, description="computers.permission.create")
93
+ ),
94
+ server.permission(
95
+ PermissionItem(point=UPDATE, description="computers.permission.update")
96
+ ),
97
+ server.permission(
98
+ PermissionItem(point=DELETE, description="computers.permission.delete")
99
+ ),
100
+ server.permission(
101
+ PermissionItem(point=SHARE, description="computers.permission.share")
102
+ ),
103
+ server.sidebar(
104
+ SidebarItem(
105
+ text="computers.title",
106
+ icon=monitor,
107
+ path="/computers",
108
+ group="server.sidebar.workbench",
109
+ permission=VIEW,
110
+ order=30,
111
+ badge="computers.count",
112
+ )
113
+ ),
114
+ server.topic(
115
+ Topic(name="computers.count", fetch=views.counted, permission=VIEW)
116
+ ),
117
+ server.topic(
118
+ Topic(name="computers.list", fetch=views.listed, permission=VIEW)
119
+ ),
120
+ server.topic(
121
+ Topic(
122
+ name="computers.computer",
123
+ fetch=views.computer,
124
+ fields=("computer",),
125
+ permission=VIEW,
126
+ )
127
+ ),
128
+ access.shared(
129
+ Shared(
130
+ kind="computer",
131
+ permission=SHARE,
132
+ points=(VIEW, UPDATE, DELETE),
133
+ name=views.name,
134
+ )
135
+ ),
136
+ ctx.accept(OVERVIEW).register(
137
+ OverviewItem(
138
+ key="computers",
139
+ title="computers.stalls",
140
+ src="/ui/computers/stall",
141
+ permission=VIEW,
142
+ columns=2,
143
+ )
144
+ ),
145
+ http.route("/starlink/v1/listen", starlink.listen, methods=["GET"]),
146
+ http.route(
147
+ "/starlink/v1/announce/{number:int}",
148
+ starlink.announce,
149
+ methods=["PUT"],
150
+ ),
151
+ http.route("/starlink/v1/report", starlink.report, methods=["POST"]),
152
+ ):
153
+ ctx.offer(COMPUTERS, calls)
154
+ try:
155
+ yield
156
+ finally:
157
+ # held listens and questions out end before the routes wait
158
+ # for their requests
159
+ starlink.stop()
160
+ hosts.close()
161
+
162
+
163
+ @asynccontextmanager
164
+ async def _relay(ctx: Context) -> AsyncIterator[None]:
165
+ relay = MemoryRelay(ctx.accept(TIMER))
166
+ ctx.offer(RELAY, relay)
167
+ try:
168
+ yield
169
+ finally:
170
+ await relay.close()
171
+
172
+
173
+ computer = Contrib(
174
+ accepts=[
175
+ SERVER,
176
+ ACCESS,
177
+ HTTP,
178
+ RELAY,
179
+ OVERVIEW,
180
+ SERVER_DB,
181
+ SERVER_SCHEMA,
182
+ SERVER_MIGRATIONS,
183
+ ],
184
+ offers=[COMPUTERS],
185
+ lifespan=_computer,
186
+ )
187
+ relay = Contrib(offers=[RELAY], accepts=[TIMER], lifespan=_relay)
@@ -0,0 +1,37 @@
1
+ """COMPUTERS: calling what a computer exposes, through the relay to wherever
2
+ it listens."""
3
+
4
+ from __future__ import annotations
5
+
6
+ lazy from bazaar_contrib_sidecar import RemoteError
7
+ lazy from bazaar_contrib_sidecar.wire import Failure, Request
8
+ lazy from pydantic import JsonValue
9
+ lazy from pydantic_core import to_jsonable_python
10
+
11
+ lazy from .contracts import Relay
12
+
13
+
14
+ class Calls:
15
+ def __init__(self, relay: Relay) -> None:
16
+ self._relay = relay
17
+
18
+ async def call(
19
+ self,
20
+ computer_id: str,
21
+ service: str,
22
+ method: str,
23
+ *args: object,
24
+ **kwargs: object,
25
+ ) -> JsonValue:
26
+ response = await self._relay.ask(
27
+ computer_id,
28
+ Request(
29
+ service=service,
30
+ method=method,
31
+ args=to_jsonable_python(args),
32
+ kwargs=to_jsonable_python(kwargs),
33
+ ),
34
+ )
35
+ if isinstance(response, Failure):
36
+ raise RemoteError(response.type, response.message)
37
+ return response.value
@@ -0,0 +1,98 @@
1
+ """What other modules hold server-computer to, and what a relay between the
2
+ server's replicas holds to."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from dataclasses import dataclass
7
+ from typing import Protocol
8
+ lazy from collections.abc import Sequence
9
+
10
+ from bazaar_core import EventKey, ServiceKey
11
+ lazy from bazaar_contrib_audit import AuditEvent
12
+ lazy from bazaar_contrib_sidecar.wire import Failure, Request, Result
13
+ lazy from pydantic import JsonValue
14
+
15
+ # how long a computer's listen is held when nothing is waiting for it
16
+ HOLD_MS = 25_000
17
+ # how many calls one listen hands over at most
18
+ CALLS_AT_MOST = 20
19
+ # how long a call waits for its answer before the computer counts as silent
20
+ ANSWER_MS = 60_000
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class Call:
25
+ """A call waiting for its computer: its number there, and what it asks."""
26
+
27
+ number: int
28
+ request: Request
29
+
30
+
31
+ class Relay(Protocol):
32
+ """What the server's replicas share about computers: the calls waiting
33
+ for them, the answers coming back, and when each was last here. A
34
+ computer listens on any replica and a page calls it from any other; the
35
+ relay keeps only what lives while it does."""
36
+
37
+ async def ask(self, computer_id: str, request: Request) -> Result | Failure:
38
+ """Hand the call to the computer and wait for its answer, for
39
+ ANSWER_MS at most; the same call still waiting is joined rather than
40
+ handed over again. TimeoutError when no answer comes."""
41
+ ...
42
+
43
+ async def calls(self, computer_id: str, after: int) -> Sequence[Call]:
44
+ """The calls for the computer numbered after the given one, oldest
45
+ first and CALLS_AT_MOST of them at most; when there are none, the
46
+ first to come within HOLD_MS, or nothing."""
47
+ ...
48
+
49
+ async def answer(
50
+ self, computer_id: str, number: int, response: Result | Failure
51
+ ) -> None:
52
+ """The computer's answer to its call; one given again counts once."""
53
+ ...
54
+
55
+ async def seen(self, computer_id: str) -> int | None:
56
+ """Note that the computer is here now; when it was here before,
57
+ nothing when not since the relay started."""
58
+ ...
59
+
60
+ async def last_seen(self, computer_ids: Sequence[str]) -> dict[str, int]:
61
+ """When each of the computers was last here, those seen since the
62
+ relay started."""
63
+ ...
64
+
65
+
66
+ RELAY = ServiceKey[Relay]("server.relay")
67
+
68
+
69
+ class Computers(Protocol):
70
+ """What other modules do with the computers."""
71
+
72
+ async def call(
73
+ self,
74
+ computer_id: str,
75
+ service: str,
76
+ method: str,
77
+ *args: object,
78
+ **kwargs: object,
79
+ ) -> JsonValue:
80
+ """Call a member the computer exposes, as sidecar's Client does:
81
+ RemoteError when it fails there, TimeoutError when the computer does
82
+ not answer in time."""
83
+ ...
84
+
85
+
86
+ COMPUTERS = ServiceKey[Computers]("server.computers")
87
+
88
+
89
+ @dataclass(frozen=True, slots=True)
90
+ class Reported:
91
+ """An audit event a computer reported, announced once however often the
92
+ computer sends it again."""
93
+
94
+ computer_id: str
95
+ event: AuditEvent
96
+
97
+
98
+ REPORTED = EventKey[Reported]("server.computer.reported")
@@ -0,0 +1,96 @@
1
+ """What each computer's node says about the machine it runs on: asked when
2
+ the computer comes online, and again once a minute old while a page shows
3
+ it."""
4
+
5
+ from __future__ import annotations
6
+
7
+ lazy import asyncio
8
+ lazy import logging
9
+ lazy from time import time_ns
10
+
11
+ from pydantic import BaseModel, ValidationError
12
+ lazy from bazaar_contrib_sidecar import RemoteError
13
+
14
+ from .contracts import Computers
15
+
16
+ # what was said is shown for a minute before the node is asked again
17
+ FRESH_MS = 60_000
18
+
19
+
20
+ class Usage(BaseModel):
21
+ total: int
22
+ used: int
23
+
24
+ @property
25
+ def percent(self) -> int:
26
+ return round(self.used * 100 / self.total) if self.total else 0
27
+
28
+
29
+ class Disk(Usage):
30
+ mount: str
31
+
32
+
33
+ class Processor(BaseModel):
34
+ cores: int | None
35
+ percent: float
36
+
37
+
38
+ class Host(BaseModel):
39
+ """node.info as a node answers it."""
40
+
41
+ version: str
42
+ system: str
43
+ name: str
44
+ release: str
45
+ machine: str
46
+ started_ms: int
47
+ cpu: Processor
48
+ memory: Usage
49
+ disks: list[Disk]
50
+
51
+ @property
52
+ def fullest(self) -> Disk | None:
53
+ return max(self.disks, key=lambda disk: disk.percent, default=None)
54
+
55
+
56
+ class Hosts:
57
+ def __init__(self, computers: Computers, tasks: asyncio.TaskGroup) -> None:
58
+ self._computers = computers
59
+ self._tasks = tasks
60
+ self._known: dict[str, tuple[int, Host]] = {}
61
+ self._asking: dict[str, asyncio.Task[None]] = {}
62
+
63
+ def ask(self, computer_id: str) -> None:
64
+ """Ask the computer about its machine again, unless that is under way."""
65
+ if computer_id not in self._asking:
66
+ self._asking[computer_id] = self._tasks.create_task(self._ask(computer_id))
67
+
68
+ async def _ask(self, computer_id: str) -> None:
69
+ try:
70
+ value = await self._computers.call(computer_id, "node", "info")
71
+ self._known[computer_id] = (
72
+ time_ns() // 1_000_000,
73
+ Host.model_validate(value),
74
+ )
75
+ except RemoteError, TimeoutError, ValidationError:
76
+ # a node that does not answer, or answers otherwise, keeps what
77
+ # was said before
78
+ logging.getLogger(__name__).info("computer.info.unanswered", exc_info=True)
79
+ finally:
80
+ del self._asking[computer_id]
81
+
82
+ def of(self, computer_id: str, online: bool) -> tuple[int, Host] | None:
83
+ """When the computer last said what about its machine; one online
84
+ is asked again once that is a minute old."""
85
+ known = self._known.get(computer_id)
86
+ if online and (known is None or time_ns() // 1_000_000 - known[0] >= FRESH_MS):
87
+ self.ask(computer_id)
88
+ return known
89
+
90
+ def forget(self, computer_id: str) -> None:
91
+ self._known.pop(computer_id, None)
92
+
93
+ def close(self) -> None:
94
+ """Stop asking: the server is going down."""
95
+ for task in self._asking.values():
96
+ task.cancel()
@@ -0,0 +1,122 @@
1
+ """server-relay-memory: the relay of a server with one replica, kept in its
2
+ own memory, as 0.2's controls were."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from collections import defaultdict
7
+ from dataclasses import dataclass
8
+ lazy import asyncio
9
+ lazy import json
10
+ lazy from collections.abc import Sequence
11
+ lazy from time import time_ns
12
+
13
+ lazy from bazaar_contrib_sidecar.wire import Failure, Request, Result
14
+ lazy from bazaar_contrib_timer import Timer, timeout
15
+
16
+ from .contracts import ANSWER_MS, CALLS_AT_MOST, HOLD_MS, Call
17
+
18
+
19
+ @dataclass(slots=True)
20
+ class _Asked:
21
+ computer_id: str
22
+ call: Call
23
+ answer: asyncio.Future[Result | Failure]
24
+ # how many askers wait on the answer; none, and the call is forgotten
25
+ waiters: int = 0
26
+
27
+
28
+ class MemoryRelay:
29
+ def __init__(self, timer: Timer) -> None:
30
+ self._timer = timer
31
+ # what each computer has been asked and not yet answered, by number
32
+ self._asked: defaultdict[str, dict[int, _Asked]] = defaultdict(dict)
33
+ # the same call to the same computer, while one is out, is asked once
34
+ self._out: dict[tuple[str, str], _Asked] = {}
35
+ # a listen waits here until its computer has something
36
+ self._arrivals: defaultdict[str, asyncio.Condition] = defaultdict(
37
+ asyncio.Condition
38
+ )
39
+ self._seen: dict[str, int] = {}
40
+ # numbers climb with the clock, so where a computer got to before the
41
+ # server restarted is behind what comes after
42
+ self._last = 0
43
+ self._closing = False
44
+
45
+ async def ask(self, computer_id: str, request: Request) -> Result | Failure:
46
+ key = (
47
+ computer_id,
48
+ json.dumps(request.model_dump(mode="json"), sort_keys=True),
49
+ )
50
+ asked = self._out.get(key)
51
+ fresh = asked is None
52
+ if asked is None:
53
+ number = self._last = max(self._last + 1, time_ns() // 1_000_000)
54
+ asked = _Asked(
55
+ computer_id,
56
+ Call(number, request),
57
+ asyncio.get_running_loop().create_future(),
58
+ )
59
+ self._out[key] = asked
60
+ self._asked[computer_id][number] = asked
61
+ asked.answer.add_done_callback(lambda _: self._forget(key, asked))
62
+ asked.waiters += 1
63
+ try:
64
+ if fresh:
65
+ arrivals = self._arrivals[computer_id]
66
+ async with arrivals:
67
+ arrivals.notify_all()
68
+ async with timeout(self._timer, ANSWER_MS):
69
+ # one asker giving up does not take the answer from the others
70
+ return await asyncio.shield(asked.answer)
71
+ finally:
72
+ asked.waiters -= 1
73
+ if asked.waiters == 0 and not asked.answer.done():
74
+ self._forget(key, asked)
75
+
76
+ def _forget(self, key: tuple[str, str], asked: _Asked) -> None:
77
+ if self._out.get(key) is asked:
78
+ del self._out[key]
79
+ self._asked[asked.computer_id].pop(asked.call.number, None)
80
+
81
+ async def calls(self, computer_id: str, after: int) -> Sequence[Call]:
82
+ arrivals = self._arrivals[computer_id]
83
+ async with arrivals:
84
+ if not self._closing and not self._pending(computer_id, after):
85
+ try:
86
+ async with timeout(self._timer, HOLD_MS):
87
+ await arrivals.wait()
88
+ except TimeoutError:
89
+ return []
90
+ return [] if self._closing else self._pending(computer_id, after)
91
+
92
+ def _pending(self, computer_id: str, after: int) -> list[Call]:
93
+ asked = self._asked[computer_id]
94
+ return [asked[number].call for number in sorted(asked) if number > after][
95
+ :CALLS_AT_MOST
96
+ ]
97
+
98
+ async def answer(
99
+ self, computer_id: str, number: int, response: Result | Failure
100
+ ) -> None:
101
+ asked = self._asked[computer_id].get(number)
102
+ if asked is not None and not asked.answer.done():
103
+ asked.answer.set_result(response)
104
+
105
+ async def seen(self, computer_id: str) -> int | None:
106
+ before = self._seen.get(computer_id)
107
+ self._seen[computer_id] = time_ns() // 1_000_000
108
+ return before
109
+
110
+ async def last_seen(self, computer_ids: Sequence[str]) -> dict[str, int]:
111
+ return {
112
+ computer_id: self._seen[computer_id]
113
+ for computer_id in computer_ids
114
+ if computer_id in self._seen
115
+ }
116
+
117
+ async def close(self) -> None:
118
+ """Let every held listen go: the relay is going away."""
119
+ self._closing = True
120
+ for arrivals in list(self._arrivals.values()):
121
+ async with arrivals:
122
+ arrivals.notify_all()