diff --git a/README.md b/README.md index 1e0c317..49554ec 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,21 @@ client.docks.ports.remove(dock.id, 3000) `url` is `None` while hosted ports are off. Leaving `visibility` out of a repeat `expose` keeps the port's current visibility. +## Environments + +`client.environments.list(organization_id)`, `get(ref, organization_id=...)` and +`update(ref, ..., provider_keys="plain")` read and change environments. Reads need +an organization member; `update` needs an organization admin. +`provider_keys` is `"gateway"` (default: provider keys stay in the control plane and +docks get a placeholder key plus gateway base URLs) or `"plain"` (the real key is +written into the dock, so anyone inside it can read it). `egress_allow=[...]` confines +docks to those HTTPS hosts and `egress_allow=None` clears the list. Posture changes +(the pass flags, `safe_for_third_parties`, `provider_keys`, `egress_allow`) make a new +environment version; renames, `is_default` and `secrets_access` do not. Variable +values are never returned, only `var_names`. Connectors +also report `credential_type` (`oauth`, `personal_token`, `bot_token`, `org_token`, +`user_token`, `token` or `None`). + ## Connectors and images Connect a workspace to a tool once (organization admins only; members get @@ -145,7 +160,9 @@ except DockError as err: Some codes raise a subclass of `DockError`, so you can catch them directly: `ConnectorNotConnectedError` (`connector_not_connected`, 409), `ConnectorAdminOnlyError` (`connector_admin_only`, 403), -`ConnectorNotFoundError` (`connector_not_found`, 404), `ImageUnknownError` +`ConnectorNotFoundError` (`connector_not_found`, 404), +`ConnectorTokenInvalidError` (`connector_token_invalid`, 400), +`ConnectorUnreachableError` (`connector_unreachable`, 502), `ImageUnknownError` (`image_unknown`, 400), `ImageUnavailableError` (`image_unavailable`, 400), `PortNotFoundError` (`port_not_found`, 404), `PortLimitReachedError` (`port_limit_reached`, 409) and `PortsUnavailableError` (`ports_unavailable`, diff --git a/src/usedock/__init__.py b/src/usedock/__init__.py index c2b59aa..3a68eb0 100644 --- a/src/usedock/__init__.py +++ b/src/usedock/__init__.py @@ -3,6 +3,8 @@ ConnectorAdminOnlyError, ConnectorNotConnectedError, ConnectorNotFoundError, + ConnectorTokenInvalidError, + ConnectorUnreachableError, DockError, ImageUnavailableError, ImageUnknownError, @@ -15,6 +17,7 @@ ConnectorState, DockInfo, DockState, + Environment, ExecResult, Image, Operation, @@ -29,10 +32,13 @@ "ConnectorNotConnectedError", "ConnectorNotFoundError", "ConnectorState", + "ConnectorTokenInvalidError", + "ConnectorUnreachableError", "Dock", "DockError", "DockInfo", "DockState", + "Environment", "ExecResult", "Image", "ImageUnavailableError", diff --git a/src/usedock/client.py b/src/usedock/client.py index dd856f7..68c8d33 100644 --- a/src/usedock/client.py +++ b/src/usedock/client.py @@ -14,6 +14,7 @@ ConnectorState, DockInfo, DockState, + Environment, ExecResult, Image, Operation, @@ -95,6 +96,7 @@ def __init__( self.operations = Operations(self) self.connectors = Connectors(self) self.images = Images(self) + self.environments = Environments(self) def close(self) -> None: self._http.close() @@ -470,3 +472,70 @@ def __init__(self, client: Dock) -> None: def list(self) -> List[Image]: return [Image.from_dict(d) for d in self._c._request("GET", "/v1/images")] + + +class Environments: + """Organization environments. ``ref`` is an id or a name; pass ``organization_id`` + when addressing by name.""" + + def __init__(self, client: Dock) -> None: + self._c = client + + def list(self, organization_id: str) -> List[Environment]: + data = self._c._request( + "GET", "/v1/environments", params={"organizationId": organization_id} + ) + return [Environment.from_dict(d) for d in data] + + def get(self, ref: str, *, organization_id: Optional[str] = None) -> Environment: + data = self._c._request( + "GET", f"/v1/environments/{_seg(ref)}", params=_org_params(organization_id) + ) + return Environment.from_dict(data) + + def update( + self, + ref: str, + *, + organization_id: Optional[str] = None, + name: Optional[str] = None, + is_default: Optional[bool] = None, + secrets_access: Optional[str] = None, + safe_for_third_parties: Optional[bool] = None, + pass_github: Optional[bool] = None, + pass_secrets: Optional[bool] = None, + pass_dock_credentials: Optional[bool] = None, + pass_agents_credentials: Optional[bool] = None, + provider_keys: Optional[str] = None, + egress_allow: Optional[List[str]] = UNSET, + ) -> Environment: + """Rename, move the default or change posture; needs an organization admin. + ``provider_keys`` is ``"gateway"`` (default; keys stay in the control plane) or + ``"plain"`` (the real key is written into the dock). ``egress_allow=None`` clears + the allowlist; leave it out to keep it. Posture changes (the pass flags, + ``safe_for_third_parties``, ``provider_keys``, ``egress_allow``) create a new + version; ``name``, ``is_default`` and ``secrets_access`` do not.""" + body = _body( + name=name, + isDefault=is_default, + secretsAccess=secrets_access, + safeForThirdParties=safe_for_third_parties, + passGithub=pass_github, + passSecrets=pass_secrets, + passDockCredentials=pass_dock_credentials, + passAgentsCredentials=pass_agents_credentials, + providerKeys=provider_keys, + ) + if egress_allow is not UNSET: + body["egressAllow"] = egress_allow + data = self._c._request( + "PATCH", + f"/v1/environments/{_seg(ref)}", + body=body, + params=_org_params(organization_id), + ) + return Environment.from_dict(data) + + +def _org_params(organization_id: Optional[str]) -> Optional[Dict[str, str]]: + return {"organizationId": organization_id} if organization_id else None diff --git a/src/usedock/errors.py b/src/usedock/errors.py index 39d1126..fff6538 100644 --- a/src/usedock/errors.py +++ b/src/usedock/errors.py @@ -31,6 +31,14 @@ class ConnectorNotFoundError(DockError): """``connector_not_found`` (404): disconnecting a connector that is not connected.""" +class ConnectorTokenInvalidError(DockError): + """``connector_token_invalid`` (400): the vendor rejected the token passed to ``connect``.""" + + +class ConnectorUnreachableError(DockError): + """``connector_unreachable`` (502): the vendor could not be reached to verify the token.""" + + class ImageUnknownError(DockError): """``image_unknown`` (400): the image is not in the Dockyard catalog.""" @@ -55,6 +63,8 @@ class PortsUnavailableError(DockError): "connector_not_connected": ConnectorNotConnectedError, "connector_admin_only": ConnectorAdminOnlyError, "connector_not_found": ConnectorNotFoundError, + "connector_token_invalid": ConnectorTokenInvalidError, + "connector_unreachable": ConnectorUnreachableError, "image_unknown": ImageUnknownError, "image_unavailable": ImageUnavailableError, "port_not_found": PortNotFoundError, diff --git a/src/usedock/models.py b/src/usedock/models.py index 87f5893..c83fd82 100644 --- a/src/usedock/models.py +++ b/src/usedock/models.py @@ -115,7 +115,9 @@ def from_dict(cls, data: Dict[str, Any]) -> Operation: @dataclass class Connector: """A workspace connector. ``status`` is ``connected``, ``not_connected`` or - ``reauth_required``; ``read_only`` kinds (github, model-keys) cannot be connected by token.""" + ``reauth_required``; ``read_only`` kinds (github, model-keys) cannot be connected by token. + ``credential_type`` is ``oauth``, ``personal_token``, ``bot_token``, ``org_token``, + ``user_token``, ``token`` or ``None`` (not connected or not recorded).""" kind: str type: str @@ -125,6 +127,7 @@ class Connector: oauth_available: bool = False read_only: bool = False updated_at: Optional[str] = None + credential_type: Optional[str] = None raw: Dict[str, Any] = field(default_factory=dict, repr=False) @classmethod @@ -138,6 +141,7 @@ def from_dict(cls, data: Dict[str, Any]) -> Connector: oauth_available=bool(data.get("oauthAvailable", False)), read_only=bool(data.get("readOnly", False)), updated_at=_opt_str(data, "updatedAt"), + credential_type=_opt_str(data, "credentialType"), raw=data, ) @@ -200,3 +204,54 @@ def from_dict(cls, data: Dict[str, Any]) -> Port: created_at=_opt_str(data, "createdAt"), raw=data, ) + + +@dataclass +class Environment: + """An environment. ``provider_keys`` is how the organization's provider keys reach + docks: ``gateway`` (default) keeps them in the control plane and gives the dock a + placeholder key plus gateway base URLs; ``plain`` writes the real key into the dock, + so anyone who can run a command in it can read the key (weaker; admins only). + ``egress_allow`` is ``None`` when egress is open minus private ranges. Variables are + listed by name only (``var_names``); values are never returned.""" + + id: str + organization_id: str + name: str + provider_keys: str = "gateway" + is_default: bool = False + current_version: Optional[int] = None + secrets_access: Optional[str] = None + safe_for_third_parties: bool = False + pass_github: bool = False + pass_secrets: bool = False + pass_dock_credentials: bool = False + pass_agents_credentials: bool = False + egress_allow: Optional[List[str]] = None + var_names: List[str] = field(default_factory=list) + created_at: Optional[str] = None + updated_at: Optional[str] = None + raw: Dict[str, Any] = field(default_factory=dict, repr=False) + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> Environment: + egress = data.get("egressAllow") + return cls( + id=_str(data, "id"), + organization_id=_str(data, "organizationId"), + name=_str(data, "name"), + provider_keys=_opt_str(data, "providerKeys") or "gateway", + is_default=bool(data.get("isDefault", False)), + current_version=_opt_int(data, "currentVersion"), + secrets_access=_opt_str(data, "secretsAccess"), + safe_for_third_parties=bool(data.get("safeForThirdParties", False)), + pass_github=bool(data.get("passGithub", False)), + pass_secrets=bool(data.get("passSecrets", False)), + pass_dock_credentials=bool(data.get("passDockCredentials", False)), + pass_agents_credentials=bool(data.get("passAgentsCredentials", False)), + egress_allow=_str_list(data, "egressAllow") if isinstance(egress, list) else None, + var_names=_str_list(data, "varNames"), + created_at=_opt_str(data, "createdAt"), + updated_at=_opt_str(data, "updatedAt"), + raw=data, + ) diff --git a/tests/test_client.py b/tests/test_client.py index abc8964..a7eeecf 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -18,6 +18,8 @@ ConnectorAdminOnlyError, ConnectorNotConnectedError, ConnectorNotFoundError, + ConnectorTokenInvalidError, + ConnectorUnreachableError, Dock, DockError, DockState, @@ -531,6 +533,7 @@ def test_missing_scenarios_skip_by_default_but_fail_when_required(tmp_path: Path "status": "connected", "accountLabel": "Acme", "scopes": ["read"], + "credentialType": "personal_token", "oauthAvailable": False, "readOnly": False, "updatedAt": "2026-01-01T00:00:00.000Z", @@ -557,6 +560,12 @@ def test_connectors_list() -> None: assert items[0].scopes == ["read"] assert items[0].read_only is False assert items[0].raw["type"] == "tool" + assert items[0].credential_type == "personal_token" + + +def test_connector_credential_type_defaults_to_none() -> None: + client, _ = recorder(ok([{**CONNECTOR, "status": "not_connected", "credentialType": None}])) + assert client.connectors.list("w")[0].credential_type is None def test_connectors_connect_and_disconnect() -> None: @@ -643,6 +652,8 @@ def test_images_list() -> None: [ ("connector_not_connected", 409, ConnectorNotConnectedError), ("connector_admin_only", 403, ConnectorAdminOnlyError), + ("connector_token_invalid", 400, ConnectorTokenInvalidError), + ("connector_unreachable", 502, ConnectorUnreachableError), ("image_unknown", 400, ImageUnknownError), ("image_unavailable", 400, ImageUnavailableError), ("port_not_found", 404, PortNotFoundError), @@ -696,3 +707,84 @@ def test_files_read_list_over_command_limit_raise_before_sending(op: str) -> Non getattr(client.docks.files, op)("d1", path) assert (info.value.code, info.value.status) == ("file_too_large", 0) assert len(seen) == 0 + + +ENVIRONMENT = { + "id": "e1", + "organizationId": "o1", + "name": "base", + "isDefault": True, + "currentVersion": 3, + "secretsAccess": "admins", + "providerKeys": "gateway", + "egressAllow": None, +} + + +def test_environments_list_and_get_read_provider_keys() -> None: + client, seen = recorder(ok([ENVIRONMENT])) + envs = client.environments.list("o 1") + assert (seen[0].method, seen[0].url.path, seen[0].url.params["organizationId"]) == ( + "GET", + "/v1/environments", + "o 1", + ) + assert envs[0].provider_keys == "gateway" + client, seen = recorder(ok(ENVIRONMENT)) + env = client.environments.get("base", organization_id="o1") + assert (seen[0].url.path, seen[0].url.params["organizationId"]) == ( + "/v1/environments/base", + "o1", + ) + assert env.provider_keys == "gateway" + assert env.egress_allow is None + + +def test_environments_update_sends_provider_keys() -> None: + client, seen = recorder(ok({**ENVIRONMENT, "providerKeys": "plain"})) + env = client.environments.update("base", organization_id="o1", provider_keys="plain") + assert (seen[0].method, seen[0].url.path) == ("PATCH", "/v1/environments/base") + assert body_of(seen[0]) == {"providerKeys": "plain"} + assert env.provider_keys == "plain" + + +def test_environments_update_egress_allow_null_clears() -> None: + client, seen = recorder(ok(ENVIRONMENT)) + client.environments.update("base", egress_allow=None) + assert body_of(seen[0]) == {"egressAllow": None} + + +def test_environment_reads_posture_fields() -> None: + data = { + **ENVIRONMENT, + "safeForThirdParties": True, + "passGithub": True, + "passSecrets": False, + "passDockCredentials": True, + "passAgentsCredentials": True, + "egressAllow": ["api.example.com"], + "varNames": ["A", "B"], + "createdAt": "2026-01-01T00:00:00.000Z", + "updatedAt": "2026-01-02T00:00:00.000Z", + } + client, _ = recorder(ok(data)) + env = client.environments.get("e1") + assert ( + env.safe_for_third_parties, + env.pass_github, + env.pass_secrets, + env.pass_dock_credentials, + env.pass_agents_credentials, + ) == (True, True, False, True, True) + assert env.egress_allow == ["api.example.com"] + assert env.var_names == ["A", "B"] + assert (env.created_at, env.updated_at) == ( + "2026-01-01T00:00:00.000Z", + "2026-01-02T00:00:00.000Z", + ) + + +def test_environments_update_sends_posture_and_egress_list() -> None: + client, seen = recorder(ok(ENVIRONMENT)) + client.environments.update("e1", pass_github=False, egress_allow=["api.example.com"]) + assert body_of(seen[0]) == {"passGithub": False, "egressAllow": ["api.example.com"]}