Monday, September 21, 2026

Microsoft Agent Framework: Hosted Agents in Microsoft Foundry with .NET

In this post, let's see how we can take an agent written with Microsoft Agent Framework and let Microsoft Foundry host it, as a hosted agent.

A while back I blogged about Microsoft Agent Framework: Agents on Azure Functions with .NET, where we hosted the agent ourselves. And in the last post, Microsoft Agent Framework: Prompt Agents in Microsoft Foundry with .NET, we looked at prompt agents, where Foundry stores the agent definition but our code still runs the tools.

A hosted agent is the other end of that. We write the agent in code as usual, and Foundry runs it for us as a container with a managed endpoint, its own Microsoft Entra identity, scaling, versions, and a playground. Our tools run inside that container, not in our application.

The agent

The agent itself is the same weekend planner from the earlier posts. The only difference is that instead of running it, we serve it over the Responses protocol.
// Foundry injects these when the agent is hosted, locally they come from launchSettings.json
string endpoint = Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")!;
string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL_DEPLOYMENT_NAME")!;

// Locally this is the developer, in Foundry it is the agent's own managed identity
var credential = new DefaultAzureCredential();

AIAgent agent = new AIProjectClient(new Uri(endpoint), credential)
    .AsAIAgent(
        model: deploymentName,
        name: "weekend-planner",
        instructions: """
            You help users plan their weekends and choose the best activities for the given weather.
            If an activity would be unpleasant in weather, don't suggest it.
            Include date of the weekend in response.
            """,
        tools: [
            AIFunctionFactory.Create(GetWeather, nameof(GetWeather)),
            AIFunctionFactory.Create(GetActivities, nameof(GetActivities)),
            AIFunctionFactory.Create(GetCurrentDate, nameof(GetCurrentDate))
        ]);

// Serve the agent over the Responses protocol, on port 8088 by default
AgentHostBuilder builder = AgentHost.CreateBuilder(args);
builder.Services.AddFoundryResponses(agent);
builder.RegisterProtocol("responses", endpoints => endpoints.MapFoundryResponses());

AgentHostApp app = builder.Build();
app.Run();
A couple of things to note. DefaultAzureCredential is used on purpose here, so the same code works locally as ourselves, and in Foundry as the agent's identity.

The project targets net10.0, because that's the runtime Foundry hosted agents run on (latest package as of today, it will change).
<PropertyGroup>
  <TargetFramework>net10.0</TargetFramework>
</PropertyGroup>

<ItemGroup>
  <PackageReference Include="Microsoft.Agents.AI.Foundry.Hosting" Version="1.22.0-preview.260918.1" />
</ItemGroup>

Running it locally first

Before deploying anything, the agent is just a web app. dotnet run and it listens on port 8088.
@host = http://localhost:8088

### Invoke the agent running locally
POST {{host}}/responses
Content-Type: application/json

{
  "input": "What should I do this weekend in Auckland?",
  "stream": false
}
The response contains the function_call items for the tools and the final message, so we can see the agent working before it goes anywhere near Azure.
Running Locally

Deploying

Deployment is done with Azure Developer CLI and the Foundry extension.
azd extension install microsoft.foundry
azd auth login
Then, from the agent's folder, initialize it against an existing Foundry project. The project id is the ARM resource id of the Foundry project.
azd ai agent init `
--no-prompt ` --src . ` --agent-name weekend-planner ` --project-id "/subscriptions/<subscription>/resourceGroups/<resource-group>/providers/Microsoft.CognitiveServices/accounts/<account>/projects/<project>" ` --deploy-mode code ` --runtime dotnet_10 ` --entry-point HostedAgent.dll ` --model-deployment <DeploymentId> ` --protocol responses ` --environment weekend-planner
That writes an azure.yaml, an .agentignore, and a .gitignore that keeps the .azure environment folder out of source control.
# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json

name: 07-hosted-agent
services:
    ai-project:
        host: azure.ai.project
        endpoint: ${FOUNDRY_PROJECT_ENDPOINT}
    weekend-planner:
        project: .
        host: azure.ai.agent
        language: csharp
        uses:
            - ai-project
        env:
            AZURE_AI_MODEL_DEPLOYMENT_NAME: ${AZURE_AI_MODEL_DEPLOYMENT_NAME}
        codeConfiguration:
            dependencyResolution: remote_build
            entryPoint: HostedAgent.dll
            runtime: dotnet_10
        container:
            resources:
                cpu: "0.5"
                memory: 1Gi
        kind: hosted
        name: weekend-planner
        protocols:
            - protocol: responses
              version: 2.0.0
