From 80a4cb2cab3677099d54e1bb52c06bf613a8b15d Mon Sep 17 00:00:00 2001 From: Pawel Winogrodzki Date: Thu, 16 Jul 2026 14:55:20 -0700 Subject: [PATCH 1/5] ci: run PR checks across deployments Generate one parallel Control Tower job for each configured deployment so one reviewer-gated run exercises DEV, STAGING, and PROD. Keep each deployment's audience, endpoint, service connection, and error policy together. Run prcheck for every deployment, make STAGING non-blocking, and limit scratch package builds to DEV. Reuse the DEV-specific audience and endpoint in the post-merge package wrapper so both pipelines share one variable-group source of truth. Use OneBranch Official because the PR pipeline accesses a production-scoped identity. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ado/package-build.yml | 10 +- .github/workflows/ado/pr-check-ct.yml | 66 +++-- .../ado/templates/pr-check-ct-stages.yml | 243 +++++++++--------- 3 files changed, 176 insertions(+), 143 deletions(-) diff --git a/.github/workflows/ado/package-build.yml b/.github/workflows/ado/package-build.yml index 8dae92e7b0c..823f3eaee24 100644 --- a/.github/workflows/ado/package-build.yml +++ b/.github/workflows/ado/package-build.yml @@ -36,15 +36,15 @@ # Variable Group (ADO Pipelines > Library): # Name: "ControlTower-PRCheck" # Required variables: -# - ApiAudience : Entra ID audience URI for the Control Tower app -# - ApiBaseAFDUrl : Base URL of the Control Tower service (Azure Front Door endpoint) +# - ApiAudienceDev : DEV Entra ID audience URI +# - ApiBaseAFDUrlDev : DEV Azure Front Door endpoint base URL # Trigger controlled by ADO branch policy — not YAML triggers. trigger: none pr: none -# Variables extracted from this group: ApiAudience, ApiBaseAFDUrl. +# Variables extracted from this group: ApiAudienceDev, ApiBaseAFDUrlDev. variables: - group: ControlTower-PRCheck @@ -78,8 +78,8 @@ extends: stages: - template: /.github/workflows/ado/templates/package-build-stages.yml@self parameters: - apiAudience: $(ApiAudience) - apiBaseAFDUrl: $(ApiBaseAFDUrl) + apiAudience: $(ApiAudienceDev) + apiBaseAFDUrl: $(ApiBaseAFDUrlDev) artifactBaseName: packagebuild containerImage: mcr.microsoft.com/onebranch/azurelinux/build:3.0 outputDirectory: $(Build.ArtifactStagingDirectory)/output diff --git a/.github/workflows/ado/pr-check-ct.yml b/.github/workflows/ado/pr-check-ct.yml index d82aa146d20..f8ad962c7b6 100644 --- a/.github/workflows/ado/pr-check-ct.yml +++ b/.github/workflows/ado/pr-check-ct.yml @@ -2,15 +2,17 @@ # Licensed under the MIT License. # # Wrapper pipeline — passed to ADO as the entry point for the PR Control Tower -# check. It calls Control Tower 'prcheck' (which uploads the changed -# components' missing lookaside sources) and then submits a *scratch* Control -# Tower build of those components, WAITS for that build to finish, and fails -# the check if the build fails (or is rejected). The package build runs in -# Control Tower's own sandbox; checkout-controlled pipeline helpers still run -# on the CI agent under the configured service connection. +# check. For each configured deployment, it calls Control Tower 'prcheck' +# (which uploads the changed components' missing lookaside sources). DEV then +# submits a *scratch* Control Tower build of those components and WAITS for it +# to finish; STAGING and PROD skip package builds. DEV and PROD API failures +# fail the check, while STAGING prcheck failures are non-blocking. Deployment +# jobs run independently in parallel. Package builds run in Control Tower's own +# sandbox; checkout-controlled pipeline helpers still run on CI agents under +# the deployment-specific service connections. # # This file owns all OneBranch-specific wiring (governed templates repo, -# NonOfficial variant, featureFlags) and delegates the actual stages/jobs/steps +# Official variant, featureFlags) and delegates the actual stages/jobs/steps # to the raw stages template at: # .github/workflows/ado/templates/pr-check-ct-stages.yml # @@ -21,9 +23,9 @@ # gate: this pipeline does NOT auto-run on every PR push, and a maintainer # triggers it only after inspecting the diff. See the prerequisite below. # -# NonOfficial: this is PR validation. It calls the Control Tower DEV endpoint -# (via the DEV service connection) and produces only scratch builds; it is not -# a production-classified pipeline. +# Official: this remains reviewer-gated PR validation and produces only scratch +# builds, but it uses the PROD service connection alongside DEV and STAGING. +# OneBranch Official governance is required for production identity use. # # Helper scripts live under: # - scripts/ci/control-tower/ - (Control Tower client + submit script). @@ -31,13 +33,13 @@ # with the GitHub Actions PR gates and the Control Tower pipelines). # # Prerequisites (ADO / Azure Portal): -# 1. Entra ID App Registration with audience URI +# 1. Entra ID App Registrations with audience URIs # "api://" (see variable group below). -# 2. Federated identity credential on the app registration for the ADO -# service connection (issuer: https://vstoken.dev.azure.com/, +# 2. Federated identity credentials on the app registrations for the ADO +# service connections (issuer: https://vstoken.dev.azure.com/, # subject: sc:////). -# 3. ARM service connection in ADO project settings using Workload Identity -# Federation (manual). +# 3. DEV, STAGING, and PROD ARM service connections in ADO project settings +# using Workload Identity Federation (manual). # 4. REVIEWER-GATED trigger (configured in ADO, not here): wire this pipeline # as a build-validation check that does NOT automatically queue on every # PR push (e.g. an optional/manual build-validation policy, or a manual @@ -46,15 +48,19 @@ # Variable Group (ADO Pipelines > Library): # Name: "ControlTower-PRCheck" # Required variables: -# - ApiAudience : Entra ID audience URI for the Control Tower app -# - ApiBaseAFDUrl : Base URL of the Control Tower service (Azure Front Door endpoint) +# - ApiAudienceDev : DEV Entra ID audience URI +# - ApiAudienceStaging : STAGING Entra ID audience URI +# - ApiAudienceProd : PROD Entra ID audience URI +# - ApiBaseAFDUrlDev : DEV Azure Front Door endpoint base URL +# - ApiBaseAFDUrlStaging : STAGING Azure Front Door endpoint base URL +# - ApiBaseAFDUrlProd : PROD Azure Front Door endpoint base URL # Trigger controlled by ADO branch policy — not YAML triggers. trigger: none pr: none -# Variables extracted from this group: ApiAudience, ApiBaseAFDUrl. +# Variables extracted from this group: deployment audiences and endpoint URLs. variables: - group: ControlTower-PRCheck @@ -66,7 +72,7 @@ resources: ref: refs/heads/main extends: - template: v2/OneBranch.NonOfficial.CrossPlat.yml@templates + template: v2/OneBranch.Official.CrossPlat.yml@templates parameters: featureFlags: golang: @@ -88,10 +94,27 @@ extends: stages: - template: /.github/workflows/ado/templates/pr-check-ct-stages.yml@self parameters: - apiAudience: $(ApiAudience) - apiBaseAFDUrl: $(ApiBaseAFDUrl) artifactBaseName: prcheckct containerImage: mcr.microsoft.com/onebranch/azurelinux/build:3.0 + deployments: + DEV: + apiAudience: $(ApiAudienceDev) + apiBaseAFDUrl: $(ApiBaseAFDUrlDev) + continueOnError: false + serviceConnection: CT-Endpoints-Access-ServiceConnection-DEV + skipPackageBuilds: false + STAGING: + apiAudience: $(ApiAudienceStaging) + apiBaseAFDUrl: $(ApiBaseAFDUrlStaging) + continueOnError: true + serviceConnection: CT-Endpoints-Access-ServiceConnection-STAGING + skipPackageBuilds: true + PROD: + apiAudience: $(ApiAudienceProd) + apiBaseAFDUrl: $(ApiBaseAFDUrlProd) + continueOnError: false + serviceConnection: CT-Endpoints-Access-ServiceConnection-PROD + skipPackageBuilds: true outputDirectory: $(Build.ArtifactStagingDirectory)/output # Distro-config RPM build-environment for the 4.0 branch. packageEnvironment: "4.0" @@ -103,5 +126,4 @@ extends: # Raise both together if a legitimate build is being killed. pollTimeoutSeconds: 21600 poolType: linux - serviceConnection: CT-Endpoints-Access-ServiceConnection-DEV timeoutInMinutes: 420 diff --git a/.github/workflows/ado/templates/pr-check-ct-stages.yml b/.github/workflows/ado/templates/pr-check-ct-stages.yml index 7b3fd5b9c34..c65522934a0 100644 --- a/.github/workflows/ado/templates/pr-check-ct-stages.yml +++ b/.github/workflows/ado/templates/pr-check-ct-stages.yml @@ -6,22 +6,26 @@ # wrapper-coupled knobs as parameters. The wrapper at # .github/workflows/ado/pr-check-ct.yml supplies concrete values. # -# Per PR: gather the change-set info (get-changes-info, mode=pr: full history, -# deps, commit range, lock + render verification, changed-component set), then -# call Control Tower 'prcheck' (uploads the changed components' missing -# lookaside sources) and submit a *scratch* Control Tower build of the PR head -# for those components, waiting for it to finish. NO PR-controlled code is built -# on the CI agent -- only read-only change detection runs here. +# Per PR and deployment: gather the change-set info (get-changes-info, mode=pr: +# full history, deps, commit range, lock + render verification, +# changed-component set), then call Control Tower 'prcheck' (uploads the changed +# components' missing lookaside sources). When skipPackageBuilds is false, also +# submit a *scratch* Control Tower build of the PR head for those components and +# wait for it to finish. Each deployment runs in a separate parallel job. +# Checkout-controlled helpers execute on the CI agent under the +# deployment-specific service connection; package builds run in Control Tower's +# sandbox. parameters: - - name: apiAudience - type: string - - name: apiBaseAFDUrl - type: string - name: artifactBaseName type: string - name: containerImage type: string + # Named map of deployment identifier to apiAudience, apiBaseAFDUrl, + # continueOnError, serviceConnection, and skipPackageBuilds. + # Identifiers must be valid Azure Pipelines job-name suffixes. + - name: deployments + type: object - name: outputDirectory type: string # Distro-config RPM build-environment used by Control Tower. @@ -37,120 +41,127 @@ parameters: - name: poolType type: string default: linux - - name: serviceConnection - type: string - name: timeoutInMinutes type: number stages: - stage: PRCheckCT jobs: - - job: PRCheckCT - # Fail-loud: a failed submission, an immediate Control Tower rejection, - # or a build that fails (or never reaches a terminal state) turns the PR - # check red. The build runs in Control Tower's own sandbox -- NOT on this - # agent -- but this pipeline WAITS for it to finish. Size the timeout to - # cover the FULL build: it must exceed pollTimeoutSeconds (6h default). - timeoutInMinutes: ${{ parameters.timeoutInMinutes }} - pool: - type: ${{ parameters.poolType }} - variables: - - name: LinuxContainerImage - value: ${{ parameters.containerImage }} - - name: ob_artifactBaseName - value: ${{ parameters.artifactBaseName }} - - name: ob_outputDirectory - value: ${{ parameters.outputDirectory }} - - name: packageEnvironment - value: ${{ parameters.packageEnvironment }} - - name: pollTimeoutSeconds - value: ${{ parameters.pollTimeoutSeconds }} - steps: - # Sets job variables: - # - baseCommit - # - changedComponentsFile - # - sourceCommit - - template: steps/get-changes-info.yml - parameters: - mode: pr + - ${{ each deployment in parameters.deployments }}: + - job: PRCheckCT_${{ deployment.key }} + displayName: "PR Control Tower check (${{ deployment.key }})" + # Fail-loud: a failed submission, an immediate Control Tower + # rejection, or a build that fails (or never reaches a terminal + # state) turns the PR check red unless that deployment explicitly + # allows prcheck errors. Deployment jobs run independently in + # parallel, and any failed job fails the stage. The build runs in + # Control Tower's own sandbox -- NOT on this agent -- but this + # pipeline WAITS for it to finish. Size the timeout to cover the + # FULL build: it must exceed pollTimeoutSeconds (6h default). + timeoutInMinutes: ${{ parameters.timeoutInMinutes }} + pool: + type: ${{ parameters.poolType }} + variables: + - name: LinuxContainerImage + value: ${{ parameters.containerImage }} + - name: ob_artifactBaseName + value: ${{ parameters.artifactBaseName }}-${{ deployment.key }} + - name: ob_outputDirectory + value: ${{ parameters.outputDirectory }}/${{ deployment.key }} + - name: packageEnvironment + value: ${{ parameters.packageEnvironment }} + - name: pollTimeoutSeconds + value: ${{ parameters.pollTimeoutSeconds }} + steps: + # Sets job variables: + # - baseCommit + # - changedComponentsFile + # - sourceCommit + - template: steps/get-changes-info.yml + parameters: + mode: pr - - template: steps/install-deps.yml - parameters: - installControlTowerClient: true + - template: steps/install-deps.yml + parameters: + installControlTowerClient: true - # Call Control Tower 'prcheck' BEFORE the build. Among other things, - # prcheck uploads the missing lookaside sources for the changed - # components, so they are available when the scratch build below - # fetches them. - - task: AzureCLI@2 - displayName: "Call Control Tower 'prcheck' API" - inputs: - azureSubscription: ${{ parameters.serviceConnection }} - scriptType: bash - scriptLocation: inlineScript - inlineScript: | - set -euo pipefail + # Call Control Tower 'prcheck' BEFORE the build. Among other + # things, prcheck uploads the missing lookaside sources for the + # changed components, so they are available when the scratch + # build below fetches them. + - task: AzureCLI@2 + displayName: "Call Control Tower 'prcheck' API (${{ deployment.key }})" + continueOnError: ${{ deployment.value.continueOnError }} + inputs: + azureSubscription: ${{ deployment.value.serviceConnection }} + scriptType: bash + scriptLocation: inlineScript + inlineScript: | + set -euo pipefail - python3 scripts/ci/control-tower/run_prcheck.py \ - --api-audience "$API_AUDIENCE" \ - --api-base-url "$API_BASE_URL" \ - --build-reason "$BUILD_REASON" \ - --changed-components-file "$CHANGED_COMPONENTS_FILE" \ - --source-commit "$SOURCE_COMMIT" \ - --repo-uri "$BUILD_REPOSITORY_URI" - env: - # apiAudience / apiBaseAFDUrl are referenced from the parameters - # directly (NOT surfaced as same-named job variables): the wrapper - # passes $(ApiAudience) / $(ApiBaseAFDUrl) from the variable group, - # and a job variable of the same (case-insensitive) name would - # self-reference -> "cyclical reference" and never resolve. - API_AUDIENCE: ${{ parameters.apiAudience }} - API_BASE_URL: ${{ parameters.apiBaseAFDUrl }} - # TODO: Base commit is not used yet. Will be needed once we move - # detection of affected components to CT. ADO task: 18816 - BASE_COMMIT: $(baseCommit) - CHANGED_COMPONENTS_FILE: $(changedComponentsFile) - SOURCE_COMMIT: $(sourceCommit) - # Build.Reason and Build.Repository.Uri are predefined and - # auto-exposed as $BUILD_REASON / $BUILD_REPOSITORY_URI; reference - # them directly and do not map them here (env: overrides of - # reserved variables are silently ignored by the agent). + python3 scripts/ci/control-tower/run_prcheck.py \ + --api-audience "$API_AUDIENCE" \ + --api-base-url "$API_BASE_URL" \ + --build-reason "$BUILD_REASON" \ + --changed-components-file "$CHANGED_COMPONENTS_FILE" \ + --source-commit "$SOURCE_COMMIT" \ + --repo-uri "$BUILD_REPOSITORY_URI" + env: + # Values come directly from parameters rather than same-named + # job variables, which would create cyclical references. + API_AUDIENCE: ${{ deployment.value.apiAudience }} + API_BASE_URL: ${{ deployment.value.apiBaseAFDUrl }} + # TODO: Base commit is not used yet. Will be needed once we + # move detection of affected components to CT. ADO task: + # 18816 + BASE_COMMIT: $(baseCommit) + CHANGED_COMPONENTS_FILE: $(changedComponentsFile) + SOURCE_COMMIT: $(sourceCommit) + # Build.Reason and Build.Repository.Uri are predefined and + # auto-exposed as $BUILD_REASON / $BUILD_REPOSITORY_URI; + # reference them directly and do not map them here (env: + # overrides of reserved variables are silently ignored by + # the agent). - # Submit a SCRATCH Control Tower build of the PR head for the changed - # components. Scratch artifacts are throwaway and are not persisted to - # a production repo. The scratch-only runner has no official endpoint - # or mode switch, preventing accidental official submissions. This is - # not an authorization boundary: PR code can modify checkout-controlled - # helpers. --wait-for-completion blocks until the build reaches a - # terminal state and fails the check on failure or poll timeout. - # - # This step assumes the pipeline is wired as a REVIEWER-GATED check in - # ADO: it should not auto-run on every PR push, so a maintainer - # eyeballs the diff before unmerged code is submitted for a build. - - task: AzureCLI@2 - displayName: "Submit scratch build to Control Tower" - inputs: - azureSubscription: ${{ parameters.serviceConnection }} - scriptType: bash - scriptLocation: inlineScript - inlineScript: | - set -euo pipefail + - ${{ if not(deployment.value.skipPackageBuilds) }}: + # Submit a SCRATCH Control Tower build of the PR head for the + # changed components. Scratch artifacts are throwaway and are + # not persisted to a production repo. The scratch-only runner + # has no official endpoint or mode switch, preventing + # accidental official submissions. This is not an + # authorization boundary: PR code can modify + # checkout-controlled helpers. --wait-for-completion blocks + # until the build reaches a terminal state and fails the + # check on failure or poll timeout. + # + # This step assumes the pipeline is wired as a REVIEWER-GATED + # check in ADO: it should not auto-run on every PR push, so a + # maintainer eyeballs the diff before unmerged code is + # submitted for a build. + - task: AzureCLI@2 + displayName: "Submit scratch build to Control Tower (${{ deployment.key }})" + continueOnError: ${{ deployment.value.continueOnError }} + inputs: + azureSubscription: ${{ deployment.value.serviceConnection }} + scriptType: bash + scriptLocation: inlineScript + inlineScript: | + set -euo pipefail - python3 scripts/ci/control-tower/run_scratch_package_build.py \ - --api-audience "$API_AUDIENCE" \ - --api-base-url "$API_BASE_URL" \ - --changed-components-file "$CHANGED_COMPONENTS_FILE" \ - --environment "$PACKAGE_ENVIRONMENT" \ - --commit-sha "$SOURCE_COMMIT" \ - --wait-for-completion \ - --poll-timeout-seconds "$POLL_TIMEOUT_SECONDS" - env: - # See the prcheck step: apiAudience / apiBaseAFDUrl come from the - # parameters directly to avoid a cyclical self-reference with the - # same-named variable-group variables. - API_AUDIENCE: ${{ parameters.apiAudience }} - API_BASE_URL: ${{ parameters.apiBaseAFDUrl }} - CHANGED_COMPONENTS_FILE: $(changedComponentsFile) - PACKAGE_ENVIRONMENT: $(packageEnvironment) - POLL_TIMEOUT_SECONDS: $(pollTimeoutSeconds) - SOURCE_COMMIT: $(sourceCommit) + python3 scripts/ci/control-tower/run_scratch_package_build.py \ + --api-audience "$API_AUDIENCE" \ + --api-base-url "$API_BASE_URL" \ + --changed-components-file "$CHANGED_COMPONENTS_FILE" \ + --environment "$PACKAGE_ENVIRONMENT" \ + --commit-sha "$SOURCE_COMMIT" \ + --wait-for-completion \ + --poll-timeout-seconds "$POLL_TIMEOUT_SECONDS" + env: + # See the prcheck step: parameter values are used directly + # to avoid cyclical same-named variable references. + API_AUDIENCE: ${{ deployment.value.apiAudience }} + API_BASE_URL: ${{ deployment.value.apiBaseAFDUrl }} + CHANGED_COMPONENTS_FILE: $(changedComponentsFile) + PACKAGE_ENVIRONMENT: $(packageEnvironment) + POLL_TIMEOUT_SECONDS: $(pollTimeoutSeconds) + SOURCE_COMMIT: $(sourceCommit) From 9c0046ce2c4e118e7682ee2b1079609e4e6e77c9 Mon Sep 17 00:00:00 2001 From: Pawel Winogrodzki Date: Thu, 16 Jul 2026 20:39:33 -0700 Subject: [PATCH 2/5] feat(lolcat): add component Import lolcat from Fedora 43 to provide a lightweight component change for exercising the multi-deployment Control Tower PR-check pipeline. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- base/comps/components.toml | 1 + locks/lolcat.lock | 6 ++ specs/l/lolcat/lolcat-Makefile.patch | 13 +++ specs/l/lolcat/lolcat.spec | 120 +++++++++++++++++++++++++++ specs/l/lolcat/sources | 1 + 5 files changed, 141 insertions(+) create mode 100644 locks/lolcat.lock create mode 100644 specs/l/lolcat/lolcat-Makefile.patch create mode 100644 specs/l/lolcat/lolcat.spec create mode 100644 specs/l/lolcat/sources diff --git a/base/comps/components.toml b/base/comps/components.toml index 5185f32ce02..2fa9a322549 100644 --- a/base/comps/components.toml +++ b/base/comps/components.toml @@ -1987,6 +1987,7 @@ includes = ["**/*.comp.toml", "component-check-disablement.toml", "component-min [components.log4j] [components.logrotate] [components.logwatch] +[components.lolcat] [components.lorax] [components.lpcnetfreedv] [components.lrzsz] diff --git a/locks/lolcat.lock b/locks/lolcat.lock new file mode 100644 index 00000000000..4811675869e --- /dev/null +++ b/locks/lolcat.lock @@ -0,0 +1,6 @@ +# Managed by azldev component update. Do not edit manually. +version = 1 +import-commit = 'b7cdd8866241494b1152a6ba01d0300bb2309df0' +upstream-commit = 'b7cdd8866241494b1152a6ba01d0300bb2309df0' +input-fingerprint = 'sha256:25249ea76688b0ba3392c349e33b9381c6f7915d502843b6a861219fd3d7c95a' +resolution-input-hash = 'sha256:466421704711c4fd3c71f0b2ed715a0e61d49e3e26f3a2637fee755795849c8e' diff --git a/specs/l/lolcat/lolcat-Makefile.patch b/specs/l/lolcat/lolcat-Makefile.patch new file mode 100644 index 00000000000..9f6773eac82 --- /dev/null +++ b/specs/l/lolcat/lolcat-Makefile.patch @@ -0,0 +1,13 @@ +--- lolcat-1.3/Makefile.orig 2022-11-19 17:40:06.000000000 +0100 ++++ lolcat-1.3/Makefile 2022-11-25 20:11:34.078297526 +0100 +@@ -22,8 +22,8 @@ + $(CC) $(CFLAGS) -o $@ $< $(LIBS) + + install: lolcat censor +- install lolcat $(DESTDIR)/lolcat +- install censor $(DESTDIR)/censor ++ $(INSTALL) lolcat $(DESTDIR)/lolcat ++ $(INSTALL) censor $(DESTDIR)/censor + + clean: + rm -f lolcat censor diff --git a/specs/l/lolcat/lolcat.spec b/specs/l/lolcat/lolcat.spec new file mode 100644 index 00000000000..9bed307121f --- /dev/null +++ b/specs/l/lolcat/lolcat.spec @@ -0,0 +1,120 @@ +# This spec file has been modified by azldev to include build configuration overlays. +# Do not edit manually; changes may be overwritten. + +Summary: High-performance implementation of a colorful cat +Name: lolcat +Version: 1.5 +Release: 5%{?dist} +Source: https://github.com/jaseg/lolcat/archive/v%{version}/%{name}-%{version}.tar.gz +URL: https://github.com/jaseg/lolcat/ + +Patch1: lolcat-Makefile.patch + +License: WTFPL +BuildRequires: make +BuildRequires: gcc + +%description +lolcat is a colorful version of 'cat'. It is faster than python-lolcat +and much faster than ruby-lolcat. It works well with "non-binary" +characters, but who would want to display binary data! + +%prep +%autosetup + +%build +%set_build_flags +%make_build all + +%install +mkdir -p $RPM_BUILD_ROOT/%{_bindir} +%make_install DESTDIR=$RPM_BUILD_ROOT/%{_bindir} + +%files +%{_bindir}/lolcat +%{_bindir}/censor +%doc README.md +%license LICENSE + +%changelog +* Thu Jul 24 2025 Fedora Release Engineering - 1.5-4 +- Rebuilt for https://fedoraproject.org/wiki/Fedora_43_Mass_Rebuild + +* Fri Jan 17 2025 Fedora Release Engineering - 1.5-3 +- Rebuilt for https://fedoraproject.org/wiki/Fedora_42_Mass_Rebuild + +* Thu Jul 18 2024 Fedora Release Engineering - 1.5-2 +- Rebuilt for https://fedoraproject.org/wiki/Fedora_41_Mass_Rebuild + +* Sun Jul 07 2024 josef radinger - 1.5-1 +- bump version + +* Thu Jan 25 2024 Fedora Release Engineering - 1.4-4 +- Rebuilt for https://fedoraproject.org/wiki/Fedora_40_Mass_Rebuild + +* Sun Jan 21 2024 Fedora Release Engineering - 1.4-3 +- Rebuilt for https://fedoraproject.org/wiki/Fedora_40_Mass_Rebuild + +* Thu Jul 20 2023 Fedora Release Engineering - 1.4-2 +- Rebuilt for https://fedoraproject.org/wiki/Fedora_39_Mass_Rebuild + +* Fri Mar 03 2023 josef radinger - 1.4-1 +- bump version + +* Thu Jan 19 2023 Fedora Release Engineering - 1.3-2 +- Rebuilt for https://fedoraproject.org/wiki/Fedora_38_Mass_Rebuild + +* Fri Nov 25 2022 josef radinger - 1.3-1 +- bump version + +* Thu Jul 21 2022 Fedora Release Engineering - 1.2-5 +- Rebuilt for https://fedoraproject.org/wiki/Fedora_37_Mass_Rebuild + +* Thu Jan 20 2022 Fedora Release Engineering - 1.2-4 +- Rebuilt for https://fedoraproject.org/wiki/Fedora_36_Mass_Rebuild + +* Thu Jul 22 2021 Fedora Release Engineering - 1.2-3 +- Rebuilt for https://fedoraproject.org/wiki/Fedora_35_Mass_Rebuild + +* Tue Jan 26 2021 Fedora Release Engineering - 1.2-2 +- Rebuilt for https://fedoraproject.org/wiki/Fedora_34_Mass_Rebuild + +* Sun Jan 17 2021 josef radinger - 1.2-0 +- bump version +- massage patch1 +- remove patch2 + +* Sun Dec 27 2020 josef radinger - 1.1-1 +- bump version +- add patch2 + +* Tue Jul 28 2020 Fedora Release Engineering - 1.0-8 +- Rebuilt for https://fedoraproject.org/wiki/Fedora_33_Mass_Rebuild + +* Wed Jan 29 2020 Fedora Release Engineering - 1.0-7 +- Rebuilt for https://fedoraproject.org/wiki/Fedora_32_Mass_Rebuild + +* Mon Aug 26 2019 josef radinger - 1.0-6 +- modify description + +* Mon Aug 26 2019 josef radinger - 1.0-5 +- use a better source-url +- use %%autosetup +- use %%make_build +- use %%make_install (plus patch1 to preserve timestamps) +- better Summary + +* Sat Aug 17 2019 josef radinger - 1.0-4 +- use %%{_bindir} instead of /usr/bin +- invoke %%set_build_flags before make + +* Fri Aug 16 2019 josef radinger - 1.0-3 +- correct license +- small cleanup in spec-file + +* Wed Aug 07 2019 josef radinger - 1.0-2 +- add URL + +* Tue Aug 06 2019 josef radinger - 1.0-1 +- initial package + diff --git a/specs/l/lolcat/sources b/specs/l/lolcat/sources new file mode 100644 index 00000000000..53ee5b790de --- /dev/null +++ b/specs/l/lolcat/sources @@ -0,0 +1 @@ +SHA512 (lolcat-1.5.tar.gz) = 5959c4a5d485a5e956985f5d1dea4bd42442ed29b0d5631c07033b526312b8d41160155542ae89b61d1ffc65292c2e6e8d89545b9ac4b8ebe11842151e03410b From ec35a4f702f3ede6ca97dfdfb434f70f1fd01f5f Mon Sep 17 00:00:00 2001 From: Pawel Winogrodzki Date: Fri, 17 Jul 2026 11:15:23 -0700 Subject: [PATCH 3/5] test(ci): use NonOfficial PR check template Use the NonOfficial governed template only in the temporary draft PR so the multi-deployment pipeline can be exercised with its test ADO setup. This override must not merge into the 4.0 branch. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/workflows/ado/pr-check-ct.yml | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ado/pr-check-ct.yml b/.github/workflows/ado/pr-check-ct.yml index f8ad962c7b6..6e16885e97e 100644 --- a/.github/workflows/ado/pr-check-ct.yml +++ b/.github/workflows/ado/pr-check-ct.yml @@ -12,7 +12,7 @@ # the deployment-specific service connections. # # This file owns all OneBranch-specific wiring (governed templates repo, -# Official variant, featureFlags) and delegates the actual stages/jobs/steps +# NonOfficial variant, featureFlags) and delegates the actual stages/jobs/steps # to the raw stages template at: # .github/workflows/ado/templates/pr-check-ct-stages.yml # @@ -23,9 +23,8 @@ # gate: this pipeline does NOT auto-run on every PR push, and a maintainer # triggers it only after inspecting the diff. See the prerequisite below. # -# Official: this remains reviewer-gated PR validation and produces only scratch -# builds, but it uses the PROD service connection alongside DEV and STAGING. -# OneBranch Official governance is required for production identity use. +# TEST-ONLY OVERRIDE: use NonOfficial governance while validating this pipeline +# from the temporary draft PR. Do not merge this override into the 4.0 branch. # # Helper scripts live under: # - scripts/ci/control-tower/ - (Control Tower client + submit script). @@ -72,7 +71,7 @@ resources: ref: refs/heads/main extends: - template: v2/OneBranch.Official.CrossPlat.yml@templates + template: v2/OneBranch.NonOfficial.CrossPlat.yml@templates parameters: featureFlags: golang: From 2cd57f0f60da7e588b4043e840594d08e75c9c1c Mon Sep 17 00:00:00 2001 From: Pawel Winogrodzki Date: Fri, 17 Jul 2026 15:57:56 -0700 Subject: [PATCH 4/5] test(ci): log CT token identity claims Compare AzureCliCredential with the DefaultAzureCredential token used by the request, while logging only non-secret JWT claims. Also surface safe APIM and Front Door correlation headers for failed requests. This diagnostic commit belongs only to the temporary draft test PR and must not merge into the 4.0 branch. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- scripts/ci/control-tower/client.py | 82 ++++++++++++++++--- scripts/ci/control-tower/run_prcheck.py | 14 +++- scripts/ci/control-tower/tests/test_client.py | 68 +++++++++++++++ 3 files changed, 150 insertions(+), 14 deletions(-) create mode 100644 scripts/ci/control-tower/tests/test_client.py diff --git a/scripts/ci/control-tower/client.py b/scripts/ci/control-tower/client.py index 966480794d0..2b8f03430d7 100644 --- a/scripts/ci/control-tower/client.py +++ b/scripts/ci/control-tower/client.py @@ -11,25 +11,26 @@ Authentication: Requires an active Azure CLI session (e.g. via an ``AzureCLI@2`` pipeline task with a Workload Identity Federation service connection). - ``DefaultAzureCredential`` discovers the session automatically. + Callers choose the credential implementation. Token diagnostics expose + non-secret identity claims so credential-chain selection can be verified. """ from __future__ import annotations +import base64 +import binascii import json import time from collections.abc import Mapping, Sequence from dataclasses import dataclass from http import HTTPStatus -from typing import TYPE_CHECKING, cast +from typing import cast import requests +from azure.core.credentials import TokenCredential from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry -if TYPE_CHECKING: - from azure.identity import DefaultAzureCredential - type JsonValue = None | bool | int | float | str | Sequence[JsonValue] | Mapping[str, JsonValue] type JsonObject = dict[str, JsonValue] @@ -47,6 +48,9 @@ # unrecognized-but-valid new state. TERMINAL_STATUSES = TERMINAL_FAILURE_STATUSES | {SUCCESS_STATUS} _MAX_ERROR_BODY_CHARS = 4000 +_SAFE_TOKEN_CLAIMS = ("aud", "iss", "tid", "appid", "azp", "oid", "roles", "scp", "idtyp", "xms_mirid", "ver", "exp") +_TOKEN_IDENTITY_CLAIMS = ("aud", "iss", "tid", "appid", "azp", "oid", "idtyp", "xms_mirid") +_DIAGNOSTIC_RESPONSE_HEADERS = ("apim-request-id", "x-azure-ref", "x-ms-request-id", "WWW-Authenticate") @dataclass @@ -79,9 +83,59 @@ def make_session() -> requests.Session: return session -def get_token(credential: DefaultAzureCredential, audience: str) -> str: - """Acquire a bearer token for the given audience.""" - return credential.get_token(f"{audience}/.default").token +def _safe_token_claims(token: str) -> JsonObject: + """Decode only non-secret identity and authorization claims from a JWT.""" + sections = token.split(".") + if len(sections) != 3: + raise ValueError("access token is not a three-section JWT") + + payload = sections[1] + "=" * (-len(sections[1]) % 4) + claims = json.loads(base64.urlsafe_b64decode(payload).decode()) + if not isinstance(claims, dict): + raise ValueError("access token payload is not a JSON object") + + return {claim: claims[claim] for claim in _SAFE_TOKEN_CLAIMS if claim in claims} + + +def print_token_diagnostics(credential_name: str, token: str) -> None: + """Print safe JWT claims without exposing the raw bearer token.""" + try: + claims = _safe_token_claims(token) + except (binascii.Error, json.JSONDecodeError, UnicodeDecodeError, ValueError) as exc: + print(f"##[warning]{credential_name} token diagnostics unavailable: {exc}") + return + + print(f"{credential_name} token claims (raw token omitted):") + print(json.dumps(claims, indent=2, sort_keys=True)) + + +def compare_token_identities(reference_token: str, actual_token: str) -> None: + """Warn when Azure CLI and DefaultAzureCredential select different identities.""" + try: + reference = _safe_token_claims(reference_token) + actual = _safe_token_claims(actual_token) + except (binascii.Error, json.JSONDecodeError, UnicodeDecodeError, ValueError) as exc: + print(f"##[warning]Token identity comparison unavailable: {exc}") + return + + differences = [ + claim for claim in _TOKEN_IDENTITY_CLAIMS + if reference.get(claim) != actual.get(claim) + ] + if differences: + print( + "##[warning]AzureCliCredential and DefaultAzureCredential tokens " + f"differ for identity claims: {', '.join(differences)}" + ) + else: + print("AzureCliCredential and DefaultAzureCredential token identities match.") + + +def get_token(credential: TokenCredential, audience: str) -> str: + """Acquire a bearer token and print safe identity diagnostics.""" + token = credential.get_token(f"{audience}/.default").token + print_token_diagnostics(type(credential).__name__, token) + return token def _auth_headers(token: str) -> dict[str, str]: @@ -116,6 +170,10 @@ def format_error(response: requests.Response) -> str: """ method = response.request.method if response.request is not None else "?" lines: list[str] = [f"HTTP {response.status_code} {response.reason} from {method} {response.url}"] + for header in _DIAGNOSTIC_RESPONSE_HEADERS: + value = response.headers.get(header) + if value: + lines.append(f" {header}: {value}") body: object try: @@ -160,7 +218,7 @@ def _request_with_refresh( session: requests.Session, method: str, url: str, - credential: DefaultAzureCredential, + credential: TokenCredential, audience: str, token_holder: TokenHolder, *, @@ -210,7 +268,7 @@ def post_scenario( session: requests.Session, base_url: str, path: str, - credential: DefaultAzureCredential, + credential: TokenCredential, audience: str, token_holder: TokenHolder, payload: JsonObject, @@ -243,7 +301,7 @@ def post_scenario( def get_job_status( session: requests.Session, base_url: str, - credential: DefaultAzureCredential, + credential: TokenCredential, audience: str, token_holder: TokenHolder, job_id: str, @@ -294,7 +352,7 @@ def _poll_interval_seconds(elapsed_seconds: float) -> int: def poll_until_terminal( session: requests.Session, base_url: str, - credential: DefaultAzureCredential, + credential: TokenCredential, audience: str, token_holder: TokenHolder, job_id: str, diff --git a/scripts/ci/control-tower/run_prcheck.py b/scripts/ci/control-tower/run_prcheck.py index 814a6b79404..9ba7272ad8b 100644 --- a/scripts/ci/control-tower/run_prcheck.py +++ b/scripts/ci/control-tower/run_prcheck.py @@ -26,7 +26,8 @@ from pathlib import Path import client as ct -from azure.identity import DefaultAzureCredential +from azure.core.exceptions import ClientAuthenticationError +from azure.identity import AzureCliCredential, DefaultAzureCredential def _parse_components(value: str) -> list[str]: @@ -182,8 +183,17 @@ def main() -> None: return # ── Acquire bearer token ───────────────────────────────────────── + azure_cli_token: str | None = None + try: + azure_cli_token = ct.get_token(AzureCliCredential(), args.api_audience) + except ClientAuthenticationError as exc: + print(f"##[warning]AzureCliCredential comparison token failed: {exc}") + credential = DefaultAzureCredential() - token_holder = ct.TokenHolder(token=ct.get_token(credential, args.api_audience)) + default_token = ct.get_token(credential, args.api_audience) + if azure_cli_token is not None: + ct.compare_token_identities(azure_cli_token, default_token) + token_holder = ct.TokenHolder(token=default_token) session = ct.make_session() diff --git a/scripts/ci/control-tower/tests/test_client.py b/scripts/ci/control-tower/tests/test_client.py new file mode 100644 index 00000000000..6bb2bb21fda --- /dev/null +++ b/scripts/ci/control-tower/tests/test_client.py @@ -0,0 +1,68 @@ +"""Tests for Control Tower HTTP and authentication diagnostics.""" + +from __future__ import annotations + +import base64 +import json +from unittest.mock import Mock + +import client +import pytest +import requests + + +def _jwt(claims: dict[str, object]) -> str: + """Build a signature-free JWT-shaped value for diagnostic tests.""" + + def encode(value: dict[str, object]) -> str: + raw = json.dumps(value).encode() + return base64.urlsafe_b64encode(raw).decode().rstrip("=") + + return f"{encode({'alg': 'none'})}.{encode(claims)}.signature" + + +def test_get_token_prints_safe_claims_without_raw_token(capsys: pytest.CaptureFixture[str]) -> None: + """Log identity claims while keeping the bearer token secret.""" + token = _jwt( + { + "aud": "api://control-tower", + "appid": "client-id", + "roles": ["ControlTower.Invoke"], + "tid": "tenant-id", + } + ) + credential = Mock() + credential.get_token.return_value = Mock(token=token) + + assert client.get_token(credential, "api://control-tower") == token + + output = capsys.readouterr().out + assert token not in output + assert "Mock token claims" in output + assert '"aud": "api://control-tower"' in output + assert '"appid": "client-id"' in output + assert '"roles": [' in output + assert '"tid": "tenant-id"' in output + + +def test_format_error_includes_safe_gateway_headers() -> None: + """Surface request correlation and authentication challenge headers.""" + response = requests.Response() + response.status_code = 401 + response.reason = "Unauthorized" + response.url = "https://control-tower.example/api/Scenario/prcheck" + response.request = requests.Request("POST", response.url).prepare() + response.headers.update( + { + "apim-request-id": "apim-correlation", + "WWW-Authenticate": 'Bearer error="invalid_token"', + "x-azure-ref": "front-door-correlation", + } + ) + response._content = b"" + + diagnostic = client.format_error(response) + + assert "apim-request-id: apim-correlation" in diagnostic + assert 'WWW-Authenticate: Bearer error="invalid_token"' in diagnostic + assert "x-azure-ref: front-door-correlation" in diagnostic From 658c5a53978b3633428b9a872a2e88701d517cc7 Mon Sep 17 00:00:00 2001 From: Pawel Winogrodzki Date: Fri, 17 Jul 2026 16:49:40 -0700 Subject: [PATCH 5/5] test(ci): adopt renewable CT credentials Mirror the actual PR's renewable Azure Pipelines WIF credential and explicit package-build skip fixes in the temporary test environment. Replace the earlier token diagnostics with the production behavior. This test-only commit must not merge into the temporary 4.0 base branch. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../ado/templates/package-build-stages.yml | 2 + .../ado/templates/pr-check-ct-stages.yml | 6 +- scripts/ci/control-tower/client.py | 104 +++++++----------- scripts/ci/control-tower/package_build.py | 3 +- scripts/ci/control-tower/run_prcheck.py | 15 +-- scripts/ci/control-tower/tests/test_client.py | 96 ++++++++-------- .../control-tower/tests/test_package_build.py | 11 +- .../ci/control-tower/tests/test_prcheck.py | 46 ++++++++ 8 files changed, 151 insertions(+), 132 deletions(-) create mode 100644 scripts/ci/control-tower/tests/test_prcheck.py diff --git a/.github/workflows/ado/templates/package-build-stages.yml b/.github/workflows/ado/templates/package-build-stages.yml index 6bc19794539..a60eeb4991a 100644 --- a/.github/workflows/ado/templates/package-build-stages.yml +++ b/.github/workflows/ado/templates/package-build-stages.yml @@ -89,6 +89,8 @@ stages: API_BASE_URL: ${{ parameters.apiBaseAFDUrl }} CHANGED_COMPONENTS_FILE: $(changedComponentsFile) PACKAGE_ENVIRONMENT: $(packageEnvironment) + # Secret predefined variables are not auto-exposed. + SYSTEM_ACCESSTOKEN: $(System.AccessToken) # Build.Reason is predefined and auto-exposed as $BUILD_REASON; # reference it directly and do not map it here (env: overrides # of reserved variables are silently ignored by the agent). diff --git a/.github/workflows/ado/templates/pr-check-ct-stages.yml b/.github/workflows/ado/templates/pr-check-ct-stages.yml index c65522934a0..f8196edad1e 100644 --- a/.github/workflows/ado/templates/pr-check-ct-stages.yml +++ b/.github/workflows/ado/templates/pr-check-ct-stages.yml @@ -117,13 +117,15 @@ stages: BASE_COMMIT: $(baseCommit) CHANGED_COMPONENTS_FILE: $(changedComponentsFile) SOURCE_COMMIT: $(sourceCommit) + # Secret predefined variables are not auto-exposed. + SYSTEM_ACCESSTOKEN: $(System.AccessToken) # Build.Reason and Build.Repository.Uri are predefined and # auto-exposed as $BUILD_REASON / $BUILD_REPOSITORY_URI; # reference them directly and do not map them here (env: # overrides of reserved variables are silently ignored by # the agent). - - ${{ if not(deployment.value.skipPackageBuilds) }}: + - ${{ if ne(deployment.value.skipPackageBuilds, true) }}: # Submit a SCRATCH Control Tower build of the PR head for the # changed components. Scratch artifacts are throwaway and are # not persisted to a production repo. The scratch-only runner @@ -165,3 +167,5 @@ stages: PACKAGE_ENVIRONMENT: $(packageEnvironment) POLL_TIMEOUT_SECONDS: $(pollTimeoutSeconds) SOURCE_COMMIT: $(sourceCommit) + # Secret predefined variables are not auto-exposed. + SYSTEM_ACCESSTOKEN: $(System.AccessToken) diff --git a/scripts/ci/control-tower/client.py b/scripts/ci/control-tower/client.py index 2b8f03430d7..7b3a95217ad 100644 --- a/scripts/ci/control-tower/client.py +++ b/scripts/ci/control-tower/client.py @@ -11,15 +11,14 @@ Authentication: Requires an active Azure CLI session (e.g. via an ``AzureCLI@2`` pipeline task with a Workload Identity Federation service connection). - Callers choose the credential implementation. Token diagnostics expose - non-secret identity claims so credential-chain selection can be verified. + Azure Pipelines uses ``AzurePipelinesCredential`` for renewable WIF tokens; + local execution falls back to ``AzureCliCredential``. """ from __future__ import annotations -import base64 -import binascii import json +import os import time from collections.abc import Mapping, Sequence from dataclasses import dataclass @@ -28,6 +27,7 @@ import requests from azure.core.credentials import TokenCredential +from azure.identity import AzureCliCredential, AzurePipelinesCredential from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry @@ -48,9 +48,12 @@ # unrecognized-but-valid new state. TERMINAL_STATUSES = TERMINAL_FAILURE_STATUSES | {SUCCESS_STATUS} _MAX_ERROR_BODY_CHARS = 4000 -_SAFE_TOKEN_CLAIMS = ("aud", "iss", "tid", "appid", "azp", "oid", "roles", "scp", "idtyp", "xms_mirid", "ver", "exp") -_TOKEN_IDENTITY_CLAIMS = ("aud", "iss", "tid", "appid", "azp", "oid", "idtyp", "xms_mirid") -_DIAGNOSTIC_RESPONSE_HEADERS = ("apim-request-id", "x-azure-ref", "x-ms-request-id", "WWW-Authenticate") +_PIPELINE_CREDENTIAL_ENV = { + "client_id": "AZURESUBSCRIPTION_CLIENT_ID", + "service_connection_id": "AZURESUBSCRIPTION_SERVICE_CONNECTION_ID", + "system_access_token": "SYSTEM_ACCESSTOKEN", + "tenant_id": "AZURESUBSCRIPTION_TENANT_ID", +} @dataclass @@ -60,6 +63,35 @@ class TokenHolder: token: str +def make_credential() -> TokenCredential: + """Create a renewable pipeline credential or a local Azure CLI credential.""" + values = { + argument: os.environ.get(variable) + for argument, variable in _PIPELINE_CREDENTIAL_ENV.items() + } + configured = [argument for argument, value in values.items() if value] + if not configured: + return AzureCliCredential() + + missing = [ + variable + for argument, variable in _PIPELINE_CREDENTIAL_ENV.items() + if not values[argument] + ] + if missing: + raise RuntimeError( + "Missing Azure Pipelines credential variables: " + + ", ".join(sorted(missing)) + ) + + return AzurePipelinesCredential( + tenant_id=cast(str, values["tenant_id"]), + client_id=cast(str, values["client_id"]), + service_connection_id=cast(str, values["service_connection_id"]), + system_access_token=cast(str, values["system_access_token"]), + ) + + def make_session() -> requests.Session: """Create a ``requests.Session`` with retries for idempotent GETs only. @@ -83,59 +115,9 @@ def make_session() -> requests.Session: return session -def _safe_token_claims(token: str) -> JsonObject: - """Decode only non-secret identity and authorization claims from a JWT.""" - sections = token.split(".") - if len(sections) != 3: - raise ValueError("access token is not a three-section JWT") - - payload = sections[1] + "=" * (-len(sections[1]) % 4) - claims = json.loads(base64.urlsafe_b64decode(payload).decode()) - if not isinstance(claims, dict): - raise ValueError("access token payload is not a JSON object") - - return {claim: claims[claim] for claim in _SAFE_TOKEN_CLAIMS if claim in claims} - - -def print_token_diagnostics(credential_name: str, token: str) -> None: - """Print safe JWT claims without exposing the raw bearer token.""" - try: - claims = _safe_token_claims(token) - except (binascii.Error, json.JSONDecodeError, UnicodeDecodeError, ValueError) as exc: - print(f"##[warning]{credential_name} token diagnostics unavailable: {exc}") - return - - print(f"{credential_name} token claims (raw token omitted):") - print(json.dumps(claims, indent=2, sort_keys=True)) - - -def compare_token_identities(reference_token: str, actual_token: str) -> None: - """Warn when Azure CLI and DefaultAzureCredential select different identities.""" - try: - reference = _safe_token_claims(reference_token) - actual = _safe_token_claims(actual_token) - except (binascii.Error, json.JSONDecodeError, UnicodeDecodeError, ValueError) as exc: - print(f"##[warning]Token identity comparison unavailable: {exc}") - return - - differences = [ - claim for claim in _TOKEN_IDENTITY_CLAIMS - if reference.get(claim) != actual.get(claim) - ] - if differences: - print( - "##[warning]AzureCliCredential and DefaultAzureCredential tokens " - f"differ for identity claims: {', '.join(differences)}" - ) - else: - print("AzureCliCredential and DefaultAzureCredential token identities match.") - - def get_token(credential: TokenCredential, audience: str) -> str: - """Acquire a bearer token and print safe identity diagnostics.""" - token = credential.get_token(f"{audience}/.default").token - print_token_diagnostics(type(credential).__name__, token) - return token + """Acquire a bearer token for the given audience.""" + return credential.get_token(f"{audience}/.default").token def _auth_headers(token: str) -> dict[str, str]: @@ -170,10 +152,6 @@ def format_error(response: requests.Response) -> str: """ method = response.request.method if response.request is not None else "?" lines: list[str] = [f"HTTP {response.status_code} {response.reason} from {method} {response.url}"] - for header in _DIAGNOSTIC_RESPONSE_HEADERS: - value = response.headers.get(header) - if value: - lines.append(f" {header}: {value}") body: object try: diff --git a/scripts/ci/control-tower/package_build.py b/scripts/ci/control-tower/package_build.py index 2082296ba47..d808e448787 100644 --- a/scripts/ci/control-tower/package_build.py +++ b/scripts/ci/control-tower/package_build.py @@ -7,7 +7,6 @@ from typing import TYPE_CHECKING import client as ct -from azure.identity import DefaultAzureCredential if TYPE_CHECKING: from pathlib import Path @@ -96,7 +95,7 @@ def submit_and_monitor( print(json.dumps(request.payload, indent=2)) base_url = api_base_url.rstrip("/") - credential = DefaultAzureCredential() + credential = ct.make_credential() token_holder = ct.TokenHolder(token=ct.get_token(credential, api_audience)) session = ct.make_session() diff --git a/scripts/ci/control-tower/run_prcheck.py b/scripts/ci/control-tower/run_prcheck.py index 9ba7272ad8b..29715c1b5d0 100644 --- a/scripts/ci/control-tower/run_prcheck.py +++ b/scripts/ci/control-tower/run_prcheck.py @@ -26,8 +26,6 @@ from pathlib import Path import client as ct -from azure.core.exceptions import ClientAuthenticationError -from azure.identity import AzureCliCredential, DefaultAzureCredential def _parse_components(value: str) -> list[str]: @@ -183,17 +181,8 @@ def main() -> None: return # ── Acquire bearer token ───────────────────────────────────────── - azure_cli_token: str | None = None - try: - azure_cli_token = ct.get_token(AzureCliCredential(), args.api_audience) - except ClientAuthenticationError as exc: - print(f"##[warning]AzureCliCredential comparison token failed: {exc}") - - credential = DefaultAzureCredential() - default_token = ct.get_token(credential, args.api_audience) - if azure_cli_token is not None: - ct.compare_token_identities(azure_cli_token, default_token) - token_holder = ct.TokenHolder(token=default_token) + credential = ct.make_credential() + token_holder = ct.TokenHolder(token=ct.get_token(credential, args.api_audience)) session = ct.make_session() diff --git a/scripts/ci/control-tower/tests/test_client.py b/scripts/ci/control-tower/tests/test_client.py index 6bb2bb21fda..62c829c0daa 100644 --- a/scripts/ci/control-tower/tests/test_client.py +++ b/scripts/ci/control-tower/tests/test_client.py @@ -1,68 +1,60 @@ -"""Tests for Control Tower HTTP and authentication diagnostics.""" +"""Tests for Control Tower credential selection.""" from __future__ import annotations -import base64 -import json from unittest.mock import Mock import client import pytest -import requests -def _jwt(claims: dict[str, object]) -> str: - """Build a signature-free JWT-shaped value for diagnostic tests.""" +_PIPELINE_ENV = { + "AZURESUBSCRIPTION_CLIENT_ID": "client-id", + "AZURESUBSCRIPTION_SERVICE_CONNECTION_ID": "service-connection-id", + "AZURESUBSCRIPTION_TENANT_ID": "tenant-id", + "SYSTEM_ACCESSTOKEN": "system-access-token", +} - def encode(value: dict[str, object]) -> str: - raw = json.dumps(value).encode() - return base64.urlsafe_b64encode(raw).decode().rstrip("=") - return f"{encode({'alg': 'none'})}.{encode(claims)}.signature" +def test_make_credential_uses_renewable_pipeline_identity(monkeypatch: pytest.MonkeyPatch) -> None: + """Use AzurePipelinesCredential when AzureCLI@2 provides pipeline context.""" + for name, value in _PIPELINE_ENV.items(): + monkeypatch.setenv(name, value) + pipeline_credential = Mock(return_value=object()) + cli_credential = Mock(side_effect=AssertionError("AzureCliCredential must not be used")) + monkeypatch.setattr(client, "AzurePipelinesCredential", pipeline_credential, raising=False) + monkeypatch.setattr(client, "AzureCliCredential", cli_credential, raising=False) -def test_get_token_prints_safe_claims_without_raw_token(capsys: pytest.CaptureFixture[str]) -> None: - """Log identity claims while keeping the bearer token secret.""" - token = _jwt( - { - "aud": "api://control-tower", - "appid": "client-id", - "roles": ["ControlTower.Invoke"], - "tid": "tenant-id", - } - ) - credential = Mock() - credential.get_token.return_value = Mock(token=token) - - assert client.get_token(credential, "api://control-tower") == token - - output = capsys.readouterr().out - assert token not in output - assert "Mock token claims" in output - assert '"aud": "api://control-tower"' in output - assert '"appid": "client-id"' in output - assert '"roles": [' in output - assert '"tid": "tenant-id"' in output - - -def test_format_error_includes_safe_gateway_headers() -> None: - """Surface request correlation and authentication challenge headers.""" - response = requests.Response() - response.status_code = 401 - response.reason = "Unauthorized" - response.url = "https://control-tower.example/api/Scenario/prcheck" - response.request = requests.Request("POST", response.url).prepare() - response.headers.update( - { - "apim-request-id": "apim-correlation", - "WWW-Authenticate": 'Bearer error="invalid_token"', - "x-azure-ref": "front-door-correlation", - } + client.make_credential() + + pipeline_credential.assert_called_once_with( + tenant_id="tenant-id", + client_id="client-id", + service_connection_id="service-connection-id", + system_access_token="system-access-token", ) - response._content = b"" + cli_credential.assert_not_called() + + +def test_make_credential_uses_azure_cli_outside_pipeline(monkeypatch: pytest.MonkeyPatch) -> None: + """Keep local execution available through the developer's Azure CLI session.""" + for name in _PIPELINE_ENV: + monkeypatch.delenv(name, raising=False) + + cli_credential = Mock(return_value=object()) + monkeypatch.setattr(client, "AzureCliCredential", cli_credential, raising=False) + + client.make_credential() + + cli_credential.assert_called_once_with() + - diagnostic = client.format_error(response) +def test_make_credential_rejects_partial_pipeline_context(monkeypatch: pytest.MonkeyPatch) -> None: + """Fail clearly instead of silently selecting a nonrenewable pipeline identity.""" + for name in _PIPELINE_ENV: + monkeypatch.delenv(name, raising=False) + monkeypatch.setenv("AZURESUBSCRIPTION_CLIENT_ID", "client-id") - assert "apim-request-id: apim-correlation" in diagnostic - assert 'WWW-Authenticate: Bearer error="invalid_token"' in diagnostic - assert "x-azure-ref: front-door-correlation" in diagnostic + with pytest.raises(RuntimeError, match="Missing Azure Pipelines credential variables"): + client.make_credential() diff --git a/scripts/ci/control-tower/tests/test_package_build.py b/scripts/ci/control-tower/tests/test_package_build.py index 8e212d5e0bd..576edfdcdef 100644 --- a/scripts/ci/control-tower/tests/test_package_build.py +++ b/scripts/ci/control-tower/tests/test_package_build.py @@ -20,7 +20,7 @@ def lifecycle_mocks(monkeypatch: pytest.MonkeyPatch) -> tuple[Mock, Mock, Mock, print_final_status = Mock() report_failure = Mock() - monkeypatch.setattr(package_build, "DefaultAzureCredential", Mock(return_value=object())) + monkeypatch.setattr(package_build.ct, "make_credential", Mock(return_value=object()), raising=False) monkeypatch.setattr(package_build.ct, "get_token", Mock(return_value="token")) monkeypatch.setattr(package_build.ct, "make_session", Mock(return_value=object())) monkeypatch.setattr(package_build.ct, "poll_until_terminal", poll_until_terminal) @@ -49,6 +49,15 @@ def _submit(*, wait_for_completion: bool = False) -> None: class TestPackageBuildLifecycle: """Verify shared submission and polling behavior.""" + def test_uses_pipeline_service_connection( + self, + lifecycle_mocks: tuple[Mock, Mock, Mock, Mock], + ) -> None: + """Use the identity authenticated by the enclosing AzureCLI task.""" + _submit() + + package_build.ct.make_credential.assert_called_once_with() + @pytest.mark.parametrize("job_id", [None, 123]) def test_missing_or_non_string_job_id_fails( self, diff --git a/scripts/ci/control-tower/tests/test_prcheck.py b/scripts/ci/control-tower/tests/test_prcheck.py new file mode 100644 index 00000000000..4cf6349d342 --- /dev/null +++ b/scripts/ci/control-tower/tests/test_prcheck.py @@ -0,0 +1,46 @@ +"""Contract tests for the Control Tower PR-check entry point.""" + +from __future__ import annotations + +from argparse import Namespace +from unittest.mock import Mock + +import pytest +import run_prcheck + + +def test_main_uses_pipeline_service_connection(monkeypatch: pytest.MonkeyPatch) -> None: + """Use the identity authenticated by the enclosing AzureCLI task.""" + credential = object() + make_credential = Mock(return_value=credential) + + args = Namespace( + api_audience="api://control-tower", + api_base_url="https://control-tower.example", + build_reason="PullRequest", + changed_components_file=None, + components=["lolcat"], + poll_timeout_seconds=60, + repo_uri="https://github.com/microsoft/azurelinux", + source_branch=None, + source_commit="source-commit", + target_branch=None, + target_commit=None, + ) + + monkeypatch.setattr(run_prcheck, "_parse_args", Mock(return_value=args)) + monkeypatch.setattr(run_prcheck.ct, "make_credential", make_credential, raising=False) + monkeypatch.setattr(run_prcheck.ct, "get_token", Mock(return_value="token")) + monkeypatch.setattr(run_prcheck.ct, "make_session", Mock(return_value=object())) + monkeypatch.setattr(run_prcheck.ct, "post_scenario", Mock(return_value={"jobId": "job-id"})) + monkeypatch.setattr( + run_prcheck.ct, + "poll_until_terminal", + Mock(return_value=({"status": run_prcheck.ct.SUCCESS_STATUS}, False)), + ) + monkeypatch.setattr(run_prcheck.ct, "print_final_status", Mock()) + monkeypatch.setattr(run_prcheck.ct, "report_failure", Mock()) + + run_prcheck.main() + + make_credential.assert_called_once_with()