Skip to content

Commit 6122d20

Browse files
authored
feat(rbac): sdk workspace identifiers (#226)
* refactor(workspace): rename slug to identifier * feat(workspace): Add unique identifiers and improve CLI lists * refactor(core): standardize entity key naming and add validation * chore: merged main
1 parent ab71874 commit 6122d20

File tree

7 files changed

+146
-38
lines changed

7 files changed

+146
-38
lines changed

.hooks/generate_docs.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,8 @@ def generate_docs_for_module(
129129
html = self.handler.render(module_data, options)
130130

131131
if "Source code in " in html:
132-
with open("debug.html", "w", encoding="utf-8") as f:
132+
debug_path = Path("debug.html")
133+
with debug_path.open("w", encoding="utf-8") as f:
133134
f.write(html)
134135

135136
return str(

dreadnode/api/client.py

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -805,17 +805,17 @@ def list_organizations(self) -> list[Organization]:
805805
response = self.request("GET", "/organizations")
806806
return [Organization(**org) for org in response.json()]
807807

808-
def get_organization(self, organization_id: str | UUID) -> Organization:
808+
def get_organization(self, org_id_or_key: UUID | str) -> Organization:
809809
"""
810810
Retrieves details of a specific organization.
811811
812812
Args:
813-
organization_id (str): The organization identifier.
813+
org_id_or_key (str | UUID): The organization identifier.
814814
815815
Returns:
816816
Organization: The Organization object.
817817
"""
818-
response = self.request("GET", f"/organizations/{organization_id!s}")
818+
response = self.request("GET", f"/organizations/{org_id_or_key!s}")
819819
return Organization(**response.json())
820820

821821
def list_workspaces(self, filters: WorkspaceFilter | None = None) -> list[Workspace]:
@@ -848,25 +848,28 @@ def list_workspaces(self, filters: WorkspaceFilter | None = None) -> list[Worksp
848848

849849
return all_workspaces
850850

851-
def get_workspace(self, workspace_id: str | UUID, org_id: UUID | None = None) -> Workspace:
851+
def get_workspace(
852+
self, workspace_id_or_key: UUID | str, org_id: UUID | None = None
853+
) -> Workspace:
852854
"""
853855
Retrieves details of a specific workspace.
854856
855857
Args:
856-
workspace_id (str): The workspace identifier.
858+
workspace_id_or_key (str | UUID): The workspace identifier.
857859
858860
Returns:
859861
Workspace: The Workspace object.
860862
"""
861863
params: dict[str, str] = {}
862864
if org_id:
863865
params = {"org_id": str(org_id)}
864-
response = self.request("GET", f"/workspaces/{workspace_id!s}", params=params)
866+
response = self.request("GET", f"/workspaces/{workspace_id_or_key!s}", params=params)
865867
return Workspace(**response.json())
866868

867869
def create_workspace(
868870
self,
869871
name: str,
872+
key: str,
870873
organization_id: UUID,
871874
description: str | None = None,
872875
) -> Workspace:
@@ -883,6 +886,7 @@ def create_workspace(
883886

884887
payload = {
885888
"name": name,
889+
"key": key,
886890
"description": description,
887891
"org_id": str(organization_id),
888892
}
@@ -895,7 +899,7 @@ def delete_workspace(self, workspace_id: str | UUID) -> None:
895899
Deletes a specific workspace.
896900
897901
Args:
898-
workspace_id (str | UUID): The workspace identifier.
902+
workspace_id (str | UUID): The workspace key.
899903
"""
900904

901905
self.request("DELETE", f"/workspaces/{workspace_id!s}")

dreadnode/api/models.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -448,8 +448,8 @@ class Workspace(BaseModel):
448448
"""Unique identifier for the workspace."""
449449
name: str
450450
"""Name of the workspace."""
451-
slug: str
452-
"""URL-friendly slug for the workspace."""
451+
key: str
452+
"""Unique key for the workspace."""
453453
description: str | None
454454
"""Description of the workspace."""
455455
created_by: UUID | None = None
@@ -469,6 +469,9 @@ class Workspace(BaseModel):
469469
updated_at: datetime
470470
"""Last update timestamp."""
471471

472+
def __str__(self) -> str:
473+
return f"{self.name} (Key: {self.key}), ID: {self.id}"
474+
472475

473476
class WorkspaceFilter(BaseModel):
474477
"""Filter parameters for workspace listing"""
@@ -498,7 +501,7 @@ class Organization(BaseModel):
498501
"""Unique identifier for the organization."""
499502
name: str
500503
"""Name of the organization."""
501-
identifier: str
504+
key: str
502505
"""URL-friendly identifer for the organization."""
503506
description: str | None
504507
"""Description of the organization."""
@@ -513,6 +516,9 @@ class Organization(BaseModel):
513516
updated_at: datetime
514517
"""Last update timestamp."""
515518

519+
def __str__(self) -> str:
520+
return f"{self.name} (Identifier: {self.key}), ID: {self.id}"
521+
516522

517523
# Derived types
518524

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
import cyclopts
2+
from rich import box
3+
from rich.table import Table
24

35
from dreadnode.cli.api import create_api_client
4-
from dreadnode.logging_ import print_info
6+
from dreadnode.logging_ import console
57

68
cli = cyclopts.App("organizations", help="View and manage organizations.", help_flags=[])
79

@@ -11,5 +13,17 @@ def show() -> None:
1113
# get the client and call the list organizations endpoint
1214
client = create_api_client()
1315
organizations = client.list_organizations()
16+
17+
table = Table(box=box.ROUNDED)
18+
table.add_column("Name", style="orange_red1")
19+
table.add_column("Key", style="green")
20+
table.add_column("ID")
21+
1422
for org in organizations:
15-
print_info(f"- {org.name} (ID: {org.id})")
23+
table.add_row(
24+
org.name,
25+
org.key,
26+
str(org.id),
27+
)
28+
29+
console.print(table)

dreadnode/cli/rbac/workspaces.py

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,34 @@
11
import cyclopts
22
from click import confirm
3+
from rich import box
4+
from rich.table import Table
35

46
from dreadnode.api.models import Organization, Workspace, WorkspaceFilter
57
from dreadnode.cli.api import create_api_client
6-
from dreadnode.logging_ import print_error, print_info
8+
from dreadnode.logging_ import console, print_error, print_info
9+
from dreadnode.util import create_key_from_name
710

811
cli = cyclopts.App("workspaces", help="View and manage workspaces.", help_flags=[])
912

1013

14+
def _print_workspace_table(workspaces: list[Workspace], organization: Organization) -> None:
15+
table = Table(box=box.ROUNDED)
16+
table.add_column("Name", style="orange_red1")
17+
table.add_column("Key", style="green")
18+
table.add_column("ID")
19+
table.add_column("dn.configure() Command", style="cyan")
20+
21+
for ws in workspaces:
22+
table.add_row(
23+
ws.name,
24+
ws.key,
25+
str(ws.id),
26+
f'dn.configure(organization="{organization.key}", workspace="{ws.key}")',
27+
)
28+
29+
console.print(table)
30+
31+
1132
@cli.command(name=["list", "ls", "show"])
1233
def show(
1334
# optional parameter of organization name or id
@@ -35,20 +56,28 @@ def show(
3556

3657
workspace_filter = WorkspaceFilter(org_id=matched_organization.id)
3758
workspaces = client.list_workspaces(filters=workspace_filter)
38-
print_info(f"Workspaces in Organization '{matched_organization.name}':")
39-
for workspace in workspaces:
40-
print_info(f"- {workspace.name} (ID: {workspace.id})")
41-
print_info("")
59+
60+
table = Table(box=box.ROUNDED)
61+
table.add_column("Name", style="orange_red1")
62+
table.add_column("Key", style="green")
63+
table.add_column("ID")
64+
table.add_column("dn.configure() Command", style="cyan")
65+
66+
_print_workspace_table(workspaces, matched_organization)
4267

4368

4469
@cli.command(name=["create", "new"])
4570
def create(
4671
name: str,
72+
key: str | None = None,
4773
description: str | None = None,
4874
organization: str | None = None,
4975
) -> None:
5076
# get the client and call the create workspace endpoint
5177
client = create_api_client()
78+
if not key:
79+
key = create_key_from_name(name)
80+
5281
if organization:
5382
matched_organization = client.get_organization(organization)
5483
if not matched_organization:
@@ -65,16 +94,21 @@ def create(
6594
)
6695
return
6796
matched_organization = user_organizations[0]
68-
print_info(f"The workspace will be created in organization '{matched_organization.name}'")
97+
print_info(
98+
f"Workspace '{name}' ([cyan]{key}[/cyan]) will be created in organization '{matched_organization.name}'"
99+
)
69100
# verify with the user
70101
if not confirm("Do you want to continue?"):
71102
print_info("Workspace creation cancelled.")
72103
return
73104

74105
workspace: Workspace = client.create_workspace(
75-
name=name, organization_id=matched_organization.id, description=description
106+
name=name,
107+
key=key,
108+
organization_id=matched_organization.id,
109+
description=description,
76110
)
77-
print_info(f"Workspace '{workspace.name}' created inwith ID: {workspace.id}")
111+
_print_workspace_table([workspace], matched_organization)
78112

79113

80114
@cli.command(name=["delete", "rm"])

dreadnode/main.py

Lines changed: 49 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,9 @@
7474
from dreadnode.user_config import UserConfig
7575
from dreadnode.util import (
7676
clean_str,
77+
create_key_from_name,
7778
handle_internal_errors,
79+
valid_key,
7880
warn_at_user_stacklevel,
7981
)
8082
from dreadnode.version import VERSION
@@ -215,6 +217,16 @@ def _resolve_organization(self) -> None:
215217
if self._api is None:
216218
raise RuntimeError("API client is not initialized.")
217219

220+
with contextlib.suppress(ValueError):
221+
self.organization = UUID(
222+
str(self.organization)
223+
) # Now, it's a UUID if possible, else str (name/slug)
224+
225+
if isinstance(self.organization, str) and not valid_key(self.organization):
226+
raise RuntimeError(
227+
f'Invalid Organization Key: "{self.organization}". The expected characters are lowercase letters, numbers, and hyphens (-).\n\nYou can get the keys for your organization using the CLI or the web interface.',
228+
)
229+
218230
if self.organization:
219231
self._organization = self._api.get_organization(self.organization)
220232
if not self._organization:
@@ -236,7 +248,7 @@ def _resolve_organization(self) -> None:
236248
)
237249
self._organization = organizations[0]
238250

239-
def _create_workspace(self, name: str) -> Workspace:
251+
def _create_workspace(self, key: str) -> Workspace:
240252
"""
241253
Create a new workspace.
242254
@@ -255,9 +267,12 @@ def _create_workspace(self, name: str) -> Workspace:
255267

256268
try:
257269
logging_console.print(
258-
f"[yellow]WARNING: This workspace was not found. Creating a new workspace '{name}'...[/]"
270+
f"[yellow]WARNING: This workspace was not found. Creating a new workspace '{key}'...[/]"
271+
)
272+
key = create_key_from_name(key)
273+
return self._api.create_workspace(
274+
name=key, key=key, organization_id=self._organization.id
259275
)
260-
return self._api.create_workspace(name=name, organization_id=self._organization.id)
261276
except RuntimeError as e:
262277
if "403: Forbidden" in str(e):
263278
raise RuntimeError(
@@ -281,6 +296,16 @@ def _resolve_workspace(self) -> None:
281296
if self._api is None:
282297
raise RuntimeError("API client is not initialized.")
283298

299+
with contextlib.suppress(ValueError):
300+
self.workspace = UUID(
301+
str(self.workspace)
302+
) # Now, it's a UUID if possible, else str (name/slug)
303+
304+
if isinstance(self.workspace, str) and not valid_key(self.workspace):
305+
raise RuntimeError(
306+
f'Invalid Workspace Key: "{self.workspace}". The expected characters are lowercase letters, numbers, and hyphens (-).\n\nYou can get the keys for your workspace using the CLI or the web interface.',
307+
)
308+
284309
found_workspace: Workspace | None = None
285310
if self.workspace:
286311
try:
@@ -298,7 +323,7 @@ def _resolve_workspace(self) -> None:
298323

299324
if not found_workspace and isinstance(self.workspace, str): # specified by name/slug
300325
# create the workspace (must be an org contributor)
301-
found_workspace = self._create_workspace(name=self.workspace)
326+
found_workspace = self._create_workspace(key=self.workspace)
302327

303328
else: # the user provided no workspace, attempt to find a default one
304329
workspaces = self._api.list_workspaces(
@@ -332,6 +357,11 @@ def _resolve_project(self) -> None:
332357
if self._api is None:
333358
raise RuntimeError("API client is not initialized.")
334359

360+
if self.project and not valid_key(self.project):
361+
raise RuntimeError(
362+
f'Invalid Project Key: "{self.project}". The expected characters are lowercase letters, numbers, and hyphens (-).\n\nYou can get the keys for your project using the CLI or the web interface.',
363+
)
364+
335365
# fetch the project
336366
found_project: Project | None = None
337367
try:
@@ -418,13 +448,22 @@ def _extract_project_components(path: str | None) -> tuple[str | None, str | Non
418448
match = re.match(pattern, path)
419449

420450
if not match:
421-
raise RuntimeError(f"Invalid project path format: '{path}'")
451+
raise RuntimeError(
452+
f"Invalid project path format: '{path}'.\n\nExpected formats are 'org/workspace/project', 'workspace/project', or 'project'. Where each component is the key for that entity.'"
453+
)
422454

423455
# The groups are: (Org, Workspace, Project)
424456
groups = match.groups()
425457

426458
present_components = [c for c in groups if c is not None]
427459

460+
# validate each component
461+
for component in present_components:
462+
if not valid_key(component):
463+
raise RuntimeError(
464+
f'Invalid Key: "{component}". The expected characters are lowercase letters, numbers, and hyphens (-).\n\nYou can get the keys for your organization, workspace, and project using the CLI or the web interface.',
465+
)
466+
428467
if len(present_components) == 3:
429468
org, workspace, project = groups
430469
elif len(present_components) == 2:
@@ -472,6 +511,10 @@ def configure(
472511
1. Environment variables:
473512
- `DREADNODE_SERVER_URL` or `DREADNODE_SERVER`
474513
- `DREADNODE_API_TOKEN` or `DREADNODE_API_KEY`
514+
- `DREADNODE_ORGANIZATION`
515+
- `DREADNODE_WORKSPACE`
516+
- `DREADNODE_PROJECT`
517+
475518
2. Dreadnode profile (from `dreadnode login`)
476519
- Uses `profile` parameter if provided
477520
- Falls back to `DREADNODE_PROFILE` environment variable
@@ -484,7 +527,7 @@ def configure(
484527
local_dir: The local directory to store data in.
485528
organization: The default organization name or ID to use.
486529
workspace: The default workspace name or ID to use.
487-
project: The default project name to associate all runs with. This can also be in the format `org/workspace/project`.
530+
project: The default project name to associate all runs with. This can also be in the format `org/workspace/project` using the keys.
488531
service_name: The service name to use for OpenTelemetry.
489532
service_version: The service version to use for OpenTelemetry.
490533
console: Log span information to the console (`DREADNODE_CONSOLE` or the default is True).
@@ -544,19 +587,8 @@ def configure(
544587
self.local_dir = local_dir
545588

546589
_org, _workspace, _project = self._extract_project_components(project)
547-
548590
self.organization = _org or organization or os.environ.get(ENV_ORGANIZATION)
549-
with contextlib.suppress(ValueError):
550-
self.organization = UUID(
551-
str(self.organization)
552-
) # Now, it's a UUID if possible, else str (name/slug)
553-
554591
self.workspace = _workspace or workspace or os.environ.get(ENV_WORKSPACE)
555-
with contextlib.suppress(ValueError):
556-
self.workspace = UUID(
557-
str(self.workspace)
558-
) # Now, it's a UUID if possible, else str (name/slug)
559-
560592
self.project = _project or project or os.environ.get(ENV_PROJECT)
561593

562594
self.service_name = service_name

0 commit comments

Comments
 (0)