infra:
    provider: microsoft.foundry

One thing in there worth a second look is version: 2.0.0 under protocols. The SDK and REST examples in the docs still show 1.0.0, and the manifest and the hosting package have to agree, so copying the version out of those examples while referencing Microsoft.Agents.AI.Foundry.Hosting 1.22.0 will not work.

And then deploy. Note that there is no azd provision here, because the Foundry project and the model deployment already exist.
azd deploy
azd deploy
One thing worth knowing about remote_build: only the agent's own folder is zipped and uploaded, and Foundry restores and builds it there. So the project needs to stand on its own. If your agent references another project in your solution, switch to dependencyResolution: bundled instead, where the zip holds dotnet publish output rather than sources. With azd you can see it doing the publish in the deploy output.

Invoking the deployed agent

azd ai agent invoke weekend-planner "What should I do this weekend in Auckland?"
Invoking the deployed agent
Note the tools ran inside the container this time, not in our process. The agent also shows up in the Foundry portal with a playground, and that is the part worth trying. With the prompt agent it stopped at the first tool call and asked us to enter the function output as JSON, because there was nothing up there to run the tools. Here they are in the container, so the playground answers on its own.
Invoking in Foundry Playground
Every azd deploy creates a new version, and container logs are available too.
azd ai agent show weekend-planner
azd ai agent monitor weekend-planner --session-id <session-id>

Calling it from code

That is fine for a quick check, but an application would call the agent from code.
string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")!;
const string agentName = "weekend-planner";

// For local development, using AzureCliCredential
var credential = new AzureCliCredential();

AIProjectClient projectClient = new(new Uri(endpoint), credential);

// A hosted agent is called through its own endpoint, not the project one
ProjectResponsesClient responsesClient = projectClient.ProjectOpenAIClient
    .GetProjectResponsesClientForAgentEndpoint(agentName);

string userInput = "What should I do this weekend in Auckland?";

Console.WriteLine("--- User ---");
Console.WriteLine(userInput);

ResponseResult response = responsesClient.CreateResponse(userInput);

Console.WriteLine();
Console.WriteLine("--- Agent Response ---");
Console.WriteLine(response.GetOutputText());

Cleaning up

Worth knowing that azd down is not the thing to reach for here. That removes the environment azd provisioned, and we didn't provision one, we deployed into a project that already existed. What we want is to delete the agent itself, from the portal or over REST.
@agent = weekend-planner

# For the deployed agent, get a token with:
# az account get-access-token --scope https://ai.azure.com/.default --query accessToken --output tsv
@endpoint = https://<resource>.services.ai.azure.com/api/projects/<projectId>
@token = <token>

### Delete the agent, its versions, and any active sessions
DELETE {{endpoint}}/agents/{{agent}}?api-version=v1&force=true
Authorization: Bearer {{token}}
Deleting an agent removes all of its versions and terminates any active sessions, and it can't be undone. The force=true is what cascades to the sessions, without it a deletion with sessions still open comes back as a 409.

So where does this leave us

With prompt agents, Foundry holds the definition and our application runs the tools. With hosted agents, Foundry runs the whole thing, code and tools, and gives us an endpoint, an identity, versions and a playground. And if we want to host it ourselves, Azure Functions is still there, which is what the earlier posts covered.

The complete sample is here.

More read:

Hope this helps.

Happy Coding.

Regards,
Jaliya

Saturday, September 19, 2026

Microsoft Agent Framework: Prompt Agents in Microsoft Foundry with .NET

In this post, let's have a look at prompt agents in Microsoft Foundry, and how we can create and run them with Microsoft Agent Framework.

Some time back I blogged about Getting Started with Microsoft Agent Framework 1.0.0 in .NET. There, the agent was created in code, it lived inside the process, and when the app stopped, so did the agent. Foundry was only providing the model. That's usually called an ephemeral agent.

A prompt agent is the other option and it's a Foundry feature rather than an Agent Framework one. The agent definition, that's the model, the instructions and the tool schemas, is stored in Foundry as a versioned resource. Your code doesn't own it anymore, it just runs it. It shows up in the Foundry portal, it has a playground, every update creates a new version, and any application can run it by name. Agent Framework is how we run it, which is the rest of this post.

