Skip to content

Commit d41aa4d

Browse files
authored
Merge branch 'main' into fix/small-logic-bugs
2 parents 7fdf06b + b7e8f44 commit d41aa4d

26 files changed

Lines changed: 1807 additions & 157 deletions

File tree

.agents/skills/transformerlab-cli/SKILL.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -833,6 +833,24 @@ This applies to launching jobs, fetching logs, checking cluster status, and ever
833833
| `lab server install` | Interactive server setup wizard | No |
834834
| `lab server version` | Show installed server version | No |
835835
| `lab server update` | Update server to latest | No |
836+
| `lab team info` | Show current team: name, your role, member count, quota | No |
837+
| `lab team rename <name>` | Rename the current team (team owners only) | No |
838+
| `lab team setup` | Onboarding wizard: add a provider, set defaults/secrets, health-check | No |
839+
| `lab team secret list` | List secrets (`--user`/`-u` for user-level, `--show-values`) | No |
840+
| `lab team secret set [name] [value]` | Set a secret (`--user`/`-u` for user-level) | No |
841+
| `lab team secret delete <name>` | Delete a secret (`--user`/`-u`, `--no-interactive`) | No |
842+
| `lab team secret keys` | Show platform-recognized secret key names | No |
843+
| `lab team quota show` | Show the current team's monthly quota (minutes; shows hours too) | No |
844+
| `lab team quota set <minutes>` | Set team monthly quota in minutes (team owners only) | No |
845+
| `lab team quota usage` | Per-user quota usage for the team (team owners only) | No |
846+
| `lab team quota set-user <email\|uuid> <minutes>` | Set a per-user quota override (team owners only) | No |
847+
| `lab team quota me` | Show your own quota status in the current team | No |
848+
| `lab team members list` | List members of the current team | No |
849+
| `lab team members invite <email>` | Invite a member by email (`--role member\|owner`, team owners only) | No |
850+
| `lab team members remove <email\|uuid>` | Remove a member (`--no-interactive`; team owners only) | No |
851+
| `lab team members set-role <email\|uuid> <role>` | Change a member's role to `member`/`owner` (team owners only) | No |
852+
| `lab team invitations list` | List pending invitations for the team (team owners only) | No |
853+
| `lab team invitations cancel <invitation_id>` | Cancel a pending invitation (`--no-interactive`; team owners only) | No |
836854

837855
## JSON Output Shapes
838856

.github/dependabot.yml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,15 @@ updates:
211211
# fails immediately on import. Revisit after refactoring the import path. See PR #2083.
212212
- dependency-name: 'azure-mgmt-resource'
213213
versions: ['>=25']
214+
# azure-mgmt-compute 35+ rebuilt the VM create REST surface around ARM
215+
# `ResourceDefinition`, dropping the flat `hardware_profile` / `storage_profile` /
216+
# `os_profile` / `network_profile` keys our provider passes to
217+
# `begin_create_or_update`. Launching any Azure VM fails with
218+
# `Could not find member 'hardware_profile' on object of type 'ResourceDefinition'`.
219+
# Hold at 34.x until the payload builder in compute_providers/azure.py is ported
220+
# to the new schema. See PR #2087 (the bump we reverted).
221+
- dependency-name: 'azure-mgmt-compute'
222+
versions: ['>=35']
214223
# boto3 patches beyond 1.40.70 are uninstallable. Each boto3 patch pins a
215224
# matching botocore patch, and aiobotocore's compatible window in the 1.40
216225
# line is botocore>=1.40.37,<1.40.71 — so boto3 1.40.71+ (and all of 1.41.x)

