diff --git a/README.md b/README.md index f0784306..9be10072 100644 --- a/README.md +++ b/README.md @@ -208,6 +208,10 @@ See the [Bitbucket Pipelines OIDC documentation](https://support.atlassian.com/b In CircleCI, OIDC credential discovery works out of the box with no extra dependencies — the CLI reads the token from the `CIRCLE_OIDC_TOKEN_V2` (preferred) or `CIRCLE_OIDC_TOKEN` environment variable that CircleCI injects into every job. The Cloudsmith OIDC provider must expect the audience CircleCI mints, which is your CircleCI organization UUID. See the [Cloudsmith CircleCI integration guide](https://docs.cloudsmith.com/integrations/integrating-with-circleci). +#### Buildkite OIDC Support + +In Buildkite, OIDC credential discovery works out of the box with no extra dependencies — the CLI requests a token for the current job through `buildkite-agent oidc request-token`. By default it requests the `cloudsmith` audience; use `--oidc-audience` or `CLOUDSMITH_OIDC_AUDIENCE` if your Cloudsmith OIDC provider expects a different audience. See the [Buildkite OIDC documentation](https://buildkite.com/docs/pipelines/security/oidc). + #### Azure DevOps OIDC Support In Azure DevOps Pipelines, OIDC credential discovery works out of the box with no extra dependencies — the CLI fetches an OIDC token from the `SYSTEM_OIDCREQUESTURI` endpoint using the pipeline's `SYSTEM_ACCESSTOKEN`. Make sure `SYSTEM_ACCESSTOKEN` is mapped into the step's environment. The Cloudsmith OIDC provider must expect the audience `api://AzureADTokenExchange`, which Azure DevOps always mints (any requested audience is ignored). See the [Cloudsmith Azure DevOps integration guide](https://docs.cloudsmith.com/integrations/integrating-with-azure-devops). @@ -242,7 +246,7 @@ By default the CLI tries each detector in a fixed priority order and uses the fi - **Disable a detector** — set `CLOUDSMITH_OIDC__DISABLED=true` to skip it entirely. Only the literal value `true` (case-insensitive) disables; anything else leaves the detector enabled. For example, `CLOUDSMITH_OIDC_AWS_DISABLED=true` skips the AWS detector so an explicitly-set `CLOUDSMITH_OIDC_TOKEN` is picked up by the generic detector instead. - **Reorder evaluation** — use `--oidc-detector-order` (or the `CLOUDSMITH_OIDC_DETECTOR_ORDER` environment variable) with a comma-separated list of detector ids to control both which detectors are considered and the order they are tried in (first match wins). Ids not listed are skipped; unrecognised ids are ignored with a warning. For example, `--oidc-detector-order=generic,aws` tries the generic detector first and considers only those two. -When both are set, the order list defines the candidate set and sequence, then the `*_DISABLED` flags are applied on top — so a disabled detector is always skipped even if it appears in the order list. Detector ids are: `aws`, `azure_devops`, `bitbucket`, `circleci`, `generic`, `github`, `gitlab`. +When both are set, the order list defines the candidate set and sequence, then the `*_DISABLED` flags are applied on top — so a disabled detector is always skipped even if it appears in the order list. Detector ids are: `aws`, `azure_devops`, `bitbucket`, `buildkite`, `circleci`, `generic`, `github`, `gitlab`. Both controls can also be set in `config.ini`, under `[default]` or a profile section: diff --git a/cloudsmith_cli/core/credentials/oidc/detectors/__init__.py b/cloudsmith_cli/core/credentials/oidc/detectors/__init__.py index 0a77eb3e..a741a426 100644 --- a/cloudsmith_cli/core/credentials/oidc/detectors/__init__.py +++ b/cloudsmith_cli/core/credentials/oidc/detectors/__init__.py @@ -8,6 +8,7 @@ from .aws import AWSDetector from .azure_devops import AzureDevOpsDetector from .bitbucket_pipelines import BitbucketPipelinesDetector +from .buildkite import BuildkiteDetector from .circleci import CircleCIDetector from .generic import GenericDetector from .github_actions import GitHubActionsDetector @@ -22,6 +23,7 @@ logger = logging.getLogger(__name__) _DETECTORS: list[type[EnvironmentDetector]] = [ + BuildkiteDetector, CircleCIDetector, AzureDevOpsDetector, GitHubActionsDetector, diff --git a/cloudsmith_cli/core/credentials/oidc/detectors/buildkite.py b/cloudsmith_cli/core/credentials/oidc/detectors/buildkite.py new file mode 100644 index 00000000..af51e571 --- /dev/null +++ b/cloudsmith_cli/core/credentials/oidc/detectors/buildkite.py @@ -0,0 +1,51 @@ +# Copyright 2026 Cloudsmith Ltd +"""Buildkite OIDC detector. + +Requests an OIDC token for the current job through the ``buildkite-agent`` +command, which is available in Buildkite pipeline jobs. + +References: + https://buildkite.com/docs/pipelines/security/oidc + https://buildkite.com/docs/agent/cli/reference/oidc +""" + +from __future__ import annotations + +import os +import subprocess + +from .base import EnvironmentDetector + +DEFAULT_AUDIENCE = "cloudsmith" + + +class BuildkiteDetector(EnvironmentDetector): + """Detects Buildkite and requests an OIDC token from its agent.""" + + name = "Buildkite" + id = "buildkite" + + def detect(self) -> bool: + return os.environ.get("BUILDKITE") == "true" and bool( + os.environ.get("BUILDKITE_JOB_ID") + ) + + def get_token(self) -> str: + audience = self.context.oidc_audience or DEFAULT_AUDIENCE + result = subprocess.run( + [ + "buildkite-agent", + "oidc", + "request-token", + "--audience", + audience, + ], + capture_output=True, + check=True, + text=True, + timeout=30, + ) + token = result.stdout.strip() + if not token: + raise ValueError("Buildkite agent OIDC request returned an empty token") + return token diff --git a/cloudsmith_cli/core/tests/test_buildkite_detector.py b/cloudsmith_cli/core/tests/test_buildkite_detector.py new file mode 100644 index 00000000..e33ee03f --- /dev/null +++ b/cloudsmith_cli/core/tests/test_buildkite_detector.py @@ -0,0 +1,91 @@ +"""Tests for the Buildkite OIDC detector.""" + +import subprocess +from unittest import mock + +import pytest + +from cloudsmith_cli.core.credentials.models import CredentialContext +from cloudsmith_cli.core.credentials.oidc.detectors import detect_environment +from cloudsmith_cli.core.credentials.oidc.detectors.buildkite import BuildkiteDetector + + +@pytest.fixture +def buildkite_env(): + env = { + "BUILDKITE": "true", + "BUILDKITE_JOB_ID": "0184990a-477b-4fa8-9968-496074483cee", + } + with mock.patch.dict("os.environ", env, clear=True): + yield env + + +class TestDetect: + def test_detects_when_buildkite_job_present(self, buildkite_env): + detector = BuildkiteDetector(context=CredentialContext()) + assert detector.detect() is True + + def test_not_detected_when_unset(self): + with mock.patch.dict("os.environ", {}, clear=True): + detector = BuildkiteDetector(context=CredentialContext()) + assert detector.detect() is False + + def test_not_detected_without_buildkite_flag(self, buildkite_env): + del buildkite_env["BUILDKITE"] + with mock.patch.dict("os.environ", buildkite_env, clear=True): + detector = BuildkiteDetector(context=CredentialContext()) + assert detector.detect() is False + + def test_not_detected_without_job_id(self, buildkite_env): + del buildkite_env["BUILDKITE_JOB_ID"] + with mock.patch.dict("os.environ", buildkite_env, clear=True): + detector = BuildkiteDetector(context=CredentialContext()) + assert detector.detect() is False + + +class TestGetToken: + def test_requests_token_with_default_audience(self, buildkite_env): + completed = subprocess.CompletedProcess([], 0, stdout="the-jwt\n", stderr="") + with mock.patch("subprocess.run", return_value=completed) as run: + detector = BuildkiteDetector(context=CredentialContext()) + + assert detector.get_token() == "the-jwt" + + run.assert_called_once_with( + [ + "buildkite-agent", + "oidc", + "request-token", + "--audience", + "cloudsmith", + ], + capture_output=True, + check=True, + text=True, + timeout=30, + ) + + def test_uses_custom_audience(self, buildkite_env): + completed = subprocess.CompletedProcess([], 0, stdout="the-jwt", stderr="") + with mock.patch("subprocess.run", return_value=completed) as run: + detector = BuildkiteDetector( + context=CredentialContext(oidc_audience="custom-audience") + ) + + detector.get_token() + + assert run.call_args.args[0][-1] == "custom-audience" + + def test_raises_when_agent_returns_empty_token(self, buildkite_env): + completed = subprocess.CompletedProcess([], 0, stdout="\n", stderr="") + with mock.patch("subprocess.run", return_value=completed): + detector = BuildkiteDetector(context=CredentialContext()) + + with pytest.raises(ValueError, match="empty token"): + detector.get_token() + + +class TestIntegration: + def test_detect_environment_selects_buildkite(self, buildkite_env): + detector = detect_environment(CredentialContext()) + assert isinstance(detector, BuildkiteDetector)