One thing that stays the same is tools. Foundry stores only the tool schemas. Your application still executes the tools.

Setup

We need the following packages (latest as of today, it will change).
<PackageReference Include="Azure.AI.Projects.Agents" Version="3.0.0-beta.2" />
<PackageReference Include="Microsoft.Agents.AI.Foundry" Version="1.22.0-preview.260918.1" />

Creating the agent

The tools are the same three methods from the getting started post, plain C# methods with [Description] attributes.
// ResponseTool, used to declare the agent's function tools, is still experimental
#pragma warning disable OPENAI001

// Agent administration lives under the project, so this needs the project endpoint,
// not the resource one the earlier samples use
string endpoint = Environment.GetEnvironmentVariable("AZURE_AI_PROJECT_ENDPOINT")!;
string deploymentName = Environment.GetEnvironmentVariable("AZURE_AI_MODEL")!;
const string agentName = "weekend-planner-prompt";

// For local development, using AzureCliCredential
var credential = new AzureCliCredential();

AIProjectClient projectClient = new(new Uri(endpoint), credential);

AIFunction[] tools =
[
    AIFunctionFactory.Create(GetWeather, nameof(GetWeather)),
    AIFunctionFactory.Create(GetActivities, nameof(GetActivities)),
    AIFunctionFactory.Create(GetCurrentDate, nameof(GetCurrentDate))
];

// Foundry stores the tool schemas, this app executes the tools
DeclarativeAgentDefinition definition = new(deploymentName)
{
    Instructions = """
        You help users plan their weekends and choose the best activities for the given weather.
        If an activity would be unpleasant in weather, don't suggest it.
        Include date of the weekend in response.
        """
};

foreach (AIFunction tool in tools)
{
    definition.Tools.Add(ResponseTool.CreateFunctionTool(
        tool.Name,
        BinaryData.FromString(tool.JsonSchema.GetRawText()),
        strictModeEnabled: false,
        functionDescription: tool.Description));
}

// Create the agent in Foundry, every run creates a new version of it
ProjectsAgentVersion agentVersion = 
await projectClient.AgentAdministrationClient.CreateAgentVersionAsync( agentName, new ProjectsAgentVersionCreationOptions(definition)); Console.WriteLine($"[Agent] Created '{agentVersion.Name}' version '{agentVersion.Version}'.");
Two things to note here. The tool schema comes straight from AIFunction.JsonSchema, so the [Description] attributes we already write for Agent Framework are what ends up stored in Foundry. And ResponseTool is still experimental, so we need to suppress OPENAI001.

Creating and deleting an agent is the one part that isn't Agent Framework. Every path in the docs goes through AgentAdministrationClient from Azure.AI.Projects.Agents, and that's what we use here.

Once this is executed, we can see our Prompt Agent in Microsoft Foundry.
Prompt Agent

Running the agent in Foundry Playground

The agent is in the project now, with a playground, so that is the first thing to try. And it stops at the first tool call and asks you to enter function output as JSON.
Prompt Agent in Playground
That is the whole point of a prompt agent. Foundry has the tool schemas, it does not have the tools, so the playground has nothing to run and asks you to play the part of the application. Paste what our code would have returned, "2026-09-26" for GetCurrentDate, and it carries on and asks for the next one.

Which is a good reminder that the playground is just another client here. To get actual answers, something has to run the tools, and that is what we do next.

Running the agent

And this is the nice part. Running a prompt agent is exactly the same as running the in-code agent, we just hand over the agent version instead of a model and instructions.
string userInput = "What should I do this weekend in Auckland?";

// Run the version that was just created, the tools are passed in from here
AIAgent agent = projectClient.AsAIAgent(agentVersion, tools: [.. tools]);
AgentResponse response = await agent.RunAsync(userInput);

Console.WriteLine();
Console.WriteLine("--- Agent Response ---");
Console.WriteLine(response.ToString());
The instructions aren't in the run call anymore, they are in Foundry. The tools are, because Foundry only knows their schemas.
[Agent] Created 'weekend-planner-prompt' version '1'.
[Tool] Getting current date
[Tool] Getting weather for 'Auckland'.
[Tool] Getting activities for 'Auckland' on '2026-09-26'.
[Tool] Getting activities for 'Auckland' on '2026-09-27'.

