FAST CI/CD Azure Devops support via project template (#3616)
* azd wip * azd wip * azd wip * wip * wip * wip * plan/apply pipelines * wip * wip * cross-repo auth, README improvements * README * README * README * module sources * self hosted agents * tfdoc * tested * test * anonymize examples * boilerplate * yamllint * yamllint
This commit is contained in:
committed by
GitHub
parent
3c2adbec2d
commit
04cf0c9d95
260
fast/project-templates/devops-azure-wif/README.md
Normal file
260
fast/project-templates/devops-azure-wif/README.md
Normal file
@@ -0,0 +1,260 @@
|
||||
# Azure Devops Pipelines via Workload Identity Federation
|
||||
|
||||
<!-- BEGIN TOC -->
|
||||
- [Project Configuration](#project-configuration)
|
||||
- [Organization Policies](#organization-policies)
|
||||
- [Data Access Logs](#data-access-logs)
|
||||
- [Hosted vs Managed Agents](#hosted-vs-managed-agents)
|
||||
- [Workload Identity Federation](#workload-identity-federation)
|
||||
- [Azure Devops Service Connections](#azure-devops-service-connections)
|
||||
- [GCP Workload Identity Federation Pool and Providers](#gcp-workload-identity-federation-pool-and-providers)
|
||||
- [IAM principals](#iam-principals)
|
||||
- [Agent Configuration](#agent-configuration)
|
||||
- [Pipeline Configuration](#pipeline-configuration)
|
||||
- [Included Examples](#included-examples)
|
||||
- [Branch Policies and Permissions](#branch-policies-and-permissions)
|
||||
- [Module Authentication (Optional)](#module-authentication-optional)
|
||||
<!-- END TOC -->
|
||||
|
||||
## Project Configuration
|
||||
|
||||
The provided `project.yaml` file provides an initial configuration, and makes a few assumptions that are explained in this section. This setup will only cover direct requirements for the Azure Devops integration, and assume that environment-specific customizations (project id, parent folder reference, specific IAM settings, region, etc.) will be implemented by the user.
|
||||
|
||||
### Organization Policies
|
||||
|
||||
A big part of the configuration outlined here involves setting up a Workload Identity Federation pool and provider. Depending on the organizational setup, this might require relaxing organization policies like shown in the `project.yaml` file contained in this example.
|
||||
|
||||
Comment this out if this constraint is enforced in the parent hierarchy.
|
||||
|
||||
```yaml
|
||||
org_policies:
|
||||
iam.workloadIdentityPoolProviders:
|
||||
rules:
|
||||
- allow:
|
||||
all: true
|
||||
```
|
||||
|
||||
### Data Access Logs
|
||||
|
||||
Workload Identity Federation often requires some amount of troubleshooting to make sure the IAM principals match the assertions in the tokens provided by the external IdP (Azure Devops in this case). This is made easier by turning on data access logs for specific services, like in this example, and also allows logging token exchanges.
|
||||
|
||||
Comment this out if you don't need to troubleshoot, or don't want to track token exchanges.
|
||||
|
||||
```yaml
|
||||
data_access_logs:
|
||||
iam.googleapis.com:
|
||||
ADMIN_READ: {}
|
||||
DATA_READ: {}
|
||||
DATA_WRITE: {}
|
||||
sts.googleapis.com:
|
||||
ADMIN_READ: {}
|
||||
DATA_READ: {}
|
||||
DATA_WRITE: {}
|
||||
```
|
||||
|
||||
### Hosted vs Managed Agents
|
||||
|
||||
The services enabled at the project level only include those required for Microsoft hosted agents. If you plan on running self hosted agents on Compute instances, additional configuration needs to be uncommented.
|
||||
|
||||
```yaml
|
||||
services:
|
||||
# TODO: uncomment for self hosted agent on GCP
|
||||
# - artifactregistry.googleapis.com
|
||||
# - compute.googleapis.com
|
||||
```
|
||||
|
||||
The same applies to the `vm-default` service account, which is only needed for self hosted agents and needs to be uncommented. Keep the rest of the definitions in both `iam_principals` and `service_accounts`, which are not shown here.
|
||||
|
||||
```yaml
|
||||
iam_by_principals:
|
||||
# TODO: uncomment for self hosted agent on GCP
|
||||
# $iam_principals:service_accounts/_self_/vm-default:
|
||||
# - roles/artifactregistry.reader
|
||||
# - roles/logging.logWriter
|
||||
# - roles/monitoring.metricWriter
|
||||
service_accounts:
|
||||
# TODO: uncomment for self hosted agent on GCP
|
||||
# vm-default:
|
||||
# display_name: VM default service account.
|
||||
```
|
||||
|
||||
And for self hosted agents instances a network is required, if using Shared VPC also edit and uncomment the following.
|
||||
|
||||
```yaml
|
||||
# TODO: uncomment for self hosted agent on GCP
|
||||
# shared_vpc_service_config:
|
||||
# host_project: $project_ids:dev-spoke-0
|
||||
```
|
||||
|
||||
## Workload Identity Federation
|
||||
|
||||
The pattern implemented here is the one we typically follow for infrastructure-level CI/CD, where two separate principals are used for each Terraform root module / state: a read-only one for PR checks, and a read-write one for merges.
|
||||
|
||||
This allows running potentially unsafe code in PRs which have not yet been reviewed in a sort of sandbox, where a read-only principal is used to run checks and Terraform plan, thus preventing any change to resources.
|
||||
|
||||
On the Azure Devops side, this requires setting up one Service Connection per principal (read-only and read-write), and then mapping to a dedicated Workload Identity provider. This is required, since the claims in the Azure Devops JWT token do not contain any information about the branch or job used for the pipeline context, and only provide the Service Connection id as a usable attribute.
|
||||
|
||||
This also forces using two separate pipelines for each of the principals, as the Service Connection access grants are done at the pipeline level.
|
||||
|
||||
### Azure Devops Service Connections
|
||||
|
||||
On the Azure Devops side, configure two Service Connections as explained in the ["Prepare your external IdP"](https://docs.cloud.google.com/iam/docs/workload-identity-federation-with-deployment-pipelines#prepare) section of the Workload Identity documentation.
|
||||
|
||||
Once the service connections are configured, copy the "Issuer" and "Subject identifier" attributes displayed in the Service Connection's "Workload Identity federation details", which will be used to configure the WIF providers and associated IAM principals.
|
||||
|
||||
Your pipelines will need to be authorized to use these service connections, but this is a simple step that is shown in the UI when the pipelines is run for the first time. Remember to allow each pipeline usage of their respective Service Connection, so as to prevent use of the read-write principal from the PR pipeline.
|
||||
|
||||
### GCP Workload Identity Federation Pool and Providers
|
||||
|
||||
Other than the issuers coming from the Service Connections, one additional source of information is also needed before we can complete the WIF providers configurations.
|
||||
|
||||
The providers allow defining an attribute condition, which is used to restrict the set of supported tokens. This is entirely optional, but it's good practice to define it as a preventive control, to avoid the risk of accepting tokens originating from other customers' Azure Devops organizations.
|
||||
|
||||
The attribute condition is a CEL expression that checks assertions in the JWT token. The only usable information presented in tokens generated by Azure Devops are the object ID of the Azure Devops enterprise application (`assertion.oid`) and the Azure tenant ID (`assertion.tid`). These IDs can be found in your Azure AD tenant, typically under 'Enterprise applications' for your Azure DevOps organization's application (for `oid`), and 'Properties' for the Tenant ID (`tid`). The condition in the example below only uses `oid`, but you can mix and match depending on specific needs.
|
||||
|
||||
The last bit of information needed for the WIF providers configurations is the set of allowed audiences, which in this case is entirely static and contains a single object id for the AAD Token Exchange Endpoint.
|
||||
|
||||
Find the following definitions in the `project.yaml` file and edit them to reflect your desired values. As explained above, when multiple pipelines need to be mapped to different IAM principals on the GCP side, one Service Connection and one WIF provider are needed for each of them. The WIF pool though can stay the same.
|
||||
|
||||
For convenience, we also copy/paste the second part of the subject identifiers shown in the Service Connections details (everything after `/sc/`) in a comment, as we'll need them later to configure IAM.
|
||||
|
||||
```yaml
|
||||
workload_identity_pools:
|
||||
# pool name on GCP
|
||||
cicd-0:
|
||||
display_name: CI/CD pool.
|
||||
providers:
|
||||
# provider name on GCP, multiple providers are supported here
|
||||
az-test-0-ro:
|
||||
# TODO: copy everything after `/sc` in the service connection sub
|
||||
# sub: 5d2face9-4998-4294-8d24-763e98b6af3e/ddf48e36-d2cc-4aed-b863-abcdefghi
|
||||
display_name: Azure Devops test (read-only).
|
||||
# TODO: use the AZD enterprise application object id in your Entra
|
||||
# the Azure tenant id (assertion.tid) can also be used
|
||||
attribute_condition: assertion.oid=="6f90190a-864b-4915-a9b2-abcdefghi"
|
||||
attribute_mapping:
|
||||
google.subject: assertion.sub.split("/sc/")[1]
|
||||
identity_provider:
|
||||
oidc:
|
||||
# TODO: use the issuer displayed in the service connection details
|
||||
issuer_uri: https://login.microsoftonline.com/a659ec42-b896-4739-824b-abcdefghi/v2.0
|
||||
# you do not need to change this
|
||||
allowed_audiences:
|
||||
- fb60f99c-7a34-4190-8149-302f77469936
|
||||
# provider name on GCP, multiple providers are supported here
|
||||
az-test-0-rw:
|
||||
# TODO: copy everything after `/sc` in the service connection sub
|
||||
# sub: 5d2face9-4998-4294-8d24-763e98b6af3e/20cef207-7699-4013-b4bf-704cebeb0037
|
||||
display_name: Azure Devops test (read-write).
|
||||
# TODO: use the same condition defined for the ro provider above
|
||||
attribute_condition: assertion.oid=="6f90190a-864b-4915-a9b2-abcdefghi"
|
||||
# TODO: use the same mapping defined for the ro provider above
|
||||
attribute_mapping:
|
||||
google.subject: assertion.sub.split("/sc/")[1]
|
||||
identity_provider:
|
||||
oidc:
|
||||
# TODO: use the issuer displayed in the service connection details
|
||||
# it should be identical to the one defined for the ro provider
|
||||
issuer_uri: https://login.microsoftonline.com/a659ec42-b896-4739-824b-abcdefghi/v2.0
|
||||
# you do not need to change this
|
||||
allowed_audiences:
|
||||
- fb60f99c-7a34-4190-8149-302f77469936
|
||||
```
|
||||
|
||||
### IAM principals
|
||||
|
||||
As explained above, IAM principals for Azure Devops tokens will only be able to use the assertion subject identifier as the only defining claim. Azure Devops does not populate project, repository, or pipeline information in the token so each Service Connection is only defined by its subject identifier, and can then only be mapped to a single principal on the GCP side.
|
||||
|
||||
Using the subject identifier also poses a different problem, as its length often exceeds the number of characters supported in Workload Identity Federation mappings. To work around this problem, the mapping is defined as `assertion.sub.split("/sc/")[1]`: the subject identifier is split into two parts, and only the second part (which contains the service connection id) is kept.
|
||||
|
||||
So for a Service Connection that defines this subject (the read-only one in the example above):
|
||||
|
||||
```txt
|
||||
/eid1/c/pub/t/QuxZppa4OUeCSx113vjOOg/a/rISbSSETf0KqFyZ8ppdXmA/sc/5d2face9-4998-4294-8d24-abcdefghi/ddf48e36-d2cc-4aed-b863-abcdefghi
|
||||
```
|
||||
|
||||
The `google.subject` mapping that identifies a IAM principal will only use the part after `/sc/`, which is composed of your Azure Devops [organization id](https://stackoverflow.com/a/67871296) and the id of the Service Connection:
|
||||
|
||||
```txt
|
||||
5d2face9-4998-4294-8d24-abcdefghi/ddf48e36-d2cc-4aed-b863-abcdefghi
|
||||
```
|
||||
|
||||
So we finally get to the IAM principal, which for the read-only service connection above has this form:
|
||||
|
||||
```txt
|
||||
principal://iam.googleapis.com/projects/[project number]/locations/global/workloadIdentityPools/[pool name]/subject/5d2face9-4998-4294-8d24-abcdefghi/ddf48e36-d2cc-4aed-b863-abcdefghi
|
||||
```
|
||||
|
||||
The principals are of course different for the read-only and read-write pipelines, allowing us to grant them impersonation to the respective service accounts on the GCP side.
|
||||
|
||||
## Agent Configuration
|
||||
|
||||
To run pipelines one or more agent pools are needed, using either [Microsoft-hosted](https://learn.microsoft.com/en-us/azure/devops/pipelines/agents/hosted?view=azure-devops&tabs=windows-images%2Cyaml) or [self-hosted](https://learn.microsoft.com/en-us/azure/devops/pipelines/agents/linux-agent?view=azure-devops&tabs=IP-V4) agents.
|
||||
|
||||
Using Microsoft-hosted agents is simpler as there's no need to manage actual instances, but allows less control over the environment and tools used by the pipelines (`gcloud`, `terraform`, etc.). If a tool is needed, it needs to be fetched at runtime by the pipeline either via a preconfigured [Task](https://learn.microsoft.com/en-us/azure/devops/pipelines/tasks/reference/?view=azure-pipelines) or a dedicated step.
|
||||
|
||||
Self-hosted agents require a lot more configuration to be set up, but allow for more customizations at the instance level. They can also be run via Docker images, which allow even simpler packaging of third-party tools.
|
||||
|
||||
This example uses Microsoft-hosted agents, but also provides some basic starting points for hosted agents running Docker images on GCP via Container Optimized OS. Refer to the [self-hosted-agents](./self-hosted-agents) folder documentation for more information on those.
|
||||
|
||||
## Pipeline Configuration
|
||||
|
||||
### Included Examples
|
||||
|
||||
Three sample pipelines are provided as examples:
|
||||
|
||||
- `sample-pipeline.yaml`: a very simple pipeline that can be used to verify the credentials exchange flow
|
||||
- `pr-pipeline.yaml`: a "PR pipeline" that runs Terraform init, validate, and plan on pull requests. It posts the plan output as a comment to the PR and updates the PR status.
|
||||
- `merge-pipeline.yaml`: a "merge pipeline" that runs Terraform init, validate, and apply on merges to the main branch.
|
||||
|
||||
Each of the above pipelines needs to be edited to match your project id and resource names. Once that has been done, the code can be copy/pasted on a new pipeline in Azure Devops. On first run, you might be asked to grant permissions to the pipeline on the service connection. Refer to the Azure Devops [Pipelines Schema Reference](https://learn.microsoft.com/en-us/azure/devops/pipelines/yaml-schema/view=azure-pipelines) can be used for further customizations.
|
||||
|
||||
### Branch Policies and Permissions
|
||||
|
||||
To enable the PR pipeline to function correctly, specifically to trigger on PR creation and post comments/status checks back to the PR, two key configurations are required in Azure DevOps:
|
||||
|
||||
1. **Branch Policies (Build Validation):**
|
||||
- Navigate to **Project Settings** -> **Repositories**.
|
||||
- Select your repository and go to the **Policies** tab.
|
||||
- Select the target branch (e.g., `main`).
|
||||
- Under **Build Validation**, add a new policy.
|
||||
- Select your pipeline and ensure the "Trigger" is set to "Automatic".
|
||||
- *Note:* The `pr:` trigger in the YAML file is effectively ignored unless this policy is in place.
|
||||
2. **Build Service Permissions:**
|
||||
- Navigate to **Project Settings** -> **Repositories**.
|
||||
- Select your repository and go to the **Security** tab.
|
||||
- Locate the **Build Service** accounts (e.g., "Project Collection Build Service" and "[Project Name] Build Service").
|
||||
- Set the **"Contribute to pull requests"** permission to **Allow**.
|
||||
- *Reason:* This permission is required for the pipeline to post the Terraform Plan output as a comment and to update the PR status check using the System Access Token.
|
||||
|
||||
3. **Status Check Policy (Optional but Recommended):**
|
||||
- Under **Branch Policies** -> **Status Checks**, add a new check.
|
||||
- Status to check: `Terraform Plan` (this name must match what the pipeline posts).
|
||||
- Policy requirement: "Required".
|
||||
- *Reason:* This ensures the PR cannot be merged unless the Terraform Plan step in the pipeline explicitly reports "succeeded".
|
||||
|
||||
### Module Authentication (Optional)
|
||||
|
||||
If the Terraform code references modules from a private Azure repository (instead of local files, or a public repository), some further configuration is needed. The following assumes that the modules repository is in the same Azure Devops project, if the repository is in a different project some additional configuration steps are needed, they are not outlined here as this is beyond the scope of this example.
|
||||
|
||||
The example pipelines include a step to configure Git to use the `System.AccessToken`.
|
||||
|
||||
```bash
|
||||
git config --global url."https://$SYSTEM_ACCESSTOKEN@dev.azure.com".insteadOf "https://dev.azure.com"
|
||||
```
|
||||
|
||||
**Permissions:**
|
||||
|
||||
To allow the pipeline to fetch these modules, you must ensure the **Build Service** account has **Read** access to the target repository, and the pipeline itself is explicitly granted access:
|
||||
|
||||
- Go to **Project Settings** -> **Repositories**.
|
||||
- Select the repository hosting the modules.
|
||||
- Go to **Security**.
|
||||
- Add/Select "Project Collection Build Service" (and/or "[Project Name] Build Service").
|
||||
- Set **Read** to **Allow**.
|
||||
- In the same **Security** dialog, locate the "Pipelines" section. Explicitly add your pipeline(s) (e.g., `pr-pipeline`, `merge-pipeline`) and grant them **Read** access to this repository. This provides an additional layer of authorization for specific pipeline runs.
|
||||
|
||||
**Important:** You must also disable a restrictive default setting that limits token scope:
|
||||
|
||||
- Go to **Project Settings** -> **Pipelines** -> **Settings**.
|
||||
- Ensure **"Protect access to repositories in YAML pipelines"** (or "Limit job authorization scope to referenced Azure DevOps repositories") is **Disabled (Unchecked)**. If enabled, this setting prevents the System Access Token from accessing repositories even if permissions are granted.
|
||||
@@ -0,0 +1,90 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
trigger:
|
||||
branches:
|
||||
include:
|
||||
- main
|
||||
|
||||
resources:
|
||||
repositories:
|
||||
- repository: modules
|
||||
type: git
|
||||
name: fast-test-ms-hosted/modules
|
||||
|
||||
pr: none
|
||||
|
||||
variables:
|
||||
- name: Azure.WorkloadIdentity.Connection
|
||||
# TODO: edit to match the name of the rw service connection
|
||||
value: az-test-0-rw
|
||||
- name: AZURE_TOKEN
|
||||
value: $(Pipeline.Workspace)/token.txt
|
||||
- name: FAST_SERVICE_ACCOUNT
|
||||
# TODO: edit to match the rw service account email
|
||||
value: az-test-0-rw@tf-playground-svpc-azd-0.iam.gserviceaccount.com
|
||||
- name: FAST_WIF_PROVIDER
|
||||
# TODO: edit to match your WIF provider name
|
||||
value: projects/592545000114/locations/global/workloadIdentityPools/cicd-0/providers/az-test-0-rw
|
||||
- name: GOOGLE_CREDENTIALS
|
||||
value: cicd-sa-credentials.json
|
||||
pool:
|
||||
name: Azure Pipelines
|
||||
vmImage: 'ubuntu-latest'
|
||||
jobs:
|
||||
- job: gcp
|
||||
displayName: Interact with GCP resources.
|
||||
steps:
|
||||
- checkout: self
|
||||
- task: TerraformInstaller@1
|
||||
displayName: Install Terraform 1.14.3
|
||||
inputs:
|
||||
terraformVersion: 1.14.3
|
||||
- task: AzureCLI@2
|
||||
displayName: 'Run Terraform Apply'
|
||||
env:
|
||||
SYSTEM_ACCESSTOKEN: $(System.AccessToken)
|
||||
inputs:
|
||||
connectedServiceNameARM: $(Azure.WorkloadIdentity.Connection)
|
||||
addSpnToEnvironment: true
|
||||
scriptType: 'bash'
|
||||
scriptLocation: 'inlineScript'
|
||||
inlineScript: |
|
||||
set -u
|
||||
set -e
|
||||
set -o pipefail
|
||||
|
||||
# Configure Git to use the System Access Token via Authorization header
|
||||
git config --global http.https://dev.azure.com.extraHeader "AUTHORIZATION: bearer $SYSTEM_ACCESSTOKEN"
|
||||
|
||||
# Setup GCP Auth
|
||||
sudo snap install google-cloud-sdk --classic
|
||||
echo $idToken > $AZURE_TOKEN
|
||||
gcloud iam workload-identity-pools create-cred-config \
|
||||
$FAST_WIF_PROVIDER \
|
||||
--service-account=$FAST_SERVICE_ACCOUNT \
|
||||
--service-account-token-lifetime-seconds=900 \
|
||||
--output-file=$GOOGLE_CREDENTIALS \
|
||||
--credential-source-file=$AZURE_TOKEN
|
||||
gcloud config set auth/credential_file_override $GOOGLE_CREDENTIALS
|
||||
|
||||
# Terraform Execution
|
||||
echo "Initializing Terraform..."
|
||||
terraform init -no-color
|
||||
|
||||
echo "Validating Terraform configuration..."
|
||||
terraform validate -no-color
|
||||
|
||||
echo "Applying Terraform configuration..."
|
||||
terraform apply -auto-approve -no-color
|
||||
@@ -0,0 +1,205 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
trigger: none
|
||||
|
||||
resources:
|
||||
repositories:
|
||||
- repository: modules
|
||||
type: git
|
||||
name: fast-test-ms-hosted/modules
|
||||
|
||||
pr:
|
||||
branches:
|
||||
include:
|
||||
- main
|
||||
|
||||
variables:
|
||||
- name: Azure.WorkloadIdentity.Connection
|
||||
# TODO: edit to match the name of the ro service connection
|
||||
value: az-test-0-ro
|
||||
- name: AZURE_TOKEN
|
||||
value: $(Pipeline.Workspace)/token.txt
|
||||
- name: FAST_SERVICE_ACCOUNT
|
||||
# TODO: edit to match the ro service account email
|
||||
value: az-test-0-ro@tf-playground-svpc-azd-0.iam.gserviceaccount.com
|
||||
- name: FAST_WIF_PROVIDER
|
||||
# TODO: edit to match your WIF provider name
|
||||
value: projects/592545000114/locations/global/workloadIdentityPools/cicd-0/providers/az-test-0-ro
|
||||
- name: GOOGLE_CREDENTIALS
|
||||
value: cicd-sa-credentials.json
|
||||
pool:
|
||||
name: Azure Pipelines
|
||||
vmImage: 'ubuntu-latest'
|
||||
jobs:
|
||||
- job: gcp
|
||||
displayName: Interact with GCP resources.
|
||||
steps:
|
||||
- checkout: self
|
||||
- task: TerraformInstaller@1
|
||||
displayName: Install Terraform 1.14.3
|
||||
inputs:
|
||||
terraformVersion: 1.14.3
|
||||
- task: AzureCLI@2
|
||||
displayName: 'Run Terraform and Post to PR'
|
||||
env:
|
||||
SYSTEM_ACCESSTOKEN: $(System.AccessToken)
|
||||
inputs:
|
||||
connectedServiceNameARM: $(Azure.WorkloadIdentity.Connection)
|
||||
addSpnToEnvironment: true
|
||||
scriptType: 'bash'
|
||||
scriptLocation: 'inlineScript'
|
||||
inlineScript: |
|
||||
set -u
|
||||
set -o pipefail
|
||||
|
||||
LOG_FILE="terraform_execution.log"
|
||||
touch "$LOG_FILE"
|
||||
|
||||
# Configure Git to use the System Access Token via Authorization header
|
||||
git config --global http.https://dev.azure.com.extraHeader "AUTHORIZATION: bearer $SYSTEM_ACCESSTOKEN"
|
||||
|
||||
post_to_pr() {
|
||||
local exit_code=$1
|
||||
local content_file=$2
|
||||
|
||||
if [[ "$BUILD_REASON" == "PullRequest" ]]; then
|
||||
echo "Posting results to Pull Request..."
|
||||
|
||||
# Read the log output
|
||||
RAW_PLAN_OUTPUT=$(cat "$content_file")
|
||||
|
||||
# Construct API URLs
|
||||
BASE_URL="${SYSTEM_COLLECTIONURI}${SYSTEM_TEAMPROJECT}/_apis/git/repositories/${BUILD_REPOSITORY_NAME}/pullRequests/${SYSTEM_PULLREQUEST_PULLREQUESTID}"
|
||||
THREADS_URL="${BASE_URL}/threads?api-version=7.1-preview.1"
|
||||
STATUS_URL="${BASE_URL}/statuses?api-version=7.1-preview.1"
|
||||
|
||||
# Determine status and header
|
||||
if [ $exit_code -eq 0 ]; then
|
||||
HEADER="## Terraform Success 🚀"
|
||||
STATUS_STATE="succeeded"
|
||||
STATUS_DESC="Terraform plan completed successfully."
|
||||
else
|
||||
HEADER="## Terraform Failed ❌"
|
||||
STATUS_STATE="failed"
|
||||
STATUS_DESC="Terraform plan failed."
|
||||
fi
|
||||
|
||||
# --- 1. Post Comment ---
|
||||
# Create comment payload with collapsible section, passing RAW_PLAN_OUTPUT directly
|
||||
COMMENT_PAYLOAD=$(jq -n \
|
||||
--arg header "$HEADER" \
|
||||
--arg raw_plan "$RAW_PLAN_OUTPUT" \
|
||||
'{
|
||||
"comments": [
|
||||
{
|
||||
"parentCommentId": 0,
|
||||
"content": "\($header)\n\n<details>\n<summary>Click to view full output</summary>\n\n```text\n\($raw_plan)\n```\n</details>",
|
||||
"commentType": 1
|
||||
}
|
||||
],
|
||||
"status": 1
|
||||
}')
|
||||
|
||||
echo "Posting comment..."
|
||||
curl -s -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $SYSTEM_ACCESSTOKEN" \
|
||||
-d "$COMMENT_PAYLOAD" \
|
||||
"$THREADS_URL"
|
||||
|
||||
# --- 2. Post Status Check ---
|
||||
BUILD_URL="${SYSTEM_COLLECTIONURI}${SYSTEM_TEAMPROJECT}/_build/results?buildId=${BUILD_BUILDID}"
|
||||
|
||||
STATUS_PAYLOAD=$(jq -n \
|
||||
--arg state "$STATUS_STATE" \
|
||||
--arg desc "$STATUS_DESC" \
|
||||
--arg url "$BUILD_URL" \
|
||||
'{
|
||||
"state": $state,
|
||||
"description": $desc,
|
||||
"context": {
|
||||
"name": "Terraform Plan",
|
||||
"genre": "terraform"
|
||||
},
|
||||
"targetUrl": $url
|
||||
}')
|
||||
|
||||
echo "Posting status check..."
|
||||
curl -s -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer $SYSTEM_ACCESSTOKEN" \
|
||||
-d "$STATUS_PAYLOAD" \
|
||||
"$STATUS_URL"
|
||||
|
||||
else
|
||||
echo "Not a Pull Request, skipping comment and status posting."
|
||||
fi
|
||||
}
|
||||
|
||||
run_tf_command() {
|
||||
local cmd="$1"
|
||||
echo "Running: $cmd"
|
||||
echo "--------------------------------------------------" >> "$LOG_FILE"
|
||||
echo "Running: $cmd" >> "$LOG_FILE"
|
||||
echo "--------------------------------------------------" >> "$LOG_FILE"
|
||||
|
||||
# Run command, capture stdout and stderr to temp file, then append to log
|
||||
# We use a pipe to tee to stdout so we can see progress in the pipeline logs too
|
||||
if eval "$cmd" 2>&1 | tee -a "$LOG_FILE"; then
|
||||
echo "" >> "$LOG_FILE"
|
||||
return 0
|
||||
else
|
||||
echo "" >> "$LOG_FILE"
|
||||
echo "Command failed." >> "$LOG_FILE"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# Setup GCP Auth
|
||||
sudo snap install google-cloud-sdk --classic
|
||||
echo $idToken > $AZURE_TOKEN
|
||||
gcloud iam workload-identity-pools create-cred-config \
|
||||
$FAST_WIF_PROVIDER \
|
||||
--service-account=$FAST_SERVICE_ACCOUNT \
|
||||
--service-account-token-lifetime-seconds=900 \
|
||||
--output-file=$GOOGLE_CREDENTIALS \
|
||||
--credential-source-file=$AZURE_TOKEN
|
||||
gcloud config set auth/credential_file_override $GOOGLE_CREDENTIALS
|
||||
|
||||
# Terraform Execution
|
||||
EXIT_CODE=0
|
||||
|
||||
run_tf_command "terraform init -no-color" || EXIT_CODE=$?
|
||||
|
||||
if [ $EXIT_CODE -eq 0 ]; then
|
||||
run_tf_command "terraform validate -no-color" || EXIT_CODE=$?
|
||||
fi
|
||||
|
||||
if [ $EXIT_CODE -eq 0 ]; then
|
||||
run_tf_command "terraform plan -out=tfplan -no-color -lock=false" || EXIT_CODE=$?
|
||||
# If plan succeeded, append the readable plan to the log
|
||||
if [ $EXIT_CODE -eq 0 ]; then
|
||||
echo "--------------------------------------------------" >> "$LOG_FILE"
|
||||
echo "Plan Details:" >> "$LOG_FILE"
|
||||
echo "--------------------------------------------------" >> "$LOG_FILE"
|
||||
terraform show -no-color tfplan >> "$LOG_FILE" 2>&1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Post results
|
||||
post_to_pr $EXIT_CODE "$LOG_FILE"
|
||||
|
||||
# Exit with the captured code
|
||||
exit $EXIT_CODE
|
||||
@@ -0,0 +1,63 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
trigger:
|
||||
branches:
|
||||
include:
|
||||
- main
|
||||
variables:
|
||||
- name: Azure.WorkloadIdentity.Connection
|
||||
# TODO: edit to match the name of the ro service connection
|
||||
value: az-test-0-ro
|
||||
- name: AZURE_TOKEN
|
||||
value: $(Pipeline.Workspace)/token.txt
|
||||
- name: FAST_SERVICE_ACCOUNT
|
||||
# TODO: edit to match the rw service account email
|
||||
value: az-test-0-ro@myprj.iam.gserviceaccount.com
|
||||
- name: FAST_WIF_PROVIDER
|
||||
# TODO: edit to match your WIF provider name
|
||||
value: projects/592545000114/locations/global/workloadIdentityPools/azd-0/providers/az-test-0-ro
|
||||
- name: GOOGLE_CREDENTIALS
|
||||
value: cicd-sa-credentials.json
|
||||
pool:
|
||||
name: Azure Pipelines
|
||||
vmImage: 'ubuntu-latest'
|
||||
jobs:
|
||||
- job: gcp
|
||||
displayName: Interact with GCP resources.
|
||||
steps:
|
||||
- task: TerraformInstaller@1
|
||||
displayName: Install Terraform 1.14.3
|
||||
inputs:
|
||||
terraformVersion: 1.14.3
|
||||
- task: AzureCLI@2
|
||||
inputs:
|
||||
connectedServiceNameARM: $(Azure.WorkloadIdentity.Connection)
|
||||
addSpnToEnvironment: true
|
||||
scriptType: 'bash'
|
||||
scriptLocation: 'inlineScript'
|
||||
# # failOnStandardError: true
|
||||
inlineScript: |
|
||||
sudo snap install google-cloud-sdk --classic
|
||||
echo $idToken > $AZURE_TOKEN
|
||||
gcloud iam workload-identity-pools create-cred-config \
|
||||
$FAST_WIF_PROVIDER \
|
||||
--service-account=$FAST_SERVICE_ACCOUNT \
|
||||
--service-account-token-lifetime-seconds=900 \
|
||||
--output-file=$GOOGLE_CREDENTIALS \
|
||||
--credential-source-file=$AZURE_TOKEN
|
||||
gcloud config set auth/credential_file_override $GOOGLE_CREDENTIALS
|
||||
# gcloud projects list
|
||||
terraform init
|
||||
terraform apply -auto-approve -no-color
|
||||
109
fast/project-templates/devops-azure-wif/project.yaml
Normal file
109
fast/project-templates/devops-azure-wif/project.yaml
Normal file
@@ -0,0 +1,109 @@
|
||||
# Copyright 2025 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
# yaml-language-server: $schema=https://cdn.jsdelivr.net/gh/GoogleCloudPlatform/cloud-foundation-fabric@fast-dev/modules/project-factory/schemas/project.schema.json
|
||||
|
||||
# TODO: set a parent folder if needed
|
||||
# parent: $folder_ids:shared
|
||||
iam_by_principals:
|
||||
$iam_principals:service_accounts/_self_/az-test-0-ro:
|
||||
- roles/viewer
|
||||
$iam_principals:service_accounts/_self_/az-test-0-rw:
|
||||
- roles/editor
|
||||
# TODO: uncomment for self hosted agent on GCP
|
||||
# $iam_principals:service_accounts/_self_/vm-default:
|
||||
# - roles/artifactregistry.reader
|
||||
# - roles/logging.logWriter
|
||||
# - roles/monitoring.metricWriter
|
||||
data_access_logs:
|
||||
iam.googleapis.com:
|
||||
ADMIN_READ: {}
|
||||
DATA_READ: {}
|
||||
DATA_WRITE: {}
|
||||
sts.googleapis.com:
|
||||
ADMIN_READ: {}
|
||||
DATA_READ: {}
|
||||
DATA_WRITE: {}
|
||||
services:
|
||||
# TODO: uncomment for self hosted agent on GCP
|
||||
# - artifactregistry.googleapis.com
|
||||
# - compute.googleapis.com
|
||||
- iam.googleapis.com
|
||||
- logging.googleapis.com
|
||||
- monitoring.googleapis.com
|
||||
- sts.googleapis.com
|
||||
org_policies:
|
||||
iam.workloadIdentityPoolProviders:
|
||||
rules:
|
||||
- allow:
|
||||
all: true
|
||||
service_accounts:
|
||||
az-test-0-ro:
|
||||
display_name: Azure Devops test pipeline (read-only).
|
||||
# TODO: change the project number to match yours and uncomment
|
||||
# iam:
|
||||
# roles/iam.workloadIdentityUser:
|
||||
# - principal://iam.googleapis.com/projects/1234567890/locations/global/workloadIdentityPools/cicd-0/subject/5d2face9-4998-4294-8d24-763e98b6af3e/ddf48e36-d2cc-4aed-b863-1234567890
|
||||
az-test-0-rw:
|
||||
display_name: Azure Devops test pipeline (read-write).
|
||||
# TODO: change the project number to match yours and uncomment
|
||||
# iam:
|
||||
# roles/iam.workloadIdentityUser:
|
||||
# - principal://iam.googleapis.com/projects/1234567890/locations/global/workloadIdentityPools/cicd-0/subject/5d2face9-4998-4294-8d24-763e98b6af3e/20cef207-7699-4013-b4bf-1234567890
|
||||
# TODO: uncomment for self hosted agent on GCP
|
||||
# vm-default:
|
||||
# display_name: VM default service account.
|
||||
# TODO: uncomment for self hosted agent on GCP
|
||||
# shared_vpc_service_config:
|
||||
# host_project: $project_ids:dev-spoke-0
|
||||
workload_identity_pools:
|
||||
# pool name on GCP
|
||||
cicd-0:
|
||||
display_name: CI/CD pool.
|
||||
providers:
|
||||
# provider name on GCP, multiple providers are supported here
|
||||
az-test-0-ro:
|
||||
# TODO: copy everything after `/sc` in the service connection sub
|
||||
# sub: 5d2face9-4998-4294-8d24-763e98b6af3e/ddf48e36-d2cc-4aed-b863-1234567890
|
||||
display_name: Azure Devops test (read-only).
|
||||
# TODO: use the AZD enterprise application object id in your Entra
|
||||
# the Azure tenant id (assertion.tid) can also be used
|
||||
attribute_condition: assertion.oid=="6f90190a-864b-4915-a9b2-abcdefghi"
|
||||
attribute_mapping:
|
||||
google.subject: assertion.sub.split("/sc/")[1]
|
||||
identity_provider:
|
||||
oidc:
|
||||
# TODO: use the issuer displayed in the service connection details
|
||||
issuer_uri: https://login.microsoftonline.com/a659ec42-b896-4739-824b-abcdefghi/v2.0
|
||||
# you do not need to change this
|
||||
allowed_audiences:
|
||||
- fb60f99c-7a34-4190-8149-302f77469936
|
||||
# provider name on GCP, multiple providers are supported here
|
||||
az-test-0-rw:
|
||||
# TODO: copy everything after `/sc` in the service connection sub
|
||||
# sub: 5d2face9-4998-4294-8d24-763e98b6af3e/20cef207-7699-4013-b4bf-1234567890
|
||||
display_name: Azure Devops test (read-write).
|
||||
# TODO: use the same condition defined for the ro provider above
|
||||
attribute_condition: assertion.oid=="6f90190a-864b-4915-a9b2-abcdefghi"
|
||||
# TODO: use the same mapping defined for the ro provider above
|
||||
attribute_mapping:
|
||||
google.subject: assertion.sub.split("/sc/")[1]
|
||||
identity_provider:
|
||||
oidc:
|
||||
# TODO: use the issuer displayed in the service connection details
|
||||
# it should be identical to the one defined for the ro provider
|
||||
issuer_uri: https://login.microsoftonline.com/a659ec42-b896-4739-824b-abcdefghi/v2.0
|
||||
# you do not need to change this
|
||||
allowed_audiences:
|
||||
- fb60f99c-7a34-4190-8149-302f77469936
|
||||
1
fast/project-templates/devops-azure-wif/self-hosted-agents/.gitignore
vendored
Normal file
1
fast/project-templates/devops-azure-wif/self-hosted-agents/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
||||
token.txt
|
||||
@@ -0,0 +1,217 @@
|
||||
# Self-Hosted Agents
|
||||
|
||||
If self-hosted agents are required, a sample Container Optimized OS based agent is provided as part of this example.
|
||||
|
||||
<!-- BEGIN TOC -->
|
||||
- [Project-level Requirements](#project-level-requirements)
|
||||
- [Azure Devops Requirements](#azure-devops-requirements)
|
||||
- [First Terraform Apply: Docker Registry and Secret](#first-terraform-apply-docker-registry-and-secret)
|
||||
- [Docker Image](#docker-image)
|
||||
- [Agent Instance](#agent-instance)
|
||||
- [Extending this Example](#extending-this-example)
|
||||
- [Docker Image Customizations](#docker-image-customizations)
|
||||
- [GCP Infrastructure Scale-up](#gcp-infrastructure-scale-up)
|
||||
- [Variables](#variables)
|
||||
- [Outputs](#outputs)
|
||||
- [Test](#test)
|
||||
<!-- END TOC -->
|
||||
|
||||
## Project-level Requirements
|
||||
|
||||
Some requirements are needed at the project level for this example to work. If you are creating the project with the [project file provided in the parent folder](../project.yaml), simply [follow the instructions in the parent README](../README.md#hosted-vs-managed-agents), uncomment the relevant lines, and run the project factory to update the project.
|
||||
|
||||
If you are using a pre-existing project or you created one by hand, go through the requirements described above and mirror them in your project configuration.
|
||||
|
||||
One last requirement for running self-hosted agents is Internet connectivity. Check the Azure Devops documentation for details on which hosts and ports are needed.
|
||||
|
||||
## Azure Devops Requirements
|
||||
|
||||
Some additional requirements are needed on the Azure Devops side:
|
||||
|
||||
- [create an agent token on Azure Devops](https://learn.microsoft.com/en-us/azure/devops/pipelines/agents/personal-access-token-agent-registration?view=azure-devops) and save it to a local `token.txt` file
|
||||
- define an agent pool in the organization and not down its name
|
||||
|
||||
## First Terraform Apply: Docker Registry and Secret
|
||||
|
||||
The first Terraform apply is used to create an Artifact Registry to host the custom Docker image for the agent, and the secret that contains the agent token.
|
||||
|
||||
Create a `terraform.tfvars` file and configure the variables needed at this stage, as in the following example. You probably should also configure a backend to persist state remotely, which is a common enough task when using Terraform and not explicitly covered here.
|
||||
|
||||
```hcl
|
||||
agent_config = {
|
||||
# TODO: Azure Devops instance (organization)
|
||||
instance = "myorg"
|
||||
# TODO: Azure Devops agent pool name
|
||||
pool_name = "hosted agent"
|
||||
# TODO: make sure token file exists at first apply
|
||||
token = {
|
||||
file = "token.txt"
|
||||
}
|
||||
}
|
||||
# TODO: set GCP resource location, defaults to "europe-west8"
|
||||
location = "europe-west1"
|
||||
# TODO: GCP project id
|
||||
project_id = "my-prj"
|
||||
```
|
||||
|
||||
Some additional variables can be customized if their defaults don't match the desired configuration, or if the Azure Devops token changes:
|
||||
|
||||
- `agent_config.agent_name` defaults to "Test Agent on GCP"
|
||||
- `agent_config.token.version` needs to be changed whenever a new token needs to be saved in the secret, this defaults to `1` so just bump the number if needed
|
||||
- `name` name used for GCP resources, defaults to "azd"
|
||||
|
||||
The Azure Devops agent token in the `token.txt` file is stored in the secret using a Terraform write-only attribute: it will not be persisted in state, and the `token.txt` file is only needed on first apply and can then be removed.
|
||||
|
||||
If you need to change the token, for example to update expiration, simply put the new token in a `token.txt` file and increment the number in `agent_config.token.version` so the secret is updated.
|
||||
|
||||
Once the Terraform configuration has been saved, run `terraform init` and `terraform apply`.
|
||||
|
||||
## Docker Image
|
||||
|
||||
This example bootstraps a [self-hosted agent in Docker](https://learn.microsoft.com/en-us/azure/devops/pipelines/agents/docker?view=azure-devops#linux), so a Docker image is needed. Follow the [instructions in the Azure Devops documentation](https://learn.microsoft.com/en-us/azure/devops/pipelines/agents/docker?view=azure-devops#create-and-build-the-dockerfile-1) to prepare and build the image.
|
||||
|
||||
Once the image has been built, tag it and push it to the Artifact Registry. The registry URL is provided in the Terraform output, so either copy it from the `apply` run in the previous step, or run `terraform output`. The principal pushing the image needs the `roles/artifactregistry.writer` on the registry: check your project configuration and set it if it's missing.
|
||||
|
||||
The image can of course be customized to include the tools required by the pipelines, like for example `gcloud` or `terraform`, so as to save time at each job run.
|
||||
|
||||
## Agent Instance
|
||||
|
||||
Once the image has been pushed, edit your `terraform.tfvars` and add the instance-level configuration like in the following example.
|
||||
|
||||
```hcl
|
||||
instance_config = {
|
||||
# TODO: full path of the Docker image
|
||||
docker_image = "europe-west8-docker.pkg.dev/my-prj/azd-docker/azp-agent:latest"
|
||||
# TODO: service account for the instance
|
||||
service_account = "vm-default@my-prj.iam.gserviceaccount.com"
|
||||
# TODO: network configuration
|
||||
vpc_config = {
|
||||
network = "projects/my-net-prj/global/networks/dev-spoke-0"
|
||||
subnetwork = "projects/my-net-prj/regions/europe-west8/subnetworks/gce"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The instance service account needs the `roles/artifactregistry.reader` role on the registry to be able to pull the image. This module automatically grants this role to the configured service account.
|
||||
|
||||
Once the Terraform configuration has been saved, run `terraform init` and `terraform apply`, then check that the instance is up and the agent is connected to your Azure Devops pool.
|
||||
|
||||
## Extending this Example
|
||||
|
||||
This example provides a minimal working solution, whose main goal is bringing up a working agent without changes to the assets provided in the official documentation. Once the example has been verified to be working, it's fairly easy to use it as a basis for further customizations.
|
||||
|
||||
### Docker Image Customizations
|
||||
|
||||
One simple area of improvement is the Docker image, where tools needed by the pipelines can be embedded in the image to simplify jobs and reduce run time. The main drawback of this approach is deviating from the providing agent Dockerfile, and the need to keep it sync with updates to the code provided in the documentation.
|
||||
|
||||
As a simple example, the following changes to the Dockerfile allow embedding gcloud and terraform in the container image by using a different base image, and slightly tweaking the original Dockerfile.
|
||||
|
||||
```dockerfile
|
||||
# FROM python:3-alpine
|
||||
|
||||
FROM gcr.io/google.com/cloudsdktool/google-cloud-cli:alpine
|
||||
|
||||
ARG TERRAFORM_VERSION=1.14.0
|
||||
ENV TARGETARCH="linux-musl-x64"
|
||||
|
||||
# Another option:
|
||||
# FROM arm64v8/alpine
|
||||
# ENV TARGETARCH="linux-musl-arm64"
|
||||
|
||||
# Alpine packages
|
||||
|
||||
RUN apk update && \
|
||||
apk upgrade && \
|
||||
apk add \
|
||||
bash curl gcc git icu-libs jq musl-dev python3-dev libffi-dev \
|
||||
openssl-dev cargo make py-pip unzip
|
||||
|
||||
# Terraform
|
||||
RUN curl -LO https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_linux_amd64.zip && \
|
||||
unzip terraform_${TERRAFORM_VERSION}_linux_amd64.zip && \
|
||||
mv terraform /usr/local/bin/ && \
|
||||
rm terraform_${TERRAFORM_VERSION}_linux_amd64.zip
|
||||
|
||||
# Azure CLI
|
||||
|
||||
RUN pip install --break-system-packages azure-cli
|
||||
|
||||
WORKDIR /azp/
|
||||
|
||||
COPY ./start.sh ./
|
||||
RUN chmod 755 ./start.sh
|
||||
|
||||
RUN adduser -D agent
|
||||
RUN chown agent ./
|
||||
USER agent
|
||||
|
||||
# Another option is to run the agent as root.
|
||||
# ENV AGENT_ALLOW_RUNASROOT="true"
|
||||
|
||||
ENTRYPOINT [ "./start.sh" ]
|
||||
```
|
||||
|
||||
Another possible change is fetching the secret from within the container. This has minimal security benefits (the token would still be available via container `exec`), but removes the need of an additional systemd unit. The following snippet in the container's `start.sh` script can be used with the above Dockerfile to fetch the secret at runtime.
|
||||
|
||||
```bash
|
||||
# Azure token from GCP secret
|
||||
|
||||
if [[ -n "${GCP_PROJECT}" && -n "${GCP_SECRET}" ]]; then
|
||||
gcloud secrets versions access latest \
|
||||
--secret $GCP_SECRET --project $GCP_PROJECT >/azp/token.txt
|
||||
AZP_TOKEN_FILE="/azp/token.txt"
|
||||
echo "Token retrieved from GCP secret"
|
||||
else
|
||||
echo "Not retrieving token from GCP secret"
|
||||
fi
|
||||
```
|
||||
|
||||
### GCP Infrastructure Scale-up
|
||||
|
||||
Another customization area is on the GCP infrastructure side, where the simple instance code in this example can be easily changed to an instance template, and then wrapped into a Managed Instance Group to provide autohealing, easier scaling, or even autoscaling. This is very easy to do by leveraging the examples in the [`compute-vm`](../../../../modules/compute-vm/) and [`compute-mig`](../../../../modules/compute-mig/) modules.
|
||||
|
||||
<!-- BEGIN TFDOC -->
|
||||
## Variables
|
||||
|
||||
| name | description | type | required | default |
|
||||
|---|---|:---:|:---:|:---:|
|
||||
| [agent_config](variables.tf#L17) | Agent configuration. | <code title="object({ agent_name = optional(string, "Test Agent on GCP") instance = string pool_name = string token = object({ file = string version = optional(number, 1) }) })">object({…})</code> | ✓ | |
|
||||
| [project_id](variables.tf#L58) | Project id where resources will be created. | <code>string</code> | ✓ | |
|
||||
| [instance_config](variables.tf#L30) | Instance configuration. | <code title="object({ docker_image = string service_account = string zone = optional(string, "b") vpc_config = object({ network = string subnetwork = string }) })">object({…})</code> | | <code>null</code> |
|
||||
| [location](variables.tf#L45) | Location used for regional resources. | <code>string</code> | | <code>"europe-west8"</code> |
|
||||
| [name](variables.tf#L51) | Prefix used for resource names. | <code>string</code> | | <code>"azd"</code> |
|
||||
|
||||
## Outputs
|
||||
|
||||
| name | description | sensitive |
|
||||
|---|---|:---:|
|
||||
| [docker_registry](outputs.tf#L17) | Docker registry URL. | |
|
||||
| [secret](outputs.tf#L22) | Azure token secret. | |
|
||||
| [ssh_command](outputs.tf#L27) | Command to SSH to the agent instance. | |
|
||||
| [vpcsc_command](outputs.tf#L35) | Command to allow egress to remotes from inside a perimeter. | |
|
||||
<!-- END TFDOC -->
|
||||
|
||||
## Test
|
||||
|
||||
```hcl
|
||||
module "test-agent" {
|
||||
source = "./fabric/fast/project-templates/devops-azure-wif/self-hosted-agents"
|
||||
agent_config = {
|
||||
instance = "myorg"
|
||||
pool_name = "Self Hosted"
|
||||
token = {
|
||||
file = "token.txt"
|
||||
}
|
||||
}
|
||||
instance_config = {
|
||||
docker_image = "europe-west8-docker.pkg.dev/my-prj/azd-docker/azd-agent:orig"
|
||||
service_account = "vm-default@my-prj.iam.gserviceaccount.com"
|
||||
vpc_config = {
|
||||
network = "projects/my-net-prj/global/networks/dev-spoke-0"
|
||||
subnetwork = "projects/my-net-prj/regions/europe-west8/subnetworks/gce"
|
||||
}
|
||||
}
|
||||
project_id = "my-prj"
|
||||
}
|
||||
# tftest modules=4 resources=6
|
||||
```
|
||||
@@ -0,0 +1,93 @@
|
||||
#cloud-config
|
||||
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
|
||||
write_files:
|
||||
# generate root docker auth configuration
|
||||
- path: /run/azp-agent/.docker/config.json
|
||||
permissions: "0644"
|
||||
owner: root
|
||||
content: |
|
||||
{
|
||||
"auths": {},
|
||||
"credHelpers": {
|
||||
"asia.gcr.io": "gcr",
|
||||
"eu.gcr.io": "gcr",
|
||||
"${location}-docker.pkg.dev": "gcr",
|
||||
"gcr.io": "gcr",
|
||||
"marketplace.gcr.io": "gcr",
|
||||
"us.gcr.io": "gcr"
|
||||
}
|
||||
}
|
||||
# limit docker log size
|
||||
- path: /var/lib/docker/daemon.json
|
||||
permissions: "0644"
|
||||
owner: root
|
||||
content: |
|
||||
{
|
||||
"live-restore": true,
|
||||
"storage-driver": "overlay2",
|
||||
"log-opts": {
|
||||
"max-size": "1024m"
|
||||
}
|
||||
}
|
||||
# agent token service
|
||||
- path: /etc/systemd/system/azp-token.service
|
||||
permissions: "0644"
|
||||
owner: root
|
||||
content: |
|
||||
[Unit]
|
||||
Description=Azure Devops Agent Token
|
||||
After=gcr-online.target docker.socket
|
||||
Before=azp-agent.service
|
||||
Wants=gcr-online.target docker.socket docker-events-collector.service
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=true
|
||||
Environment="HOME=/run/azp-agent"
|
||||
ExecStart=/usr/bin/docker run --rm \
|
||||
-v /run/azp-agent:/azp-agent \
|
||||
gcr.io/google.com/cloudsdktool/google-cloud-cli:alpine \
|
||||
gcloud secrets versions access latest \
|
||||
--secret ${name} --project ${project_id} --out-file=/azp-agent/token.txt
|
||||
ExecStart=chgrp 1000 /run/azp-agent/token.txt
|
||||
ExecStart=chmod 640 /run/azp-agent/token.txt
|
||||
ExecStop=rm -f /run/azp-agent/token.txt
|
||||
# agent container service
|
||||
- path: /etc/systemd/system/azp-agent.service
|
||||
permissions: "0644"
|
||||
owner: root
|
||||
content: |
|
||||
[Unit]
|
||||
Description=Start Azure Pipelines Agent container
|
||||
After=gcr-online.target docker.socket azp-token.service
|
||||
Wants=gcr-online.target docker.socket docker-events-collector.service azp-token.service
|
||||
[Service]
|
||||
Environment="HOME=/run/azp-agent"
|
||||
ExecStart=/usr/bin/docker run --rm --name=azp-agent \
|
||||
-v /run/azp-agent/token.txt:/token.txt:ro \
|
||||
-e AZP_TOKEN_FILE=/token.txt \
|
||||
-e AZP_URL="https://dev.azure.com/${instance}" \
|
||||
-e AZP_POOL="${pool_name}" \
|
||||
-e AZP_AGENT_NAME="${agent_name}" \
|
||||
${image}
|
||||
ExecStop=/usr/bin/docker stop azp-agent
|
||||
bootcmd:
|
||||
- systemctl start node-problem-detector
|
||||
runcmd:
|
||||
# - iptables -I INPUT 1 -p tcp -m tcp --dport 8080 -m state --state NEW,ESTABLISHED -j ACCEPT
|
||||
- systemctl daemon-reload
|
||||
- systemctl start azp-token
|
||||
- systemctl start azp-agent
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Copyright 2026 Google LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
locals {
|
||||
cloud_config = !local.create_instance ? null : templatefile(
|
||||
"${path.module}/cloud-config.yaml", merge(var.agent_config, {
|
||||
image = var.instance_config.docker_image
|
||||
location = var.location
|
||||
name = var.name
|
||||
project_id = var.project_id
|
||||
})
|
||||
)
|
||||
create_instance = (
|
||||
try(var.instance_config.docker_image, null) == null ? false : true
|
||||
)
|
||||
}
|
||||
|
||||
module "registry" {
|
||||
source = "../../../../modules/artifact-registry"
|
||||
project_id = var.project_id
|
||||
location = var.location
|
||||
name = "${var.name}-docker"
|
||||
format = {
|
||||
docker = {
|
||||
standard = {}
|
||||
}
|
||||
}
|
||||
iam = var.instance_config == null ? {} : {
|
||||
"roles/artifactregistry.reader" = [
|
||||
"serviceAccount:${var.instance_config.service_account}"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
module "secret" {
|
||||
source = "../../../../modules/secret-manager"
|
||||
project_id = var.project_id
|
||||
secrets = {
|
||||
(var.name) = {
|
||||
iam = var.instance_config == null ? {} : {
|
||||
"roles/secretmanager.secretAccessor" = [
|
||||
"serviceAccount:${var.instance_config.service_account}"
|
||||
]
|
||||
}
|
||||
versions = {
|
||||
"v-${var.agent_config.token.version}" = {
|
||||
data = try(file(var.agent_config.token.file), null)
|
||||
data_config = {
|
||||
write_only_version = var.agent_config.token.version
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module "instance" {
|
||||
source = "../../../../modules/compute-vm"
|
||||
count = local.create_instance ? 1 : 0
|
||||
project_id = var.project_id
|
||||
zone = "${var.location}-${var.instance_config.zone}"
|
||||
name = "${var.name}-agent"
|
||||
instance_type = "e2-micro"
|
||||
boot_disk = {
|
||||
auto_delete = false
|
||||
initialize_params = {
|
||||
image = "projects/cos-cloud/global/images/family/cos-117-lts"
|
||||
size = 10
|
||||
}
|
||||
}
|
||||
network_interfaces = [{
|
||||
network = var.instance_config.vpc_config.network
|
||||
subnetwork = var.instance_config.vpc_config.subnetwork
|
||||
}]
|
||||
metadata = {
|
||||
user-data = local.cloud_config
|
||||
}
|
||||
service_account = {
|
||||
email = var.instance_config.service_account
|
||||
}
|
||||
depends_on = [module.secret]
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Copyright 2026 Google LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
output "docker_registry" {
|
||||
description = "Docker registry URL."
|
||||
value = module.registry.url
|
||||
}
|
||||
|
||||
output "secret" {
|
||||
description = "Azure token secret."
|
||||
value = module.secret.secrets[var.name].id
|
||||
}
|
||||
|
||||
output "ssh_command" {
|
||||
description = "Command to SSH to the agent instance."
|
||||
value = nonsensitive(try(
|
||||
"gcloud compute ssh ${module.instance[0].instance.name} --zone ${module.instance[0].instance.zone} --project ${var.project_id}",
|
||||
null
|
||||
))
|
||||
}
|
||||
|
||||
output "vpcsc_command" {
|
||||
description = "Command to allow egress to remotes from inside a perimeter."
|
||||
value = (
|
||||
"gcloud artifacts vpcsc-config allow --project=${var.project_id} --location=${var.location}"
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Copyright 2026 Google LLC
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
variable "agent_config" {
|
||||
description = "Agent configuration."
|
||||
type = object({
|
||||
agent_name = optional(string, "Test Agent on GCP")
|
||||
instance = string
|
||||
pool_name = string
|
||||
token = object({
|
||||
file = string
|
||||
version = optional(number, 1)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
variable "instance_config" {
|
||||
description = "Instance configuration."
|
||||
type = object({
|
||||
docker_image = string
|
||||
service_account = string
|
||||
zone = optional(string, "b")
|
||||
vpc_config = object({
|
||||
network = string
|
||||
subnetwork = string
|
||||
})
|
||||
})
|
||||
nullable = true
|
||||
default = null
|
||||
}
|
||||
|
||||
variable "location" {
|
||||
description = "Location used for regional resources."
|
||||
type = string
|
||||
default = "europe-west8"
|
||||
}
|
||||
|
||||
variable "name" {
|
||||
description = "Prefix used for resource names."
|
||||
type = string
|
||||
nullable = true
|
||||
default = "azd"
|
||||
}
|
||||
|
||||
variable "project_id" {
|
||||
description = "Project id where resources will be created."
|
||||
type = string
|
||||
}
|
||||
@@ -204,16 +204,19 @@ org_policies_imports = [
|
||||
Once org policies have been imported, the variable definition can be removed from the tfvars file.
|
||||
|
||||
#### Importing existing organization level IAM bindings
|
||||
|
||||
For brownfield implementations you may need to import existing organization IAM policies. These snippets can help you add existing settings into the YAML file.
|
||||
|
||||
Scripts below require [yq](https://github.com/mikefarah/yq/) in at least version 4. It was tested using yq `v4.47.2`.
|
||||
|
||||
To create `iam:` part of the `/organization/.config.yaml` file, you can use following snippet:
|
||||
|
||||
```shell
|
||||
gcloud <resource> get-iam-policy <resource name> | yq '.bindings | map({"key": .role, "value": .members}) | from_entries'
|
||||
```
|
||||
|
||||
To create `iam_by_principals:` part of the factory YAML file, you can use following snippet:
|
||||
|
||||
```shell
|
||||
gcloud <resource> get-iam-policy <resource name> | yq '
|
||||
[.bindings | .[] | .members[] as $member | { "member": $member, "role": .role}] |
|
||||
@@ -321,7 +324,7 @@ This dataset implements a design where internal tenants are given control over p
|
||||
|
||||
### Enabling Optional Features
|
||||
|
||||
The "Classic FAST" dataset is designed to be more lightweight than the "Hardened FAST" dataset regarding controls and policies.
|
||||
The "Classic FAST" dataset is designed to be more lightweight than the "Hardened FAST" dataset regarding controls and policies.
|
||||
But, it fully supports more advanced features like SCC Custom SHA modules and Observability factories if needed.
|
||||
Note that the configuration described below is already implemented when using the "Hardened FAST" dataset.
|
||||
|
||||
@@ -347,7 +350,6 @@ To configure and provision observability resources such as log-based metrics and
|
||||
1. Create a folder `datasets/classic/observability`.
|
||||
2. Place your monitoring alerts and log-based metrics in this folder. Sample of existing alerts and log-based metrics can be found in the [hardened dataset](./datasets/hardened/observability).
|
||||
|
||||
|
||||
## Detailed configuration
|
||||
|
||||
The following sections explain how to configure and run this stage, and should be read in sequence when using it for the first time.
|
||||
@@ -655,7 +657,7 @@ The provided project configurations also create several key resources for the st
|
||||
|
||||
### CI/CD configuration
|
||||
|
||||
CI/CD support is implemented via two different sets of connfigurations:
|
||||
CI/CD support is implemented via two different sets of configurations:
|
||||
|
||||
- [Workload Identity](https://docs.cloud.google.com/iam/docs/workload-identity-federation) providers are defined in project configurations
|
||||
- CI/CD service accounts and templated workflow generation are defined in a dedicated configuration (`var.factories_config.cicd_workflows`).
|
||||
@@ -685,7 +687,7 @@ workload_identity_pools:
|
||||
|
||||
The above configuration can be easily extended to support multiple pools and providers, and is not limited to OpenId Connect but can also leverage other provider types. Check the project module or project schema for the full interface.
|
||||
|
||||
Once one or more providers have been defined they can be referenced in the CI/CD cofniguration file. The following example defines a workflow configuration for this stage.
|
||||
Once one or more providers have been defined they can be referenced in the CI/CD configuration file. The following example defines a workflow configuration for this stage.
|
||||
|
||||
```yaml
|
||||
# cicd-workflows.yaml
|
||||
@@ -713,6 +715,8 @@ org-setup:
|
||||
|
||||
The configuration prepares a sample workflow file for the target repository, and configures IAM on the service accounts referenced in the configuration, so that repository tokens can impersonate them via the Workload Identity provider.
|
||||
|
||||
The above setup supports GitHub and Gitlab providers out of the box. Azure Devops is also supported via a separate, more complex configuration [defined in a dedicated project template](../../project-templates/devops-azure-wif/).
|
||||
|
||||
#### Read-write and read-only impersonation
|
||||
|
||||
The access pattern implemented above allows impersonation from any branch to the read-only (`-ro`) service account, and impersonation from explicitly mentioned branches to the read-write (`rw`) service account. This ensures that PR-related actions run with limited privileges, and higher level privileges are only used for merges after PR checks and approvals. If a more relaxed approach where any branch can access the read-write service account, simply omit the `repository.apply_branches` block.
|
||||
|
||||
Reference in New Issue
Block a user