Crear agentes de Azure DevOps de manera dinámica con Azure Logic Apps

Ayer te conté cómo podías generar agentes de Azure DevOps de manera dinámica, utilizando una pipeline del tipo multi-stage. Quizás, la lógica de creación sea más compleja y puede que estés pensando en abstraerla de la propia pipeline. Es por eso que hoy quiero mostrarte otra posible solución utilizando Azure Logic Apps.

Crear un pool de agentes

Al igual que en el ejemplo anterior, necesitas tener creado un pool de agentes antes de comenzar el flujo. Este se puede crear tanto a nivel de organización, como viste en el otro artículo, o bien a nivel de proyecto. Vamos a configurarlo en este caso a nivel de proyecto a través de Project Settings > Agent pools > Add pool.

Proyect Settings > Agent pools > Add pool

Crear workflow en Azure Logic Apps

La creación de los agentes y el lanzamiento de una nueva build la vamos a gestionar desde un flujo de Azure Logic Apps. A modo de demostración vamos a dibujar el siguiente:

He creado una Logic App llamada PrepareAgentsAndBuild, la cual se lanza a través de un trigger del tipo HTTP. Cuando esto ocurre hace una llamada a una Azure Durable Function, que tiene la lógica necesaria para crear un agente en Azure Container Instances:

using System.Collections.Generic;
using Microsoft.Azure.WebJobs;
using Microsoft.Extensions.Logging;
using System.Threading.Tasks;
using System.Net.Http;
using Microsoft.Azure.WebJobs.Extensions.Http;
using Microsoft.Azure.Management.ResourceManager.Fluent;
using Microsoft.Azure.Management.ResourceManager.Fluent.Authentication;
using Microsoft.Azure.Management.ResourceManager.Fluent.Core;
using Microsoft.Azure.Management.Fluent;
using System;

namespace AgentOrchestrator
{
    public static class Durable
    {
        [FunctionName("HttpStart")]
        public static async Task<HttpResponseMessage> HttpStart(
           [HttpTrigger(AuthorizationLevel.Function, "get", "post")]HttpRequestMessage req,
           [OrchestrationClient]DurableOrchestrationClient starter,
           ILogger log)
        {
            // Function input comes from the request content.
            string instanceId = await starter.StartNewAsync("Orchestrator", null);

            log.LogInformation($"Started orchestration with ID = '{instanceId}'.");

            return starter.CreateCheckStatusResponse(req, instanceId);
        }

        [FunctionName("Orchestrator")]
        public static async Task<List<string>> RunOrchestrator(
            [OrchestrationTrigger] DurableOrchestrationContext context)
        {
            var outputs = new List<string>
            {
                await context.CallActivityAsync<string>("CreateAgent", "tokyo")
            };


            return outputs;
        }

        [FunctionName("CreateAgent")]
        public static string CreateAgent([ActivityTrigger] string name, ILogger log)
        {
            //Get environment variables     
            var random = new Random().Next(0, 500);
            var containerGroupName = Environment.GetEnvironmentVariable("CONTAINER_GROUP_NAME");
            var resourceGroupName = $"{Environment.GetEnvironmentVariable("RESOURCE_GROUP_NAME")}-{random}";
            var containerImage = Environment.GetEnvironmentVariable("CONTAINER_IMAGE");

            //Enviroment variables for the container           
            Dictionary<string, string> envVars = new Dictionary<string, string>
            {
                { "VSTS_ACCOUNT", Environment.GetEnvironmentVariable("VSTS_ACCOUNT")},
                { "VSTS_TOKEN", Environment.GetEnvironmentVariable("VSTS_TOKEN") },
                {"VSTS_POOL", Environment.GetEnvironmentVariable("VSTS_POOL")},
                {"VSTS_AGENT", $"{name}-agent"}
            };


            //Create Azure Container Instance
            log.LogInformation($"Create ACI with name {name}.");
            log.LogInformation($"Resource group: ${resourceGroupName}");
            log.LogInformation($"Container image: {containerImage}");

            //az ad sp create-for-rbac -n "AgentOrchestrator" --sdk-auth
            var azureCredentials = new AzureCredentials(new ServicePrincipalLoginInformation
            {
                ClientId = Environment.GetEnvironmentVariable("CLIENT_ID"),
                ClientSecret = Environment.GetEnvironmentVariable("CLIENT_SECRET")
            },
            Environment.GetEnvironmentVariable("TENANT_ID"), AzureEnvironment.AzureGlobalCloud);

            //1. Set context
            var azure = Azure.Authenticate(azureCredentials).WithDefaultSubscription();
            var subscription = azure.GetCurrentSubscription();

            log.LogInformation($"Authenticated with the subscription {subscription.DisplayName} ({subscription.SubscriptionId})");

            //2. Create the resource group
            var resourceGroup = azure.ResourceGroups.Define(resourceGroupName)
                                 .WithRegion(Region.EuropeNorth)
                                 .Create();

            //3. Create a container instance
            log.LogInformation($"Creating {name}");
            var containerGroup = azure.ContainerGroups.Define(containerGroupName)
                                                    .WithRegion(Region.EuropeNorth)
                                                    .WithExistingResourceGroup(resourceGroupName)
                                                    .WithLinux()
                                                    .WithPublicImageRegistryOnly()
                                                    .WithoutVolume()
                                                    .DefineContainerInstance(name)
                                                        .WithImage(containerImage)
                                                        .WithExternalTcpPort(80)
                                                        .WithCpuCoreCount(2)
                                                        .WithMemorySizeInGB(4)
                                                        .WithEnvironmentVariables(envVars)
                                                        .Attach()
                                                    .WithDnsPrefix($"{containerGroupName}-{random}")
                                                    .Create();

            log.LogInformation($"{containerGroup.State}");
            log.LogInformation($"The container {name} was created");

            return resourceGroupName; //It returns the name of the resource group to pass it to the Azure DevOps pipeline
        }       
    }
}