--- Agent Response ---
Weekend in Auckland: Saturday 26 Sep 2026 - Sunday 27 Sep 2026.
Forecast: ~18C and rainy, so indoor plans will work better.
// Omitted for brevity

Running an agent someone else created

Because the agent is a resource now, we don't need to be the one who created it. We can ask Foundry for it by name and run the latest version.
// Or resolve an existing agent by name and run its latest version, no instructions needed here
ProjectsAgentRecord agentRecord = 
await projectClient.AgentAdministrationClient.GetAgentAsync(agentName); AIAgent latestAgent = projectClient.AsAIAgent(agentRecord, tools: [.. tools]);
There is no model, no instructions and no version pinned in this code. All it provides is the tool implementations and the input. Someone can update the instructions in the portal, and the next run picks them up without a code change or a deployment.

Finally, since every run of the sample creates a version, let's clean up after ourselves.
// Delete the agent, remove this to keep it in the project
await projectClient.AgentAdministrationClient.DeleteAgentAsync(agentName);

So which one to use

If the agent ships with your application and nothing else needs to call it, creating it in code is simpler, there is nothing to create, version or delete. If you want the agent to be a thing in its own right, something with versions, a playground, and other applications running it by name, then a prompt agent is what you want.

The complete sample is here.

More read:
   Microsoft Agent Framework: Microsoft Foundry

Hope this helps.

Happy Coding.

Regards,
Jaliya

Tuesday, September 15, 2026

C#: Using TimeProvider and FakeTimeProvider

In this post, let's have a look at TimeProvider in C#

I have seen this abstract class around for a while (it was introduced with .NET 8), but I haven't really used it up until very recently. I just love it.

Before TimeProvider, whenever I needed to control time in tests, I had to write my own abstraction, something like IDateTimeProvider wrapping DateTime.UtcNowTimeProvider is the built-in abstraction for that. It's in the System namespace and exposes members like GetUtcNow(), GetLocalNow(), GetTimestamp() and CreateTimer().

To fake the implementation like in tests, we can install the following NuGet package.
dotnet add package Microsoft.Extensions.TimeProvider.Testing
Now let's see the code.
using Microsoft.Extensions.Time.Testing;

TimeProvider timeProvider = TimeProvider.System;
Console.WriteLine($"System date: {timeProvider.GetUtcNow():yyyy-MM-dd}");

FakeTimeProvider fakeTimeProvider = new(DateTimeOffset.Parse("2500-01-01Z")); 
Console.WriteLine($"Fake date: {fakeTimeProvider.GetUtcNow():yyyy-MM-dd}");
And the output:
System date: 2026-09-15
Fake date: 2500-01-01
TimeProvider.System is the concrete implementation that gives the actual system time. FakeTimeProvider derives from TimeProvider, so it can be passed anywhere a TimeProvider is expected, and it returns whatever time we set it to.

So in the application code, I can depend on TimeProvider and register TimeProvider.System, and in tests pass in a FakeTimeProviderFakeTimeProvider also has methods like Advance(TimeSpan) and SetUtcNow(DateTimeOffset) to move time forward, which also fires any timers and completes any Task.Delay created with that provider.

More read:

Hope this helps.

Happy Coding.

Regards,
Jaliya

Friday, September 11, 2026

EF Core 11.0: What's New with Migrations

.NET 11 Release Candidate 1 is out, and that means we are getting really close to the finish line. Next month we should get RC2, and then in November the GA release at .NET Conf 2026. You can read more about RC1 here: Announcing .NET 11 Release Candidate 1.

In this post, let's have a look at some of the nice improvements that are coming to EF Core 11.0 Migrations. There are quite a few of them, but I am going to focus on the following three.
  • Excluding foreign key constraints from migrations
  • Create and apply migrations in one step
  • Connection and offline options for migrations remove
First, the project file. I am using the latest RC as of today, it will change.
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net11.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="11.0.0-rc.1.26425.128">
      <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
      <PrivateAssets>all</PrivateAssets>
    </PackageReference>
    <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="11.0.0-rc.1.26425.128" />
  </ItemGroup>

</Project>

And make sure you have the matching version of the dotnet-ef tool.
dotnet tool update -g dotnet-ef --version 11.0.0-rc.1.26425.128

