{"id":2571,"date":"2025-12-13T19:53:00","date_gmt":"2025-12-13T19:53:00","guid":{"rendered":"https:\/\/www.mainmind.com\/blog\/?p=2571"},"modified":"2026-05-04T20:30:11","modified_gmt":"2026-05-04T20:30:11","slug":"migrar-azure-devops-agent-self-hosted-lecciones","status":"publish","type":"post","link":"https:\/\/www.mainmind.com\/blog\/migrar-azure-devops-agent-self-hosted-lecciones\/","title":{"rendered":"Migrando un pipeline de Azure DevOps a un agent self-hosted: cuatro lecciones que la nube te oculta"},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">Al pasar un pipeline Microsoft-hosted a un agent self-hosted en un NAS, dos tests que llevaban meses verdes empezaron a parpadear por jitter del scheduler. La migraci\u00f3n funcion\u00f3, pero por el camino salieron a la luz cuatro problemas latentes que el hardware dedicado de Microsoft enmascaraba. Este post los documenta con ejemplos para que no te cuesten lo mismo.<\/p>\n\n\n\n<!--more-->\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">El detonante: el free tier ya no llegaba<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Si tu organizaci\u00f3n est\u00e1 en Azure DevOps con plan gratuito, tienes <strong>un \u00fanico parallel job<\/strong> a tu disposici\u00f3n. No importa si lo eliges Microsoft-hosted (en la nube de MS) o self-hosted (en tu infra): es <strong>uno y solo uno<\/strong>. Si dos pipelines se disparan simult\u00e1neamente, uno espera al otro en cola.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Para muchos equipos peque\u00f1os esto basta \u2014 pero el d\u00eda que pides incrementar el grant y tarda en llegar, o cuando los tiempos de cola empiezan a comerse parte de la jornada, la opci\u00f3n m\u00e1s pr\u00e1ctica deja de ser \u00abesperar\u00bb y pasa a ser \u00abmonto un agent en una m\u00e1quina que ya tengo\u00bb.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">En nuestro caso esa m\u00e1quina era un <strong>NAS de <\/strong>QNAP con suficientes recursos libres para correr Docker. Suficiente para un pipeline de build + deploy de una API .NET y su panel web.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">El plan inicial parec\u00eda corto:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Levantar un agent self-hosted en el NAS, registrarlo contra Azure DevOps.<\/li>\n\n\n\n<li>Cambiar el <code>pool<\/code> de los YAML.<\/li>\n\n\n\n<li>Pushear y mirar.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">Spoiler: cada uno de esos pasos abri\u00f3 una grieta.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">El paso 1 (sin sorpresas): cambiar el pool<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">El cambio en el YAML es de manual. Donde antes pon\u00eda:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: yaml; title: ; notranslate\" title=\"\">\npool:\n  vmImage: &#039;ubuntu-latest&#039;\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\">ahora pone:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: yaml; title: ; notranslate\" title=\"\">\npool:\n  name: OFI-NAS-Linux\n  demands:\n    - dotnet.sdk -equals 8.0\n    - azure.cli -equals true\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\">Dos cosas que conviene hacer aqu\u00ed y muchos pipelines no:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>demands<\/code><\/strong> declara qu\u00e9 capabilities tiene que ofrecer el agent para que el job se le asigne. Como el pool solo tiene un agent, parece superfluo \u2014 pero el d\u00eda que a\u00f1adas un segundo (por failover, por especializaci\u00f3n, por lo que sea) los demands son lo que evita que un job que requiere <code>azure.cli<\/code> aterrice en un agent que no la tiene.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><code>batch: true<\/code><\/strong> en el <code>trigger<\/code> agrupa pushes consecutivos cuando ya hay un run en curso, en lugar de encolar uno por commit. Con un solo parallel job es la diferencia entre tener cinco runs encolados a las 15:00 o tener uno solo que entra cuando termine el actual:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: yaml; title: ; notranslate\" title=\"\">\ntrigger:\n  batch: true\n  branches:\n    include:\n      - main\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\">Tambi\u00e9n conviene <strong>no apurar el clean de workspace<\/strong> si el agent es persistente. En Microsoft-hosted cada run empieza desde cero, as\u00ed que <code>clean<\/code> no aporta nada (ya estaba limpio). En self-hosted, un agent con workspace persistente reaprovecha lo descargado entre runs:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: yaml; title: ; notranslate\" title=\"\">\njobs:\n  - job: BuildTest\n    workspace:\n      clean: outputs   # limpia bin\/obj entre runs, no las sources\n    steps:\n      - checkout: self\n        clean: false   # reaprovecha el clon del repo\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\">Hasta aqu\u00ed, todo lo que cualquier gu\u00eda oficial te cuenta. Es lo que pasa despu\u00e9s lo que no.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Lecci\u00f3n 1: los feeds privados necesitan credenciales expl\u00edcitas en CI<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Primer push, primer error:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\nerror NU1301: Unable to load the service index for source\nhttps:\/\/nuget.ejemplo.com\/v2\/index.json\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\"><code>nuget.ejemplo.com<\/code> es el feed comercial de un proveedor que requiere API key. <strong>Ese restore funcionaba en local desde hac\u00eda meses.<\/strong> \u00bfQu\u00e9 cambi\u00f3?<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Nada en el c\u00f3digo. Lo que cambi\u00f3 es <strong>d\u00f3nde corre el restore<\/strong>:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>En tu m\u00e1quina, NuGet resuelve credenciales del feed privado mediante el <code>nuget.config<\/code> global del usuario (<code>%APPDATA%\\NuGet\\NuGet.Config<\/code> en Windows), donde la API key est\u00e1 cifrada por usuario.<\/li>\n\n\n\n<li>En un agent (cualquier agent \u2014 Microsoft-hosted o self-hosted) <strong>no hay credenciales globales del usuario<\/strong>: el proceso de NuGet s\u00f3lo ve el <code>nuget.config<\/code> versionado del repo, que t\u00edpicamente declara el feed pero no contiene la key (porque versionarla ser\u00eda un suicidio de seguridad).<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">El primer pipeline en hosted nunca hab\u00eda pasado del restore porque era el primer run real. La migraci\u00f3n a self-hosted fue lo que dispar\u00f3 el problema, pero el problema <strong>no era de self-hosted<\/strong>: era latente y habr\u00eda petado igual en hosted.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">El fix sin meter secretos en el repo<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Tres movimientos:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>1.<\/strong> Subir la API key a un secret store. En Azure ese sitio natural es Key Vault:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: powershell; title: ; notranslate\" title=\"\">\naz keyvault secret set \\\n    --vault-name company-kv \\\n    --name EjemploNuGetKey \\\n    --value &quot;&lt;api-key-real&gt;&quot;\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\"><strong>2.<\/strong> Crear un Variable Group en Azure DevOps que enlace ese secret. En \u00abManage Key Vault secrets\u00bb se marca el secret y queda expuesto al pipeline como <code>$(EjemploNuGetKey)<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>3.<\/strong> Crear un <code>nuget.ci.config<\/code> separado (versionado, sin secretos):<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: xml; title: ; notranslate\" title=\"\">\n&lt;?xml version=&quot;1.0&quot; encoding=&quot;utf-8&quot;?&gt;\n&lt;configuration&gt;\n  &lt;packageSources&gt;\n    &lt;clear \/&gt;\n    &lt;add key=&quot;nuget.org&quot; value=&quot;https:\/\/api.nuget.org\/v3\/index.json&quot; protocolVersion=&quot;3&quot; \/&gt;\n    &lt;add key=&quot;ejemplo&quot; value=&quot;https:\/\/nuget.ejemplo.com\/v3\/index.json&quot; \/&gt;\n  &lt;\/packageSources&gt;\n  &lt;packageSourceCredentials&gt;\n    &lt;ejemplo&gt;\n      &lt;add key=&quot;Username&quot; value=&quot;api-key&quot; \/&gt;\n      &lt;add key=&quot;ClearTextPassword&quot; value=&quot;%EJEMPLO_NUGET_KEY%&quot; \/&gt;\n    &lt;\/ejemplo&gt;\n  &lt;\/packageSourceCredentials&gt;\n&lt;\/configuration&gt;\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\">Lo cr\u00edtico: el valor <code>%EJEMPLO_NUGET_KEY%<\/code> <strong>no es la key<\/strong>, es una referencia a una variable de entorno que NuGet expande en runtime. El archivo va a git tal cual, sin riesgo.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">En el pipeline:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: yaml; title: ; notranslate\" title=\"\">\n- script: dotnet restore src\/MyApp.API\/MyApp.API.csproj --configfile pipelines\/nuget.ci.config\n  env:\n    EJEMPLO_NUGET_KEY: $(EjemploNuGetKey)\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\">\u00bfPor qu\u00e9 un <code>nuget.ci.config<\/code> separado y no modificar el <code>nuget.config<\/code> ra\u00edz? Porque si en el ra\u00edz pones <code>&lt;packageSourceCredentials&gt;<\/code> con <code>%EJEMPLO_NUGET_KEY%<\/code> y un dev local hace <code>dotnet restore<\/code> sin la env var definida, NuGet expande la variable a literal <code>%EJEMPLO_NUGET_KEY%<\/code> y la auth falla con un mensaje confuso. Mantener dos archivos \u2014uno ra\u00edz para dev local con su mecanismo de credenciales del SO, y uno <code>pipelines\/nuget.ci.config<\/code> para CI\u2014 preserva los dos flujos sin colisi\u00f3n.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Lecci\u00f3n 2: <code>dotnet test<\/code> no entiende <code>--configfile<\/code><\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Push, segundo run, segundo error:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\nMSBUILD : error MSB1001: Unknown switch.\n    Full command line: &#039;...MSBuild.dll ... tests\/MyApp.Tests\/MyApp.Tests.csproj --configfile pipelines\/nuget.ci.config ...&#039;\nSwitches appended by response files:\nSwitch: --configfile\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\">Aqu\u00ed el copy-paste de la soluci\u00f3n anterior no lleg\u00f3. La intuici\u00f3n razonable es: si <code>dotnet restore --configfile<\/code> funciona, <code>dotnet test --configfile<\/code> tambi\u00e9n \u2014 al fin y al cabo <code>dotnet test<\/code> hace un restore impl\u00edcito.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">La intuici\u00f3n es razonable y est\u00e1 mal.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><code>dotnet test<\/code> invoca MSBuild internamente y los argumentos desconocidos los pasa crudos al MSBuild subyacente. <code>--configfile<\/code> es un switch que s\u00f3lo entiende el comando <code>dotnet restore<\/code> (parseado en el CLI antes de delegar). MSBuild ve <code>--configfile<\/code> y dice \u00abno s\u00e9 qu\u00e9 es esto\u00bb \u2192 <code>MSB1001<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">La forma correcta para <code>dotnet test<\/code> (y para <code>dotnet build<\/code>, <code>dotnet publish<\/code>, cualquier comando que delegue a MSBuild) es la <strong>propiedad MSBuild equivalente<\/strong>:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n- script: |\n    dotnet test tests\/MyApp.Tests\/MyApp.Tests.csproj \\\n      -c Release \\\n      -p:RestoreConfigFile=pipelines\/nuget.ci.config \\\n      --logger trx\n  env:\n    EJEMPLO_NUGET_KEY: $(EjemploNuGetKey)\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\"><code>-p:RestoreConfigFile=...<\/code> es propiedad MSBuild est\u00e1ndar que el target <code>Restore<\/code> lee. Funciona en <code>test<\/code>, <code>build<\/code> y <code>publish<\/code>. La diferencia con <code>--configfile<\/code> no es estil\u00edstica: es que pertenecen a parsers distintos.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Es de las cosas que parecen \u00abdocumentaci\u00f3n oficial seguro la cuenta\u00bb pero rara vez la cuenta lado a lado. Una vez quemado, la regla mental es simple:<\/p>\n\n\n\n<blockquote class=\"wp-block-quote is-layout-flow wp-block-quote-is-layout-flow\">\n<p class=\"wp-block-paragraph\">Para herramientas wrapper alrededor de MSBuild, los flags propios (<code>-c<\/code>, <code>--logger<\/code>, <code>--no-restore<\/code>) siguen su gram\u00e1tica. Cualquier configuraci\u00f3n fina del restore va por <strong>propiedad MSBuild<\/strong> con <code>-p:<\/code>.<\/p>\n<\/blockquote>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Lecci\u00f3n 3: los tests timing-sensitive saltan en hardware m\u00e1s lento<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Tercer run, los YAML ya pasan el restore, los tests se ejecutan, y justo cuando cre\u00eda que la migraci\u00f3n estaba cerrada:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\nFailed!  - Failed: 2, Passed: 426, Skipped: 0, Total: 428\n  Failed: PhotoNoCaption_ThenSeparateText_FlushesAsSingleBatch\n  Failed: B36_PhotoNoCaption_ThenSeparateText_FlowEngineExecutedOnce\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\">Dos tests rojos. <strong>En local pasaban<\/strong>. \u00bfSospechoso? Mucho. Mirando los tests, la trampa es evidente:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: csharp; title: ; notranslate\" title=\"\">\n\/\/ Pseudoc\u00f3digo simplificado del test que fallaba\nvar debouncer = new MessageDebouncer(window: TimeSpan.FromMilliseconds(200));\n\n\/\/ Encola un mensaje &quot;tipo A&quot;\nvar ownerTask = debouncer.EnqueueAsync(messageA);\n\n\/\/ Espera &quot;un poco&quot; \u2014 menos que la ventana del debouncer\nawait Task.Delay(40);\n\n\/\/ Encola un mensaje &quot;tipo B&quot; que debe unirse al batch del A\nvar followUp = await debouncer.EnqueueAsync(messageB);\n\n\/\/ Aserci\u00f3n: A y B forman un \u00fanico batch consolidado\nawait ownerTask;\nAssert.Equal(2, owner.Batch.Count);\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\">La l\u00f3gica del test:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Ventana del debouncer: <strong>200 ms<\/strong>.<\/li>\n\n\n\n<li>Gap entre encolar A y encolar B: <strong>40 ms<\/strong>.<\/li>\n\n\n\n<li>Margen disponible: <strong>160 ms<\/strong>.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Si el thread pool tarda <strong>m\u00e1s de 160 ms<\/strong> en procesar el <code>Task.Delay(40)<\/code> y devolver el control al test, el batch del primer mensaje ya se flushe\u00f3 antes de que llegue el segundo. <strong>Eso no pasa nunca en una VM de Microsoft-hosted con CPU dedicada. Pasa habitualmente en un agent corriendo en Docker dentro de un NAS que comparte CPU con otros servicios<\/strong>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Estos son <strong>tests flaky por dise\u00f1o<\/strong>: dependen de wall-clock real para algo que la l\u00f3gica de producci\u00f3n modela como \u00abuna ventana de tiempo\u00bb. El bug no es del c\u00f3digo: es de la suite, que asume que <code>Task.Delay(40)<\/code> significa exactamente 40 ms cuando puede significar 200 ms en un contenedor con CPU saturada. En hardware dedicado el test es estable, en hardware compartido es loter\u00eda.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">El parche f\u00e1cil y la soluci\u00f3n correcta<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Parche f\u00e1cil<\/strong>: ampliar las ventanas (200 ms \u2192 1000 ms, gap 40 ms \u2192 200 ms) manteniendo el ratio. Funciona, no toca producci\u00f3n, lo aplica un trainee en 5 minutos. Pero <strong>no es soluci\u00f3n<\/strong> \u2014 es diferir el problema. Cuando el NAS tenga m\u00e1s carga, el ratio se romper\u00e1 igual y los tests volver\u00e1n a fallar. Y mientras tanto, los tests son m\u00e1s lentos.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Soluci\u00f3n correcta<\/strong>: hacer que el debouncer no dependa de <code>Task.Delay<\/code> real. Para eso <code>.NET 8<\/code> introdujo el tipo abstracto <code>TimeProvider<\/code>, que sustituye los <code>DateTime.UtcNow<\/code> y <code>Task.Delay<\/code> puros por una abstracci\u00f3n mockeable. En producci\u00f3n se usa <code>TimeProvider.System<\/code> (id\u00e9ntico al wall-clock). En tests se usa <code>FakeTimeProvider<\/code> (del paquete <code>Microsoft.Extensions.TimeProvider.Testing<\/code>), que <strong>no avanza solo<\/strong>: el tiempo lo controla el test con <code>time.Advance(...)<\/code>.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">El cambio en producci\u00f3n es trivial:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: csharp; title: ; notranslate\" title=\"\">\npublic sealed class MessageDebouncer\n{\n    private readonly TimeProvider _timeProvider;\n\n    public MessageDebouncer(\n        ILogger&lt;MessageDebouncer&gt; logger,\n        TimeSpan? delay = null,\n        TimeProvider? timeProvider = null)\n    {\n        _delay = delay ?? TimeSpan.FromMilliseconds(3000);\n        _timeProvider = timeProvider ?? TimeProvider.System; \/\/ default: wall-clock real\n    }\n\n    private async Task FlushAfterDelayAsync(CancellationToken ct)\n    {\n        \/\/ antes: await Task.Delay(_delay, ct);\n        \/\/ despu\u00e9s:\n        await Task.Delay(_delay, _timeProvider, ct);\n    }\n}\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\"><code>Task.Delay(TimeSpan, TimeProvider, CancellationToken)<\/code> es sobrecarga nativa de <code>.NET 8+<\/code>, <strong>sin paquete extra en producci\u00f3n<\/strong>. El comportamiento por defecto (con <code>TimeProvider.System<\/code>) es id\u00e9ntico al <code>Task.Delay(TimeSpan, CancellationToken)<\/code> original. Cero impacto en runtime real.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">El test se vuelve determinista:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code \"><pre class=\"brush: csharp; title: ; notranslate\" title=\"\">\nvar time = new FakeTimeProvider();\nvar debouncer = new MessageDebouncer(window: TimeSpan.FromMilliseconds(200), timeProvider: time);\n\nvar ownerTask = debouncer.EnqueueAsync(messageA);\n\/\/ (ya no hay Task.Delay real entre llegadas)\nvar followUp = await debouncer.EnqueueAsync(messageB);\n\n\/\/ Ceder al thread pool para que el Task.Delay del debouncer se registre\n\/\/ con el FakeTimeProvider antes de avanzarlo:\nawait Task.Delay(20);\n\n\/\/ Avanzar el reloj fake m\u00e1s all\u00e1 de la ventana del debouncer\ntime.Advance(TimeSpan.FromMilliseconds(250));\n\nawait ownerTask;\nAssert.Equal(2, owner.Batch.Count);\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\">Resultados antes\/despu\u00e9s:<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>M\u00e9trica<\/th><th>Tests con <code>Task.Delay<\/code> real<\/th><th>Tests con <code>FakeTimeProvider<\/code><\/th><\/tr><\/thead><tbody><tr><td>Duraci\u00f3n por test<\/td><td>200\u20132000 ms<\/td><td>30\u201380 ms<\/td><\/tr><tr><td>Determinismo en hosted<\/td><td>OK<\/td><td>OK<\/td><\/tr><tr><td>Determinismo en self-hosted bajo carga<\/td><td>Flaky<\/td><td>OK<\/td><\/tr><tr><td>Suite completa (428 tests)<\/td><td>depende del CI<\/td><td>502 ms<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">Es la diferencia entre \u00abun test que pasa siempre que tengas suerte\u00bb y \u00abun test que verifica un invariante de la l\u00f3gica, sin contaminaci\u00f3n del entorno\u00bb.<\/p>\n\n\n\n<blockquote class=\"wp-block-quote is-layout-flow wp-block-quote-is-layout-flow\">\n<p class=\"wp-block-paragraph\"><strong>Nota de implementaci\u00f3n<\/strong>: <code>FakeTimeProvider<\/code> con <code>Task.Run<\/code> interno requiere un peque\u00f1o <em>settle<\/em> (<code>await Task.Delay(20)<\/code> real, no del provider) para dar al thread pool tiempo de registrar el <code>Task.Delay(time, ...)<\/code> antes de avanzar el reloj. Es la \u00fanica concesi\u00f3n al wall-clock \u2014 no afecta a la l\u00f3gica del test, s\u00f3lo cede el scheduler.<\/p>\n<\/blockquote>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Lecci\u00f3n 4: lo que la nube te oculta<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">La meta-lecci\u00f3n de los tres puntos anteriores: <strong>un agent Microsoft-hosted te da garant\u00edas de entorno que enmascaran problemas latentes<\/strong>:<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><thead><tr><th>S\u00edntoma latente<\/th><th>Por qu\u00e9 no sal\u00eda en hosted<\/th><\/tr><\/thead><tbody><tr><td>Auth de feed privado<\/td><td>Si el agent siempre arranca limpio, la primera vez que el restore tira del feed comercial es la primera vez que el problema sale<\/td><\/tr><tr><td><code>dotnet test --configfile<\/code> MSB1001<\/td><td>S\u00f3lo lo ves cuando tienes raz\u00f3n para usar <code>--configfile<\/code> (que en hosted nunca hab\u00edas necesitado porque el feed era an\u00f3nimo)<\/td><\/tr><tr><td>Tests flaky por timing<\/td><td>CPU dedicada, sin contenci\u00f3n con vecinos. Tu <code>Task.Delay(40)<\/code> realmente espera ~40 ms con jitter m\u00ednimo<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">No son bugs introducidos por la migraci\u00f3n: son <strong>bugs preexistentes<\/strong> que la abundancia de recursos del hosted disimulaba. Migrar a self-hosted es, entre otras cosas, <strong>un test de robustez involuntario<\/strong> del c\u00f3digo y de la suite de tests.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">\u00bfCu\u00e1ndo merece la pena pasarse a self-hosted?<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Despu\u00e9s de pagar el precio de las cuatro lecciones, mi heur\u00edstica:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>S\u00ed, conviene si<\/strong>:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>Ya tienes una m\u00e1quina infrautilizada (NAS, servidor de oficina, VM idle) con Docker.<\/li>\n\n\n\n<li>Tu pipeline necesita acceso a recursos internos de tu red (BD, servicios) sin tunneling.<\/li>\n\n\n\n<li>Te molesta el grant pendiente de Microsoft y prefieres autonom\u00eda.<\/li>\n\n\n\n<li>Est\u00e1s dispuesto a invertir media jornada en endurecer el c\u00f3digo a un entorno menos generoso (saca a la luz problemas reales que tarde o temprano salen).<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>No, mejor pagar Microsoft-hosted parallelism si<\/strong>:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>No tienes infra de servidor estable.<\/li>\n\n\n\n<li>Tu pipeline depende de tools preinstaladas en im\u00e1genes hosted que no quieres mantener t\u00fa (Android SDKs, Xcode, etc.).<\/li>\n\n\n\n<li>Tu equipo no tiene tiempo para mantener el host del agent (actualizaciones, PAT renovals, reinicios tras cortes de red).<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">En cualquier caso, <strong>el primer run en self-hosted es un saneamiento gratuito<\/strong>: si tu pipeline pasa all\u00ed, tambi\u00e9n pasar\u00e1 en cualquier sitio. Si falla, los problemas que te encuentres ya estaban ah\u00ed \u2014 s\u00f3lo que eran invisibles.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\">Ap\u00e9ndice: checklist de migraci\u00f3n<\/h2>\n\n\n\n<div class=\"wp-block-columns is-layout-flex wp-container-core-columns-is-layout-8f761849 wp-block-columns-is-layout-flex\">\n<div class=\"wp-block-column is-layout-flow wp-block-column-is-layout-flow\" style=\"flex-basis:66.66%\">\n<p class=\"wp-block-paragraph\">Si vas a hacer este movimiento ma\u00f1ana, los puntos cr\u00edticos:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>[ ] Imagen del agent con todas las herramientas que el pipeline usa (<code>dotnet sdk<\/code>, <code>azure-cli<\/code> si tocas Azure, <code>docker<\/code>, <code>git<\/code>, <code>jq<\/code>, etc.). Lo que falta sale como <code>command not found<\/code> el d\u00eda del primer run.<\/li>\n\n\n\n<li>[ ] Vol\u00famenes persistentes para cach\u00e9 de NuGet\/npm en el agent. Sin esto, cada run descarga todo de cero y pierdes la ventaja de tener un host estable.<\/li>\n\n\n\n<li>[ ] <strong>Capabilities declaradas<\/strong> en el portal del agent + <strong>demands en el pipeline<\/strong>. La duplicaci\u00f3n parece redundante hasta que tienes dos agents y uno difiere.<\/li>\n\n\n\n<li>[ ] Variable group con secretos enlazados desde Key Vault (o equivalente). No metas secretos en el repo aunque sea temporal.<\/li>\n\n\n\n<li>[ ] Approval manual en environments para deploys productivos durante las primeras semanas. Cuando lleves 5-10 deploys verdes, lo quitas.<\/li>\n\n\n\n<li>[ ] Una pasada por los tests <code>[Fact]<\/code> que usan <code>Task.Delay<\/code>, <code>DateTime.UtcNow<\/code>, <code>Stopwatch<\/code>. Cualquiera de ellos es flaky candidate en hardware compartido.<\/li>\n\n\n\n<li>[ ] Plan de rotaci\u00f3n del PAT del agent (caducan al a\u00f1o por defecto). Programarlo en el calendario hoy, no la semana que caduque.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">El refactor completo nos llev\u00f3 algo m\u00e1s de lo planeado precisamente por las tres lecciones intermedias. La cuarta \u2014la meta\u2014 es la que justifica el esfuerzo: un pipeline m\u00e1s robusto, sobre un c\u00f3digo probado en condiciones m\u00e1s cercanas a la realidad. La nube es muy c\u00f3moda; pero a veces es <strong>demasiado<\/strong> c\u00f3moda.<\/p>\n<\/div>\n\n\n\n<div class=\"wp-block-column is-layout-flow wp-block-column-is-layout-flow\" style=\"flex-basis:33.33%\"><div class=\"wp-block-image\">\n<figure class=\"aligncenter size-full is-resized\"><img loading=\"lazy\" decoding=\"async\" width=\"364\" height=\"682\" src=\"https:\/\/www.mainmind.com\/blog\/wp-content\/uploads\/2026\/azure_devops_pipeline.jpg\" alt=\"azure_devops_pipeline sobre 2026\" class=\"wp-image-2574\" style=\"width:271px;height:auto\" srcset=\"https:\/\/www.mainmind.com\/blog\/wp-content\/uploads\/2026\/azure_devops_pipeline.jpg 364w, https:\/\/www.mainmind.com\/blog\/wp-content\/uploads\/2026\/azure_devops_pipeline-160x300.jpg 160w\" sizes=\"auto, (max-width: 364px) 100vw, 364px\" \/><\/figure>\n<\/div><\/div>\n<\/div>\n\n\n\n<ul class=\"wp-block-list\"><\/ul>\n\n\n\n<p class=\"wp-block-paragraph\"><\/p>\n","protected":false},"excerpt":{"rendered":"<p>Migrar un pipeline de Azure DevOps desde agentes Microsoft-hosted a un entorno self-hosted puede parecer un cambio trivial, pero expone problemas ocultos que la infraestructura gestionada enmascara. En este art\u00edculo se documentan cuatro lecciones clave: gesti\u00f3n correcta de credenciales en feeds privados de NuGet, diferencias entre comandos CLI y propiedades MSBuild en .NET, fragilidad de tests dependientes del tiempo en entornos con recursos compartidos y c\u00f3mo el uso de Azure DevOps en modo self-hosted act\u00faa como prueba real de robustez. Incluye soluciones pr\u00e1cticas y patrones recomendados para evitar errores comunes en CI\/CD<\/p>\n","protected":false},"author":1,"featured_media":2573,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[15],"tags":[1047,1049,1048,1051,1050],"class_list":["post-2571","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-visual-studio","tag-azure-devops","tag-devops","tag-self-hosted-agents","tag-testing","tag-timeprovider"],"_links":{"self":[{"href":"https:\/\/www.mainmind.com\/blog\/wp-json\/wp\/v2\/posts\/2571","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.mainmind.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.mainmind.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.mainmind.com\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/www.mainmind.com\/blog\/wp-json\/wp\/v2\/comments?post=2571"}],"version-history":[{"count":2,"href":"https:\/\/www.mainmind.com\/blog\/wp-json\/wp\/v2\/posts\/2571\/revisions"}],"predecessor-version":[{"id":2576,"href":"https:\/\/www.mainmind.com\/blog\/wp-json\/wp\/v2\/posts\/2571\/revisions\/2576"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/www.mainmind.com\/blog\/wp-json\/wp\/v2\/media\/2573"}],"wp:attachment":[{"href":"https:\/\/www.mainmind.com\/blog\/wp-json\/wp\/v2\/media?parent=2571"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.mainmind.com\/blog\/wp-json\/wp\/v2\/categories?post=2571"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.mainmind.com\/blog\/wp-json\/wp\/v2\/tags?post=2571"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}