|
| 1 | +# image-copy-acr |
| 2 | + |
| 3 | +This repo contains a Go application and a Terraform module that deploys the `image-copy-acr` service to Azure Container Apps. The service subscribes to Chainguard registry push events and automatically copies new images into an Azure Container Registry (ACR) when the event is received. |
| 4 | + |
| 5 | +> This is an image mirroring service, not a pull-through caching service. |
| 6 | +> This service will mirror all images in your Chainguard registry to your Azure Container Registry; it is not currently configured for targeted image replication. |
| 7 | +
|
| 8 | +If you supply `existing_acr_name` (and `existing_acr_resource_group`), Terraform will reuse that registry instead of creating a new ACR instance. It will still create a new Resource Group to deploy the other required resources. If you want to create a new ACR instance, leave both variables unset and Terraform creates a fresh Basic-tier ACR in the generated resource group. The rest of the deployment (Container App, managed identity, Chainguard identity and subscription) is identical. |
| 9 | + |
| 10 | +## How it works |
| 11 | + |
| 12 | +```mermaid |
| 13 | +flowchart LR |
| 14 | + subgraph CG[Chainguard] |
| 15 | + REG[(Chainguard Registry)] |
| 16 | + SUB[/Chainguard Subscription/] |
| 17 | + REG -- image pushed --> SUB |
| 18 | + end |
| 19 | +
|
| 20 | + subgraph AZ[Azure] |
| 21 | + APP{{"Container App<br/>(ca-cgr-replicator)"}} |
| 22 | + ACR[(Azure Container Registry)] |
| 23 | + APP -- copies image --> ACR |
| 24 | + end |
| 25 | +
|
| 26 | + SUB -- "CloudEvent<br/>(HTTPS POST)" --> APP |
| 27 | + APP -- "pull image<br/>(OIDC)" --> REG |
| 28 | +``` |
| 29 | + |
| 30 | +1. Chainguard sends a CloudEvent (HTTPS POST) to the Container App's public HTTPS endpoint whenever an image is pushed to any repository in your group. |
| 31 | +2. The app requests a managed identity token scoped to a dedicated Azure AD application. |
| 32 | +3. The app copies the image to the target ACR using its managed identity for authentication. |
| 33 | + |
| 34 | +--- |
| 35 | + |
| 36 | +## Resources created |
| 37 | + |
| 38 | +### Azure |
| 39 | + |
| 40 | +| Resource | Name | Notes | |
| 41 | +|---|---|---| |
| 42 | +| Resource Group | `rg-cgr-imagereplication-<random>` | All resources below are placed here unless noted | |
| 43 | +| Container Registry | `acrcgr<random>` | Created only when no existing ACR is supplied | |
| 44 | +| User-Assigned Managed Identity | `mi-cgr-acr-pushpull` | Used by the Container App to pull/push ACR images | |
| 45 | +| Role Assignment | `AcrPull` | Scoped to the resource group that contains the ACR | |
| 46 | +| Role Assignment | `AcrPush` | Scoped to the resource group that contains the ACR | |
| 47 | +| Container App Environment | `ace-cgr-replicator` | Consumption (serverless) plan | |
| 48 | +| Container App | `ca-cgr-replicator` | Runs the ko-built replicator image | |
| 49 | +| Azure AD Application | `cgr-image-copier-chainguard-audience` | Permission-free app used as the token audience for Chainguard STS exchange | |
| 50 | +| Azure AD Service Principal | `cgr-image-copier-chainguard-audience` | Required for Azure AD to recognise the app as a valid token resource | |
| 51 | + |
| 52 | +### Chainguard |
| 53 | + |
| 54 | +| Resource | Description | |
| 55 | +|---|---| |
| 56 | +| `chainguard_identity` | Workload identity bound to the managed identity via Azure AD claim matching | |
| 57 | +| `chainguard_rolebinding` | Grants the identity `registry.pull` on the target group | |
| 58 | +| `chainguard_rolebinding` | Grants the identity `viewer` on the target group (needed for signature verification) | |
| 59 | +| `chainguard_subscription` | Sends push events to the Container App's public URL | |
| 60 | + |
| 61 | +--- |
| 62 | + |
| 63 | +## Prerequisites |
| 64 | + |
| 65 | +### Tools |
| 66 | + |
| 67 | +- [Terraform](https://developer.hashicorp.com/terraform/install) >= 1.3 |
| 68 | +- [Azure CLI](https://learn.microsoft.com/en-us/cli/azure/install-azure-cli) — authenticated with `az login` |
| 69 | +- [Chainguard CLI (`chainctl`)](https://edu.chainguard.dev/chainguard/chainguard-enforce/how-to-install-chainctl/) — authenticated with `chainctl auth login` |
| 70 | + |
| 71 | +### Azure permissions |
| 72 | + |
| 73 | +The identity running Terraform needs: |
| 74 | + |
| 75 | +- **Contributor** (or equivalent) on the subscription or resource group to create resources |
| 76 | +- **User Access Administrator** on the ACR's resource group to assign AcrPull/AcrPush roles |
| 77 | +- **Permission to register Azure AD applications** — required when `create_application = true` (the default). Enabled for all users in most tenants. If app registration is disabled in your tenant, have an admin create the app, set `create_application = false` and supply `token_scope` and `claim_match_audience` (see security note in the Variables section). |
| 78 | + |
| 79 | +### Chainguard permissions |
| 80 | + |
| 81 | +The identity running Terraform needs **Owner** or **Editor** on the target Chainguard group to create identities, role bindings, and subscriptions. |
| 82 | + |
| 83 | +--- |
| 84 | + |
| 85 | +## Deployment steps |
| 86 | + |
| 87 | +### 1. Authenticate |
| 88 | + |
| 89 | +```sh |
| 90 | +az login |
| 91 | +chainctl auth login |
| 92 | +``` |
| 93 | + |
| 94 | +### 2. Copy and edit tfvars |
| 95 | + |
| 96 | +```sh |
| 97 | +cp iac/terraform.tfvars.example iac/terraform.tfvars |
| 98 | +``` |
| 99 | + |
| 100 | +Edit `iac/terraform.tfvars`. See the [Variables](#variables) section below for the full list of available variables. |
| 101 | + |
| 102 | +### 3. Authenticate to the ACR |
| 103 | + |
| 104 | +Terraform builds and pushes the container image using `ko` during `tf apply`. The Azure CLI must be authenticated to the ACR before running `tf apply` so that ko can push the image. |
| 105 | + |
| 106 | +**New ACR (Terraform will create it):** Targeted apply that will create the ACR registry which you will then log into using the `az acr login` command: |
| 107 | + |
| 108 | +```sh |
| 109 | +cd iac |
| 110 | +tf init |
| 111 | +tf apply -target=azurerm_container_registry.new |
| 112 | +az acr login --name $(tf output -raw new_acr_name) |
| 113 | +``` |
| 114 | + |
| 115 | +**Existing ACR:** |
| 116 | + |
| 117 | +```sh |
| 118 | +az acr login --name <existing_acr_name> |
| 119 | +``` |
| 120 | + |
| 121 | +### 4. Initialize and apply |
| 122 | + |
| 123 | +```sh |
| 124 | +cd iac #If not already there from previous step |
| 125 | +tf init |
| 126 | +tf apply |
| 127 | +``` |
| 128 | + |
| 129 | +Review the plan and confirm. On the first run this takes a few minutes — ko builds and pushes the image before the Container App is created. |
| 130 | + |
| 131 | +### 5. Verify |
| 132 | + |
| 133 | +```sh |
| 134 | +tf output webhook_url # Chainguard subscription sink |
| 135 | +tf output dst_repo # Where images are copied to |
| 136 | +``` |
| 137 | + |
| 138 | +You can also check the Container App logs in the Azure Portal or via: |
| 139 | + |
| 140 | +```sh |
| 141 | +az containerapp logs show \ |
| 142 | + --name ca-cgr-replicator \ |
| 143 | + --resource-group $(tf output -raw resource_group) \ |
| 144 | + --follow |
| 145 | +``` |
| 146 | + |
| 147 | +You should see output similar to: |
| 148 | +```sh |
| 149 | +{"TimeStamp": "2026-04-27T21:07:15.01427", "Log": "Connecting to the container 'replicator'..."} |
| 150 | +{"TimeStamp": "2026-04-27T21:07:15.05442", "Log": "Successfully Connected to container: 'replicator' [Revision: 'ca-cgr-replicator--ralzvog', Replica: 'ca-cgr-replicator--ralzvog-76f4c6f8ff-z9dtl']"} |
| 151 | +``` |
| 152 | + |
| 153 | +Successful replication events will look like: |
| 154 | +```sh |
| 155 | +{"TimeStamp": "2026-04-27T21:49:57.5512087+00:00", "Log": "21:49:57 Copying cgr.dev/.../replication-test-004:latest-dev to acrcgrqiwfg3i1.azurecr.io/cgr/replication-test-004:latest-dev..."} |
| 156 | +{"TimeStamp": "2026-04-27T21:49:57.8207404+00:00", "Log": "21:49:57 Copied!"} |
| 157 | +``` |
| 158 | + |
| 159 | +--- |
| 160 | + |
| 161 | +## Variables |
| 162 | + |
| 163 | +| Variable | Required | Default | Description | |
| 164 | +|---|---|---|---| |
| 165 | +| `chainguard_org` | yes | — | Chainguard organization name (e.g. `your.org.com`) | |
| 166 | +| `location` | no | `eastus` | Azure region (Update providers.tf as well for GovCloud) | |
| 167 | +| `dst_repo_prefix` | no | `cgr` | Path prefix in the ACR for copied images | |
| 168 | +| `ignore_referrers` | no | `true` | Skip copying signature/attestation tags | |
| 169 | +| `verify_signatures` | no | `false` | Verify Chainguard signatures before copying | |
| 170 | +| `existing_acr_name` | no | `""` | Name of an existing ACR to use; leave blank to create one | |
| 171 | +| `existing_acr_resource_group` | no | `""` | Resource group of the existing ACR; required when `existing_acr_name` is set | |
| 172 | +| `create_application` | no | `true` | Create a dedicated Azure AD app to scope Chainguard tokens | |
| 173 | +| `token_scope` | no | `""` | OAuth2 scope passed to GetToken (e.g. `api://<client-id>`); required when `create_application = false` | |
| 174 | +| `claim_match_audience` | no | `""` | Expected `aud` claim in the issued token; required when `create_application = false` | |
| 175 | +| `claim_match_issuer` | no | `""` | Expected `iss` claim; defaults to the v2 tenant-specific Azure AD issuer | |
| 176 | + |
| 177 | +### Token audience security note |
| 178 | + |
| 179 | +When `create_application = true` (the default), Terraform creates a dedicated, permission-free Entra ID application and automatically derives `token_scope` and `claim_match_audience` from it. Because the app has no permissions and its client ID is unique to this deployment, the token cannot be used to access any Azure resource or accepted by any other federated identity service. |
| 180 | + |
| 181 | +If you don't have permissions to create an application, ask your administrator |
| 182 | +to create one for you and provide the client ID with the `token_scope` and |
| 183 | +`claim_match_audience` variables directly. |
| 184 | + |
| 185 | +```hcl |
| 186 | +create_application = false |
| 187 | +token_scope = "api://<client-id>" |
| 188 | +claim_match_audience = "<client-id>" |
| 189 | +``` |
| 190 | + |
| 191 | +If you can't create an application, you may consider using the `api://AzureADTokenExchange` scope. However, be aware that `api://AzureADTokenExchange` is intended for inbound and cross-tenant workload identity federation. Tokens issued for it may be usable across other tenants or for applications that allow federation from other clouds — it is not scoped exclusively to Chainguard. |
| 192 | + |
| 193 | +```hcl |
| 194 | +create_application = false |
| 195 | +token_scope = "api://AzureADTokenExchange" |
| 196 | +claim_match_audience = "fb60f99c-7a34-4190-8149-302f77469936" |
| 197 | +``` |
| 198 | + |
| 199 | +## Outputs |
| 200 | + |
| 201 | +| Output | Description | |
| 202 | +|---|---| |
| 203 | +| `resource_group` | Name of the generated resource group | |
| 204 | +| `new_acr_name` | Name of the newly created ACR; null when reusing an existing one | |
| 205 | +| `acr_login_server` | ACR hostname (e.g. `myregistry.azurecr.io`) | |
| 206 | +| `acr_id` | Full Azure resource ID of the ACR | |
| 207 | +| `dst_repo` | Full destination repo prefix for copied images | |
| 208 | +| `webhook_url` | Public URL of the Container App / Chainguard subscription sink | |
| 209 | +| `managed_identity_id` | Resource ID of `mi-cgr-acr-pushpull` | |
| 210 | +| `chainguard_identity_id` | Chainguard identity ID used by the Container App | |
| 211 | + |
| 212 | +--- |
| 213 | + |
| 214 | +## Teardown |
| 215 | + |
| 216 | +```sh |
| 217 | +cd iac |
| 218 | +tf destroy |
| 219 | +``` |
| 220 | + |
| 221 | +This removes all resources created by this module, including the Chainguard subscription, identity, and role binding. |
0 commit comments