Excluding foreign key constraints from migrations


Say you are working with a legacy database that doesn't have foreign key constraints, or you have some data synchronization process where referential integrity constraints get in the way of the synchronization order. You still want EF Core to know about the relationship, but you don't want migrations to create the constraint in the database.

With EF Core 11.0, we now have ExcludeForeignKeyFromMigrations() for exactly that. Consider the following entities.
public class Customer
{
    public int Id { get; set; }

    public string Name { get; set; }
}

public class Order
{
    public int Id { get; set; }

    public string OrderNumber { get; set; }

    public Customer Customer { get; set; }

    public int CustomerId { get; set; }
}
And here is the DbContext. The relationship is configured as usual, and we just call ExcludeForeignKeyFromMigrations() at the end.
public class MyDbContext : DbContext
{
    public DbSet<Customer> Customers { get; set; }

    public DbSet<Order> Orders { get; set; }

    protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
    {
        optionsBuilder
            .UseSqlServer(@"<ConnectionString>")
            .LogTo(Console.WriteLine, LogLevel.Information);
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Order>()
            .HasOne(x => x.Customer)
            .WithMany()
            .HasForeignKey(x => x.CustomerId)
            .ExcludeForeignKeyFromMigrations();
    }
}
When we add a migration, the Orders table no longer has a ForeignKey constraint. Note that an index is still created on the foreign key column.
protected override void Up(MigrationBuilder migrationBuilder)
{
    migrationBuilder.CreateTable(
        name: "Customers",
        columns: table => new
        {
            Id = table.Column<int>(type: "int", nullable: false)
                .Annotation("SqlServer:Identity", "1, 1"),
            Name = table.Column<string>(type: "nvarchar(max)", nullable: false)
        },
        constraints: table =>
        {
            table.PrimaryKey("PK_Customers", x => x.Id);
        });

    migrationBuilder.CreateTable(
        name: "Orders",
        columns: table => new
        {
            Id = table.Column<int>(type: "int", nullable: false)
                .Annotation("SqlServer:Identity", "1, 1"),
            OrderNumber = table.Column<string>(type: "nvarchar(max)", nullable: false),
            CustomerId = table.Column<int>(type: "int", nullable: false)
        },
        constraints: table =>
        {
            table.PrimaryKey("PK_Orders", x => x.Id);
        });

    migrationBuilder.CreateIndex(
        name: "IX_Orders_CustomerId",
        table: "Orders",
        column: "CustomerId");
}
The relationship itself is fully supported in EF Core for queries, change tracking etc. Only the constraint in the database is suppressed.
using var context = new MyDbContext();

Customer customer = new() 
{
Name = "John Doe"
}; await context.Customers.AddAsync(customer); await context.Orders.AddRangeAsync( new Order { OrderNumber = "ORD-001", Customer = customer }, new Order { OrderNumber = "ORD-002", Customer = customer }); await context.SaveChangesAsync(); foreach (Order order in await context.Orders .Include(x => x.Customer) .ToListAsync()) { Console.WriteLine($"Order: '{order.OrderNumber}', Customer: '{order.Customer.Name}'."); } // Query //SELECT[o].[Id], [o].[CustomerId], [o].[OrderNumber], [c].[Id], [c].[Name] //FROM[Orders] AS[o] //INNER JOIN[Customers] AS [c] ON[o].[CustomerId] = [c].[Id] // Output // Order: 'ORD-001', Customer: 'John Doe'. // Order: 'ORD-002', Customer: 'John Doe'

Create and apply migrations in one step


Up until now, create and apply migrations has always been two commands: dotnet ef migrations add followed by dotnet ef database update. With EF Core 11.0, dotnet ef database update has a new --add option that scaffolds the migration, compiles it at runtime using Roslyn, and applies it to the database, all in one go.
dotnet ef database update InitialCreate --add
And the output (trimmed):
Build started...
Build succeeded.
Creating and applying migration 'InitialCreate'.
...
Applying migration '20260911004844_InitialCreate'.
...
      CREATE TABLE [Customers] (
          [Id] int NOT NULL IDENTITY,
          [Name] nvarchar(max) NOT NULL,
          CONSTRAINT [PK_Customers] PRIMARY KEY ([Id])
      );
