From cd96e0348e23055e9005770e7c013a58945ecc8b Mon Sep 17 00:00:00 2001 From: IaroslavTitov <iaro@pulumi.com> Date: Wed, 19 Mar 2025 12:03:22 -0600 Subject: [PATCH 1/3] Adding workspace/account logic for python --- CHANGELOG_PENDING.md | 2 + sdk/python/pulumi_esc_sdk/esc_client.py | 9 ++ sdk/python/pulumi_esc_sdk/workspace.py | 89 +++++++++++++++++++ sdk/python/pulumi_esc_sdk/workspace_models.py | 41 +++++++++ sdk/python/test/test_esc_api.py | 2 +- .../test/test_pulumi_home/credentials.json | 25 ++++++ .../.esc/credentials.json | 3 + .../test_pulumi_home_esc/credentials.json | 25 ++++++ sdk/python/test/test_workspace_accounts.py | 51 +++++++++++ 9 files changed, 246 insertions(+), 1 deletion(-) create mode 100644 sdk/python/pulumi_esc_sdk/workspace.py create mode 100644 sdk/python/pulumi_esc_sdk/workspace_models.py create mode 100644 sdk/python/test/test_pulumi_home/credentials.json create mode 100644 sdk/python/test/test_pulumi_home_esc/.esc/credentials.json create mode 100644 sdk/python/test/test_pulumi_home_esc/credentials.json create mode 100644 sdk/python/test/test_workspace_accounts.py diff --git a/CHANGELOG_PENDING.md b/CHANGELOG_PENDING.md index 14cbe53..0006413 100644 --- a/CHANGELOG_PENDING.md +++ b/CHANGELOG_PENDING.md @@ -4,6 +4,8 @@ - All SDK now automatically read in configuration environment variables - Go SDK also automatically picks up configuration from CLI Pulumi accounts [#76](https://github.com/pulumi/esc-sdk/pull/76) +- Adding CLI Pulumi accounts loading logic into Python SDK + [#81](https://github.com/pulumi/esc-sdk/pull/81) ### Bug Fixes diff --git a/sdk/python/pulumi_esc_sdk/esc_client.py b/sdk/python/pulumi_esc_sdk/esc_client.py index 59543e9..c4c3e10 100644 --- a/sdk/python/pulumi_esc_sdk/esc_client.py +++ b/sdk/python/pulumi_esc_sdk/esc_client.py @@ -12,6 +12,7 @@ import yaml import os from urllib.parse import urlparse, urlunparse +import pulumi_esc_sdk.workspace as workspace class EscClient: @@ -441,6 +442,14 @@ def default_config(host=None, access_token = os.getenv("PULUMI_ACCESS_TOKEN") if not host: host = os.getenv("PULUMI_BACKEND_URL") + + account, backend_url = workspace.get_current_account(False) + + if not access_token: + access_token = account.accessToken if account else None + if not host: + host = backend_url + return configuration.Configuration( host=host, access_token=access_token, diff --git a/sdk/python/pulumi_esc_sdk/workspace.py b/sdk/python/pulumi_esc_sdk/workspace.py new file mode 100644 index 0000000..39684f0 --- /dev/null +++ b/sdk/python/pulumi_esc_sdk/workspace.py @@ -0,0 +1,89 @@ +# Copyright 2025, Pulumi Corporation. All rights reserved. + +"""Recreating Pulumi workspace and account logic for python SDK.""" + +from dataclasses import dataclass, field +import json +import os +import pathlib +from typing import Optional + +from pulumi_esc_sdk.workspace_models import Account, Credentials + +def get_pulumi_home_dir() -> str: + """ + Returns the path of the '.pulumi' folder where Pulumi puts its artifacts. + """ + # Allow the folder we use to be overridden by an environment variable + dir_env = os.getenv("PULUMI_HOME") + if dir_env: + return dir_env + + # Otherwise, use the current user's home dir + .pulumi + home_dir = pathlib.Path.home() + + return str(home_dir / ".pulumi") + +def get_pulumi_path(*elem: str) -> str: + """ + Returns the path to a file or directory under the '.pulumi' folder. + It joins the path of the '.pulumi' folder with elements passed as arguments. + """ + home_dir = get_pulumi_home_dir() + return str(pathlib.Path(home_dir).joinpath(*elem)) + +def get_esc_bookkeeping_dir() -> str: + """ + Returns the path of the '.esc' folder inside Pulumi home dir. + """ + return get_pulumi_path(".esc") + +def append_creds_file(dir: str) -> str: + """ + Returns the path to the esc credentials file on disk. + """ + return str(pathlib.Path(dir) / "credentials.json") + +def get_esc_current_account_name() -> Optional[str]: + """ + Returns the current account name from the ESC credentials file. + """ + creds_file = append_creds_file(get_esc_bookkeeping_dir()) + try: + with open(creds_file, 'r') as f: + data = json.loads(f.read()) + return data['name'] + except FileNotFoundError: + return None + except Exception as e: + print(f"An unexpected error occured: {e}") + return None + +def get_stored_credentials() -> Credentials: + """ + Reads and parses credentials from the Pulumi credentials file. + """ + creds_file = append_creds_file(get_pulumi_path()) + try: + with open(creds_file, 'r') as f: + data = f.read() + creds = Credentials.from_json(data) + return creds + except FileNotFoundError: + return None + except Exception as e: + print(f"An unexpected error occured: {e}") + return None + +def get_current_account(shared) -> tuple[Account, str]: + """ + Gets current account values from credentials file. + """ + backend_url = get_esc_current_account_name() + pulumi_credentials = get_stored_credentials() + if not pulumi_credentials: + return None, None + if backend_url is None or shared: + backend_url = pulumi_credentials.current + pulumi_account = pulumi_credentials.accounts[backend_url] + return pulumi_account, backend_url diff --git a/sdk/python/pulumi_esc_sdk/workspace_models.py b/sdk/python/pulumi_esc_sdk/workspace_models.py new file mode 100644 index 0000000..04738d4 --- /dev/null +++ b/sdk/python/pulumi_esc_sdk/workspace_models.py @@ -0,0 +1,41 @@ +# Copyright 2025, Pulumi Corporation. All rights reserved. + +"""Pulumi workspace models.""" + +from dataclasses import dataclass, field +import json +from typing import Dict, List, Optional + +@dataclass +class Account: + accessToken: str + username: str + organizations: List[str] + + @classmethod + def from_json(self, data: dict): + return self( + accessToken=data.get("accessToken"), + username=data.get("username"), + organizations=data.get("organizations", []), + ) + +@dataclass +class Credentials: + current: str + accessTokens: Dict[str, str] + accounts: Dict[str, Account] + + @classmethod + def from_json(self, dataJson: str): + data = json.loads(dataJson) + accounts: Dict[str, Account] = {} + accounts_dict = data.get("accounts") + if accounts_dict: + for account_name, account_data in accounts_dict.items(): + accounts[account_name] = Account.from_json(account_data) + return self( + current=data.get("current"), + accessTokens=data.get("accessTokens"), + accounts=accounts + ) \ No newline at end of file diff --git a/sdk/python/test/test_esc_api.py b/sdk/python/test/test_esc_api.py index d3f5e8b..1eddfd6 100644 --- a/sdk/python/test/test_esc_api.py +++ b/sdk/python/test/test_esc_api.py @@ -16,7 +16,7 @@ class TestEscApi(unittest.TestCase): """EscApi unit test stubs""" def setUp(self) -> None: - self.orgName = "pulumi" + self.orgName = os.getenv("PULUMI_ORG") self.assertIsNotNone(self.orgName, "PULUMI_ORG must be set") self.client = esc.esc_client.default_client() diff --git a/sdk/python/test/test_pulumi_home/credentials.json b/sdk/python/test/test_pulumi_home/credentials.json new file mode 100644 index 0000000..1af2f2c --- /dev/null +++ b/sdk/python/test/test_pulumi_home/credentials.json @@ -0,0 +1,25 @@ +{ + "current": "https://api.moolumi.com", + "accessTokens": { + "https://api.moolumi.com": "pul-fake-token-moo", + "https://api.boolumi.com": "pul-fake-token-boo" + }, + "accounts": { + "https://api.moolumi.com": { + "accessToken": "pul-fake-token-moo", + "username": "moolumipus", + "organizations": [ + "moolumipus-org" + ], + "lastValidatedAt": "2025-03-17T11:19:28.392525488-06:00" + }, + "https://api.boolumi.com": { + "accessToken": "pul-fake-token-boo", + "username": "boolumipus", + "organizations": [ + "boolumipus-org2" + ], + "lastValidatedAt": "2026-03-17T11:19:28.392525488-06:00" + } + } +} \ No newline at end of file diff --git a/sdk/python/test/test_pulumi_home_esc/.esc/credentials.json b/sdk/python/test/test_pulumi_home_esc/.esc/credentials.json new file mode 100644 index 0000000..0dbca97 --- /dev/null +++ b/sdk/python/test/test_pulumi_home_esc/.esc/credentials.json @@ -0,0 +1,3 @@ +{ + "name": "https://api.boolumi.com" +} \ No newline at end of file diff --git a/sdk/python/test/test_pulumi_home_esc/credentials.json b/sdk/python/test/test_pulumi_home_esc/credentials.json new file mode 100644 index 0000000..1af2f2c --- /dev/null +++ b/sdk/python/test/test_pulumi_home_esc/credentials.json @@ -0,0 +1,25 @@ +{ + "current": "https://api.moolumi.com", + "accessTokens": { + "https://api.moolumi.com": "pul-fake-token-moo", + "https://api.boolumi.com": "pul-fake-token-boo" + }, + "accounts": { + "https://api.moolumi.com": { + "accessToken": "pul-fake-token-moo", + "username": "moolumipus", + "organizations": [ + "moolumipus-org" + ], + "lastValidatedAt": "2025-03-17T11:19:28.392525488-06:00" + }, + "https://api.boolumi.com": { + "accessToken": "pul-fake-token-boo", + "username": "boolumipus", + "organizations": [ + "boolumipus-org2" + ], + "lastValidatedAt": "2026-03-17T11:19:28.392525488-06:00" + } + } +} \ No newline at end of file diff --git a/sdk/python/test/test_workspace_accounts.py b/sdk/python/test/test_workspace_accounts.py new file mode 100644 index 0000000..f84fe0b --- /dev/null +++ b/sdk/python/test/test_workspace_accounts.py @@ -0,0 +1,51 @@ +# coding: utf-8 + +# Copyright 2024, Pulumi Corporation. All rights reserved. + +from typing import Optional +import unittest +import os + +import pulumi_esc_sdk as esc + +class TestWorkspaceAccounts(unittest.TestCase): + """WorkspaceAccounts unit test stubs""" + tokenBefore: Optional[str] + backendBefore: Optional[str] + homeBefore: Optional[str] + + def setUp(self) -> None: + self.tokenBefore = os.getenv("PULUMI_ACCESS_TOKEN") + self.backendBefore = os.getenv("PULUMI_BACKEND_URL") + self.homeBefore = os.getenv("PULUMI_HOME") + os.environ["PULUMI_ACCESS_TOKEN"] = "" + os.environ["PULUMI_BACKEND_URL"] = "" + + def tearDown(self) -> None: + os.environ["PULUMI_ACCESS_TOKEN"] = self.tokenBefore or '' + os.environ["PULUMI_BACKEND_URL"] = self.backendBefore or '' + os.environ["PULUMI_HOME"] = self.homeBefore or '' + + def test_no_creds_at_all(self): + os.environ["PULUMI_HOME"] = "/not_real_dir" + self.client = esc.esc_client.default_client() + self.config = self.client.esc_api.api_client.configuration + self.assertEqual(self.config.host, "https://api.pulumi.com/api/esc") + self.assertTrue('Authorization' not in self.config.api_key) + + def test_just_pulumi_creds(self): + os.environ["PULUMI_HOME"] = os.getcwd() + "/test/test_pulumi_home" + self.client = esc.esc_client.default_client() + self.config = self.client.esc_api.api_client.configuration + self.assertEqual(self.config.host, "https://api.moolumi.com/api/esc") + self.assertTrue(self.config.api_key['Authorization'], "pul-fake-token-moo") + + def test_pulumi_creds_with_esc(self): + os.environ["PULUMI_HOME"] = os.getcwd() + "/test/test_pulumi_home_esc" + self.client = esc.esc_client.default_client() + self.config = self.client.esc_api.api_client.configuration + self.assertEqual(self.config.host, "https://api.boolumi.com/api/esc") + self.assertTrue(self.config.api_key['Authorization'], "pul-fake-token-boo") + +if __name__ == '__main__': + unittest.main() \ No newline at end of file From 0e2f959149d3420bac6199829fd9f88165c84886 Mon Sep 17 00:00:00 2001 From: IaroslavTitov <iaro@pulumi.com> Date: Wed, 19 Mar 2025 15:33:54 -0600 Subject: [PATCH 2/3] Following up on comments --- .gitattributes | 2 ++ sdk/python/pulumi_esc_sdk/esc_client.py | 12 ++++++------ 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/.gitattributes b/.gitattributes index ed7a363..4b6536e 100644 --- a/.gitattributes +++ b/.gitattributes @@ -4,6 +4,8 @@ sdk/go/api_esc_test.go linguist-generated=false sdk/python/**/* linguist-generated=true sdk/python/pulumi_esc_sdk/esc_client.py linguist-generated=false +sdk/python/pulumi_esc_sdk/workspace.py linguist-generated=false +sdk/python/pulumi_esc_sdk/workspace_models.py linguist-generated=false sdk/python/test/**/* linguist-generated=false sdk/python/pyproject.toml linguist-generated=false diff --git a/sdk/python/pulumi_esc_sdk/esc_client.py b/sdk/python/pulumi_esc_sdk/esc_client.py index c4c3e10..03a6d23 100644 --- a/sdk/python/pulumi_esc_sdk/esc_client.py +++ b/sdk/python/pulumi_esc_sdk/esc_client.py @@ -443,12 +443,12 @@ def default_config(host=None, if not host: host = os.getenv("PULUMI_BACKEND_URL") - account, backend_url = workspace.get_current_account(False) - - if not access_token: - access_token = account.accessToken if account else None - if not host: - host = backend_url + if not access_token or not host: + account, backend_url = workspace.get_current_account(False) + if not access_token: + access_token = account.accessToken if account else None + if not host: + host = backend_url return configuration.Configuration( host=host, From e477f0d5e1d380b5af3bd5d88f1c4f4ede69eb96 Mon Sep 17 00:00:00 2001 From: IaroslavTitov <iaro@pulumi.com> Date: Wed, 19 Mar 2025 16:45:15 -0600 Subject: [PATCH 3/3] Fixing up bad syntax based on comments --- CHANGELOG_PENDING.md | 2 +- sdk/python/pulumi_esc_sdk/workspace.py | 16 ++++++++++------ sdk/python/pulumi_esc_sdk/workspace_models.py | 8 ++++++-- .../test/test_pulumi_home/credentials.json | 2 +- .../test_pulumi_home_esc/.esc/credentials.json | 2 +- .../test/test_pulumi_home_esc/credentials.json | 2 +- sdk/python/test/test_workspace_accounts.py | 2 +- 7 files changed, 21 insertions(+), 13 deletions(-) diff --git a/CHANGELOG_PENDING.md b/CHANGELOG_PENDING.md index 0006413..3806a0f 100644 --- a/CHANGELOG_PENDING.md +++ b/CHANGELOG_PENDING.md @@ -4,7 +4,7 @@ - All SDK now automatically read in configuration environment variables - Go SDK also automatically picks up configuration from CLI Pulumi accounts [#76](https://github.com/pulumi/esc-sdk/pull/76) -- Adding CLI Pulumi accounts loading logic into Python SDK +- Adds support for reading credentials from disk to Python SDK [#81](https://github.com/pulumi/esc-sdk/pull/81) ### Bug Fixes diff --git a/sdk/python/pulumi_esc_sdk/workspace.py b/sdk/python/pulumi_esc_sdk/workspace.py index 39684f0..1667c20 100644 --- a/sdk/python/pulumi_esc_sdk/workspace.py +++ b/sdk/python/pulumi_esc_sdk/workspace.py @@ -1,6 +1,10 @@ # Copyright 2025, Pulumi Corporation. All rights reserved. -"""Recreating Pulumi workspace and account logic for python SDK.""" +""" +Pulumi workspace and account logic for python SDK. +This is a partial port of ESC and Pulumi CLI code found in +https://github.com/pulumi/esc/tree/main/cmd/esc/cli/workspace +""" from dataclasses import dataclass, field import json @@ -22,7 +26,7 @@ def get_pulumi_home_dir() -> str: # Otherwise, use the current user's home dir + .pulumi home_dir = pathlib.Path.home() - return str(home_dir / ".pulumi") + return str(home_dir.joinpath(".pulumi")) def get_pulumi_path(*elem: str) -> str: """ @@ -38,17 +42,17 @@ def get_esc_bookkeeping_dir() -> str: """ return get_pulumi_path(".esc") -def append_creds_file(dir: str) -> str: +def get_path_to_creds_file(dir: str) -> str: """ Returns the path to the esc credentials file on disk. """ - return str(pathlib.Path(dir) / "credentials.json") + return str(pathlib.Path(dir).joinpath("credentials.json")) def get_esc_current_account_name() -> Optional[str]: """ Returns the current account name from the ESC credentials file. """ - creds_file = append_creds_file(get_esc_bookkeeping_dir()) + creds_file = get_path_to_creds_file(get_esc_bookkeeping_dir()) try: with open(creds_file, 'r') as f: data = json.loads(f.read()) @@ -63,7 +67,7 @@ def get_stored_credentials() -> Credentials: """ Reads and parses credentials from the Pulumi credentials file. """ - creds_file = append_creds_file(get_pulumi_path()) + creds_file = get_path_to_creds_file(get_pulumi_path()) try: with open(creds_file, 'r') as f: data = f.read() diff --git a/sdk/python/pulumi_esc_sdk/workspace_models.py b/sdk/python/pulumi_esc_sdk/workspace_models.py index 04738d4..e96be88 100644 --- a/sdk/python/pulumi_esc_sdk/workspace_models.py +++ b/sdk/python/pulumi_esc_sdk/workspace_models.py @@ -1,6 +1,10 @@ # Copyright 2025, Pulumi Corporation. All rights reserved. -"""Pulumi workspace models.""" +""" +Models for Pulumi workspace and account logic for python SDK. +This is a partial port of ESC and Pulumi CLI code found in +https://github.com/pulumi/esc/tree/main/cmd/esc/cli/workspace +""" from dataclasses import dataclass, field import json @@ -38,4 +42,4 @@ def from_json(self, dataJson: str): current=data.get("current"), accessTokens=data.get("accessTokens"), accounts=accounts - ) \ No newline at end of file + ) diff --git a/sdk/python/test/test_pulumi_home/credentials.json b/sdk/python/test/test_pulumi_home/credentials.json index 1af2f2c..764cbf7 100644 --- a/sdk/python/test/test_pulumi_home/credentials.json +++ b/sdk/python/test/test_pulumi_home/credentials.json @@ -22,4 +22,4 @@ "lastValidatedAt": "2026-03-17T11:19:28.392525488-06:00" } } -} \ No newline at end of file +} diff --git a/sdk/python/test/test_pulumi_home_esc/.esc/credentials.json b/sdk/python/test/test_pulumi_home_esc/.esc/credentials.json index 0dbca97..ce9c84c 100644 --- a/sdk/python/test/test_pulumi_home_esc/.esc/credentials.json +++ b/sdk/python/test/test_pulumi_home_esc/.esc/credentials.json @@ -1,3 +1,3 @@ { "name": "https://api.boolumi.com" -} \ No newline at end of file +} diff --git a/sdk/python/test/test_pulumi_home_esc/credentials.json b/sdk/python/test/test_pulumi_home_esc/credentials.json index 1af2f2c..764cbf7 100644 --- a/sdk/python/test/test_pulumi_home_esc/credentials.json +++ b/sdk/python/test/test_pulumi_home_esc/credentials.json @@ -22,4 +22,4 @@ "lastValidatedAt": "2026-03-17T11:19:28.392525488-06:00" } } -} \ No newline at end of file +} diff --git a/sdk/python/test/test_workspace_accounts.py b/sdk/python/test/test_workspace_accounts.py index f84fe0b..56f4f07 100644 --- a/sdk/python/test/test_workspace_accounts.py +++ b/sdk/python/test/test_workspace_accounts.py @@ -48,4 +48,4 @@ def test_pulumi_creds_with_esc(self): self.assertTrue(self.config.api_key['Authorization'], "pul-fake-token-boo") if __name__ == '__main__': - unittest.main() \ No newline at end of file + unittest.main()