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/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()