...
      CREATE TABLE [Orders] (
          [Id] int NOT NULL IDENTITY,
          [OrderNumber] nvarchar(max) NOT NULL,
          [CustomerId] int NOT NULL,
          CONSTRAINT [PK_Orders] PRIMARY KEY ([Id])
      );
...
      CREATE INDEX [IX_Orders_CustomerId] ON [Orders] ([CustomerId]);
...
Migration '20260911004844_InitialCreate' was successfully created and applied.
The migration files are still written to disk, so you can commit them to source control as usual. All the options you would use with dotnet ef migrations add are supported as well.

If you are using the Package Manager Console, you can use the -Add parameter.
Update-Database -Migration InitialCreate -Add

Connection and offline options for migrations remove


dotnet ef migrations remove and dotnet ef database drop now accept a --connection option, so we can pass in the connection string directly instead of relying on whatever is configured in the DbContext.
# Remove migration using a specific connection
dotnet ef migrations remove --connection "<Connection String>"

# Drop a specific database using a connection string
dotnet ef database drop --connection "<Connection String>" --force
And migrations remove has a new --offline option. Previously, migrations remove always connected to the database to check whether the migration had been applied. With --offline, that check is skipped entirely, which is useful when the database isn't reachable or when you know the migration hasn't been applied.
dotnet ef migrations add InitialCreate
dotnet ef migrations remove --offline
And the output:
Build started...
Build succeeded.
Removing migration '20260911004851_AddOrderDate'.
Reverting the model snapshot.
Done.
Note that --offline and --force can't be used together. --force reverts the migration if it has been applied, and to know that, it needs a database connection.
# The --offline and --force options cannot be used together.
dotnet ef migrations remove --offline --force
In the Package Manager Console, use the -Connection and -Offline parameters.
Remove-Migration -Connection "<Connection String>"
Remove-Migration -Offline
Drop-Database -Connection "<Connection String>" -Force
There are more migration improvements in EF Core 11.0 like the latest migration ID being recorded in the model snapshot, a configuration file for dotnet ef, -NoBuild for PMC commands, and wildcard context support. Do check them out.

More read:

Hope this helps.

Happy Coding.

Regards,
Jaliya

Monday, August 24, 2026

Azure DevOps: Copy Azure Container Registry (ACR) Images to an External Tenant's ACR

Consider a scenario where an App Service running in an external tenant needs to pull an image from our own ACR. The lazy way is to share our ACR credentials (username/password) with the external party, so they can set DOCKER_REGISTRY_SERVER_USERNAME and DOCKER_REGISTRY_SERVER_PASSWORD.

But obviously that's not something we should be doing. We can't be handing our own passwords to anyone, and we shouldn't be using passwords or secrets in the first place.

You might think the fix is for them to give their App Service a managed identity and grant it AcrPull on our ACR. That doesn't work across tenants. A managed identity only exists in its own tenant.

What does work is copying the image into their ACR first. Then their App Service pulls from their own registry, in their own tenant, with their own managed identity, and that's just the ordinary same-tenant case.

So in this post, let's see how we can copy Azure Container Registry (ACR) images to an external tenant's ACR in Azure DevOps..

Prerequisites
  • Both registries need public network access. A registry on Selected networks still counts as enabled, but it also has to allow trusted Azure services to bypass the network, which is on by default.
  • Both registries accept Entra ARM tokens. You can test it using the following script.
$TENANT_ID = "<TENANT_ID>"
$SUBSCRIPTION_ID = "<SUBSCRIPTION_ID>"

$ACR_RESOURCE_GROUP_NAME = "<ACR_RESOURCE_GROUP_NAME>"
$ACR_NAME = "<ACR_NAME>"
$ACR_RESOURCE_ID = "/subscriptions/$SUBSCRIPTION_ID/resourceGroups/$ACR_RESOURCE_GROUP_NAME/providers/Microsoft.ContainerRegistry/registries/$ACR_NAME"

# Login to tenant and set the subscription
az login `
  --tenant $TENANT_ID

az account set `
  --subscription $SUBSCRIPTION_ID

# Confirm the registry accepts Entra ARM tokens
az rest `
  --method get `
  --url "https://management.azure.com$ACR_RESOURCE_ID`?api-version=2023-01-01-preview" `
  --query "properties.policies.azureADAuthenticationAsArmPolicy.status" `
  --output tsv