Esta pieza puede ser reemplazada por cualquier llamada a cualquier sitio con cualquier tecnología. Su cometido es simple: debe crear un agente, en algún sitio, asociado al pool pool-on-the-fly. En mi caso, he utilizado la librería Microsoft.Azure.Management.Fluent que me ayuda con la creación de los recursos.

En este ejemplo he puesto las variables que necesito en el App Settings de la función:

Una vez que tienes el agente, ya puedes lanzar una build al pipeline que quieras. En el flujo compruebo que la creación del agente ha sido correcta y lanzo la nueva build a través del conector de Azure DevOps para Logic App. Como mi función devuelve el nombre del resource group que acaba de crear, este se lo paso a la build para su posterior eliminación:

Lanzar una nueva build con el conector de Azure DevOps para Logic App

Lo que hago es coger la salida (output) de la respuesta del conector de mi Azure Function y lo paso en formato JSON, con el nombre agentResourceGroup. Aquí te dejo el fragmento del código del true de la condición:

"Condition": {
                "actions": {
                    "Queue_a_new_build": {
                        "inputs": {
                            "body": {
                                "parameters": "{\"agentResourceGroup\":\"@{body('Call_an_Azure_Durable_Function_to_create_an_agent').output[0]}\"}",
                                "sourceBranch": "master"
                            },
                            "host": {
                                "connection": {
                                    "name": "@parameters('$connections')['visualstudioteamservices']['connectionId']"
                                }
                            },
                            "method": "post",
                            "path": "/@{encodeURIComponent('Conscious')}/_apis/build/builds",
                            "queries": {
                                "account": "returngis",
                                "buildDefId": "44"
                            }
                        },
                        "runAfter": {},
                        "type": "ApiConnection"
                    }
                },

Antes de continuar, puedes probar el flujo completo a través del botón Run.

Ejecución de prueba desde Logic Apps

Debes tener en cuenta que si usas Azure Function, necesitarás utilizar un plan de tipo App Service para que el timeout máximo no sea un problema. En este escenario la creación del contenedor se va a 12 minutos, por lo que en un modelo por consumo se iría de tiempo.

Conectar este workflow con Azure DevOps

El último paso es conectar este flujo de alguna manera con Azure DevOps. Lo habitual sería que cada vez que el código sufriera algún cambio se lanzara este flujo, para que finalmente la pipeline definida se lance. Este evento podemos gestionarlo gracias al apartado Service Hooks donde podemos dar de alta del tipo Web Hook.

Configuralo para el evento de tipo Code pushed.

Configurar el Web Hook para el tipo de evento Code pushed

Además del tipo de evento, puedes elegir incluso un repositorio concreto dentro del proyecto, qué branch e incluso qué grupo de usuarios serán los que provocarán que este web hook se lance.

Lo último que necesitas definir es la acción que se va a producir en base a este evento. Al tratarse de un Web Hook, ya se espera que dicha acción sea una llamada HTTP a algún sitio, en este caso a nuestra Logic App 🙂

Vuelve a la definición de la misma y copia la URL del primer connector y pégala en el campo URL.

Copia la URL generada del trigger de tu Logic App en la acción del Web Hook de Azure DevOps

Por último, recuerda eliminar el agente una vez que finalice tus tareas. Puedes hacerlo dentro de la propia pipeline, utilizando como parámetro el nombre del grupo de recursos que enviamos desde la Logic App:

Eliminamos en la pipeline de Azure DevOps el grupo de recursos recibido como parámetro

En este caso, en la llamada con Azure CLI, debemos utilizar el parámetro –no-wait para evitar que la build falle (ya que te estás cargando el agente que está haciendo esta misma operación, por lo que pierde la conexión). Podrías hacer esta misma operación a través de otro Web Hook, Build completed por ejemplo, o incluso invocando a una Azure Function desde un agente serverless, si quieres esperar por la eliminación completa.

¡Saludos!