diff --git a/.gitignore b/.gitignore index 69d9aa1..0f18a21 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .claude +*local* .venv __pycache__ \ No newline at end of file diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..6324d40 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.14 diff --git a/README.md b/README.md index 74e0525..676f3f0 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,53 @@ This action can be used in workflows to ensure that the `uv.lock` file is correc repo-path: path/to/repository ~~~ +## copy-git-clone-script Action + +A GitHub Action that copies the `git-clone-ref.py` script into a directory of the calling repository. + +### Usage + +**Inputs:** +- **`target-path`** (required): The directory where `git-clone-ref.py` should be copied + +**Example workflow:** +~~~yaml + - name: Copy git clone script + uses: bopen/ci-cd/copy-git-clone-script@main + with: + target-path: scripts +~~~ + +## Scripts + +The scripts backing the actions live in `src/ci_cd` and are packaged as a uv project, so they can also be run locally: + +- **`git-clone-ref`**: clones multiple repositories (used by the `git-clone` action) +- **`get-kube`**: downloads a kubeconfig from Proton Pass + +### get-kube + +Downloads the kubeconfig of a Kubernetes environment stored in Proton Pass, using the `pass-cli` command-line tool. + +Each project is a Proton Pass vault, and each environment is an item in that vault with the kubeconfig as its only attachment. + +**Requirements:** +- `pass-cli` installed and on `PATH` +- Logged in with `pass-cli login` + +**Options:** +- **`-p, --project`** (required, env `CICD_PROJECT`): Name of the vault containing the kubeconfig +- **`-e, --environment`** (required, env `CICD_ENVIRONMENT`): Name of the item holding the kubeconfig +- **`-o, --output-path`** (optional, env `CICD_OUTPUT_PATH`): Where to save the kubeconfig. Defaults to `local..kubeconfig.yaml` in the current directory, with spaces replaced by `_` + +**Example:** +~~~bash +uv run get-kube -p my-project -e "production eu" +export KUBECONFIG=$PWD/local.production_eu.kubeconfig.yaml +~~~ + +The file is saved readable only by its owner (`0600`), since it contains cluster credentials. Running `uv run get-kube` with no arguments prints the help. + ## License ``` diff --git a/copy-git-clone-script/action.yml b/copy-git-clone-script/action.yml index 2085091..b45074e 100644 --- a/copy-git-clone-script/action.yml +++ b/copy-git-clone-script/action.yml @@ -2,7 +2,7 @@ name: 'Copy Git Clone Script' description: 'It copies the git clone script' inputs: target-path: - description: 'The path where the script should be copied' + description: 'The directory where git-clone-ref.py should be copied' required: true runs: using: "composite" @@ -12,4 +12,4 @@ runs: TARGET_PATH: ${{ inputs.target-path }} shell: bash run: | - cp ${{ github.action_path }}/../scripts/git-clone-ref.py ${TARGET_PATH} + cp ${{ github.action_path }}/../src/ci_cd/git_clone_ref.py ${TARGET_PATH}/git-clone-ref.py diff --git a/git-clone/action.yml b/git-clone/action.yml index a1a5134..55ce569 100644 --- a/git-clone/action.yml +++ b/git-clone/action.yml @@ -29,4 +29,4 @@ runs: DEFAULT_REPO_REF: ${{ inputs.default-repo-ref }} PYPROJECT_PATH: ${{ inputs.pyproject-path }} run: | - uv run "$GITHUB_ACTION_PATH/../scripts/git-clone-ref.py" ${REPO_LIST} --default-repo-ref "${DEFAULT_REPO_REF}" --pyproject-path "${PYPROJECT_PATH}" + uv run --project "$GITHUB_ACTION_PATH/.." git-clone-ref ${REPO_LIST} --default-repo-ref "${DEFAULT_REPO_REF}" --pyproject-path "${PYPROJECT_PATH}" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..d34bba1 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,21 @@ +[project] +name = "ci-cd" +version = "0.1.0" +description = "Reusable GitHub Actions and the scripts backing them" +readme = "README.md" +authors = [ + { name = "Francesco Nazzaro", email = "francesconazzaro89@gmail.com" } +] +requires-python = ">=3.12" +dependencies = [ + "gitpython", + "typer", +] + +[project.scripts] +git-clone-ref = "ci_cd.git_clone_ref:app" +get-kube = "ci_cd.get_kube:app" + +[build-system] +requires = ["uv_build>=0.12.7,<0.13.0"] +build-backend = "uv_build" diff --git a/src/ci_cd/__init__.py b/src/ci_cd/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ci_cd/get_kube.py b/src/ci_cd/get_kube.py new file mode 100755 index 0000000..d061bc1 --- /dev/null +++ b/src/ci_cd/get_kube.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python +# /// script +# requires-python = ">=3.12" +# dependencies = [ +# "typer", +# ] +# /// + + +import json +import logging +import pathlib +import subprocess +from typing import Annotated + +import typer + +logger = logging.getLogger(__name__) + +app = typer.Typer() + + +def run_pass_cli(*args: str) -> dict: + result = subprocess.run( + ["pass-cli", *args, "--output", "json"], + capture_output=True, + text=True, + check=True, + ) + return json.loads(result.stdout) + + +def get_vault_id(name: str) -> str: + """ + Get the vault ID for a given name using the `pass-cli` command-line tool. + + Args: + name (str): The name of the vault to retrieve the ID for. + """ + vaults = run_pass_cli("vault", "list")["vaults"] + for vault in vaults: + if vault["name"] == name: + logger.debug(f"Found vault {name!r} with ID {vault['share_id']!r}") + return vault["share_id"] + raise ValueError(f"vault {name!r} not found") + + +def get_item_id(vault_id: str, item_name: str) -> str: + """ + Get the item ID for a given vault ID and item name using the `pass-cli` command-line tool. + + Args: + vault_id (str): The ID of the vault containing the item. + item_name (str): The name of the item to retrieve the ID for. + """ + items = run_pass_cli("item", "list", f"--share-id={vault_id}")["items"] + for item in items: + if item["title"] == item_name: + logger.debug(f"Found item {item_name!r} with ID {item['id']!r} in vault {vault_id!r}") + return item["id"] + raise ValueError(f"item {item_name!r} not found in vault {vault_id!r}") + + +def get_attachment_id(vault_id: str, item_id: str) -> str: + """ + Get the ID of the single attachment of a given item using the `pass-cli` command-line tool. + + Args: + vault_id (str): The ID of the vault containing the item. + item_id (str): The ID of the item containing the attachment. + """ + attachments = run_pass_cli( + "item", "view", f"--share-id={vault_id}", f"--item-id={item_id}" + )["attachments"] + if len(attachments) != 1: + raise ValueError( + f"expected exactly one attachment in item {item_id!r} of vault {vault_id!r}, found {len(attachments)}" + ) + logger.debug(f"Found attachment {attachments[0]['content']['name']!r} with ID {attachments[0]['id']!r} in item {item_id!r} of vault {vault_id!r}") + return attachments[0]["id"] + + +def download_kubeconfig( + vault_id: str, item_id: str, attachment_id: str, output_path: pathlib.Path +) -> None: + """ + Download a kubeconfig file from a vault and save it to the specified output path. + + Args: + vault_id (str): The ID of the vault containing the kubeconfig item. + item_id (str): The ID of the kubeconfig item to download. + attachment_id (str): The ID of the attachment to download. + output_path (pathlib.Path): The path where the kubeconfig file will be saved. + """ + logger.debug(f"Downloading kubeconfig from vault {vault_id!r}, item {item_id!r}, attachment {attachment_id!r}") + output_path.parent.mkdir(parents=True, exist_ok=True) + subprocess.run( + [ + "pass-cli", + "item", + "attachment", + "download", + f"--share-id={vault_id}", + f"--item-id={item_id}", + f"--attachment-id={attachment_id}", + f"--output={output_path}", + ], + check=True, + capture_output=True, + text=True, + ) + output_path.chmod(0o600) + logger.info(f"Downloaded kubeconfig to {str(output_path)!r}") + + +@app.command(no_args_is_help=True) +def main( + project: Annotated[ + str, + typer.Option( + "--project", + "-p", + envvar="CICD_PROJECT", + help="Name of the project containing the kubeconfig.", + ), + ], + environment: Annotated[ + str, + typer.Option( + "--environment", + "-e", + envvar="CICD_ENVIRONMENT", + help="Name of the kubernetes environment to download.", + ), + ], + output_path: Annotated[ + pathlib.Path | None, + typer.Option( + "--output-path", "-o", envvar="CICD_OUTPUT_PATH", help="Path where the kubeconfig file will be saved." + ), + ] = None, +): + logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") + if output_path is None: + output_path = pathlib.Path(f"local.{environment.replace(' ', '_')}.kubeconfig.yaml") + try: + logged_in = subprocess.run(["pass-cli", "info"], capture_output=True, text=True, check=False) + if logged_in.returncode != 0: + logger.error( + "You are not logged in to pass-cli. Please log in with 'pass-cli login' and try again." + ) + raise typer.Exit(1) + logger.info(f"Downloading kubeconfig for project {project!r} and environment {environment!r} to {str(output_path)!r}") + vault_id = get_vault_id(project) + item_id = get_item_id(vault_id, environment) + attachment_id = get_attachment_id(vault_id, item_id) + download_kubeconfig(vault_id, item_id, attachment_id, output_path) + except FileNotFoundError as e: + if e.filename != "pass-cli": + raise + logger.error("pass-cli is not installed or not on PATH.") + raise typer.Exit(1) + except subprocess.CalledProcessError as e: + logger.error(f"pass-cli failed: {(e.stderr or '').strip() or e}") + raise typer.Exit(1) + except ValueError as e: + logger.error(str(e)) + raise typer.Exit(1) + + +if __name__ == "__main__": + app() diff --git a/scripts/git-clone-ref.py b/src/ci_cd/git_clone_ref.py similarity index 99% rename from scripts/git-clone-ref.py rename to src/ci_cd/git_clone_ref.py index f3c692f..021b2b5 100755 --- a/scripts/git-clone-ref.py +++ b/src/ci_cd/git_clone_ref.py @@ -1,5 +1,6 @@ #!/usr/bin/env python # /// script +# requires-python = ">=3.12" # dependencies = [ # "gitpython", # "typer", @@ -10,10 +11,10 @@ import os import shutil import tempfile +import tomllib from typing import Annotated import git -import tomllib import typer logger = logging.getLogger(__name__) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..fbbace6 --- /dev/null +++ b/uv.lock @@ -0,0 +1,136 @@ +version = 1 +revision = 3 +requires-python = ">=3.12" + +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, +] + +[[package]] +name = "ci-cd" +version = "0.1.0" +source = { editable = "." } +dependencies = [ + { name = "gitpython" }, + { name = "typer" }, +] + +[package.metadata] +requires-dist = [ + { name = "gitpython" }, + { name = "typer" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "gitdb" +version = "4.0.12" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "smmap" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/72/94/63b0fc47eb32792c7ba1fe1b694daec9a63620db1e313033d18140c2320a/gitdb-4.0.12.tar.gz", hash = "sha256:5ef71f855d191a3326fcfbc0d5da835f26b13fbcba60c32c21091c349ffdb571", size = 394684, upload-time = "2025-01-02T07:20:46.413Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/61/5c78b91c3143ed5c14207f463aecfc8f9dbb5092fb2869baf37c273b2705/gitdb-4.0.12-py3-none-any.whl", hash = "sha256:67073e15955400952c6565cc3e707c554a4eea2e428946f7a4c162fab9bd9bcf", size = 62794, upload-time = "2025-01-02T07:20:43.624Z" }, +] + +[[package]] +name = "gitpython" +version = "3.2.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "gitdb" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/ff/fee293f1ec3e1b64db6eb270e00964f29c086d769201474cedb8d1608930/gitpython-3.2.1.tar.gz", hash = "sha256:0f1229ec5c057bdabdeeda8bda4f17bda679ab33dbccc00f24e6a480553c8613", size = 241688, upload-time = "2026-10-14T12:20:20.589Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6b/50/2a923c4dc02522ff9567f07c67ed1deca6d5fa9474f4708168b8088dfa37/gitpython-3.2.1-py3-none-any.whl", hash = "sha256:d66b0832aa8755c1a84892937282df0befb16c97e5a64aeebd68be9c5e323f53", size = 231548, upload-time = "2026-10-14T12:20:18.844Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "shellingham" +version = "1.5.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, +] + +[[package]] +name = "smmap" +version = "5.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/ea/49c993d6dfdd7338c9b1000a0f36817ed7ec84577ae2e52f890d1a4ff909/smmap-5.0.3.tar.gz", hash = "sha256:4d9debb8b99007ae47165abc08670bd74cb74b5227dda7f643eccc4e9eb5642c", size = 22506, upload-time = "2026-03-09T03:43:26.1Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/d4/59e74daffcb57a07668852eeeb6035af9f32cbfd7a1d2511f17d2fe6a738/smmap-5.0.3-py3-none-any.whl", hash = "sha256:c106e05d5a61449cf6ba9a1e650227ecfb141590d2a98412103ff35d89fc7b2f", size = 24390, upload-time = "2026-03-09T03:43:24.361Z" }, +] + +[[package]] +name = "typer" +version = "0.27.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, + { name = "shellingham" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/03/51/d33db42cc72ffd8c30777547b42d01f0cbf9d95a770457698d0174b3ed71/typer-0.27.3.tar.gz", hash = "sha256:d0396f770a560ab1b0a8504e13b5f254b728cedb05c61cf0359e944e50ce8901", size = 205303, upload-time = "2026-10-06T17:24:16.61Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/ea/2e31b67051e91a133189e9c000c222502ddc6969856416de0d095de4c0b0/typer-0.27.3-py3-none-any.whl", hash = "sha256:e50022f28b82a86313e54501317a1db64bf8f8d036ff8cfe5ca7e47675454aff", size = 123312, upload-time = "2026-10-06T17:24:15.054Z" }, +]