Skip to content

Commit 1d49ca9

Browse files
feat(cli): add tower create-join-token command
Add `cwsandbox tower create-join-token` to create join tokens and store them in Kubernetes secrets for tower registration. Features: - Calls the sandbox platform API to generate a single-use join token - Stores the token in a K8s secret (default: sandbox-tower-join-token) - Supports --json flag for API-compatible JSON payloads (inline or stdin) - Supports --generate-only to print token without K8s storage - Configurable via flags and env vars (BOX_API_KEY, BOX_ATC_SERVER) - Kubeconfig resolution via flag, KUBECONFIG env, or in-cluster config Adds httpx and kubernetes as optional [cli] dependencies. Closes #69
1 parent 8f0a544 commit 1d49ca9

5 files changed

Lines changed: 377 additions & 0 deletions

File tree

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ cwsandbox = "cwsandbox.__main__:main"
3737
[project.optional-dependencies]
3838
cli = [
3939
"click>=8.0",
40+
"httpx>=0.27.0",
41+
"kubernetes>=28.0.0",
4042
]
4143
dev = [
4244
"click>=8.0",

src/cwsandbox/cli/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525

2626
from cwsandbox.cli.exec import exec_command
2727
from cwsandbox.cli.list import list_sandboxes
28+
from cwsandbox.cli.tower import tower
2829
from cwsandbox.exceptions import CWSandboxError
2930

3031

@@ -50,3 +51,4 @@ def cli() -> None:
5051

5152
cli.add_command(list_sandboxes, "ls")
5253
cli.add_command(exec_command, "exec")
54+
cli.add_command(tower, "tower")

src/cwsandbox/cli/tower.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# SPDX-FileCopyrightText: 2025 CoreWeave, Inc.
2+
# SPDX-License-Identifier: Apache-2.0
3+
# SPDX-PackageName: cwsandbox-client
4+
5+
"""cwsandbox tower — tower management commands."""
6+
7+
from __future__ import annotations
8+
9+
import click
10+
11+
from cwsandbox.cli.tower_create_join_token import create_join_token
12+
13+
14+
@click.group("tower")
15+
def tower() -> None:
16+
"""Tower management commands."""
17+
18+
19+
tower.add_command(create_join_token, "create-join-token")
Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
# SPDX-FileCopyrightText: 2025 CoreWeave, Inc.
2+
# SPDX-License-Identifier: Apache-2.0
3+
# SPDX-PackageName: cwsandbox-client
4+
5+
"""cwsandbox tower create-join-token — create a tower join token and store it in K8s."""
6+
7+
from __future__ import annotations
8+
9+
import json
10+
import sys
11+
from typing import Any
12+
13+
import click
14+
15+
DEFAULT_SECRET_NAME = "sandbox-tower-join-token"
16+
DEFAULT_SECRET_KEY = "token"
17+
DEFAULT_NAMESPACE = "sandbox-system"
18+
DEFAULT_ATC_SERVER = "https://atc.cw-sandbox.com"
19+
20+
21+
@click.command("create-join-token")
22+
@click.option(
23+
"--tower-id",
24+
default=None,
25+
help="Tower ID to assign (required unless provided via --json).",
26+
)
27+
@click.option(
28+
"--tower-group-id",
29+
default=None,
30+
help='Tower group for scheduling affinity (default: "default").',
31+
)
32+
@click.option(
33+
"--ttl",
34+
"ttl_seconds",
35+
type=int,
36+
default=None,
37+
help="Token TTL in seconds (default: 86400 = 24 hours).",
38+
)
39+
@click.option("--description", default=None, help="Human-readable description for the token.")
40+
@click.option(
41+
"--atc-server",
42+
envvar="BOX_ATC_SERVER",
43+
default=DEFAULT_ATC_SERVER,
44+
show_envvar=True,
45+
help="ATC server address.",
46+
)
47+
@click.option(
48+
"--api-key",
49+
envvar="BOX_API_KEY",
50+
default=None,
51+
show_envvar=True,
52+
help="API key for ATC authentication.",
53+
)
54+
@click.option(
55+
"--kubeconfig",
56+
envvar="KUBECONFIG",
57+
default=None,
58+
show_envvar=True,
59+
help="Path to kubeconfig file.",
60+
)
61+
@click.option(
62+
"--namespace",
63+
default=DEFAULT_NAMESPACE,
64+
help="Kubernetes namespace for the secret.",
65+
)
66+
@click.option(
67+
"--secret-name",
68+
default=DEFAULT_SECRET_NAME,
69+
help="Name of the Kubernetes secret.",
70+
)
71+
@click.option(
72+
"--secret-key",
73+
default=DEFAULT_SECRET_KEY,
74+
help="Key within the Kubernetes secret.",
75+
)
76+
@click.option(
77+
"--json",
78+
"json_payload",
79+
default=None,
80+
help="JSON payload matching the API request format (use '-' to read from stdin).",
81+
)
82+
@click.option(
83+
"--generate-only",
84+
is_flag=True,
85+
default=False,
86+
help="Only generate the token and print it; do not store in Kubernetes.",
87+
)
88+
def create_join_token(
89+
tower_id: str | None,
90+
tower_group_id: str | None,
91+
ttl_seconds: int | None,
92+
description: str | None,
93+
atc_server: str,
94+
api_key: str | None,
95+
kubeconfig: str | None,
96+
namespace: str,
97+
secret_name: str,
98+
secret_key: str,
99+
json_payload: str | None,
100+
generate_only: bool,
101+
) -> None:
102+
"""Create a join token for a tower and store it in a Kubernetes secret.
103+
104+
Creates a join token by calling the ATC API, then stores the token
105+
in a Kubernetes secret on the target cluster. The tower's Helm chart
106+
reads this secret during the join process.
107+
108+
Examples:
109+
110+
cwsandbox tower create-join-token --tower-id=my-tower
111+
112+
cwsandbox tower create-join-token --tower-id=my-tower --namespace=sandbox-system
113+
114+
cwsandbox tower create-join-token --json='{"tower_id":"my-tower","ttl_seconds":3600}'
115+
116+
echo '{"tower_id":"my-tower"}' | cwsandbox tower create-join-token --json=-
117+
118+
cwsandbox tower create-join-token --tower-id=my-tower --generate-only
119+
"""
120+
if not api_key:
121+
raise click.ClickException("--api-key or BOX_API_KEY is required")
122+
123+
# If --json is provided, parse and use as base values (flags override)
124+
if json_payload is not None:
125+
tower_id, tower_group_id, ttl_seconds, description = _apply_json_payload(
126+
json_payload, tower_id, tower_group_id, ttl_seconds, description
127+
)
128+
129+
if not tower_id:
130+
raise click.ClickException("--tower-id is required (via flag or JSON payload)")
131+
132+
# Build the API request body
133+
body: dict[str, Any] = {"tower_id": tower_id}
134+
if tower_group_id:
135+
body["tower_group_id"] = tower_group_id
136+
if ttl_seconds is not None:
137+
body["ttl_seconds"] = ttl_seconds
138+
if description:
139+
body["description"] = description
140+
141+
token_resp = _create_token(atc_server, api_key, body)
142+
143+
if generate_only:
144+
click.echo(f"Tower ID: {token_resp.get('tower_id', '')}")
145+
click.echo(f"Tower Group: {token_resp.get('tower_group_id', '')}")
146+
click.echo(f"Organization: {token_resp.get('organization_id', '')}")
147+
click.echo(f"Token ID: {token_resp.get('token_id', '')}")
148+
click.echo(f"Expires: {token_resp.get('expires_at', '')}")
149+
click.echo(f"Token: {token_resp.get('token', '')}")
150+
return
151+
152+
click.echo(
153+
f"Created join token for tower {tower_id!r} "
154+
f"(token_id: {token_resp.get('token_id', '')}, "
155+
f"expires: {token_resp.get('expires_at', '')})",
156+
err=True,
157+
)
158+
159+
_store_token_in_secret(
160+
token=token_resp["token"],
161+
kubeconfig=kubeconfig,
162+
namespace=namespace,
163+
secret_name=secret_name,
164+
secret_key=secret_key,
165+
)
166+
167+
click.echo(
168+
f"Stored join token in secret {namespace}/{secret_name} (key: {secret_key})",
169+
err=True,
170+
)
171+
172+
173+
def _apply_json_payload(
174+
json_payload: str,
175+
tower_id: str | None,
176+
tower_group_id: str | None,
177+
ttl_seconds: int | None,
178+
description: str | None,
179+
) -> tuple[str | None, str | None, int | None, str | None]:
180+
"""Parse JSON payload and merge with flag values. Flags take precedence."""
181+
if json_payload == "-":
182+
data = sys.stdin.read()
183+
else:
184+
data = json_payload
185+
186+
try:
187+
req = json.loads(data)
188+
except json.JSONDecodeError as e:
189+
raise click.ClickException(f"Invalid JSON: {e}") from None
190+
191+
if tower_id is None and req.get("tower_id"):
192+
tower_id = req["tower_id"]
193+
if tower_group_id is None and req.get("tower_group_id"):
194+
tower_group_id = req["tower_group_id"]
195+
if ttl_seconds is None and req.get("ttl_seconds"):
196+
ttl_seconds = req["ttl_seconds"]
197+
if description is None and req.get("description"):
198+
description = req["description"]
199+
200+
return tower_id, tower_group_id, ttl_seconds, description
201+
202+
203+
def _create_token(atc_server: str, api_key: str, body: dict[str, Any]) -> dict[str, Any]:
204+
"""Call the ATC API to create a tower join token."""
205+
try:
206+
import httpx
207+
except ModuleNotFoundError:
208+
raise click.ClickException(
209+
"httpx is required for this command. Install it with: pip install httpx"
210+
) from None
211+
212+
url = f"{atc_server.rstrip('/')}/v1beta1/towers/tokens"
213+
214+
try:
215+
resp = httpx.post(
216+
url,
217+
json=body,
218+
headers={"Authorization": f"Bearer {api_key}"},
219+
timeout=30.0,
220+
)
221+
resp.raise_for_status()
222+
except httpx.HTTPStatusError as e:
223+
raise click.ClickException(
224+
f"API error ({e.response.status_code}): {e.response.text}"
225+
) from None
226+
except httpx.RequestError as e:
227+
raise click.ClickException(f"Failed to connect to ATC: {e}") from None
228+
229+
return resp.json() # type: ignore[no-any-return]
230+
231+
232+
def _store_token_in_secret(
233+
*,
234+
token: str,
235+
kubeconfig: str | None,
236+
namespace: str,
237+
secret_name: str,
238+
secret_key: str,
239+
) -> None:
240+
"""Store the join token in a Kubernetes secret."""
241+
try:
242+
from kubernetes import client as k8s_client
243+
from kubernetes import config as k8s_config
244+
from kubernetes.client.exceptions import ApiException
245+
except ModuleNotFoundError:
246+
raise click.ClickException(
247+
"kubernetes client is required for this command. "
248+
"Install it with: pip install kubernetes"
249+
) from None
250+
251+
# Load kubeconfig
252+
try:
253+
if kubeconfig:
254+
k8s_config.load_kube_config(config_file=kubeconfig)
255+
else:
256+
k8s_config.load_kube_config()
257+
except k8s_config.ConfigException:
258+
try:
259+
k8s_config.load_incluster_config()
260+
except k8s_config.ConfigException:
261+
raise click.ClickException(
262+
"Could not load Kubernetes configuration. "
263+
"Provide --kubeconfig or run inside a cluster."
264+
) from None
265+
266+
v1 = k8s_client.CoreV1Api()
267+
268+
secret = k8s_client.V1Secret(
269+
metadata=k8s_client.V1ObjectMeta(
270+
name=secret_name,
271+
namespace=namespace,
272+
labels={
273+
"app.kubernetes.io/name": "sandbox-tower",
274+
"app.kubernetes.io/component": "join-token",
275+
"app.kubernetes.io/managed-by": "box",
276+
},
277+
),
278+
type="Opaque",
279+
string_data={secret_key: token},
280+
)
281+
282+
try:
283+
v1.create_namespaced_secret(namespace=namespace, body=secret)
284+
except ApiException as e:
285+
if e.status == 409:
286+
# Already exists — update it
287+
v1.replace_namespaced_secret(name=secret_name, namespace=namespace, body=secret)
288+
click.echo(f"Updated existing secret {namespace}/{secret_name}", err=True)
289+
else:
290+
raise click.ClickException(f"Failed to create Kubernetes secret: {e.reason}") from None

0 commit comments

Comments
 (0)