tuneplane-node 0.3.15__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.
- tuneplane_node/__init__.py +1 -0
- tuneplane_node/allocator.py +596 -0
- tuneplane_node/cli.py +189 -0
- tuneplane_node/daemon.py +497 -0
- tuneplane_node/inventory.py +163 -0
- tuneplane_node/join.py +199 -0
- tuneplane_node/runtime.py +611 -0
- tuneplane_node/settings.py +77 -0
- tuneplane_node/wire.py +85 -0
- tuneplane_node-0.3.15.dist-info/METADATA +12 -0
- tuneplane_node-0.3.15.dist-info/RECORD +13 -0
- tuneplane_node-0.3.15.dist-info/WHEEL +4 -0
- tuneplane_node-0.3.15.dist-info/entry_points.txt +2 -0
tuneplane_node/cli.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""tuneplane-node: the entry point of the daemon that runs on a GPU node.
|
|
2
|
+
|
|
3
|
+
TUNEPLANE_NODE_TOKEN=<shared token> tuneplane-node serve --host 0.0.0.0 --port 7070
|
|
4
|
+
|
|
5
|
+
The node side takes the same TUNEPLANE_LOCAL_* settings as the local backend (container
|
|
6
|
+
runtime, card count, health checks), plus TUNEPLANE_STORAGE_ROOT (shared storage, mounted
|
|
7
|
+
at the same path as on the console).
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
import typer
|
|
14
|
+
|
|
15
|
+
app = typer.Typer(add_completion=False, no_args_is_help=True, help="tuneplane agent daemon")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@app.callback()
|
|
19
|
+
def _main() -> None:
|
|
20
|
+
"""Keep `serve` a subcommand.
|
|
21
|
+
|
|
22
|
+
Typer folds a lone command into the root, which made the documented
|
|
23
|
+
`tuneplane-node serve --port 7070` fail with "Got unexpected extra argument
|
|
24
|
+
(serve)" -- in the published operator docs, this module's own docstring, and
|
|
25
|
+
any systemd unit written from either. server/cli.py carries the same guard for
|
|
26
|
+
the same reason; the agent was simply missing it.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _state_dir(settings) -> Path:
|
|
31
|
+
return Path(str(settings.node_state_dir)).expanduser()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@app.command()
|
|
35
|
+
def serve(
|
|
36
|
+
host: str = typer.Option("0.0.0.0", help="Address to bind"),
|
|
37
|
+
port: int = typer.Option(7070, help="Port to listen on"),
|
|
38
|
+
) -> None:
|
|
39
|
+
"""Start the agent daemon. Configured by env / .env, the same way the console is.
|
|
40
|
+
|
|
41
|
+
If this node has joined a fleet, a heartbeat runs alongside the daemon. The
|
|
42
|
+
daemon serves whether or not it has: losing the console is not a reason to
|
|
43
|
+
stop running work, and the containers on this machine stay the truth about
|
|
44
|
+
what is running (ADR-002).
|
|
45
|
+
"""
|
|
46
|
+
import asyncio
|
|
47
|
+
|
|
48
|
+
import uvicorn
|
|
49
|
+
|
|
50
|
+
from tuneplane_node.daemon import create_node_app
|
|
51
|
+
from tuneplane_node.join import heartbeat_forever, load_identity
|
|
52
|
+
from tuneplane_node.settings import NodeSettings
|
|
53
|
+
|
|
54
|
+
settings = NodeSettings()
|
|
55
|
+
api = create_node_app(settings)
|
|
56
|
+
identity = load_identity(_state_dir(settings))
|
|
57
|
+
|
|
58
|
+
if identity is not None:
|
|
59
|
+
address = f"http://{host}:{port}" if host not in ("0.0.0.0", "::") else ""
|
|
60
|
+
|
|
61
|
+
@api.on_event("startup")
|
|
62
|
+
async def _start_heartbeat() -> None: # pragma: no cover - process wiring
|
|
63
|
+
api.state.heartbeat = asyncio.create_task(
|
|
64
|
+
heartbeat_forever(
|
|
65
|
+
identity, settings, node_version=_node_version(), address=address
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
@api.on_event("shutdown")
|
|
70
|
+
async def _stop_heartbeat() -> None: # pragma: no cover - process wiring
|
|
71
|
+
task = getattr(api.state, "heartbeat", None)
|
|
72
|
+
if task is not None:
|
|
73
|
+
task.cancel()
|
|
74
|
+
|
|
75
|
+
typer.echo(f"joined fleet {identity.fleet_id} as node {identity.node_id}")
|
|
76
|
+
else:
|
|
77
|
+
typer.echo("not joined to any fleet; run `tuneplane-node join` to register this machine")
|
|
78
|
+
|
|
79
|
+
uvicorn.run(api, host=host, port=port, log_level="info")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@app.command()
|
|
83
|
+
def join(
|
|
84
|
+
server: str = typer.Option(
|
|
85
|
+
..., "--server",
|
|
86
|
+
help="Where the control plane answers, e.g. https://tuneplane.corp",
|
|
87
|
+
),
|
|
88
|
+
fleet: str = typer.Option(..., "--fleet", help="Fleet to join"),
|
|
89
|
+
token: str = typer.Option(..., "--token", help="Join token an administrator minted"),
|
|
90
|
+
address: str = typer.Option(
|
|
91
|
+
"", "--address",
|
|
92
|
+
help="Where the console reaches this machine, e.g. http://gpu-1:7070. Defaults to this "
|
|
93
|
+
"host's name and the agent port.",
|
|
94
|
+
),
|
|
95
|
+
port: int = typer.Option(7070, "--port", help="Port the agent daemon listens on"),
|
|
96
|
+
force: bool = typer.Option(False, "--force", help="Re-join even though this machine already has an identity"),
|
|
97
|
+
) -> None:
|
|
98
|
+
"""Register this machine into a fleet.
|
|
99
|
+
|
|
100
|
+
**No platform login.** The join token an administrator gave you is the whole
|
|
101
|
+
authorization -- you need root on this box, not an account on the console
|
|
102
|
+
(ADR-0013).
|
|
103
|
+
|
|
104
|
+
Run it once per machine. What comes back is this node's identity and a
|
|
105
|
+
credential, written to the agent state directory; `tuneplane-node serve` then
|
|
106
|
+
heartbeats with them.
|
|
107
|
+
"""
|
|
108
|
+
import asyncio
|
|
109
|
+
import socket
|
|
110
|
+
|
|
111
|
+
from tuneplane_node.inventory import collect
|
|
112
|
+
from tuneplane_node.join import JoinError, load_identity, save_identity
|
|
113
|
+
from tuneplane_node.join import join as do_join
|
|
114
|
+
from tuneplane_node.settings import NodeSettings
|
|
115
|
+
|
|
116
|
+
settings = NodeSettings()
|
|
117
|
+
state_dir = _state_dir(settings)
|
|
118
|
+
|
|
119
|
+
existing = load_identity(state_dir)
|
|
120
|
+
if existing is not None and not force:
|
|
121
|
+
# Re-joining silently would strand the old identity: the console still
|
|
122
|
+
# believes that node exists, and its containers would reconcile against
|
|
123
|
+
# a node id nothing reports on any more.
|
|
124
|
+
typer.echo(
|
|
125
|
+
f"this machine is already node {existing.node_id} in fleet {existing.fleet_id}. "
|
|
126
|
+
f"Pass --force to register it again as a new node.",
|
|
127
|
+
err=True,
|
|
128
|
+
)
|
|
129
|
+
raise typer.Exit(2)
|
|
130
|
+
|
|
131
|
+
reachable = address or f"http://{socket.gethostname()}:{port}"
|
|
132
|
+
|
|
133
|
+
async def _run():
|
|
134
|
+
inventory = await collect(settings)
|
|
135
|
+
return inventory, await do_join(
|
|
136
|
+
server,
|
|
137
|
+
fleet_id=fleet,
|
|
138
|
+
token=token,
|
|
139
|
+
address=reachable,
|
|
140
|
+
inventory=inventory.to_dict(),
|
|
141
|
+
node_version=_node_version(),
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
inventory, (identity, warnings) = asyncio.run(_run())
|
|
146
|
+
except JoinError as exc:
|
|
147
|
+
typer.echo(str(exc), err=True)
|
|
148
|
+
raise typer.Exit(2) from exc
|
|
149
|
+
|
|
150
|
+
path = save_identity(state_dir, identity)
|
|
151
|
+
typer.echo(f"joined fleet {fleet} as node {identity.node_id}")
|
|
152
|
+
cards = inventory.accelerator_name or inventory.accelerator_series or "unknown"
|
|
153
|
+
typer.echo(f" {inventory.accelerator_count} x {cards}")
|
|
154
|
+
if inventory.accelerator_name and not inventory.accelerator_series:
|
|
155
|
+
# The console can place a job on this node, but not one that pins a
|
|
156
|
+
# series -- and the operator is the only person who can fix that.
|
|
157
|
+
typer.echo(
|
|
158
|
+
f" ! this card is not in the platform's hardware registry, so jobs that pin a "
|
|
159
|
+
f"series will not be placed here. Ask an administrator to add {cards!r}."
|
|
160
|
+
)
|
|
161
|
+
typer.echo(f" driver {inventory.driver or 'unknown'}, runtime {inventory.runtime or 'unknown'}")
|
|
162
|
+
for warning in warnings:
|
|
163
|
+
typer.echo(f" ! {warning}", err=True)
|
|
164
|
+
typer.echo(
|
|
165
|
+
f" storage root {'visible' if inventory.storage_root_visible else 'NOT visible'}"
|
|
166
|
+
)
|
|
167
|
+
typer.echo(f" identity written to {path}")
|
|
168
|
+
typer.echo(" start the agent with: tuneplane-node serve")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _node_version() -> str:
|
|
172
|
+
"""This the node daemon's own version, reported to the console on every heartbeat.
|
|
173
|
+
|
|
174
|
+
Its own distribution, never the console's: a machine that installed
|
|
175
|
+
`tuneplane-node` has no `server` package to ask, and reaching for one
|
|
176
|
+
here would be the exact dependency direction this package exists to remove.
|
|
177
|
+
"""
|
|
178
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
179
|
+
|
|
180
|
+
try:
|
|
181
|
+
return version("tuneplane-node")
|
|
182
|
+
except PackageNotFoundError:
|
|
183
|
+
# Running from a checkout. Empty rather than a guess: the console shows
|
|
184
|
+
# it verbatim, and an invented version is worse than a blank.
|
|
185
|
+
return ""
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
if __name__ == "__main__":
|
|
189
|
+
app()
|