api/pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ dependencies = [
1919
"fastapi==0.125.0",
2020
"fastapi-users[sqlalchemy,oauth]==15.0.5",
2121
"httpx-oauth==0.16.1",
22-
"nebius==0.3.71",
22+
"nebius==0.3.72",
2323
"packaging==26.2",
2424
"psutil==7.2.2",
2525
"pydantic>=2.13.4,<3.0",
@@ -33,7 +33,7 @@ dependencies = [
3333
"werkzeug==3.1.8",
3434
"google-cloud-storage==3.10.1",
3535
"azure-identity==1.25.3",
36-
"azure-mgmt-compute==38.0.0",
36+
"azure-mgmt-compute==34.1.0",
3737
"azure-mgmt-network==30.2.0",
3838
"azure-mgmt-resource==24.0.0",
3939
"azure-mgmt-authorization==4.0.0",

api/transformerlab/routers/auth.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -648,9 +648,19 @@ async def set_user_secrets(
648648
# Ensure workspace directory exists
649649
await storage.makedirs(workspace_dir, exist_ok=True)
650650

651+
# Special secrets live in the same file but are only mutable via the
652+
# special secrets endpoint. Preserve them so a regular-secrets PUT
653+
# (which overwrites the whole file) does not silently wipe them.
654+
preserved_special = {}
655+
if await storage.exists(secrets_path):
656+
async with await storage.open(secrets_path, "r") as f:
657+
existing = json.loads(await f.read())
658+
preserved_special = {k: v for k, v in existing.items() if k in SPECIAL_SECRET_KEYS}
659+
merged = {**secrets_data.secrets, **preserved_special}
660+
651661
# Write secrets to file
652662
async with await storage.open(secrets_path, "w") as f:
653-
await f.write(json.dumps(secrets_data.secrets, indent=2))
663+
await f.write(json.dumps(merged, indent=2))
654664

655665
return {
656666
"status": "success",

api/transformerlab/services/team_service.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -735,8 +735,17 @@ async def set_team_secrets(workspace_dir: str, secrets: dict) -> dict:
735735
secrets_path = storage.join(workspace_dir, "team_secrets.json")
736736
try:
737737
await storage.makedirs(workspace_dir, exist_ok=True)
738+
# Special secrets live in the same file but are only mutable via the
739+
# special secrets endpoint. Preserve them so a regular-secrets PUT
740+
# (which overwrites the whole file) does not silently wipe them.
741+
preserved_special = {}
742+
if await storage.exists(secrets_path):
743+
async with await storage.open(secrets_path, "r") as f:
744+
existing = json.loads(await f.read())
745+
preserved_special = {k: v for k, v in existing.items() if k in SPECIAL_SECRET_KEYS}
746+
merged = {**secrets, **preserved_special}
738747
async with await storage.open(secrets_path, "w") as f:
739-
await f.write(json.dumps(secrets, indent=2))
748+
await f.write(json.dumps(merged, indent=2))
740749
return {"status": "success", "message": "Team secrets saved successfully", "secret_keys": list(secrets.keys())}
741750
except HTTPException:
742751
raise

cli/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "transformerlab-cli"
7-
version = "0.0.55"
7+
version = "0.0.57"
88
description = "Transformer Lab CLI"
99
requires-python = ">=3.10"
1010
authors = [{ name = "Transformer Lab", email = "hello@transformerlab.ai" }]

cli/src/transformerlab_cli/commands/provider.py

Lines changed: 117 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,27 @@ def _extract_error_detail(response) -> str:
100100
return response.text
101101

102102

103+
def _resolve_provider_id(identifier: str) -> str | None:
104+
"""Resolve a provider identifier (id or name) to a provider id.
105+
106+
Provider names are unique within a team (enforced by the API), and the CLI
107+
is always team-scoped, so a name resolves to at most one provider. Returns
108+
the provider id, or None if no provider matches the identifier.
109+
"""
110+
response = api.get("/compute_provider/providers/?include_disabled=true")
111+
if response.status_code != 200:
112+
return None
113+
114+
providers = response.json()
115+
for provider in providers:
116+
if provider.get("id") == identifier:
117+
return identifier
118+
for provider in providers:
119+
if provider.get("name") == identifier:
120+
return provider.get("id")
121+
return None
122+
123+
103124
def _extract_provider_check_reason(result: dict) -> str:
104125
"""Extract a human-readable provider check failure reason."""
105126
reason = result.get("reason")
@@ -189,7 +210,8 @@ def _upload_aws_credentials(provider_id: str, access_key_id: str, secret_access_
189210
json_data={"access_key_id": access_key_id, "secret_access_key": secret_access_key},
190211
)
191212
if response.status_code == 200:
192-
console.print("[success]✓[/success] AWS credentials saved to API host.")
213+
if cli_state.output_format != "json":
214+
console.print("[success]✓[/success] AWS credentials saved to API host.")
193215
else:
194216
console.print(f"[error]Error:[/error] Failed to upload AWS credentials. {_extract_error_detail(response)}")
195217
raise typer.Exit(1)
@@ -205,68 +227,25 @@ def _upload_gcp_service_account(provider_id: str, service_account_json: str) ->
205227
json_data={"service_account_json": service_account_json},
206228
)
207229
if response.status_code == 200:
208-
console.print("[success]✓[/success] GCP service account saved.")
230+
if cli_state.output_format != "json":
231+
console.print("[success]✓[/success] GCP service account saved.")
209232
else:
210233
console.print(f"[error]Error:[/error] Failed to upload GCP service account. {_extract_error_detail(response)}")
211234
raise typer.Exit(1)
212235

213236

214-
## COMMANDS ##
215-
216-
217-
@app.command("list")
218-
def command_provider_list(
219-
include_disabled: bool = typer.Option(False, "--include-disabled", help="Include disabled providers"),
220-
):
221-
"""List all compute providers."""
222-
check_configs(output_format=cli_state.output_format)
223-
224-
with console.status("[bold success]Fetching providers...[/bold success]", spinner="dots"):
225-
response = api.get(f"/compute_provider/providers/?include_disabled={str(include_disabled).lower()}")
226-
227-
if response.status_code == 200:
228-
providers = response.json()
229-
table_columns = ["id", "name", "type", "disabled", "is_default", "created_at", "updated_at"]
230-
render_table(
231-
data=providers,
232-
format_type=cli_state.output_format,
233-
table_columns=table_columns,
234-
title="Providers",
235-
column_options={
236-
# Keep provider names as a single token (no wrapping to "Skypilot / New").
237-
"name": {"no_wrap": True, "overflow": "crop"},
238-
},
239-
)
240-
else:
241-
console.print(f"[error]Error:[/error] Failed to fetch providers. {_extract_error_detail(response)}")
242-
raise typer.Exit(1)
243-
244-
245-
@app.command("add")
246-
def command_provider_add(
247-
name: str = typer.Option(None, "--name", help="Provider name"),
248-
provider_type: str = typer.Option(
249-
None,
250-
"--type",
251-
help=f"Provider type ({', '.join(PROVIDER_TYPES)})",
252-
),
253-
config: str = typer.Option(None, "--config", help="Provider config as JSON string"),
254-
interactive: bool = typer.Option(True, "--interactive/--no-interactive", help="Use interactive prompts"),
255-
credentials_file: str = typer.Option(
256-
None,
257-
"--credentials-file",
258-
help=(
259-
"Path to a JSON file containing provider secrets. Keeps secrets out of argv "
260-
"(shell history, ps listings). Shape depends on --type: for aws, "
261-
'{"aws_access_key_id": "...", "aws_secret_access_key": "..."}; for gcp, the '
262-
"raw service account JSON key file; for everything else, a flat object whose "
263-
"fields are merged on top of --config (file values take precedence)."
264-
),
265-
),
266-
):
267-
"""Add a new compute provider."""
268-
check_configs(output_format=cli_state.output_format)
237+
def create_provider_interactively(
238+
name: str | None,
239+
provider_type: str | None,
240+
config: str | None,
241+
interactive: bool,
242+
credentials_file: str | None,
243+
) -> str:
244+
"""Resolve provider inputs, create the provider, upload any credentials, and return its id.
269245
246+
Does NOT run the health check — callers run it when appropriate. Raises typer.Exit on any
247+
validation or API failure, printing an error first (matching the CLI's existing behavior).
248+
"""
270249
if not interactive:
271250
if not name or not provider_type or config is None:
272251
console.print("[error]Error:[/error] --name, --type, and --config are required with --no-interactive")
@@ -300,11 +279,6 @@ def command_provider_add(
300279
else:
301280
config_dict = _prompt_config_fields(provider_type)
302281

303-
# --credentials-file: shape depends on provider type.
304-
# aws: JSON object with aws_access_key_id / aws_secret_access_key (uploaded via /aws/credentials).
305-
# Any remaining keys merge into config.
306-
# gcp: the raw service account JSON key file (uploaded via /gcp/credentials).
307-
# other: a flat JSON object merged on top of --config (file values win).
308282
aws_access_key_id: str = ""
309283
aws_secret_access_key: str = ""
310284
gcp_service_account_json: str | None = None
@@ -318,7 +292,6 @@ def command_provider_add(
318292
aws_secret_access_key = str(creds.pop("aws_secret_access_key", "") or "")
319293
config_dict.update(creds)
320294

321-
# AWS access key pair: must be both-or-neither.
322295
if provider_type == "aws":
323296
if bool(aws_access_key_id) != bool(aws_secret_access_key):
324297
console.print(
@@ -336,7 +309,6 @@ def command_provider_add(
336309
)
337310
raise typer.Exit(1)
338311

339-
# GCP service account JSON: required at create time (provider will be unhealthy without it).
340312
if provider_type == "gcp":
341313
if not gcp_service_account_json and interactive:
342314
gcp_service_account_json = _prompt_gcp_service_account_json()
@@ -359,22 +331,89 @@ def command_provider_add(
359331

360332
result = response.json()
361333
provider_id = result.get("id", "unknown")
362-
console.print(f"[success]✓[/success] Provider created with ID: [bold]{provider_id}[/bold]")
334+
if cli_state.output_format != "json":
335+
console.print(f"[success]✓[/success] Provider created with ID: [bold]{provider_id}[/bold]")
363336

364-
# Submit AWS credentials and GCP service account JSON via their dedicated endpoints,
365-
# before the health check (the check will fail without credentials).
366337
if provider_type == "aws" and aws_access_key_id and aws_secret_access_key:
367338
_upload_aws_credentials(provider_id, aws_access_key_id, aws_secret_access_key)
368339
if provider_type == "gcp" and gcp_service_account_json:
369340
_upload_gcp_service_account(provider_id, gcp_service_account_json)
370341

342+
return provider_id
343+
344+
345+
## COMMANDS ##
346+
347+
348+
@app.command("list")
349+
def command_provider_list(
350+
include_disabled: bool = typer.Option(False, "--include-disabled", help="Include disabled providers"),
351+
):
352+
"""List all compute providers."""
353+
check_configs(output_format=cli_state.output_format)
354+
355+
with console.status("[bold success]Fetching providers...[/bold success]", spinner="dots"):
356+
response = api.get(f"/compute_provider/providers/?include_disabled={str(include_disabled).lower()}")
357+
358+
if response.status_code == 200:
359+
providers = response.json()
360+
table_columns = ["id", "name", "type", "disabled", "is_default", "created_at", "updated_at"]
361+
render_table(
362+
data=providers,
363+
format_type=cli_state.output_format,
364+
table_columns=table_columns,
365+
title="Providers",
366+
column_options={
367+
# Keep provider names as a single token (no wrapping to "Skypilot / New").
368+
"name": {"no_wrap": True, "overflow": "crop"},
369+
},
370+
)
371+
else:
372+
console.print(f"[error]Error:[/error] Failed to fetch providers. {_extract_error_detail(response)}")
373+
raise typer.Exit(1)
374+
375+
376+
@app.command("add")
377+
def command_provider_add(
378+
name: str = typer.Option(None, "--name", help="Provider name"),
379+
provider_type: str = typer.Option(
380+
None,
381+
"--type",
382+
help=f"Provider type ({', '.join(PROVIDER_TYPES)})",
383+
),
384+
config: str = typer.Option(None, "--config", help="Provider config as JSON string"),
385+
interactive: bool = typer.Option(True, "--interactive/--no-interactive", help="Use interactive prompts"),
386+
credentials_file: str = typer.Option(
387+
None,
388+
"--credentials-file",
389+
help=(
390+
"Path to a JSON file containing provider secrets. Keeps secrets out of argv "
391+
"(shell history, ps listings). Shape depends on --type: for aws, "
392+
'{"aws_access_key_id": "...", "aws_secret_access_key": "..."}; for gcp, the '
393+
"raw service account JSON key file; for everything else, a flat object whose "
394+
"fields are merged on top of --config (file values take precedence)."
395+
),
396+
),
397+
):
398+
"""Add a new compute provider."""
399+
check_configs(output_format=cli_state.output_format)
400+
401+
provider_id = create_provider_interactively(
402+
name=name,
403+
provider_type=provider_type,
404+
config=config,
405+
interactive=interactive,
406+
credentials_file=credentials_file,
407+
)
408+
371409
with console.status(f"[bold success]Checking provider {provider_id}...[/bold success]", spinner="dots"):
372410
check_response = api.get(f"/compute_provider/providers/{provider_id}/check", timeout=60.0)
373411

374412
if check_response.status_code == 200:
375413
check_result = check_response.json()
376414
if check_result.get("status"):
377-
console.print("[success]✓[/success] Provider health check passed.")
415+
if cli_state.output_format != "json":
416+
console.print("[success]✓[/success] Provider health check passed.")
378417
else:
379418
reason = _extract_provider_check_reason(check_result)
380419
console.print(f"[error]Error:[/error] Provider health check failed. {reason}")
@@ -519,14 +558,21 @@ def command_provider_update(
519558

520559
@app.command("delete")
521560
def command_provider_delete(
522-
provider_id: str = typer.Argument(..., help="Provider ID to delete"),
561+
identifier: str = typer.Argument(..., help="Provider ID or name to delete"),
523562
no_interactive: bool = typer.Option(False, "--no-interactive", help="Skip confirmation prompt"),
524563
):
525-
"""Delete a compute provider."""
564+
"""Delete a compute provider by ID or name."""
526565
check_configs(output_format=cli_state.output_format)
527566

567+
# Resolve the identifier (id or name) up front so we only prompt for
568+
# confirmation when the provider actually exists.
569+
provider_id = _resolve_provider_id(identifier)
570+
if provider_id is None:
571+
console.print(f"[error]Error:[/error] Provider {identifier} not found.")
572+
raise typer.Exit(1)
573+
528574
if not no_interactive:
529-
typer.confirm(f"Delete provider {provider_id}?", abort=True)
575+
typer.confirm(f"Delete provider {identifier}?", abort=True)
530576

531577
with console.status(f"[bold success]Deleting provider {provider_id}...[/bold success]", spinner="dots"):
532578
response = api.delete(f"/compute_provider/providers/{provider_id}")

0 commit comments

Comments
 (0)