The concept

  • Two Azure DevOps service connections, both in our project, each federated to a different managed identity in a different tenant. No secret in either one, just a trust.
  • Ours/Source: A user-assigned managed identity with AcrPull on our ACR, and a service connection federated to it. We add the federated credential to that identity ourselves, because it lives in our tenant.
    • If your source registry is ABAC-enabled, grant Container Registry Repository Reader instead. AcrPull isn't honoured there.
  • Theirs/External:  A user-assigned managed identity with Container Registry Data Importer and Data Reader on their ACR. We create the second service connection pointing at their tenant, and send them the Issuer and Subject it generates. They add the federated credential to their identity.
  • At deploy time the pipeline mints a short-lived token using the first connection, then calls az acr import with the second connection.

az acr import `
  --name "<THEIR_ACR_NAME>" `
  --resource-group "<THEIR_ACR_RESOURCE_GROUP_NAME>" `
  --source "<OUR_ACR_LOGIN_SERVER>/<IMAGE_NAME>:<IMAGE_TAG>" `
  --image "<IMAGE_NAME>:<IMAGE_TAG>" `
  --password "<ACCESS_TOKEN_CREATED_USING_OUR_SERVICE_CONNECTION>"

    • Note there is no --username. The access token is only accepted as a lone --password, and adding a username turns it into a basic auth pair.
    • az acr import authenticates the source and the target separately. 
    • The call goes to their registry as an identity in their tenant, and our registry is named in the same request with its own credential alongside. 
    • Their registry then pulls the image straight from ours, server to server, so the build agent never downloads it.

Now let's see this in action.

Some variables.

$SOURCE_TENANT_ID = "<OUR_TENANT_ID>"
$SOURCE_SUBSCRIPTION_ID = "<OUR_SUBSCRIPTION_ID>"

# Identity
$MIRROR_IDENTITY_NAME = "<MANAGED_IDENTITY_NAME>"
$MIRROR_IDENTITY_RESOURCE_GROUP_NAME = "<MANAGED_IDENTITY_RESOURCE_GROUP_NAME>"
$MIRROR_IDENTITY_LOCATION = "<LOCATION>"

# Source ACR
$SOURCE_ACR_RESOURCE_GROUP_NAME = "<ACR_RESOURCE_GROUP>"
$SOURCE_ACR_NAME = "<ACR_NAME>"
$SOURCE_ACR_RESOURCE_ID = "/subscriptions/$SOURCE_SUBSCRIPTION_ID/resourceGroups/$SOURCE_ACR_RESOURCE_GROUP_NAME/providers/Microsoft.ContainerRegistry/registries/$SOURCE_ACR_NAME"

First step is creating a Managed Identity.

# Login to the tenant and set subscription
az login `
  --tenant $SOURCE_TENANT_ID

az account set `
  --subscription $SOURCE_SUBSCRIPTION_ID

# Create a user-assigned managed identity
az identity create `
  --name $MIRROR_IDENTITY_NAME `
  --resource-group $MIRROR_IDENTITY_RESOURCE_GROUP_NAME `
  --subscription $SOURCE_SUBSCRIPTION_ID `
  --location $MIRROR_IDENTITY_LOCATION

$sourceIdentity = az identity show `
  --name $MIRROR_IDENTITY_NAME `
  --resource-group $MIRROR_IDENTITY_RESOURCE_GROUP_NAME `
  --subscription $SOURCE_SUBSCRIPTION_ID | ConvertFrom-Json

$MIRROR_IDENTITY_PRINCIPAL_ID = $sourceIdentity.principalId
$MIRROR_IDENTITY_CLIENT_ID = $sourceIdentity.clientId

Now let's grant the MI AcrPull OR Container Registry Repository Reader on our ACR.

# Assign the AcrPull OR Container Registry Repository Reader role to the managed identity
az role assignment create `
  --assignee-object-id $MIRROR_IDENTITY_PRINCIPAL_ID `
  --assignee-principal-type ServicePrincipal `
  --role "<AcrPull OR Container Registry Repository Reader>" `
  --scope $SOURCE_ACR_RESOURCE_ID

Now let's create the source Service Connection.

New Service Connection: Azure Resource Manager

Note: 

  • Identity Type: App registration or managed identity (manual)
  • Credential: Workload identity federation
  • Directory (tenant) ID: Our Tenant ID.

New Service Connection App Registration Details
Here, the Application (client) ID is the MIRROR_IDENTITY_CLIENT_ID.

Now before clicking on Verify and save, run the following using displayed Issuer and Subject identifier to create federated credentials.

# Create federated credential for the managed identity
az identity federated-credential create `
  --name azure-devops-mirror-source `
  --identity-name $MIRROR_IDENTITY_NAME `
  --resource-group $MIRROR_IDENTITY_RESOURCE_GROUP_NAME `
  --subscription $SOURCE_SUBSCRIPTION_ID `
  --issuer "<Issuer>" `
  --subject "<Subject identifier>" `
  --audiences "api://AzureADTokenExchange"
After this is executed, wait for a few seconds and then click on Verify and save. It should be verified and saved successfully.

Now we need to configure the external side. Almost all of the steps are exact same as above with different values. On external side,
  • The managed identity needs to be granted role: Container Registry Data Importer and Data Reader on their ACR.
  • To create the second service connection, the only thing we need from them up front is their Tenant ID. Note the Directory (tenant) ID is their tenant, not ours.
    • Fill in the tenant id, move to the next step, and Azure DevOps generates the Issuer and Subject identifier. Copy both and select Keep as draft
    • Send them the Issuer and Subject identifier so they can create the federated credential on their managed identity. When they confirm, they need to send back the following
      • Subscription ID
      • Subscription Name
      • ACR Name
      • ACR Resource Group Name
      • Client ID of their Managed Identity
    • Once the information is received, go back to the draft connection, fill those in, then Finish setup and Verify and save.
One caution: Tenant ID cannot be edited after the connection is created. If it is wrong, you have to delete the connection and create a new one, and therefore a new Subject identifier, so the federated credential has to be recreated as well.

At this point, second service connection should be verified and saved successfully.

Now the moment of truth. We can create a simple pipeline to test the az acr import end-to-end.
trigger: none
pr: none

parameters:
  - name: sourceImageName
    displayName: Source image, repository:tag with no host
    type: string
    default: <IMAGE_NAME>:<IMAGE_TAG>

pool:
  vmImage: ubuntu-latest

variables:
  sourceServiceConnection: <SOURCE_SERVICE_CONNECTION_NAME>
  targetServiceConnection: <EXTERNAL_SERVICE_CONNECTION_NAME>
  sourceAcrLoginServer: <SOURCE_ACR_NAME>.azurecr.io
  targetAcrName: <TARGET_ACR_NAME>
  targetAcrResourceGroup: <TARGET_ACR_RESOURCE_GROUP>

steps:
  - task: AzureCLI@2
    displayName: Get source ACR read token
    inputs:
      azureSubscription: $(sourceServiceConnection)
      scriptType: pscore
      scriptLocation: inlineScript
      inlineScript: |
        az account show `
          --query "{tenant:tenantId, subscription:name, id:id, principal:user.name}" `
          --output table

        $SOURCE_ACCESS_TOKEN = (az account get-access-token `
          --query accessToken `
          --output tsv).Trim()

        Write-Host "##vso[task.setvariable variable=sourceAccessToken;issecret=true]$SOURCE_ACCESS_TOKEN"

  - task: AzureCLI@2
    displayName: Import image into target ACR
    inputs:
      azureSubscription: $(targetServiceConnection)
      scriptType: pscore
      scriptLocation: inlineScript
      inlineScript: |
        az account show `
          --query "{tenant:tenantId, subscription:name, id:id, principal:user.name}" `
          --output table

        az acr import `
          --name "$(targetAcrName)" `
          --resource-group "$(targetAcrResourceGroup)" `
          --source "$(sourceAcrLoginServer)/${{ parameters.sourceImageName }}" `
          --image "${{ parameters.sourceImageName }}" `
          --password "$(sourceAccessToken)" `
          --force

  - task: AzureCLI@2
    displayName: Confirm the tag landed
    inputs:
      azureSubscription: $(targetServiceConnection)
      scriptType: pscore
      scriptLocation: inlineScript
      inlineScript: |
        $IMAGE_NAME = "${{ parameters.sourceImageName }}"

        az acr repository show-tags `
          --name "$(targetAcrName)" `
          --repository ($IMAGE_NAME.Split(":")[0]) `
          --output table
And when you run this, it should do the import.
Pipeline Run
Hope this helps.

Happy Coding.

Regards,
Jaliya