<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Nnamdi Felix Ibe</title>
    <description>The latest articles on DEV Community by Nnamdi Felix Ibe (@ndcodes).</description>
    <link>https://dev.to/ndcodes</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3994627%2F3c1e9fc3-98cd-45c4-9e34-3ef72ece6604.jpg</url>
      <title>DEV Community: Nnamdi Felix Ibe</title>
      <link>https://dev.to/ndcodes</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/ndcodes"/>
    <language>en</language>
    <item>
      <title>Day 77: A Pipeline and a Private Subnet</title>
      <dc:creator>Nnamdi Felix Ibe</dc:creator>
      <pubDate>Mon, 02 Nov 2026 18:00:00 +0000</pubDate>
      <link>https://dev.to/ndcodes/day-77-a-pipeline-and-a-private-subnet-4dl3</link>
      <guid>https://dev.to/ndcodes/day-77-a-pipeline-and-a-private-subnet-4dl3</guid>
      <description>&lt;p&gt;Day 77 of DevOps, Day 27 of Azure. Both tasks today were about a path that exists until it doesn't.&lt;/p&gt;

&lt;p&gt;A two-line shell script deployed an application and quietly depended on three things nobody had checked. And an Azure VM built with no public IP turned out to be the exact configuration that the platform is in the middle of changing underneath.&lt;/p&gt;

&lt;p&gt;One Jenkins task, one Azure task. Write a first declarative pipeline, then build a VM reachable only from inside its own network. The tasks come from the KodeKloud Engineer platform.&lt;/p&gt;

&lt;h2&gt;
  
  
  Task 1: A first declarative pipeline
&lt;/h2&gt;

&lt;p&gt;The task: a Pipeline job named &lt;code&gt;datacenter-webapp-job&lt;/code&gt; that pulls the latest code from &lt;code&gt;master&lt;/code&gt; onto &lt;code&gt;stapp01&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: The agent and a stale instruction
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Plugins:  SSH Build Agents, Pipeline
Agent:    App Server 1, label stapp01, remote root /home/sarah/jenkins_agent
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In every Jenkins task in this batch, I had to install Java 17 on the node, "as Jenkins (or its remoting agent) is compiled for Java 17".&lt;/p&gt;

&lt;p&gt;The shape of that is right, and the version is two baselines stale. Jenkins' Java requirement is a project decision with dates on it, not a property of a Java release. It became 17 at the 2.463 weekly release in June 2024, covering "agent JVMs (i.e., JVMs running &lt;code&gt;remoting.jar&lt;/code&gt;)" explicitly. And it has moved again since: "Beginning with Jenkins 2.555.1, Jenkins requires Java 21 or Java 25 on both the controller JVM and agent JVMs", with the Java 17 images retired.&lt;/p&gt;

&lt;p&gt;So Java 17 is correct for this lab, which runs 2.541.3, the last LTS supporting it. As a general rule, it is already out of date. Match the controller's requirement, and upgrade the controller and agents together.&lt;/p&gt;

&lt;p&gt;Day 75 covered what this prevents, and my notes undersell it by framing it as a compilation detail: "Running a Java 11 agent with a Java 17 controller produces an &lt;code&gt;UnsupportedClassVersionError&lt;/code&gt;." The agent does not start at all.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: The pipeline
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight groovy"&gt;&lt;code&gt;&lt;span class="n"&gt;pipeline&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;agent&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="n"&gt;label&lt;/span&gt; &lt;span class="s1"&gt;'stapp01'&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;stages&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;stage&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Deploy'&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;steps&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;sh&lt;/span&gt; &lt;span class="s1"&gt;'''
                cd /var/www/html
                git pull origin master
                '''&lt;/span&gt;
            &lt;span class="o"&gt;}&lt;/span&gt;
        &lt;span class="o"&gt;}&lt;/span&gt;
    &lt;span class="o"&gt;}&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;agent { label 'stapp01' }&lt;/code&gt; is documented plainly: it will "Execute the Pipeline, or stage, on an agent available in the Jenkins environment with the provided label." Label expressions work too, so &lt;code&gt;'my-label1 &amp;amp;&amp;amp; my-label2'&lt;/code&gt; is valid once more than one agent shares labels.&lt;/p&gt;

&lt;p&gt;The thing worth noticing is that the job names a label, not a machine. That is the payoff from Day 75's agent work: pointing this deployment at a different server is a label change, not a job rewrite.&lt;/p&gt;

&lt;p&gt;And the &lt;code&gt;sh '''...'''&lt;/code&gt; uses triple single quotes. Nothing in this script references a variable, so today it makes no difference whatsoever. Tomorrow it makes a large one.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the docs recommend
&lt;/h3&gt;

&lt;p&gt;Two lines of shell, and three assumptions underneath them.&lt;/p&gt;

&lt;p&gt;That &lt;code&gt;/var/www/html&lt;/code&gt; is already a git working copy with an &lt;code&gt;origin&lt;/code&gt; remote. On a fresh server, it is a document root and nothing else, and &lt;code&gt;git pull&lt;/code&gt; there fails with a message about not being a git repository, which reads like a path typo.&lt;/p&gt;

&lt;p&gt;That the agent's user can write to it, this pipeline runs as &lt;code&gt;sarah&lt;/code&gt;, and it works only if &lt;code&gt;sarah&lt;/code&gt; owns the directory or has write access. Worth watching across this batch, because three days answer this one question three different ways: today leaves it implicit, Day 79 checks the ownership explicitly, and Day 80 reaches for &lt;code&gt;sudo&lt;/code&gt; with a password in the build step.&lt;/p&gt;

&lt;p&gt;That a pull is the right operation at all. This is the one I would change. &lt;code&gt;git pull&lt;/code&gt; merges. A local edit in the document root produces a merge instead of a deployment, and a conflict leaves the web root half-merged with conflict markers in the files Apache is serving.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git fetch origin
git reset &lt;span class="nt"&gt;--hard&lt;/span&gt; origin/master
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the deployment-shaped operation, because it makes the working tree match the remote regardless of what was there before. A deployment should not care about local state. A pull does, by design.&lt;/p&gt;

&lt;h2&gt;
  
  
  Task 2: A VM reachable only from inside
&lt;/h2&gt;

&lt;p&gt;The task: Day 26 inverted. Same resource shapes, opposite reachability, with SSH permitted only from the VNet's own CIDR block.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: One flag decides the whole task
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nt"&gt;--nsg-rule&lt;/span&gt; NONE
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--nsg-rule&lt;/code&gt; accepts &lt;code&gt;{NONE, RDP, SSH}&lt;/code&gt;, and for a Linux VM it defaults to &lt;code&gt;SSH&lt;/code&gt;, which creates &lt;code&gt;default-allow-ssh&lt;/code&gt; with source &lt;code&gt;*&lt;/code&gt;. That permits SSH from anywhere on the internet, which is the direct opposite of what this task asks for.&lt;/p&gt;

&lt;p&gt;Day 26 left the default in place and was right to. Same command, opposite requirement, one flag, and it is easy not to think about precisely because it is the default.&lt;/p&gt;

&lt;p&gt;So the verification has to list everything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Name            Pri   Dir      Access  Proto  Src          Dst          Port
Allow-SSH-VNet  100   Inbound  Allow   Tcp    10.0.0.0/16  10.0.0.0/16  22
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One row. A &lt;code&gt;default-allow-ssh&lt;/code&gt; row sitting next to it would mean the VM was internet-reachable while every other check still looked fine, and the NSG would read as "scoped to the VNet" at a glance.&lt;/p&gt;

&lt;p&gt;When the requirement is about what is not permitted, confirming the rule you added proves nothing. You have to enumerate what exists.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Two layers, and only one of them is about inbound
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"PubIP"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="nl"&gt;"PrivIP"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"10.0.1.4"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--public-ip-address ""&lt;/code&gt; means no internet-routable address, so there is no inbound path. The NSG then restricts source even inside the VNet. Either alone would do it; both together survive a single mistake, because adding a public IP later would not open SSH and loosening the NSG would not either.&lt;/p&gt;

&lt;p&gt;That much I had. What I had missed is that the absence of a public IP is also the condition that a platform change is now acting on, and it affects what this VM can do rather than what can reach it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: This is the VM that loses its way out
&lt;/h3&gt;

&lt;p&gt;How does a VM with no public IP reach the package mirrors at all? Microsoft: "when you deploy a virtual machine (VM) in a virtual network without an explicitly defined outbound connectivity method, Azure automatically assigns it an outbound public IP address."&lt;/p&gt;

&lt;p&gt;That address is not yours. "This IP, known as the default outbound access IP, is owned by Microsoft and can change without notice", and "default outbound connectivity relies on an implicit platform behaviour and may be affected by platform-level changes."&lt;/p&gt;

&lt;p&gt;And it is going away for new subnets. "Creating a subnet to be Private prevents any virtual machines on the subnet from utilising default outbound access to connect to public endpoints." Day 76 recorded the date, which has passed: subnets in new virtual networks now default to &lt;code&gt;defaultOutboundAccess&lt;/code&gt; set to false.&lt;/p&gt;

&lt;p&gt;So a VM built to exactly this specification, in a new virtual network today, has no inbound path and no outbound path.&lt;/p&gt;

&lt;p&gt;The four explicit outbound methods are a NAT gateway on the subnet, an outbound-rule load balancer, a Standard public IP on the NIC, and a firewall or appliance behind a user-defined route. Day 26's VM had the third one by accident: the public IP that made it reachable also made its egress explicit. This VM has none of them, and a NAT gateway is the documented recommendation for most scenarios.&lt;/p&gt;

&lt;p&gt;Two exceptions stop a private VM from being completely cut off, and they are worth knowing in opposite directions. "Virtual machines are still able to access Azure Storage accounts in the same region in a private subnet without an explicit method of outbound." But "Certain services don't function on a virtual machine in a private subnet without an explicit method of egress (examples are Windows Activation and Windows Updates)."&lt;/p&gt;

&lt;p&gt;Microsoft's stated reasons read as an argument rather than a deprecation notice: "Security: Default internet access contradicts Zero Trust principles. Clarity: Explicit connectivity is preferred over implicit access. Stability: The default outbound IP isn't customer-owned and can change."&lt;/p&gt;

&lt;h3&gt;
  
  
  What the docs recommend
&lt;/h3&gt;

&lt;p&gt;The enforcement here is entirely outside the VM, and it is worth being precise about that, because "SSH accessible from within the VNet only" sounds like host configuration and is not:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;LISTEN 0 128 0.0.0.0:22  users:(("sshd",pid=861,fd=3))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;sshd is bound to &lt;code&gt;0.0.0.0&lt;/code&gt;, accepting from any address. Nothing in the guest narrows it. Which means an NSG change takes effect immediately with no reboot, and also that an NSG mistake has no backstop: there is no firewall rule or &lt;code&gt;ListenAddress&lt;/code&gt; on the host to catch it.&lt;/p&gt;

&lt;p&gt;One more thing I did not do, and would rather say than imply. The task says SSH should work from inside the VNet, and I never tested that, because there is no second VM in the VNet to connect from. What I established is that sshd is listening, the NSG permits 22 from &lt;code&gt;10.0.0.0/16&lt;/code&gt;, no other inbound rule exists, and there is no public IP. That makes the conclusion sound, and it is an inference.&lt;/p&gt;

&lt;p&gt;The documented way to actually test it is Azure Bastion, which deploys into an &lt;code&gt;AzureBastionSubnet&lt;/code&gt; and brokers SSH without putting a public IP on the target. Given Day 38 failed precisely by treating configuration as proof of behaviour, the distinction is worth keeping visible.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;p&gt;Both halves were about a path that works until something stops holding it up.&lt;/p&gt;

&lt;p&gt;The pipeline deploys correctly, right now, on a server where someone previously made &lt;code&gt;/var/www/html&lt;/code&gt; a git working copy and gave &lt;code&gt;sarah&lt;/code&gt; write access to it. None of that is in the pipeline. The job is two lines, and the configuration it depends on is somewhere else entirely, undocumented, and discovered only when it is missing.&lt;/p&gt;

&lt;p&gt;The Azure VM reaches the internet, right now, through an address Microsoft owns and assigned implicitly because the VM had no other way out. That is being retired, with a date that has already passed for new networks, and Microsoft's own first reason is that implicit internet access contradicts Zero Trust. The VM is not misconfigured. The thing it was resting on is being removed.&lt;/p&gt;

&lt;p&gt;So here is the Day 77 question. What does your deployment job assume about the server it deploys to that the job itself does not say?&lt;/p&gt;

&lt;p&gt;Day 77 down. Twenty-three to go.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>jenkins</category>
      <category>azure</category>
      <category>cicd</category>
    </item>
    <item>
      <title>Day 76: A Backup of Nothing and a Retiring Default</title>
      <dc:creator>Nnamdi Felix Ibe</dc:creator>
      <pubDate>Sun, 01 Nov 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/ndcodes/day-76-a-backup-of-nothing-and-a-retiring-default-e1h</link>
      <guid>https://dev.to/ndcodes/day-76-a-backup-of-nothing-and-a-retiring-default-e1h</guid>
      <description>&lt;p&gt;Day 76 of DevOps, Day 26 of Azure. Both tasks today went green, and both were quietly resting on something that will not hold.&lt;/p&gt;

&lt;p&gt;A database backup job ran on schedule, produced a file, copied it across two machines, and backed up no tables at all. And an Azure network behaviour I had written in my notes as how Azure works turned out to be a platform default that Microsoft is in the middle of retiring.&lt;/p&gt;

&lt;p&gt;One Jenkins task, one Azure task. Schedule a database backup across three servers, then put a VM in a purpose-built virtual network. The tasks come from the KodeKloud Engineer platform.&lt;/p&gt;

&lt;h2&gt;
  
  
  Task 1: A database backup on a ten-minute schedule
&lt;/h2&gt;

&lt;p&gt;The task: a job named &lt;code&gt;database-backup&lt;/code&gt; that dumps a database on &lt;code&gt;stapp01&lt;/code&gt; every ten minutes and copies the dump to the storage server.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Two hops, two keys
&lt;/h3&gt;

&lt;p&gt;Three machines are involved, and the Jenkins controller never touches the storage server directly. So each hop needs its own key, belonging to the account that opens it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;jenkins@jenkins:~&lt;span class="nv"&gt;$ &lt;/span&gt;ssh-keygen &lt;span class="nt"&gt;-t&lt;/span&gt; rsa &lt;span class="nt"&gt;-b&lt;/span&gt; 4096
Created directory &lt;span class="s1"&gt;'/var/lib/jenkins/.ssh'&lt;/span&gt;&lt;span class="nb"&gt;.&lt;/span&gt;
jenkins@jenkins:~&lt;span class="nv"&gt;$ &lt;/span&gt;ssh-copy-id tony@stapp01

jenkins@jenkins:~&lt;span class="nv"&gt;$ &lt;/span&gt;ssh tony@stapp01
&lt;span class="o"&gt;[&lt;/span&gt;tony@stapp01 ~]&lt;span class="nv"&gt;$ &lt;/span&gt;ssh-keygen &lt;span class="nt"&gt;-t&lt;/span&gt; rsa &lt;span class="nt"&gt;-b&lt;/span&gt; 4096
&lt;span class="o"&gt;[&lt;/span&gt;tony@stapp01 ~]&lt;span class="nv"&gt;$ &lt;/span&gt;ssh-copy-id natasha@ststor01
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second key had to be generated as &lt;code&gt;tony&lt;/code&gt;, because &lt;code&gt;tony@stapp01&lt;/code&gt; is where the first hop lands and therefore the account that makes the second call. One key cannot do both hops.&lt;/p&gt;

&lt;p&gt;This is the chain Day 74 could not account for: there, the transcript showed the second hop keyed and never showed the first, and the build still went green. Here both are on the record.&lt;/p&gt;

&lt;p&gt;One detail on the second hop worth noticing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;The authenticity of host 'ststor01 (10.244.240.154)' can't be established.
ED25519 key fingerprint is SHA256:yEyN8qvzhNxfcKVE+H05zwQPmQMKCXj4JyGWuOP1HIg.
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That prompt is exactly what Day 75's agents switched off with their non-verifying host key strategy. Answered once interactively, recorded, done.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: The build step, and one thing it gets right
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh tony@stapp01 &lt;span class="s2"&gt;"
  mysqldump -u kodekloud_roy -pasdfgdsd kodekloud_db01 &amp;gt; /tmp/db_&lt;/span&gt;&lt;span class="se"&gt;\$&lt;/span&gt;&lt;span class="s2"&gt;(date +%F).sql &amp;amp;&amp;amp;
  scp /tmp/db_&lt;/span&gt;&lt;span class="se"&gt;\$&lt;/span&gt;&lt;span class="s2"&gt;(date +%F).sql natasha@ststor01:/home/natasha/db_backups/
"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt; is inside the quotes, which is Day 71's bug written correctly. There, &lt;code&gt;ssh host cmd1 &amp;amp;&amp;amp; cmd2&lt;/code&gt; split in the local shell and only the first half ever reached the remote machine. Here the whole conditional is one argument to ssh, so the &lt;code&gt;scp&lt;/code&gt; runs on &lt;code&gt;stapp01&lt;/code&gt; and only if the dump succeeded.&lt;/p&gt;

&lt;p&gt;The escaping is right too, and for a reason my notes got slightly wrong. I had written that the backslash makes the filename come out in the right format. What it actually does is choose whose clock names the file: &lt;code&gt;\$&lt;/code&gt; stops the local shell expanding the substitution, so the literal text is sent and &lt;code&gt;date&lt;/code&gt; runs on &lt;code&gt;stapp01&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Which is the exact inverse of Day 71, where &lt;code&gt;"$PACKAGE"&lt;/code&gt; had to expand locally because the Jenkins parameter only exists on the Jenkins side. Same mechanism, opposite intent. Escape it to evaluate remotely, leave it bare to evaluate locally, and know which one you mean.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: The job works. The backup does not.
&lt;/h3&gt;

&lt;p&gt;The file arrived on both machines and the build went green. Then I read it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- MariaDB dump 10.19  Distrib 10.5.29-MariaDB, for Linux (x86_64)&lt;/span&gt;
&lt;span class="c1"&gt;--&lt;/span&gt;
&lt;span class="c1"&gt;-- Host: localhost    Database: kodekloud_db01&lt;/span&gt;
&lt;span class="c1"&gt;-- ------------------------------------------------------&lt;/span&gt;
&lt;span class="c1"&gt;-- Server version       10.5.29-MariaDB&lt;/span&gt;

&lt;span class="cm"&gt;/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;...&lt;/span&gt;
&lt;span class="cm"&gt;/*!40101 SET SQL_MODE=@OLD_SQL_MODE */&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;...&lt;/span&gt;
&lt;span class="c1"&gt;-- Dump completed on 2026-04-14  7:02:52&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A header, session settings, the restore of those session settings, and a completion line. No &lt;code&gt;CREATE TABLE&lt;/code&gt;. No &lt;code&gt;INSERT&lt;/code&gt;. Nothing whatsoever between the opening &lt;code&gt;SET&lt;/code&gt; block and the closing one.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;kodekloud_db01&lt;/code&gt; either has no tables, or &lt;code&gt;kodekloud_roy&lt;/code&gt; cannot see the ones it has. Either way this job backs up zero rows, successfully, every ten minutes, and produces a plausible-looking file while doing it.&lt;/p&gt;

&lt;p&gt;The task is satisfied. It asked for a backup job and there is one. The backup is worthless, and nothing anywhere in the pipeline says so: valid SQL, reasonable size, green build, file present at the destination.&lt;/p&gt;

&lt;p&gt;This is Day 74's lesson with the stakes raised. There, a scheduled job copying no logs succeeds on time forever, and the fix was to confirm the source files existed first. Here the equivalent check is one line:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;mysql &lt;span class="nt"&gt;-u&lt;/span&gt; kodekloud_roy &lt;span class="nt"&gt;-p&lt;/span&gt;... &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"SHOW TABLES IN kodekloud_db01"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the verification that belongs in the job itself asserts on content rather than exit status:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-qi&lt;/span&gt; &lt;span class="s1"&gt;'CREATE TABLE'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DUMP&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"dump has no tables"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An empty lab database and a privilege problem look identical from outside, and &lt;code&gt;mysqldump&lt;/code&gt; exits zero for both. Only the content tells you which you have.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the docs recommend
&lt;/h3&gt;

&lt;p&gt;Three things, and the first is the one that would matter in a real environment.&lt;/p&gt;

&lt;p&gt;The password is in the job. MySQL does not hedge: "Specifying a password on the command line should be considered insecure. To avoid giving the password on the command line, use an option file." The documentation also explains the odd-looking flag, since "there must be no space between &lt;code&gt;--password=&lt;/code&gt; or &lt;code&gt;-p&lt;/code&gt; and the password following it."&lt;/p&gt;

&lt;p&gt;In a Jenkins job this is worse than at a terminal, because the command is stored rather than typed. The build step lives in the job's &lt;code&gt;config.xml&lt;/code&gt;, and anyone with Job/Configure on the job can read it in a browser.&lt;/p&gt;

&lt;p&gt;Day 73 granted Job/Configure on a job to two people. The same grant here hands over a database credential, and nothing in the permission grid hints at it. The column says Configure. What it conveys is the contents of every build step.&lt;/p&gt;

&lt;p&gt;Jenkins points at the fix. Credentials have their own permission group, with Credentials/View and Credentials/Update kept separate from job permissions, which is Jenkins separating credential access from job access on purpose. And its Pipeline documentation gives the pattern directly: never interpolate a secret into the command text, send a literal command string, and let "the shell read secrets from its environment", which the credentials binding has already populated. Two things improve at once, because the value leaves &lt;code&gt;config.xml&lt;/code&gt; and Jenkins masks it in the console output.&lt;/p&gt;

&lt;p&gt;Second, there is a latent bug in the date. &lt;code&gt;date&lt;/code&gt; is called twice, and the two substitutions are evaluated at different moments. If midnight falls between the dump finishing and the &lt;code&gt;scp&lt;/code&gt; starting, &lt;code&gt;mysqldump&lt;/code&gt; writes one filename and &lt;code&gt;scp&lt;/code&gt; looks for another and fails. On a ten-minute schedule that is one build a day exposed to it, presenting as an occasional failure with no obvious cause.&lt;/p&gt;

&lt;p&gt;Computing it once removes the whole class of problem, and lets the quoting get simpler because nothing needs to expand locally any more:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh tony@stapp01 &lt;span class="s1"&gt;'
  D=$(date +%F)
  mysqldump --defaults-file=~/.my.cnf kodekloud_db01 &amp;gt; "/tmp/db_$D.sql" &amp;amp;&amp;amp;
  scp "/tmp/db_$D.sql" natasha@ststor01:/home/natasha/db_backups/
'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Third, the schedule. &lt;code&gt;H/10 * * * *&lt;/code&gt; rather than &lt;code&gt;*/10 * * * *&lt;/code&gt;, for Day 74's reasons: &lt;code&gt;H&lt;/code&gt; "should be used wherever possible" and is "a hash of the job name, not a random function". With a log-copy job already on a six-minute cycle, round intervals guarantee the two collide eventually, and &lt;code&gt;H&lt;/code&gt; separates them without anyone coordinating. And &lt;code&gt;mysqldump&lt;/code&gt; without &lt;code&gt;--single-transaction&lt;/code&gt; locks tables while it reads, which costs nothing on an empty database and blocks writes every ten minutes on a real one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Task 2: A VM in a purpose-built network
&lt;/h2&gt;

&lt;p&gt;The task: a VNet, a subnet, and a VM inside it with a public IP and SSH reachable from the internet.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Name things rather than letting the VM name them
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nt"&gt;--vnet-name&lt;/span&gt; devops-pub-vnet &lt;span class="nt"&gt;--subnet&lt;/span&gt; devops-pub-subnet
&lt;span class="nt"&gt;--public-ip-address&lt;/span&gt; devops-pub-vm-pip &lt;span class="nt"&gt;--public-ip-sku&lt;/span&gt; Standard
&lt;span class="nt"&gt;--nsg&lt;/span&gt; devops-pub-vm-nsg
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every earlier VM in this track let &lt;code&gt;az vm create&lt;/code&gt; build its own network, which produces names derived from the VM, like &lt;code&gt;&amp;lt;vm&amp;gt;VNET&lt;/code&gt; and &lt;code&gt;&amp;lt;vm&amp;gt;VMNic&lt;/code&gt;. Here the network came first and the VM was told where to go, so the names come from the design. That starts mattering the moment a second VM shares the network.&lt;/p&gt;

&lt;p&gt;Naming the public IP also carries the Day 21 benefit: it survives the VM being deleted and recreated, so the address stays put.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: What makes the network public, and why my answer has expired
&lt;/h3&gt;

&lt;p&gt;My notes said a VNet has no public or private attribute, and that reachability comes entirely from a public IP on the NIC plus an NSG rule. The first half still holds. The second is now incomplete, and what has changed is outbound rather than inbound.&lt;/p&gt;

&lt;p&gt;Subnets do now carry a relevant property, and Microsoft's own word for the result is private: "Azure is changing the default subnet configuration for new virtual networks to require explicit outbound connectivity." With a date attached that has already passed: "For the API version released after March 31, 2026, subnets in new virtual networks have &lt;code&gt;defaultOutboundAccess&lt;/code&gt; set to &lt;code&gt;false&lt;/code&gt; by default."&lt;/p&gt;

&lt;p&gt;So a VNet created today with a current API version gets subnets whose VMs "can't rely on default outbound access". My note's claim that Azure handles the routing and the NSG is the only gate describes a platform behaviour being retired, not a property of Azure networking.&lt;/p&gt;

&lt;p&gt;Three things keep that from affecting this task, and the second is the interesting one.&lt;/p&gt;

&lt;p&gt;Existing networks are untouched: "Existing virtual networks aren't changed", and deployments on earlier API versions "retain the earlier default behavior", which the lab's CLI may well have done.&lt;/p&gt;

&lt;p&gt;And this VM already has an explicit outbound method, without my having chosen one for that purpose. Microsoft lists "Associate a Standard public IP to any of the virtual machine's network interfaces" among the four explicit methods, and &lt;code&gt;--public-ip-sku Standard&lt;/code&gt; provided exactly that. A VM built to be reachable from the internet happens to have the thing that also makes its egress explicit.&lt;/p&gt;

&lt;p&gt;Of those four methods, Microsoft marks one as preferred: "Associate a NAT gateway to the subnet of your virtual machine. Note this is the recommended method for most scenarios."&lt;/p&gt;

&lt;p&gt;Which means the AWS comparison in my notes needs qualifying rather than deleting. Azure still has no internet gateway or route table to configure for basic reachability, which is why Day 40 and Day 45 have no counterpart on this track. What it no longer has is outbound connectivity you can assume. A private subnet needs a NAT gateway, and that is the closest Azure analogue to the work Day 45 did on AWS.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the docs recommend
&lt;/h3&gt;

&lt;p&gt;Two smaller things, both about not doing the same work twice.&lt;/p&gt;

&lt;p&gt;The port 22 rule was created twice. &lt;code&gt;--nsg-rule&lt;/code&gt; was not passed to &lt;code&gt;az vm create&lt;/code&gt;, and its accepted values are &lt;code&gt;{NONE, RDP, SSH}&lt;/code&gt; with Linux defaulting to &lt;code&gt;SSH&lt;/code&gt;, which creates a &lt;code&gt;default-allow-ssh&lt;/code&gt; rule at priority 1000. The explicit &lt;code&gt;az vm open-port --port 22 --priority 1001&lt;/code&gt; then added a second rule for the same port. Harmless, because both are Allow and the one at 1000 matches first, and still redundant configuration in two places. Either &lt;code&gt;--nsg-rule NONE&lt;/code&gt; plus the explicit call, or &lt;code&gt;--nsg-rule SSH&lt;/code&gt; and no call, leaves one rule and one place to change it.&lt;/p&gt;

&lt;p&gt;And the disk flags, which I have now carried forward three times and explained wrongly twice:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nt"&gt;--storage-sku&lt;/span&gt; Standard_LRS &lt;span class="nt"&gt;--os-disk-size-gb&lt;/span&gt; 30
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Day 21 failed on this, and Days 22, 23 and 26 all pass the flags without being asked to. My explanation, that &lt;code&gt;az vm create&lt;/code&gt; defaults the OS disk to Premium on premium-capable sizes, is wrong, as Day 72 covered: &lt;code&gt;--storage-sku&lt;/code&gt; has no documented default at all, and the managed-disk path sends no storage account type whatsoever. Something below the CLI chooses, and the rule is not published. The flag is still mandatory in this subscription.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;p&gt;Both halves of today were green and both were built on something that does not hold up.&lt;/p&gt;

&lt;p&gt;The backup job is the one I would want to find before someone needed it. Every signal said it worked: exit zero, file created, file copied, file identical on both machines, build green, schedule firing. The only thing that said otherwise was reading the file, and what it said was that the job had been faithfully backing up nothing. No monitoring built on exit codes would ever have caught it, because nothing failed.&lt;/p&gt;

&lt;p&gt;The Azure half is the same error made against documentation rather than against a file. I wrote "Azure handles the routing" in my notes as a fact about the platform. It was a default, and Microsoft had already announced its retirement with a date that has since passed. The VM survives on a technicality that happens to be sound: the Standard public IP that makes it reachable is also, separately, one of the four documented ways to make its egress explicit.&lt;/p&gt;

&lt;p&gt;The pattern underneath both is the same one from Day 75, arriving by a different route. A thing that is true today, recorded as a thing that is true.&lt;/p&gt;

&lt;p&gt;So here is the Day 76 question. Which of your backups has anyone actually opened?&lt;/p&gt;

&lt;p&gt;Day 76 down. Twenty-four to go.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>jenkins</category>
      <category>azure</category>
      <category>cicd</category>
    </item>
    <item>
      <title>Day 75: A Java Baseline and a Moving Disk</title>
      <dc:creator>Nnamdi Felix Ibe</dc:creator>
      <pubDate>Sat, 31 Oct 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/ndcodes/day-75-a-java-baseline-and-a-moving-disk-4oh6</link>
      <guid>https://dev.to/ndcodes/day-75-a-java-baseline-and-a-moving-disk-4oh6</guid>
      <description>&lt;p&gt;Day 75 of DevOps, Day 25 of Azure. Both tasks today punished something I had written down as a fact when it was really a snapshot.&lt;/p&gt;

&lt;p&gt;A Jenkins agent would not start because the Java version on it had been fine until the controller moved past it. And an Azure disk that was &lt;code&gt;sda&lt;/code&gt; before the task was &lt;code&gt;sdb&lt;/code&gt; after it, with a different disk holding the old name.&lt;/p&gt;

&lt;p&gt;One Jenkins task, one Azure task. Attach the app servers to Jenkins as build agents, then expand one disk and add another. The tasks come from the KodeKloud Engineer platform.&lt;/p&gt;

&lt;h2&gt;
  
  
  Task 1: Three servers as build agents
&lt;/h2&gt;

&lt;p&gt;The task: attach the three app servers to Jenkins as agents. For the first one, name &lt;code&gt;App_server_1&lt;/code&gt;, label the same, remote root directory &lt;code&gt;/home/tony/jenkins&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: The plugin, and what it is called now
&lt;/h3&gt;

&lt;p&gt;The plugin is SSH Build Agents. It "is formerly known as 'SSH Slaves Plugin'" and was renamed in version 1.31.0, and its plugin ID is still &lt;code&gt;ssh-slaves&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So three names for one thing, which is why searching for any of them lands in the same place. The KodeKloud task uses the old vocabulary, and so does the identifier you would put in a configuration file.&lt;/p&gt;

&lt;p&gt;Its documented scope is almost nothing: it "Allows to launch agents over SSH", using "a Java implementation of the SSH protocol", and it "Requires Jenkins 2.504.1". That is the whole of what its page says about behaviour.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Create the node, and find the fields are undocumented
&lt;/h3&gt;

&lt;p&gt;Name, label, remote root directory, host, credentials, host key verification strategy. Jenkins' Managing Nodes page defines none of them. It does not describe the Remote root directory, the Labels field, the Usage field, or the launch methods, and does not list SSH among them.&lt;/p&gt;

&lt;p&gt;That is the third day running. Day 71 was the Execute shell build step, with no page. Day 72 was String and Choice parameters, with no page. Today it is the entire node configuration form.&lt;/p&gt;

&lt;p&gt;What Jenkins does document is the requirement and the reason for the whole exercise. "An agent can use any operating system that supports Java", and you should "Ensure that you have a java version available on your agent machine". And then, plainly: "running tasks on the built-in node is discouraged for security, performance, and scalability reasons."&lt;/p&gt;

&lt;p&gt;That last line is why this task exists. Days 71 through 74 ran everything on the controller, directly or over ssh from it. This is the day the work moves off the machine Jenkins itself lives on.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: The agent refuses to start
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;java.lang.UnsupportedClassVersionError: hudson/remoting/Launcher has been compiled by a
more recent version of the Java Runtime (class file version 61.0), this version of the
Java Runtime only recognizes class file versions up to 55.0
Agent JVM has terminated. Exit code=1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="o"&gt;[&lt;/span&gt;tony@stapp01 ~]&lt;span class="nv"&gt;$ &lt;/span&gt;java &lt;span class="nt"&gt;-version&lt;/span&gt;
openjdk version &lt;span class="s2"&gt;"11.0.20.1"&lt;/span&gt; 2023-08-24 LTS
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Java 11 on the agent. Installing &lt;code&gt;java-17-openjdk&lt;/code&gt; and relaunching brought it online.&lt;/p&gt;

&lt;p&gt;The fix was right. My explanation of it was wrong in three ways, and the correction is more useful than the fix.&lt;/p&gt;

&lt;p&gt;I had written that Jenkins requires Java 17 "because it is the current Long-Term Support (LTS) version, and its components are compiled to run on it, ensuring better stability".&lt;/p&gt;

&lt;p&gt;Java 17 is not the current LTS release and was not then. Java 21 is a later one, and Jenkins has moved past both: "Beginning with Jenkins 2.555.1, Jenkins requires Java 21 or Java 25 on both the controller JVM and agent JVMs." The same page adds that "With the release of Jenkins 2.555.1, the Java 17 images have been retired."&lt;/p&gt;

&lt;p&gt;The reason Java 17 was required at all is a project decision with a date on it. Jenkins raised its baseline at the 2.463 weekly release in June 2024, announced as "Jenkins requires Java 17 or newer", with 2.462 being "the last weekly release to support Java 11". The requirement covers "agent JVMs (i.e., JVMs running &lt;code&gt;remoting.jar&lt;/code&gt;)" explicitly, and the announcement names this exact failure: "Running a Java 11 agent with a Java 17 controller produces an &lt;code&gt;UnsupportedClassVersionError&lt;/code&gt;."&lt;/p&gt;

&lt;p&gt;And it is not about stability. The agent's JVM cannot load a class file built for a newer JVM than itself, so the agent does not start. Saying Java 17 gives "better stability" implies Java 11 would be worse. Java 11 would be nothing.&lt;/p&gt;

&lt;p&gt;The fix was still correct for this Jenkins: Day 68 established the lab runs 2.541.3, the last LTS release supporting Java 17. Written as a general rule for today, "install Java 17 for Jenkins agents" is already out of date. The general rule is to install whatever the controller's version requires, and to upgrade controller and agents together, agents first.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the docs recommend
&lt;/h3&gt;

&lt;p&gt;Two choices in this node configuration are worth more attention than the Java version.&lt;/p&gt;

&lt;p&gt;The host key verification strategy was set to Non verifying, which I justified as skipping manual trust setup in a lab. Practical, and the strategy names turn out to be undocumented: the plugin's page lists no host key verification options at all.&lt;/p&gt;

&lt;p&gt;What its page does carry is a security advisory, and it is precisely this one: "Man-in-the-middle vulnerability due to missing host key verification", affecting version 1.14 and earlier.&lt;/p&gt;

&lt;p&gt;So the non-verifying strategy is not a missing feature. It is an option to reinstate, per node, the exposure that advisory was issued to fix. Choosing it accepts that anyone who can answer on the agent's address and port can impersonate the agent, and Jenkins will hand over the connection and the credentials used to open it. Which in this task are &lt;code&gt;tony&lt;/code&gt;'s password.&lt;/p&gt;

&lt;p&gt;That is the second choice. The credential here is a username and password, where Days 71 and 74 both set up key-based trust for the same class of connection. The plugin supports SSH keys. A password is a reusable secret sitting in the credential store for no benefit.&lt;/p&gt;

&lt;p&gt;To be fair to the credential store, it is a boundary. Jenkins gives credentials their own permission group, with Credentials/View and Credentials/Update separate from job permissions, so credential access is deliberately not the same as job access. A password in the store is at least behind that line.&lt;/p&gt;

&lt;p&gt;A password typed into a build step is not. That is tomorrow's problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  Task 2: Expand one disk, add another
&lt;/h2&gt;

&lt;p&gt;The task: grow the VM's disk from 32 to 64 GiB, then create a 64 GiB standard HDD named &lt;code&gt;devops-disk&lt;/code&gt; and mount it at &lt;code&gt;/mnt/devops-disk&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: The outage belongs to the OS disk, not the task
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az vm deallocate ...
az disk update &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="nv"&gt;$OSDISK&lt;/span&gt; &lt;span class="nt"&gt;--size-gb&lt;/span&gt; 64
az disk create &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; devops-disk &lt;span class="nt"&gt;--size-gb&lt;/span&gt; 64 &lt;span class="nt"&gt;--sku&lt;/span&gt; Standard_LRS
az vm disk attach &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;--vm-name&lt;/span&gt; devops-vm &lt;span class="nt"&gt;--name&lt;/span&gt; devops-disk
az vm start ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I had written that expanding a managed OS disk requires the VM deallocated. Right conclusion, and Microsoft never says it in those words, so it is worth showing how it follows.&lt;/p&gt;

&lt;p&gt;Expanding without downtime is documented, and its scope is explicit: it "Is supported only for data disks." For the path that does not qualify, "the VM must be deallocated." An OS disk is outside the downtime-free feature, so it lands on the deallocation path by elimination rather than by a rule written about OS disks.&lt;/p&gt;

&lt;p&gt;Two things follow that I had not drawn out. The data disk alone could have been expanded with the VM running, so the outage was the OS disk's requirement, not the task's. And &lt;code&gt;deallocate&lt;/code&gt; is not a synonym for &lt;code&gt;stop&lt;/code&gt;: "Stopping the VM with &lt;code&gt;az vm stop&lt;/code&gt; doesn't release the compute resources. To release compute resources, use &lt;code&gt;az vm deallocate&lt;/code&gt;."&lt;/p&gt;

&lt;p&gt;Since the VM had to stop anyway, creating and attaching the data disk in the same window meant one outage covering both halves of the task instead of two.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: The reboot did the other two layers, and I overstated how
&lt;/h3&gt;

&lt;p&gt;Day 50's lesson was that a disk has three layers and each needs its own action. After the VM started:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sdb1   63.9G   part /
/dev/root  ext4  62G  1.7G  61G  3% /
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Already grown, with nothing run inside the guest.&lt;/p&gt;

&lt;p&gt;I credited that to cloud-init running &lt;code&gt;growpart&lt;/code&gt; on every boot. The documented claim is narrower and says nothing about every boot: "On Ubuntu 16.x and newer, the root partition of the OS disk and filesystems are automatically expanded to use all free contiguous space on the root disk by cloud-init. A small amount of free space must be available for the resize operation."&lt;/p&gt;

&lt;p&gt;So cloud-init does it, on Ubuntu 16.x and newer, to the root partition of the OS disk, and it needs a little free space. Whether it checks on every boot or only when something changed is not stated.&lt;/p&gt;

&lt;p&gt;The mirror-image reading survives the correction, and it is the part worth keeping:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;AWS allowed an online resize, so no reboot happened, so cloud-init never ran, so the partition and filesystem were manual.&lt;/li&gt;
&lt;li&gt;Azure required a deallocation, and the restart that followed is what expanded them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The platform constraint supplied the convenience. Which matters, because a data disk expanded with the VM running gets no reboot, and so still needs the filesystem grown by hand.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: The letters moved while I was working
&lt;/h3&gt;

&lt;p&gt;Before this task the OS disk was &lt;code&gt;sda&lt;/code&gt;. After attaching one data disk:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sda   64G                      &amp;lt;- devops-disk, the NEW disk
sdb   64G   sdb1 /             &amp;lt;- the OS disk
sdc    4G   sdc1 /mnt          &amp;lt;- the temporary disk
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The OS disk became &lt;code&gt;sdb&lt;/code&gt;, and the new data disk took the name the OS disk used to have.&lt;/p&gt;

&lt;p&gt;Day 8 and Day 50 both warned that device letters are assigned in enumeration order and are not stable. This is that warning arriving. A script written as "format &lt;code&gt;/dev/sda1&lt;/code&gt;" against yesterday's layout would have reformatted the root volume today.&lt;/p&gt;

&lt;p&gt;So the script resolved the device instead of naming it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;DEV&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;readlink&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /dev/disk/azure/scsi1/lun0&lt;span class="si"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The LUN is the slot on the VM and does not change. The letter is an artefact of enumeration order. And for the persistent mount, the UUID does the same job, because an fstab entry naming &lt;code&gt;/dev/sda1&lt;/code&gt; would, after the next disk change, mount the wrong filesystem or fail the boot.&lt;/p&gt;

&lt;p&gt;Which is the Jenkins half of today in different clothing. &lt;code&gt;sda&lt;/code&gt; meant one disk and now means another. &lt;code&gt;remoting.jar&lt;/code&gt; ran on Java 11 and now will not. Both were true when written down and neither was a stable fact about the system.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the docs recommend
&lt;/h3&gt;

&lt;p&gt;The mount point the task asked for is the thing I would change in real infrastructure.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;/mnt/devops-disk&lt;/code&gt; sits inside &lt;code&gt;/mnt&lt;/code&gt;, and on an Azure Linux VM &lt;code&gt;/mnt&lt;/code&gt; is where the ephemeral temporary disk is mounted. So this is a persistent disk mounted at a path inside a filesystem that gets wiped on deallocation. It works now. After the next stop and start, the directory will not exist on the fresh temporary disk.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;nofail&lt;/code&gt; keeps that from blocking the boot, and that is exactly the problem: the data disk may simply not be mounted, and nothing complains. Azure's own fstab line for the temporary disk shows the pattern for this, ordering itself after cloud-init, and the equivalent here would be &lt;code&gt;x-systemd.requires-mounts-for=/mnt&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I have not applied it, because the task asked for the mount and the mount is there. But a persistent disk underneath an ephemeral mount point is the wrong shape, and &lt;code&gt;/data&lt;/code&gt; or &lt;code&gt;/srv/devops-disk&lt;/code&gt; at the root avoids the whole question.&lt;/p&gt;

&lt;p&gt;One more number worth knowing before someone asks. The 64 GiB disk reports 63G to the filesystem and 60G available, and both are correct: ext4's metadata takes the first slice and its root reserve takes the second. Three numbers, one disk, nothing wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;p&gt;Everything that broke today was something I had recorded as a fact and that was really a reading at a moment in time.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;sda&lt;/code&gt; was the OS disk. Java 11 was sufficient for the agent. Neither statement was wrong when written. Both became wrong without anything on those machines changing: one because a disk was attached, the other because the Jenkins project moved its baseline, twice, and my note about it was already stale when I wrote it down.&lt;/p&gt;

&lt;p&gt;The defence in both cases turned out to be the same, which is what makes it worth noticing. Do not name the thing, resolve it. The disk script asks for LUN 0 and follows the symlink to whatever letter the kernel chose this boot. The agent should take whatever Java the controller's version requires, checked against the controller, rather than the version that worked in a tutorial.&lt;/p&gt;

&lt;p&gt;And the two configuration choices nobody made me justify were the ones carrying actual risk: a host key strategy that reinstates a patched man-in-the-middle vulnerability, and a password where a key would have done.&lt;/p&gt;

&lt;p&gt;So here is the Day 75 question. Which version number or device name is hardcoded in your scripts because it was true the day you wrote them?&lt;/p&gt;

&lt;p&gt;Day 75 down. Twenty-five to go.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>jenkins</category>
      <category>azure</category>
      <category>cicd</category>
    </item>
    <item>
      <title>Day 74: A Key Each Way and a Schedule</title>
      <dc:creator>Nnamdi Felix Ibe</dc:creator>
      <pubDate>Fri, 30 Oct 2026 18:00:00 +0000</pubDate>
      <link>https://dev.to/ndcodes/day-74-a-key-each-way-and-a-schedule-5991</link>
      <guid>https://dev.to/ndcodes/day-74-a-key-each-way-and-a-schedule-5991</guid>
      <description>&lt;p&gt;Day 74 of DevOps, Day 24 of Azure. Both tasks today were about SSH keys, and specifically about which way round they point.&lt;/p&gt;

&lt;p&gt;A Jenkins job needed one server to be able to reach another. An Azure task started by checking whether a key already existed, and the &lt;code&gt;.ssh&lt;/code&gt; directory turned out to be full of a file that pointed the other way. Same two files, opposite directions, and getting them mixed up produces a setup that looks finished and cannot connect.&lt;/p&gt;

&lt;p&gt;One Jenkins task, one Azure task. Schedule a job that copies Apache logs between servers, then build a VM that only accepts key-based SSH. The tasks come from the KodeKloud Engineer platform.&lt;/p&gt;

&lt;h2&gt;
  
  
  Task 1: A job on a six-minute schedule
&lt;/h2&gt;

&lt;p&gt;The task: a freestyle job named &lt;code&gt;copy-logs&lt;/code&gt;, running every six minutes, copying the Apache logs from &lt;code&gt;stapp03&lt;/code&gt; into &lt;code&gt;/usr/src/finance/&lt;/code&gt; on the storage server.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Check the logs are actually there
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="o"&gt;[&lt;/span&gt;banner@stapp03 ~]&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /var/log/httpd/access_log
127.0.0.1 - - &lt;span class="o"&gt;[&lt;/span&gt;-] &lt;span class="s2"&gt;"GET / HTTP/1.1"&lt;/span&gt; 200 -
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Boring step, and it is the one that makes everything after it mean something. A scheduled job that copies nothing succeeds, on time, forever. The build goes green, the destination stays empty, and nothing in Jenkins tells you why, because from Jenkins' point of view nothing went wrong.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Key the connection between the two servers
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="o"&gt;[&lt;/span&gt;banner@stapp03 ~]&lt;span class="nv"&gt;$ &lt;/span&gt;ssh-keygen &lt;span class="nt"&gt;-t&lt;/span&gt; rsa &lt;span class="nt"&gt;-b&lt;/span&gt; 4096
Enter file &lt;span class="k"&gt;in &lt;/span&gt;which to save the key &lt;span class="o"&gt;(&lt;/span&gt;/home/banner/.ssh/id_rsa&lt;span class="o"&gt;)&lt;/span&gt;:

&lt;span class="o"&gt;[&lt;/span&gt;banner@stapp03 ~]&lt;span class="nv"&gt;$ &lt;/span&gt;ssh-copy-id natasha@ststor01
/usr/bin/ssh-copy-id: INFO: 1 key&lt;span class="o"&gt;(&lt;/span&gt;s&lt;span class="o"&gt;)&lt;/span&gt; remain to be installed &lt;span class="nt"&gt;--&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;you are prompted now it is to &lt;span class="nb"&gt;install &lt;/span&gt;the new keys
natasha@ststor01&lt;span class="s1"&gt;'s password:
Number of key(s) added: 1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Generated as &lt;code&gt;banner&lt;/code&gt;, not as root, which is why &lt;code&gt;ssh-keygen&lt;/code&gt; offered &lt;code&gt;/home/banner/.ssh/id_rsa&lt;/code&gt;. The identity has to belong to whichever account opens the connection.&lt;/p&gt;

&lt;p&gt;Then one detail in the login banner on the way in, which is worth reading rather than scrolling past:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Last failed login: Sun Apr 12 09:41:57 UTC 2026 from 10.244.81.53 on ssh:notty
There were 2 failed login attempts since the last successful login.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those two failures are &lt;code&gt;ssh-copy-id&lt;/code&gt; probing with the new key before it was installed, which is exactly what its "1 key(s) remain to be installed" message was telling me. Benign here. On a server where you had not just run &lt;code&gt;ssh-copy-id&lt;/code&gt;, two failed logins from another host is something to go and look at.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Let the schedule fire
&lt;/h3&gt;

&lt;p&gt;Six minutes later the build ran on its own, and:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="o"&gt;[&lt;/span&gt;natasha@ststor01 ~]&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;ls&lt;/span&gt; /usr/src/finance/
access_log  error_log
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  What the docs recommend
&lt;/h3&gt;

&lt;p&gt;A six-minute schedule written the obvious way is &lt;code&gt;*/6 * * * *&lt;/code&gt;. Jenkins documents a better form, and the reason is load.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;H&lt;/code&gt; symbol "should be used wherever possible". The example Jenkins gives is a dozen jobs all set to &lt;code&gt;0 0 * * *&lt;/code&gt;, which produces "a large spike at midnight", against &lt;code&gt;H H * * *&lt;/code&gt;, which still runs each job once a day but at a time of its own.&lt;/p&gt;

&lt;p&gt;The part worth knowing is that &lt;code&gt;H&lt;/code&gt; is not random. It is "a hash of the job name, not a random function", so the value "remains stable for any given project". You get spreading without losing reproducibility. For short cycles the documented example is "every fifteen minutes (perhaps at :07, :22, :37, :52)" written as &lt;code&gt;H/15 * * * *&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;H/6 * * * *&lt;/code&gt; is the form for this job. Still every six minutes, but offset within each window by a hash of this job's name, so that this job and the next one somebody writes do not fire in the same instant for the rest of time.&lt;/p&gt;

&lt;p&gt;Jenkins also warns about exactly this kind of schedule: "short cycles such as &lt;code&gt;*/3&lt;/code&gt; or &lt;code&gt;H/3&lt;/code&gt; will not work consistently near the end of most months", because "Hashes are always chosen in the 1-28 range". Worth knowing before a six-minute job becomes load-bearing.&lt;/p&gt;

&lt;h3&gt;
  
  
  One link in the chain I cannot account for
&lt;/h3&gt;

&lt;p&gt;This job needs two hops. The logs are on &lt;code&gt;stapp03&lt;/code&gt;. The destination is &lt;code&gt;ststor01&lt;/code&gt;. The build runs as &lt;code&gt;jenkins&lt;/code&gt; on the Jenkins controller, which is neither machine.&lt;/p&gt;

&lt;p&gt;What I set up and verified is the second hop, &lt;code&gt;stapp03&lt;/code&gt; to &lt;code&gt;ststor01&lt;/code&gt;. What I did not set up anywhere in this task is the first, from the controller to &lt;code&gt;stapp03&lt;/code&gt;. And the job ran on schedule and the files arrived.&lt;/p&gt;

&lt;p&gt;Two explanations fit. The controller may already have been trusted by the app servers from earlier work, since Day 71 keyed &lt;code&gt;jenkins@jenkins&lt;/code&gt; to the storage server and the same thing could have been done to &lt;code&gt;stapp03&lt;/code&gt; on a day my notes do not cover. Or the build did not run on the controller.&lt;/p&gt;

&lt;p&gt;I cannot tell from what I have, so I am recording it as an open question rather than writing in the step that would make the story tidy. Same gap as Day 71, where the build command in my notes could not have produced the result my notes also recorded. Same cause, too: a screenshot shows you a green build, not the chain of trust that let it go green.&lt;/p&gt;

&lt;h2&gt;
  
  
  Task 2: A VM that only accepts keys
&lt;/h2&gt;

&lt;p&gt;The task: create an SSH key if one does not already exist, build a VM that trusts it, and prove password-less access works.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: A non-empty .ssh directory is not a key pair
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;-rw------- 1 root root  562  authorized_keys
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;/root/.ssh/&lt;/code&gt; existed and already had a file in it. No &lt;code&gt;id_rsa&lt;/code&gt;, no &lt;code&gt;id_rsa.pub&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Those two serve opposite directions:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;File&lt;/th&gt;
&lt;th&gt;Direction&lt;/th&gt;
&lt;th&gt;Purpose&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;authorized_keys&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Inbound&lt;/td&gt;
&lt;td&gt;Keys allowed to log in to this host&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;id_rsa&lt;/code&gt; and &lt;code&gt;id_rsa.pub&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Outbound&lt;/td&gt;
&lt;td&gt;This host's identity for logging in elsewhere&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The lab seeds &lt;code&gt;authorized_keys&lt;/code&gt; so that I can reach the client machine. It does not give the client machine an identity of its own.&lt;/p&gt;

&lt;p&gt;So "check if a key already exists" has to mean checking for the file the next step needs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="nt"&gt;-f&lt;/span&gt; /root/.ssh/id_rsa &lt;span class="o"&gt;]&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"key exists, reusing"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;else &lt;/span&gt;ssh-keygen ... &lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Written as "does &lt;code&gt;~/.ssh&lt;/code&gt; exist", or "is it empty", the check finds files, concludes a key is present, and leaves the VM creation with no public key to install.&lt;/p&gt;

&lt;p&gt;Which is the Jenkins half of today from the other end. There, &lt;code&gt;stapp03&lt;/code&gt; needed an outbound identity and &lt;code&gt;ststor01&lt;/code&gt; needed that public key in its inbound list. Two files, two directions. Confuse them and you get a setup that looks complete and cannot connect.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: The key size was a downgrade and I did not notice
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh-keygen &lt;span class="nt"&gt;-t&lt;/span&gt; rsa &lt;span class="nt"&gt;-b&lt;/span&gt; 2048 &lt;span class="nt"&gt;-f&lt;/span&gt; /root/.ssh/id_rsa &lt;span class="nt"&gt;-N&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;-b 2048&lt;/code&gt; reads like a sensible conservative default. It is not a default, and it is below the real one.&lt;/p&gt;

&lt;p&gt;The ssh-keygen manual gives ed25519 as the default key type, and for RSA, "the minimum size is 1024 bits and the default is 3072 bits". So this command overrode the type to get RSA and then overrode the size downwards, from 3072 to 2048. Omitting &lt;code&gt;-b&lt;/code&gt; entirely would have produced a stronger key than asking for 2048 did.&lt;/p&gt;

&lt;p&gt;Worth putting beside Day 71, three days ago, where I used &lt;code&gt;-t rsa -b 4096&lt;/code&gt; and called 4096 an upgrade from 3072. Same series, same flag, used in opposite directions, and in neither case because I had checked what the default was. The honest conclusion is to omit &lt;code&gt;-b&lt;/code&gt; and take 3072, or omit both flags and take ed25519.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;-N ''&lt;/code&gt; is the flag that makes this password-less: it sets an empty passphrase, and a passphrase-protected key prompts on every connection unless an agent holds it. Real tradeoff rather than a convenience, though. The private key on disk is now the only thing between anyone who can read that file and the VM.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Name the key rather than generating one
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nt"&gt;--ssh-key-values&lt;/span&gt; /root/.ssh/id_rsa.pub
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Earlier tasks used &lt;code&gt;--generate-ssh-keys&lt;/code&gt;, which is documented to "Generate SSH public and private key files if missing" and store them in &lt;code&gt;~/.ssh&lt;/code&gt;. Convenient, and it hides which of the two branches ran.&lt;/p&gt;

&lt;p&gt;Here the first step had already settled the key, so naming it explicitly means the VM gets the key I just checked, with no conditional behaviour in between. Two steps that each do one thing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Password authentication came out disabled
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"PasswordAuthDisabled"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the observation that makes the task's title accurate, and it is worth separating from my explanation of it. I had written that creating a Linux VM with a key and no &lt;code&gt;--admin-password&lt;/code&gt; makes Azure set &lt;code&gt;disablePasswordAuthentication: true&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The observation is real. The mechanism is not documented in those terms. The ARM reference describes the property as specifying "whether password authentication should be disabled" and gives no default for it, where neighbouring properties do state theirs. What the CLI does document is &lt;code&gt;--authentication-type&lt;/code&gt;, which "Defaults to password for Windows and SSH public key for Linux".&lt;/p&gt;

&lt;p&gt;So the chain from "Linux VM, key supplied, no password" to "password authentication off" is consistent with the documentation rather than stated by it. Which I would rather say than present an inference as a fact.&lt;/p&gt;

&lt;p&gt;The consequence holds regardless: there is no password to guess, so the VM is not exposed to credential stuffing against port 22 even with the NSG allowing SSH from anywhere. Passing &lt;code&gt;--admin-password&lt;/code&gt; instead would have left password authentication on with port 22 open to the internet, which is the combination behind most compromised cloud VMs. It also means losing the private key locks you out, and recovery runs through &lt;code&gt;az vm user update&lt;/code&gt; or Run Command.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the docs recommend
&lt;/h3&gt;

&lt;p&gt;The verification is the part I would reuse. Configuration and behaviour are different claims and need separate checks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PasswordAuthDisabled: true          &amp;lt;- configuration
KeyPaths: [.../authorized_keys]     &amp;lt;- configuration

ssh -o BatchMode=yes ... 'whoami'   -&amp;gt; azureuser     &amp;lt;- behaviour
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;az vm show&lt;/code&gt; proves Azure was asked for the right thing. The SSH proves the guest accepted the key. And &lt;code&gt;BatchMode=yes&lt;/code&gt; is what makes the second one mean "password-less" rather than just "worked": without it, a successful login is ambiguous, because ssh could have fallen back to a prompt that got satisfied somehow. With it, a prompt is impossible, so the key is the only explanation.&lt;/p&gt;

&lt;p&gt;One last distinction, because it is easy to assume otherwise. This key is not an Azure resource. Day 1 created a &lt;code&gt;Microsoft.Compute/sshPublicKeys&lt;/code&gt; object, reusable across VMs by name. Today's key exists only on the client machine and as a copy inside the VM's &lt;code&gt;authorized_keys&lt;/code&gt;, and &lt;code&gt;az sshkey list&lt;/code&gt; would return nothing. Both give working SSH; only one gives you something you can refer to later.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;p&gt;Two files, two directions, and almost every SSH problem I have had is one of them in the wrong place.&lt;/p&gt;

&lt;p&gt;The Azure task made it explicit by seeding &lt;code&gt;authorized_keys&lt;/code&gt; and nothing else, so that a naive "is there a key here" check would say yes and be wrong. The Jenkins task made the same point by needing an outbound identity on one server and an inbound entry on another, and my own notes then failed to account for one of the two hops the job must have used.&lt;/p&gt;

&lt;p&gt;The key size is the smaller finding and the more embarrassing one. I used &lt;code&gt;-b 4096&lt;/code&gt; three days ago and &lt;code&gt;-b 2048&lt;/code&gt; here, in both cases without checking that the RSA default is 3072. One was an upgrade, one was a downgrade, and neither was a decision.&lt;/p&gt;

&lt;p&gt;So here is the Day 74 question. In the SSH setups you maintain, which machine holds an identity it no longer needs, and which one is still trusting a key whose owner left?&lt;/p&gt;

&lt;p&gt;Day 74 down. Twenty-six to go.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>jenkins</category>
      <category>azure</category>
      <category>cicd</category>
    </item>
    <item>
      <title>Part 3: Nine Ways an HL7 v2 to FHIR Mapping Goes Quietly Wrong</title>
      <dc:creator>Nnamdi Felix Ibe</dc:creator>
      <pubDate>Wed, 28 Oct 2026 19:26:21 +0000</pubDate>
      <link>https://dev.to/ndcodes/nine-ways-an-hl7-v2-to-fhir-mapping-goes-quietly-wrong-2gd4</link>
      <guid>https://dev.to/ndcodes/nine-ways-an-hl7-v2-to-fhir-mapping-goes-quietly-wrong-2gd4</guid>
      <description>&lt;p&gt;Part 1 built the translation layer. Part 2 built the pipeline that checks it. This part is about the translation itself, which turns out to be where the interesting failures live.&lt;/p&gt;

&lt;p&gt;None of what follows threw an exception. Every one produced a FHIR Bundle that a server would accept, containing the wrong thing. That is the particular danger of mapping work: the output is always well-formed, so the only way to find the problems is to run the mapper and look at what comes out.&lt;/p&gt;

&lt;p&gt;I found nine. One of them stores a positive culture result as the number zero, and three of the nine I did not find at all: readers of Part 1 did.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;A mapping is a long list of small decisions, and each one looks obviously right while you are making it. PID-5 is the name. PID-7 is the date of birth. You write the line, the test passes, you move on.&lt;/p&gt;

&lt;p&gt;What makes it dangerous in a clinical setting is that the failures are silent and plausible. A missing middle name is not an error. A date of birth that vanished is not an error. The Bundle validates, the server returns 200, and the wrong record is now in the system of record with every appearance of being right.&lt;/p&gt;

&lt;p&gt;So this week I stopped reading the mapper and started feeding it things.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I found
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffkdtuj9wqfehd6x9y42q.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffkdtuj9wqfehd6x9y42q.png" alt="A table of nine mapping behaviours, each failing before the change and passing after" width="800" height="744"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Nine behaviours, each checked by running the real mapper on a real message:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;behaviour                                before   after
--------------------------------------------------------
middle name kept                          FAIL     pass
partial date YYYYMM kept                  FAIL     pass
dob with tz offset kept                   FAIL     pass
escape sequence decoded                   FAIL     pass
fullUrls are real UUIDs                   FAIL     pass
patient resend updates, not duplicates    FAIL     pass
text result not made numeric              FAIL     pass
no JSON null in the resource              FAIL     pass
sex A maps to other, per HL7              FAIL     pass
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  A positive culture result became zero
&lt;/h3&gt;

&lt;p&gt;This is the one I would want to know about if it were my trust.&lt;/p&gt;

&lt;p&gt;An OBX segment carries a result. OBX-2 says what type the value is: &lt;code&gt;NM&lt;/code&gt; for a number, &lt;code&gt;ST&lt;/code&gt; for text, &lt;code&gt;CE&lt;/code&gt; for a coded entry. The mapper ignored OBX-2 entirely and tried to turn every value into a &lt;code&gt;valueQuantity&lt;/code&gt;. When the text would not parse as a number, it did this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;units&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;comparator&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;_raw&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So a microbiology result of &lt;code&gt;POSITIVE&lt;/code&gt; came out as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"valueQuantity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"value"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"unit"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"comparator"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"_raw"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"POSITIVE"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmbe4betkieh55jmm3xdf.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmbe4betkieh55jmm3xdf.png" alt="The POSITIVE culture result mapped before and after: valueQuantity 0 against valueString POSITIVE" width="800" height="480"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A qualitative result became the number zero. Anything downstream reading &lt;code&gt;valueQuantity.value&lt;/code&gt;, a chart, a rule, a dashboard, sees a measurement of nought. &lt;code&gt;_raw&lt;/code&gt; is not a FHIR element either. It was invented, so a conformant server is free to reject the resource or drop the property, and with it the only surviving copy of the actual result.&lt;/p&gt;

&lt;p&gt;The fix is to read OBX-2 and pick the matching &lt;code&gt;value[x]&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;vt&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;_CODED_VALUE_TYPES&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;valueCodeableConcept&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;}}&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;vt&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;_NUMERIC_VALUE_TYPES&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;vt&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;quantity&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;value&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;
    &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;valueString&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;valueString&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;POSITIVE&lt;/code&gt; is now &lt;code&gt;{"valueString": "POSITIVE"}&lt;/code&gt;. A segment that claims &lt;code&gt;NM&lt;/code&gt; and carries text also becomes a string, because inventing a number is the failure mode I am trying to get away from.&lt;/p&gt;

&lt;h3&gt;
  
  
  Every admission created a new patient, and the obvious fix is the wrong one
&lt;/h3&gt;

&lt;p&gt;The Bundle posted the Patient like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"method"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"POST"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Patient"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An unconditional create. Send the same admission twice, get two patients. Resend a day's traffic after an outage, get a duplicate for every message.&lt;/p&gt;

&lt;p&gt;This one came from a reader of Part 1. Anjan Kumar Gundaboina asked on LinkedIn whether the mapper was idempotent, or whether a resend produced a duplicate Patient. It produced a duplicate.&lt;/p&gt;

&lt;p&gt;The obvious fix is a conditional create. FHIR has &lt;code&gt;Bundle.entry.request.ifNoneExist&lt;/code&gt;, defined as "Instruct the server not to perform the create if a specified resource already exists". One match returns 200 and creates nothing, so a resend stops duplicating.&lt;/p&gt;

&lt;p&gt;That is not enough, because an A08 is a patient update message. If the patient exists and the message carries a corrected date of birth, a conditional create looks at the one match and does nothing at all. The duplicate is gone and so is the correction.&lt;/p&gt;

&lt;p&gt;A conditional update handles both. FHIR defines it as &lt;code&gt;PUT [base]/[type]?[search parameters]&lt;/code&gt;, where with no matches "The server creates the resource", with one match "The server performs the update against the matching resource", and with several it returns 412.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"method"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PUT"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Patient?identifier=https://fhir.nhs.uk/Id/nhs-number|9000000009"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When there is no NHS number, there is no condition and it stays a plain create. A condition that matches everything is worse than none: it would merge every patient without an NHS number into one record.&lt;/p&gt;

&lt;h3&gt;
  
  
  The dates it threw away
&lt;/h3&gt;

&lt;p&gt;PID-7 is a time stamp, not a date, and senders fill in as much as they have. The mapper accepted &lt;code&gt;YYYYMMDD&lt;/code&gt; and returned nothing for anything else.&lt;/p&gt;

&lt;p&gt;Readers raised this one too. Lee Wilson pointed out that in many EHRs only the year of birth is known, and Anjan named the partial-date fallback as exactly the kind of silent failure he would want a dashboard on.&lt;/p&gt;

&lt;p&gt;FHIR is more accommodating than the mapper was. It defines &lt;code&gt;date&lt;/code&gt; as "A date, or partial date (e.g. just year or year + month)" in the format "YYYY, YYYY-MM, or YYYY-MM-DD".&lt;/p&gt;

&lt;p&gt;So &lt;code&gt;198001&lt;/code&gt; should be &lt;code&gt;1980-01&lt;/code&gt;. It was becoming nothing at all. So was &lt;code&gt;19800101120000+0100&lt;/code&gt;, a perfectly ordinary full time stamp with a time zone offset, because the offset did not match any of the three formats being tried. A date of birth with a time zone on it was silently dropped.&lt;/p&gt;

&lt;p&gt;Both now map. The offset is stripped, because a FHIR date "SHALL be no time zone".&lt;/p&gt;

&lt;h3&gt;
  
  
  The names it mangled
&lt;/h3&gt;

&lt;p&gt;HL7 reserves five characters as delimiters and defines five escape sequences so they can appear inside a field anyway. &lt;code&gt;\T\&lt;/code&gt; is the subcomponent separator, so &lt;code&gt;O\T\BRIEN&lt;/code&gt; is how the wire carries O&amp;amp;Brien. The mapper stored the escape sequence.&lt;/p&gt;

&lt;p&gt;One subtlety is worth getting right. &lt;code&gt;\E\&lt;/code&gt; stands for the escape character itself, so decoding has to be a single pass. Replace &lt;code&gt;\F\&lt;/code&gt; first and then &lt;code&gt;\E\&lt;/code&gt;, and the sequence &lt;code&gt;\E\F\E\&lt;/code&gt;, which means the literal text &lt;code&gt;\F\&lt;/code&gt;, becomes a field separator instead. Sequences outside the five, like &lt;code&gt;\H\&lt;/code&gt;, are passed through rather than stripped.&lt;/p&gt;

&lt;h3&gt;
  
  
  The middle name, and the test that approved of losing it
&lt;/h3&gt;

&lt;p&gt;PID-5 is &lt;code&gt;DOE^JOHN^A&lt;/code&gt;. Component 3 is the middle name. FHIR puts given and middle names together in &lt;code&gt;given&lt;/code&gt;, in order.&lt;/p&gt;

&lt;p&gt;The mapper read component 2 and stopped, so &lt;code&gt;A&lt;/code&gt; was dropped. That is the least serious of the nine and the most instructive, because of what I found in the test file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;patient&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;given&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;JOHN&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The test asserted the bug. It had passed since the day it was written, because it was written by reading what the mapper did rather than what the segment said. A green suite told me the mapping was right, and what it actually told me was that the mapping had not changed.&lt;/p&gt;

&lt;h3&gt;
  
  
  The identifiers that were not identifiers
&lt;/h3&gt;

&lt;p&gt;Each Bundle entry had a &lt;code&gt;fullUrl&lt;/code&gt; like &lt;code&gt;urn:uuid:patient-1&lt;/code&gt;. FHIR's &lt;code&gt;uuid&lt;/code&gt; type is "A UUID (aka GUID) represented as a URI", meaning RFC 4122. &lt;code&gt;patient-1&lt;/code&gt; is not one. HAPI accepted them, which is why it survived; a stricter server need not. They are real UUIDs now.&lt;/p&gt;

&lt;h3&gt;
  
  
  Nulls where FHIR forbids them
&lt;/h3&gt;

&lt;p&gt;When PID-7 would not parse at all, &lt;code&gt;birthDate&lt;/code&gt; came out as &lt;code&gt;None&lt;/code&gt; and serialised to &lt;code&gt;"birthDate": null&lt;/code&gt;. FHIR's JSON rules say properties "never have null values" and to "Omit a property if it is empty".&lt;/p&gt;

&lt;p&gt;Testing that turned up a second case: a PID with no name at all produced &lt;code&gt;"family": ""&lt;/code&gt;. The same page says "String property values can never be empty".&lt;/p&gt;

&lt;p&gt;Both are now pruned on the way into the Bundle, along with empty arrays and objects. Zero and &lt;code&gt;false&lt;/code&gt; survive the pruning, because a quantity of 0 is a real measurement and that is the mistake this article started with.&lt;/p&gt;

&lt;h3&gt;
  
  
  The answer I said did not exist
&lt;/h3&gt;

&lt;p&gt;I had written PID-8 up as an open question. HL7 table 0001 carries &lt;code&gt;A&lt;/code&gt; for ambiguous and &lt;code&gt;N&lt;/code&gt; for not applicable, FHIR's &lt;code&gt;administrative-gender&lt;/code&gt; has only &lt;code&gt;male&lt;/code&gt;, &lt;code&gt;female&lt;/code&gt;, &lt;code&gt;other&lt;/code&gt; and &lt;code&gt;unknown&lt;/code&gt;, and I concluded there was no clean target and left the mapper sending both to &lt;code&gt;unknown&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Then Dalisay Giffard suggested on LinkedIn that I check the PID-8 mapping, because administrative sex has a different meaning to gender. The field held up: FHIR R4 defines &lt;code&gt;Patient.gender&lt;/code&gt; as administrative gender, and that is where HL7's v2-to-FHIR guide puts PID-8. The codes did not. Checking them properly, I found that HL7 publishes a concept map for exactly this, in the v2-to-FHIR implementation guide. It sends &lt;code&gt;A&lt;/code&gt; to &lt;code&gt;other&lt;/code&gt; and &lt;code&gt;N&lt;/code&gt; to &lt;code&gt;other&lt;/code&gt;, and rates both "is equivalent to".&lt;/p&gt;

&lt;p&gt;Which is obviously right once you see it. &lt;code&gt;unknown&lt;/code&gt; says we do not know. The sender did know; the answer was simply neither male nor female. I had been treating a recorded answer as a missing one, and I had reasoned my way to a conclusion the standard had already settled in a table I had not looked for.&lt;/p&gt;

&lt;h2&gt;
  
  
  The map was not the territory
&lt;/h2&gt;

&lt;p&gt;There is a &lt;code&gt;docs/hl7-fhir-mapping.md&lt;/code&gt; in the repo, a careful table of HL7 fields and their FHIR targets. I checked each row against the running mapper. Three described mappings that do not exist: PID-11 to &lt;code&gt;Patient.address&lt;/code&gt;, RXE-4 to &lt;code&gt;dosageInstruction&lt;/code&gt;, RXE-13 to &lt;code&gt;requester&lt;/code&gt;. It also called &lt;code&gt;_raw&lt;/code&gt; an extension, which gave an invented property the air of deliberate design.&lt;/p&gt;

&lt;p&gt;That is three weeks running where the most useful finding was a document describing a system nobody had checked. Week 2 it was a security scanner with no rules loaded. This week a mapping table describing a mapper I had not written.&lt;/p&gt;

&lt;p&gt;The rewritten version says "not mapped" where that is the truth, and every row was produced by running the thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What readers found that I have not fixed yet
&lt;/h2&gt;

&lt;p&gt;Part 1 and Part 2 are public, and so is the repo, which means the mapping has had more eyes on it than mine. Several more things came back that are not in this week's change, named here so they do not quietly disappear.&lt;/p&gt;

&lt;p&gt;PID-3, raised by Kevin Mayfield, Ian Haywood and Lee Wilson. PID-3 reads only CX-1 and CX-4. The CX-5 identifier type code, &lt;code&gt;NH&lt;/code&gt; or &lt;code&gt;MR&lt;/code&gt;, is dropped, there is no &lt;code&gt;use&lt;/code&gt;, and every non-NHS identifier lands in one &lt;code&gt;local-patient-identifier&lt;/code&gt; system with no assigner, so medical record numbers from two different trusts collide. The &lt;a href="https://nw-gmsa.github.io/en/hl7v2.html#pid" rel="noopener noreferrer"&gt;North West GMSA HL7 v2 guide&lt;/a&gt; treats CX-1, CX-4 and CX-5 as mandatory and uses the ODS code as the assigning authority. My fixture uses &lt;code&gt;GENERAL_HOSP&lt;/code&gt;, which is not an ODS code. Ian's warning was the practical one: unless the namespace carries through, you'll have fun later when another source uses the same ID. Kevin added that the MRN system should be a URI or an OID, that EPR vendors and trusts have each made their own choice, and that his organisation uses a URI.&lt;/p&gt;

&lt;p&gt;Replay ordering, which follows from Anjan's question. Now that writes update rather than create, replaying an older dead-lettered message after a newer one would overwrite newer data with older. The fix I have in mind is to check MSH-7 against what was last applied, at the point of the write. Using the NHS number as an SQS FIFO message group ID orders live traffic but does nothing for a replay out of the dead-letter folder.&lt;/p&gt;

&lt;p&gt;Field-level failures are silent, which Anjan asked about directly. Only a whole-message parse failure dead-letters. A single field that fails to map produces no flag, no metric and no log line, which is how all nine of these survived.&lt;/p&gt;

&lt;p&gt;No canonical model, which Mitchel Coon asked about. The mapper goes straight from segments to FHIR resources, with no intermediate representation, so every quirk is handled inline. HL7's own v2-to-FHIR guide maps directly too, so this is defensible, but it is why the fixes above are scattered across the file rather than sitting in one place.&lt;/p&gt;

&lt;p&gt;Lee also pointed out that the NHS number is not guaranteed, that address fields are often misused, and that phone fields can hold free text like "don't call after 6pm". Without an NHS number the Patient falls back to a plain create, so those patients still duplicate. PID-11 and PID-13 are not mapped at all yet, so the free text is a problem waiting for whoever maps them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The one thing still open
&lt;/h2&gt;

&lt;p&gt;PID-8 is settled and the duplicates are fixed. One judgement call is left, and I would rather leave it visible than decide it quietly in code.&lt;/p&gt;

&lt;p&gt;The Encounter has no identifier and no period, so a resent admission duplicates it exactly the way the Patient used to.&lt;/p&gt;

&lt;p&gt;Mustafa Erbay made the right suggestion in the comments on Part 1 here on dev.to: use PV1-19, the visit number, rather than building a composite key out of the fields that happen to be present. It is the natural identity for a visit and it is what the segment is for. The reason it is not in this week's pull request is the case he and I did not settle, which is what to do when PV1-19 is absent, since the standard does not require it. A conditional create with an empty condition matches every Encounter that also lacks a visit number and merges them, which is a worse failure than the duplicates I have now. So the duplicates stay until the absence case has an answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Worth stealing
&lt;/h2&gt;

&lt;p&gt;Feed the mapper, don't read it. Every one of these nine produced valid output. Reading the code tells you what it says. Running it tells you what it does, and in mapping work those come apart constantly.&lt;/p&gt;

&lt;p&gt;Write the test from the specification, not from the output. The middle-name test passed for months because it was written by running the code and pasting the result. A test written from PID-5's definition would have failed on day one.&lt;/p&gt;

&lt;p&gt;Check the mapping document against the mapper. A mapping table is the thing colleagues trust most and verify least, because it reads like a specification when it is actually a description, and descriptions drift.&lt;/p&gt;

&lt;p&gt;Look for the concept map before you reason from first principles. I decided PID-8 had no clean FHIR target and wrote that conclusion into a document. HL7 had already published the answer as a table. The standards bodies have done more of this work than it is comfortable to assume.&lt;/p&gt;

&lt;p&gt;Let the message decide the shape. OBX-2 exists precisely to tell you what kind of value you are holding. Guessing with &lt;code&gt;float()&lt;/code&gt; and falling back to zero is how a culture result becomes a number.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where AI helps here, and where it doesn't
&lt;/h2&gt;

&lt;p&gt;The fixes were quick to write once I knew what was wrong. The escape decoder, the date precision logic, the OBX-2 branch: all small, well-specified, mechanical.&lt;/p&gt;

&lt;p&gt;Finding them was a different activity, and it was not reading. It was constructing awkward messages, running them through, and looking at the JSON. A patient with a middle initial. A date of birth with a time zone. A name with an ampersand. A culture result that says POSITIVE. None of those are exotic, and every one of those defects is invisible in a code review, because each line is individually reasonable.&lt;/p&gt;

&lt;p&gt;That is now three weeks where running the thing found what reading it could not. I am going to stop describing it as a surprise.&lt;/p&gt;

&lt;p&gt;The other half is less comfortable. Three of the nine I did not find by any method, and would not have: they came from people who read Part 1 and asked questions I had not thought to ask. Publishing the thing in an unfinished state is the only reason those arrived at all, which is an argument for writing the series while the work is still wrong rather than after it is right.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;I would have written the mapping document and the tests from the HL7 and FHIR specifications first, and the mapper second. I did it the other way round, and ended up with a test suite and a document that both described the implementation back to me. Two sources of confirmation, neither of them independent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://hl7.org/fhir/R4/datatypes.html" rel="noopener noreferrer"&gt;HL7 FHIR R4: Data Types&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://hl7.org/fhir/R4/bundle-definitions.html" rel="noopener noreferrer"&gt;HL7 FHIR R4: Bundle, element definitions&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://hl7.org/fhir/R4/http.html" rel="noopener noreferrer"&gt;HL7 FHIR R4: RESTful API, conditional create&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.hl7.eu/HL7v2x/v251/std251/ch02.html" rel="noopener noreferrer"&gt;HL7 v2.5.1, Chapter 2: Control, escape sequences (hosted by HL7 Europe)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://terminology.hl7.org/CodeSystem-v2-0001.html" rel="noopener noreferrer"&gt;HL7 v2 table 0001, Administrative Sex (HL7 Terminology)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/ND-codes/nhs-interop-platform/tree/part-3" rel="noopener noreferrer"&gt;The code for this article, tag part-3&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Next
&lt;/h2&gt;

&lt;p&gt;Part 4, on 4 November, is weekly rather than fortnightly from here, and it is about observability, including an alert in this repo that has never been able to fire.&lt;/p&gt;

&lt;p&gt;Which of your mappings has a test that was written by pasting in what the code already did?&lt;/p&gt;




&lt;p&gt;This is a personal project. It is not affiliated with or endorsed by NHS England or any NHS organisation. It uses the public PDS FHIR sandbox and synthetic test data only. Views are my own.&lt;/p&gt;

</description>
      <category>discuss</category>
      <category>python</category>
      <category>ai</category>
      <category>programming</category>
    </item>
    <item>
      <title>Day 73: Six Permissions and a Repeat</title>
      <dc:creator>Nnamdi Felix Ibe</dc:creator>
      <pubDate>Wed, 28 Oct 2026 18:00:00 +0000</pubDate>
      <link>https://dev.to/ndcodes/day-73-six-permissions-and-a-repeat-g79</link>
      <guid>https://dev.to/ndcodes/day-73-six-permissions-and-a-repeat-g79</guid>
      <description>&lt;p&gt;Day 73 of DevOps, Day 23 of Azure. Both tasks today looked uniform and were not.&lt;/p&gt;

&lt;p&gt;Six permissions in one list turned out to come from three different places in Jenkins. And an Azure deployment I had run the day before, with the same script and the same image, behaved differently enough to change which verification I would trust.&lt;/p&gt;

&lt;p&gt;One Jenkins task, one Azure task. Give two users different permissions on one job, then repeat yesterday's self-configuring VM from the CLI. The tasks come from the KodeKloud Engineer platform.&lt;/p&gt;

&lt;h2&gt;
  
  
  Task 1: Two users, one job, different access
&lt;/h2&gt;

&lt;p&gt;The task: grant Rohan &lt;code&gt;build&lt;/code&gt;, &lt;code&gt;cancel&lt;/code&gt;, &lt;code&gt;configure&lt;/code&gt;, &lt;code&gt;read&lt;/code&gt;, &lt;code&gt;update&lt;/code&gt; and &lt;code&gt;tag&lt;/code&gt; on an existing job, and grant Sam &lt;code&gt;build&lt;/code&gt;, &lt;code&gt;configure&lt;/code&gt; and &lt;code&gt;read&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Check, do not create
&lt;/h3&gt;

&lt;p&gt;The task says to grant permissions to users. It does not say to create them, and both already existed.&lt;/p&gt;

&lt;p&gt;Worth being deliberate about, because creating a second &lt;code&gt;rohan&lt;/code&gt; leaves the grant attached to an account nobody logs into, and the symptom is a user who still cannot do anything. That reads like a permissions problem for as long as you let it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Install the plugin, again
&lt;/h3&gt;

&lt;p&gt;Matrix Authorization Strategy is not part of Jenkins. Its own page describes it as offering "matrix-based security authorization strategies (global and per-project)", and on a stock Jenkins the Security page has no matrix option at all.&lt;/p&gt;

&lt;p&gt;Same finding as Day 70, and it is worth repeating because the task's wording assumes it. A task that says "using a project-based matrix authorization strategy" is describing a plugin, and the authorisation half cannot start until it is installed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: The step the task does not mention
&lt;/h3&gt;

&lt;p&gt;Grant Overall/Read to authenticated users, globally, before touching the job.&lt;/p&gt;

&lt;p&gt;Without it, logging in as Rohan gives an access-denied message naming the missing Overall/Read permission, so you cannot verify anything. The usual shorthand for this is that nothing works without Overall/Read, and that overstates it.&lt;/p&gt;

&lt;p&gt;Jenkins is careful here: "While Overall/Read is a prerequisite for more extensive access to Jenkins, some features are available without it." The plugin says the same from the other side: "A user not granted read access to Jenkins in general will not be able to use most of the other permissions."&lt;/p&gt;

&lt;p&gt;Most, not all. So the accurate version is that Overall/Read gates almost everything a person would recognise as using Jenkins, and granting it to authenticated users is a real grant rather than a formality. Every logged-in user can then see that the instance exists and enumerate what they have.&lt;/p&gt;

&lt;p&gt;If you want to avoid that, Jenkins has a narrower tool. Job/Discover is documented as "only useful if anonymous users have Overall/Read permission, but not Job/Read", which is the shape for hiding specific jobs from users who can still reach Jenkins.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: The six permissions come from three places
&lt;/h3&gt;

&lt;p&gt;This is the part I would have got wrong by reading the grid's column labels.&lt;/p&gt;

&lt;p&gt;Jenkins groups its permissions as Overall, Agent, Job, Run, View and Credentials. The Job group is Build, Cancel, Configure, Create, Delete, Discover, Move, Read and Workspace. Four of the task's six names map straight onto it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Task name&lt;/th&gt;
&lt;th&gt;Documented permission&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;build&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Job/Build&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cancel&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Job/Cancel&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;configure&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Job/Configure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;read&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Job/Read&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The other two do not.&lt;/p&gt;

&lt;p&gt;There is no Job/Update. The only documented Update permissions are Run/Update and Credentials/Update, and the one that appears in a per-job grid is Run/Update, which sits in the Run group and concerns individual builds rather than the job's configuration.&lt;/p&gt;

&lt;p&gt;And there is no documented permission called Tag anywhere on Jenkins' permissions page. Tag comes from a plugin, and shows up in the grid only when that plugin is installed. I could not work out which plugin supplies it in this lab, so I am leaving that as unknown rather than filling it in.&lt;/p&gt;

&lt;p&gt;Which makes the lesson a practical one rather than a pedantic one. A permission grid looks like one decision and is several, and a column header does not tell you whether you are granting something about the job or about its builds. Read the grid by column group, not by column label.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: Log in as both of them
&lt;/h3&gt;

&lt;p&gt;The grid tells you what was ticked. It does not tell you what works.&lt;/p&gt;

&lt;p&gt;Logging in as Sam and confirming the job can be configured and built, then as Rohan and confirming the same plus what he has extra, is the only check that distinguishes a configured grid from a working one. Day 70 made this point for a single read-only user, and it is stronger with two users who are supposed to differ.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the docs recommend
&lt;/h3&gt;

&lt;p&gt;Granting on the job rather than globally is the whole point, and the documentation explains why in one line. Global grants apply everywhere: "Permission applying to such items or agents that are granted in the global configuration apply to all of them." Per-item grants sit on top of that, additively, under the default mode: "Permissions explicitly granted on individual items or agents will only add to permissions defined globally."&lt;/p&gt;

&lt;p&gt;So Rohan's six permissions granted globally would have given him them on every job, present and future. On the job, the scope stays where the task put it, and the global grid carries only the Overall/Read everyone needs.&lt;/p&gt;

&lt;p&gt;There is one thing in this task worth more worry than the permission names, though, and it is that both users got Configure.&lt;/p&gt;

&lt;p&gt;A freestyle job's configuration is its commands. Job/Configure lets a user change the build steps, which means anything in those build steps is visible to and editable by that user, including a password typed into a shell command. If the job runs on the controller rather than an agent, that command runs where Jenkins itself lives.&lt;/p&gt;

&lt;p&gt;That is not a hypothetical in this series. Day 76's job puts a database password directly into its shell step. Anyone with Configure on it can read that password. Granting Configure is closer to granting shell access than the word sounds, and the fix is to keep secrets out of job configuration rather than to be careful about who gets Configure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Task 2: Yesterday's VM, from the CLI
&lt;/h2&gt;

&lt;p&gt;The task: the same self-configuring nginx VM as Day 22, named &lt;code&gt;datacenter-vm&lt;/code&gt;, with the region named explicitly.&lt;/p&gt;

&lt;p&gt;Everything from yesterday carried over. The Azure CLI base64-encodes &lt;code&gt;--custom-data&lt;/code&gt;. &lt;code&gt;--nsg-rule&lt;/code&gt; accepts only &lt;code&gt;{NONE, RDP, SSH}&lt;/code&gt;, so port 80 needs &lt;code&gt;az vm open-port&lt;/code&gt;. Cloud-init dispatches on the script's first line. And &lt;code&gt;--storage-sku Standard_LRS&lt;/code&gt; is mandatory in this subscription, because the policy refuses whatever the undocumented default produces.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Pass the region anyway
&lt;/h3&gt;

&lt;p&gt;The resource group is already in eastus, so omitting &lt;code&gt;--location&lt;/code&gt; would have worked. I passed it because the task names a region, which is the Day 4 rule: inheriting a default that happens to match is luck, not correctness.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;eastus&lt;/code&gt; is the CLI name. "East US", as the task writes it, is the display name, and the CLI rejects it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: The same commands took a different amount of time
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Day 22:  attempt 1: 000    attempt 2: 000    attempt 3: 200
Day 23:  attempt 1: 200
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same script, same image, same size, same region, one day apart. Yesterday nginx needed about 40 seconds after &lt;code&gt;az vm create&lt;/code&gt; returned. Today it answered immediately.&lt;/p&gt;

&lt;p&gt;Nothing changed on my side. The variance is in &lt;code&gt;apt-get update&lt;/code&gt; and &lt;code&gt;apt-get install&lt;/code&gt;, which depend on mirror latency and package cache state on the day.&lt;/p&gt;

&lt;p&gt;That is the entire argument for polling a condition rather than sleeping an interval. A &lt;code&gt;sleep 45&lt;/code&gt; tuned to yesterday wastes 45 seconds today. A &lt;code&gt;sleep 20&lt;/code&gt; tuned to today fails yesterday.&lt;/p&gt;

&lt;p&gt;The sharper version: a single immediate &lt;code&gt;curl&lt;/code&gt; would have passed today and failed yesterday. That is the worst kind of check, because it works often enough to earn trust it has not demonstrated. A check that fails loudly is worth more than one that is usually right.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the docs recommend
&lt;/h3&gt;

&lt;p&gt;The task asked for two things that sound like one: HTTP reachable from the internet, and the nginx service running. Three checks, three different claims:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;What it proves&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;curl&lt;/code&gt; returns 200&lt;/td&gt;
&lt;td&gt;Reachable now, end to end: NSG rule, public IP, listening process, content&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;systemctl is-active&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Running now, independent of the network path&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;systemctl is-enabled&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Still running after a reboot&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;curl&lt;/code&gt; subsumes "running", since a stopped service cannot answer. What it cannot reach is &lt;code&gt;is-enabled&lt;/code&gt;, which reports whether nginx starts at boot. That is invisible over HTTP, and it only becomes visible after the next restart, by which point nobody is looking.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;systemctl enable --now&lt;/code&gt; in the boot script set both. Only the direct check confirms the &lt;code&gt;enable&lt;/code&gt; half took effect, and &lt;code&gt;az vm run-command invoke&lt;/code&gt; made that possible without SSH.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;p&gt;Two lists that looked uniform and were not.&lt;/p&gt;

&lt;p&gt;Rohan's six permissions read as one decision about one job. They are four permissions about the job, one about its builds, and one that Jenkins does not document at all. Nothing in the grid says so. You find out by reading the column groups instead of the column labels, or by granting something and discovering later it did not mean what you thought.&lt;/p&gt;

&lt;p&gt;And two runs of the same deployment, with every input identical, differed by about 40 seconds in whether the thing I was checking for existed yet. Which is the more useful of the two findings, because it tells you something about verification rather than about Azure: if a check would have passed today and failed yesterday, it was never a check. It was a coin flip that happened to land well.&lt;/p&gt;

&lt;p&gt;So here is the Day 73 question. Which of your verification steps would still pass if the thing it verifies had not finished yet?&lt;/p&gt;

&lt;p&gt;Day 73 down. Twenty-seven to go.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>jenkins</category>
      <category>azure</category>
      <category>cicd</category>
    </item>
    <item>
      <title>Day 72: A Parameter and a Boot Script</title>
      <dc:creator>Nnamdi Felix Ibe</dc:creator>
      <pubDate>Mon, 26 Oct 2026 18:07:58 +0000</pubDate>
      <link>https://dev.to/ndcodes/day-72-a-parameter-and-a-boot-script-4gjj</link>
      <guid>https://dev.to/ndcodes/day-72-a-parameter-and-a-boot-script-4gjj</guid>
      <description>&lt;p&gt;Day 72 of DevOps, Day 22 of Azure. Both tasks today were about a value crossing from outside into something that runs it, and what happens at the boundary.&lt;/p&gt;

&lt;p&gt;A Jenkins parameter crosses into a shell script. A boot script crosses into a brand new VM. In both cases, the interesting part is not the value, it is the handoff, and yesterday's bug turns out to explain today's safety.&lt;/p&gt;

&lt;p&gt;One Jenkins task, one Azure task. Add two parameters to a job and use them, then build a VM that configures itself at first boot. The tasks come from the KodeKloud Engineer platform.&lt;/p&gt;

&lt;h2&gt;
  
  
  Task 1: Two build parameters
&lt;/h2&gt;

&lt;p&gt;The task: a job named &lt;code&gt;parameterised-job&lt;/code&gt;, with a String parameter &lt;code&gt;Stage&lt;/code&gt; defaulting to &lt;code&gt;Build&lt;/code&gt;, and a Choice parameter &lt;code&gt;env&lt;/code&gt; offering &lt;code&gt;Development&lt;/code&gt;, &lt;code&gt;Staging&lt;/code&gt; and &lt;code&gt;Production&lt;/code&gt;. Then a shell step that uses them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Add them, and notice the documentation gap
&lt;/h3&gt;

&lt;p&gt;Both go under This project is parameterised. That part is a form.&lt;/p&gt;

&lt;p&gt;What is missing is any documentation of it. Jenkins' Pipeline docs describe the &lt;code&gt;parameters&lt;/code&gt; directive, show a &lt;code&gt;string&lt;/code&gt; parameter, and say parameters "are accessible as members of the &lt;code&gt;params&lt;/code&gt; variable". They never mention choice parameters, and they never mention freestyle projects at all. This is the second day running that the freestyle feature a task asks for has no page: Day 71 had the same gap for the Execute shell step.&lt;/p&gt;

&lt;p&gt;There is exactly one documented sentence connecting the two halves, and it is the one that matters: "parameterised freestyle projects pass parameter values to Shell and Batch build steps as environment variables."&lt;/p&gt;

&lt;p&gt;So nothing in the script declares anything. Jenkins exports &lt;code&gt;Stage&lt;/code&gt; and &lt;code&gt;env&lt;/code&gt; into the build's environment, and the shell expands them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: The Choice parameter is yesterday's recommendation
&lt;/h3&gt;

&lt;p&gt;Day 71's job took a free-text package name and interpolated it into a command on a remote host with passwordless sudo. I said a validated choice list was the fix and had no example to point at. This is the example.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;env&lt;/code&gt; can only ever be one of three strings. The set of possible build behaviours is finite, and it is visible in the job configuration rather than in whoever typed into the box.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;Stage&lt;/code&gt; is still free text, and its default value is what makes the job runnable without input. Worth knowing that a String parameter with no default is an empty string rather than an error, so a script that assumes a value will get one, and it will be empty.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Why this is safer than Pipeline, by accident
&lt;/h3&gt;

&lt;p&gt;Jenkins documents the injection risk for Pipeline carefully, and the mechanism it names is specific. The hazard is Groovy string interpolation: "Groovy string interpolation can inject rogue commands into command interpreters via special characters", which "can result in problems analogous to SQL injection".&lt;/p&gt;

&lt;p&gt;The worked example is good. Give a string parameter the default &lt;code&gt;hello; ls /&lt;/code&gt;, then write &lt;code&gt;sh("echo ${STATEMENT}")&lt;/code&gt;, and the &lt;code&gt;ls&lt;/code&gt; runs. Groovy substitutes the value into the command text before the shell ever sees it, so a user who controls that value "would be able to make the &lt;code&gt;sh&lt;/code&gt; step run arbitrary code on the agent".&lt;/p&gt;

&lt;p&gt;The documented fix is to stop substituting and let the shell read the value instead. Arguments referencing parameters should "use single quotes to avoid Groovy interpolation", so Jenkins sends the literal command string and the shell picks the value out of its environment.&lt;/p&gt;

&lt;p&gt;A freestyle Execute shell step is already in that second form. There is no Groovy layer. Jenkins exports the parameter, and &lt;code&gt;$Stage&lt;/code&gt; in the script is a plain shell parameter expansion.&lt;/p&gt;

&lt;p&gt;And the shell's own ordering is what makes that safe. Bash performs expansions in a fixed order, "brace expansion, then tilde, parameter and variable, arithmetic, and command substitution (left to right), then word splitting, then filename expansion, and finally quote removal". Of all of those, "Only brace expansion, word splitting, and filename expansion can increase the number of words."&lt;/p&gt;

&lt;p&gt;Parameter expansion is not on that list. A value containing &lt;code&gt;;&lt;/code&gt; or &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt; expands into characters sitting inside a word, not into new command separators, because the shell decided where its commands began when it broke the input into words and operators, and that happened before any expansion.&lt;/p&gt;

&lt;p&gt;Which is yesterday's bug seen from the other side. On Day 71, &lt;code&gt;ssh host cmd1 &amp;amp;&amp;amp; cmd2&lt;/code&gt; split into two commands on two machines because the &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt; was in the script text, tokenised before &lt;code&gt;ssh&lt;/code&gt; ran. Today, an &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt; arriving inside a parameter value cannot split anything, for the same reason and in the same order.&lt;/p&gt;

&lt;p&gt;Tokenise, then expand. Text you write in the script is structure. Text that arrives in a variable is data. One rule, and it cuts both ways.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the docs recommend
&lt;/h3&gt;

&lt;p&gt;None of that makes an unquoted expansion fine. Word splitting and filename expansion do still apply, and they are the next two steps in that same list:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"Stage is &lt;/span&gt;&lt;span class="nv"&gt;$Stage&lt;/span&gt;&lt;span class="s2"&gt;, environment is &lt;/span&gt;&lt;span class="nv"&gt;$env&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Unquoted, a value with spaces becomes several arguments, and a value containing &lt;code&gt;*&lt;/code&gt; gets expanded against the working directory. Those are ordinary bugs rather than remote code execution, and they are still bugs.&lt;/p&gt;

&lt;p&gt;One naming note. &lt;code&gt;env&lt;/code&gt; is also a standard Unix command, and in Jenkins Pipeline it is the global environment namespace. As a freestyle parameter it collides with nothing, but the same name in a Pipeline would read as though it should. &lt;code&gt;TARGET_ENV&lt;/code&gt; says the same thing and costs nobody an afternoon.&lt;/p&gt;

&lt;h2&gt;
  
  
  Task 2: A VM that configures itself
&lt;/h2&gt;

&lt;p&gt;The task: an Ubuntu VM named &lt;code&gt;nautilus-vm&lt;/code&gt; that installs and starts nginx on first boot, reachable on port 80.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Pass the disk flags, and a correction about why
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nt"&gt;--storage-sku&lt;/span&gt; Standard_LRS &lt;span class="nt"&gt;--os-disk-size-gb&lt;/span&gt; 30
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Day 21 failed on exactly this, and nothing in today's task mentions disks, so the same default would have applied, and the same policy rejection would have followed. First time in this track that a recorded failure has prevented its own repeat rather than being rediscovered.&lt;/p&gt;

&lt;p&gt;My explanation of the default was wrong, though, and it is worth correcting because it is in two of my notes. I had written that &lt;code&gt;az vm create&lt;/code&gt; defaults the OS disk to &lt;code&gt;Premium_LRS&lt;/code&gt; on premium-capable sizes.&lt;/p&gt;

&lt;p&gt;The CLI reference lists allowed values for &lt;code&gt;--storage-sku&lt;/code&gt; and gives no default at all. The only hardcoded &lt;code&gt;Premium_LRS&lt;/code&gt; in the CLI's own validation applies to the old unmanaged disk profiles, and the managed-disk path, which is the default, sends no storage account type whatsoever. Something below the CLI chooses, and the rule it uses is not written down anywhere I can read.&lt;/p&gt;

&lt;p&gt;The finding survives, the mechanism does not: omit &lt;code&gt;--storage-sku&lt;/code&gt; in this subscription and you get a Premium disk and a policy refusal. Specify it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: The CLI encodes the script for you
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nt"&gt;--custom-data&lt;/span&gt; /root/cloud-init.sh
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Microsoft puts the requirement and the exemption in one sentence: "To use custom data, you must Base64-encode the contents before passing the data to the API--unless you're using a CLI tool that does the conversion for you, such as the Azure CLI. The size can't exceed 64 KB."&lt;/p&gt;

&lt;p&gt;Worth setting beside the AWS equivalent, because the asymmetry bit me on Day 44:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Call&lt;/th&gt;
&lt;th&gt;Encoding&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;aws ec2 run-instances --user-data file://...&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Automatic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;aws ec2 create-launch-template&lt;/code&gt; UserData&lt;/td&gt;
&lt;td&gt;Manual. Raw text succeeds and never runs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;az vm create --custom-data&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Automatic&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The launch template case is the dangerous one, because nothing fails. The template is created, the instance launches, the script simply never executes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Custom data or user data
&lt;/h3&gt;

&lt;p&gt;Azure has both flags and they are not the same field. My notes hedged, saying which one a given image acts on varies by image and cloud-init version. That hedge was unnecessary and I could not find anything supporting it.&lt;/p&gt;

&lt;p&gt;Microsoft draws the line by purpose: "Unlike custom data, user data is retrievable from the Azure Instance Metadata Service (IMDS), is persistent for the lifetime of the VM, and can be updated without stopping or reimaging the VM. Use user data when you need post-provision access or updates; use custom data when you need a payload processed by the provisioning agent at first boot."&lt;/p&gt;

&lt;p&gt;So custom data is the one the provisioning agent runs at first boot, which is what this task wanted. Not the safer of two uncertain options, just the right one.&lt;/p&gt;

&lt;p&gt;One warning attaches to custom data and not to user data: "We advise not to store sensitive data in custom data."&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Port 80 needs its own command
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;--nsg-rule&lt;/code&gt; cannot do it. Its accepted values are &lt;code&gt;{NONE, RDP, SSH}&lt;/code&gt;, full stop, so an arbitrary port goes through a second call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az vm open-port &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; nautilus-vm &lt;span class="nt"&gt;--port&lt;/span&gt; 80 &lt;span class="nt"&gt;--priority&lt;/span&gt; 900
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;open-port-80       80   900   Allow
default-allow-ssh  22   1000  Allow
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Priority 900 sits ahead of the SSH rule deliberately. Nothing conflicts today, so order does not matter today. It matters in general, because NSG rules are evaluated in priority order and the first match wins, so a Deny landing between two Allows changes everything. Leaving the low numbers free for rules that need to win is the habit worth having.&lt;/p&gt;

&lt;p&gt;My notes also claimed &lt;code&gt;az vm open-port&lt;/code&gt; defaults to priority 900 and to &lt;code&gt;*&lt;/code&gt; for the port. I could not verify either. Passing both flags explicitly makes the question moot, which is reason enough to pass them.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the docs recommend
&lt;/h3&gt;

&lt;p&gt;The script is four lines, and its first line is the one that decides whether any of it happens:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;#!/bin/bash&lt;/span&gt;
apt-get update &lt;span class="nt"&gt;-y&lt;/span&gt;
apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; nginx
systemctl &lt;span class="nb"&gt;enable&lt;/span&gt; &lt;span class="nt"&gt;--now&lt;/span&gt; nginx
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cloud-init dispatches on that first line. &lt;code&gt;#!&lt;/code&gt; means run it as a script, &lt;code&gt;#cloud-config&lt;/code&gt; means parse it as YAML, and neither means it is ignored. Which is the same silent failure as unencoded AWS user data, arriving by a different route.&lt;/p&gt;

&lt;p&gt;Then the thing that actually caught me:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;attempt 1: 000
attempt 2: 000
attempt 3: 200
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;az vm create&lt;/code&gt; reported &lt;code&gt;VM running&lt;/code&gt; straight away. The VM did not serve HTTP for about another 40 seconds, while cloud-init ran &lt;code&gt;apt-get update&lt;/code&gt; and installed nginx. A &lt;code&gt;000&lt;/code&gt; from curl means no TCP connection at all, so for those 40 seconds the port was open at the NSG with nothing listening behind it.&lt;/p&gt;

&lt;p&gt;This is Day 38's lesson in its mildest form. There an ECS task reported &lt;code&gt;RUNNING&lt;/code&gt; and the service was never reachable, and I failed the task because verification stopped at the status field. Here the gap is 40 seconds rather than forever, and the shape is identical: the control plane reports the resource, not the application.&lt;/p&gt;

&lt;p&gt;The retry loop is the right instrument, and not because it is more patient than a &lt;code&gt;sleep&lt;/code&gt;. It makes the gap visible rather than hiding it, and it tells "not ready yet" apart from "broken" by showing progress. Had it stayed at &lt;code&gt;000&lt;/code&gt;, &lt;code&gt;cloud-init status&lt;/code&gt; separates the three possibilities that matter: still running, finished, or finished with an error.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;p&gt;Both halves came down to the same question: at what point does a value stop being text and start being structure?&lt;/p&gt;

&lt;p&gt;In the Jenkins job, the answer is that the shell decides where commands begin before it expands any variable. That is why Day 71's &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt; broke a command in two, and it is why an &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt; inside today's parameter cannot. Same rule, opposite outcomes, and knowing the order is what lets you predict which one you get.&lt;/p&gt;

&lt;p&gt;On Azure, the answer is that the first line of a boot script decides whether the rest of it is a script at all, and the CLI decides whether the payload arrives in a form the agent can read. Get either wrong and nothing errors. You get a running VM that quietly did none of what you asked.&lt;/p&gt;

&lt;p&gt;The disk SKU is the third version of the same thing, and the one I had explained wrongly. A value I never wrote became part of the request, something I cannot read chose it, and the policy refused it.&lt;/p&gt;

&lt;p&gt;So here is the Day 72 question. In your build scripts, which values are you writing into the command text that should be arriving through the environment instead?&lt;/p&gt;

&lt;p&gt;Day 72 down. Twenty-eight to go.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>jenkins</category>
      <category>azure</category>
      <category>cicd</category>
    </item>
    <item>
      <title>Day 71: One Command, Two Machines</title>
      <dc:creator>Nnamdi Felix Ibe</dc:creator>
      <pubDate>Sun, 25 Oct 2026 09:00:00 +0000</pubDate>
      <link>https://dev.to/ndcodes/day-71-one-command-two-machines-1i3e</link>
      <guid>https://dev.to/ndcodes/day-71-one-command-two-machines-1i3e</guid>
      <description>&lt;p&gt;Day 71 of DevOps, Day 21 of Azure. Both tasks today turned on the same mistake: treating one line of a command as one unit of work.&lt;/p&gt;

&lt;p&gt;A Jenkins build step I wrote as a single remote command was actually two commands on two different machines, and only one of them reached the server I meant. An &lt;code&gt;az vm create&lt;/code&gt; I thought of as one command turned out to be a deployment of six resources, so when it failed, five of them stayed behind.&lt;/p&gt;

&lt;p&gt;One Jenkins task, one Azure task. Build a parameterised job that installs a package on a remote host, then gives a VM a static public IP. The tasks come from the KodeKloud Engineer platform.&lt;/p&gt;

&lt;h2&gt;
  
  
  Task 1: A Jenkins job that installs a package
&lt;/h2&gt;

&lt;p&gt;The task: a freestyle job named &lt;code&gt;install-package&lt;/code&gt;, with a string parameter &lt;code&gt;PACKAGE&lt;/code&gt;, which installs the named package on the storage server &lt;code&gt;ststor01&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Find out what the target runs
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /etc/os-release
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;NAME="CentOS Stream"
VERSION="9"
ID="centos"
PLATFORM_ID="platform:el9"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;CentOS Stream 9, so &lt;code&gt;dnf&lt;/code&gt;. The Jenkins node, from Day 68, is Ubuntu 24.04, so &lt;code&gt;apt&lt;/code&gt;. Two package managers in one job, which is the detail that makes the next step go wrong.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: The parameter, and how it reaches the shell
&lt;/h3&gt;

&lt;p&gt;A String parameter named &lt;code&gt;PACKAGE&lt;/code&gt;, then a shell build step using &lt;code&gt;$PACKAGE&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That works because of something Jenkins documents directly: "parameterised freestyle projects pass parameter values to Shell and Batch build steps as environment variables." The parameter is not substituted into the script by Jenkins. It is exported, and the shell expands it.&lt;/p&gt;

&lt;p&gt;Worth noting what is not documented. Jenkins' own site has no page describing the freestyle "Execute shell" build step, its shell, or its exit-status behaviour. The documented equivalent is Pipeline's &lt;code&gt;sh&lt;/code&gt; step, which "Runs a Bourne shell script", uses "the system default shell ... using the &lt;code&gt;-xe&lt;/code&gt; flags", and fails the build when "a script which exits with a nonzero status code" runs. Freestyle behaves the same way in practice. I could not cite that from Jenkins.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: A key for the storage server
&lt;/h3&gt;

&lt;p&gt;Jenkins has no credentials for &lt;code&gt;ststor01&lt;/code&gt;, so:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh jenkins@jenkins
ssh-keygen &lt;span class="nt"&gt;-t&lt;/span&gt; rsa &lt;span class="nt"&gt;-b&lt;/span&gt; 4096
ssh-copy-id natasha@ststor01
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key has to belong to the &lt;code&gt;jenkins&lt;/code&gt; user, because that is who the build runs as. Logging in as root and generating a key in &lt;code&gt;/root/.ssh&lt;/code&gt; produces a key the build cannot see. &lt;code&gt;ssh-keygen&lt;/code&gt; offering &lt;code&gt;/var/lib/jenkins/.ssh/id_rsa&lt;/code&gt; is how you know you are the right user, since &lt;code&gt;JENKINS_HOME&lt;/code&gt; is &lt;code&gt;/var/lib/jenkins&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;-t rsa -b 4096&lt;/code&gt; also overrides two defaults, and not the ones usually assumed. The ssh-keygen manual gives the default key type as ed25519, and for RSA, "the minimum size is 1024 bits and the default is 3072 bits". So 4096 is an upgrade from 3072, not from 2048.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;sudo&lt;/code&gt; then works over that connection without a password because &lt;code&gt;natasha&lt;/code&gt; has a passwordless rule, and it has to, because there is nowhere to type one. The ssh manual: ssh "by default will only request a pseudo-terminal (pty) for interactive sessions when the client has one". Supplying a command is not an interactive session, so there is no terminal for a prompt to appear on.&lt;/p&gt;

&lt;p&gt;I had attributed that to sudo's &lt;code&gt;requiretty&lt;/code&gt;. It is a different thing. Requiretty "will only run when the user is logged in to a real tty", it "is off by default", and it governs whether sudo runs at all, not whether it can prompt.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: The build step was wrong
&lt;/h3&gt;

&lt;p&gt;This is what went into the job:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh natasha@ststor01 &lt;span class="nb"&gt;sudo &lt;/span&gt;dnf makecache &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;sudo &lt;/span&gt;dnf &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nv"&gt;$PACKAGE&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Read as one remote command, it makes sense. It is not one remote command.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt; belongs to the local shell. Bash's manual: "A list is a sequence of one or more pipelines separated by one of the operators ';', '&amp;amp;', '&amp;amp;&amp;amp;', or '||'", and the shell "Breaks the input into words and operators" before it runs anything. So &lt;code&gt;ssh&lt;/code&gt; never receives &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt; as an argument at all. By the time ssh starts, the line has already been split.&lt;/p&gt;

&lt;p&gt;ssh builds its remote command only from the arguments it does receive. Its synopsis is &lt;code&gt;destination [command [argument ...]]&lt;/code&gt;, and "If a command is specified, it will be executed on the remote host instead of a login shell."&lt;/p&gt;

&lt;p&gt;Which means the line runs on two machines:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sudo dnf makecache              -&amp;gt; on ststor01
sudo dnf install -y $PACKAGE    -&amp;gt; on the Jenkins node
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the Jenkins node is Ubuntu. It has no &lt;code&gt;dnf&lt;/code&gt;. The install half never ran anywhere it could work.&lt;/p&gt;

&lt;p&gt;The fix is to quote the whole remote command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh natasha@ststor01 &lt;span class="s2"&gt;"sudo dnf makecache &amp;amp;&amp;amp; sudo dnf install -y &lt;/span&gt;&lt;span class="nv"&gt;$PACKAGE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Double quotes, deliberately, not single. The local shell expands &lt;code&gt;$PACKAGE&lt;/code&gt; before sending, which is what is wanted here, because the Jenkins parameter only exists on the Jenkins side. Single quotes would send the literal &lt;code&gt;$PACKAGE&lt;/code&gt;, and the remote shell would expand it to nothing.&lt;/p&gt;

&lt;p&gt;One thing I cannot reconcile, and would rather say than quietly tidy up. My own notes record the build succeeding, and &lt;code&gt;vim-enhanced.x86_64 2:8.2.2637-27.el9&lt;/code&gt; present on &lt;code&gt;ststor01&lt;/code&gt; afterwards. The command as I wrote it down cannot produce that. The likeliest explanation is that the build step in Jenkins was not identical to the line I copied into my notes, which is the gap a screenshot-driven write-up leaves. The corrected form above is the one to use either way.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the docs recommend
&lt;/h3&gt;

&lt;p&gt;The risk in this job is not the package manager. It is that &lt;code&gt;$PACKAGE&lt;/code&gt; is free text that ends up inside a command line on a remote host, with passwordless sudo at the other end.&lt;/p&gt;

&lt;p&gt;Jenkins warns about exactly this shape. Using a Windows example: "A variable value that contains valid Batch commands may result in the execution of these commands by Jenkins", and "special care may need to be taken to filter or sanitise environment variable values". Then the general case: "Any user who can add environment variables with a name they choose may be able to modify the behaviour of builds this way."&lt;/p&gt;

&lt;p&gt;So anyone who can trigger this build can run an arbitrary command on the storage server as a user with passwordless sudo. A validated choice parameter instead of a free string closes most of it. And as on Day 69 and Day 70, the job's definition is worth living in a file where that constraint is reviewable, rather than in a form where it is one edit away from gone.&lt;/p&gt;

&lt;h2&gt;
  
  
  Task 2: A static public IP for a VM
&lt;/h2&gt;

&lt;p&gt;The task: &lt;code&gt;datacenter-vm&lt;/code&gt;, Ubuntu, &lt;code&gt;Standard_B1s&lt;/code&gt;, with an SSH key and a static public IP named &lt;code&gt;datacenter-pip&lt;/code&gt;, reachable over SSH.&lt;/p&gt;

&lt;p&gt;It took four attempts, and the sequence is the interesting part.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: The default OS disk is banned
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;RequestDisallowedByPolicy: Resource 'datacenter-vm_OsDisk_...' was disallowed by policy.
Reasons: 'Global: This storage configuration is not allowed. Ensure the disk size is
128 GB or less and the SKU is not Premium for Compute disks.'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fix was to stop leaving the disk at the default:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nt"&gt;--storage-sku&lt;/span&gt; Standard_LRS &lt;span class="nt"&gt;--os-disk-size-gb&lt;/span&gt; 30
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Day 2 and Day 3 both passed &lt;code&gt;--storage-sku Standard_LRS&lt;/code&gt; and both worked, not because I knew about the policy but because those tasks specified a disk type. This one said nothing about disks, so the default applied, and the default is the one thing this subscription refuses.&lt;/p&gt;

&lt;p&gt;My explanation of which default was wrong, and it was specific enough to be worth correcting. I had written that &lt;code&gt;az vm create&lt;/code&gt; picks &lt;code&gt;Premium_LRS&lt;/code&gt; whenever the VM size supports premium storage, which &lt;code&gt;Standard_B1s&lt;/code&gt; does.&lt;/p&gt;

&lt;p&gt;The CLI does not do that. &lt;code&gt;--storage-sku&lt;/code&gt; has no documented default, and the only hardcoded &lt;code&gt;Premium_LRS&lt;/code&gt; in the CLI's validation applies to the old unmanaged disk profiles rather than to managed disks. On the managed-disk path, which is the default, the CLI sends no storage account type at all and leaves the Compute service to choose. What it chooses is not stated in the CLI reference.&lt;/p&gt;

&lt;p&gt;Which makes the lesson stronger, not weaker: something downstream picked a Premium disk, and I cannot read the rule it used. In a policy-constrained subscription, the values you do not specify are the ones most likely to be rejected, and the mechanism behind them may not be documented anywhere you can check.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: az vm create does not roll back
&lt;/h3&gt;

&lt;p&gt;The failed attempt left this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;datacenter-pip      Microsoft.Network/publicIPAddresses
datacenter-vmVNET   Microsoft.Network/virtualNetworks
datacenter-vmNSG    Microsoft.Network/networkSecurityGroups
datacenter-vmVMNic  Microsoft.Network/networkInterfaces
datacenter-vm       Microsoft.Compute/virtualMachines    &amp;lt;- provisioningState: Failed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;az vm create&lt;/code&gt; is one command and six resources. When the VM fails, the five that already exist stay, and the VM itself remains as a resource in &lt;code&gt;Failed&lt;/code&gt; state.&lt;/p&gt;

&lt;p&gt;That there is no rollback is not documented for the command. The surrounding behaviour is the same, and it points the same way: ARM's undo is opt-in. &lt;code&gt;--rollback-on-error&lt;/code&gt; is "The name of a deployment to roll back to on error, or use as a flag to roll back to the last successful deployment", with a warning that it "rollbacks a failed deployment by redeploying an earlier deployment" and the result "may be different than what you would expect from undoing the failed deployment". ARM replays. It does not reverse. And it does nothing unless asked.&lt;/p&gt;

&lt;p&gt;This is Day 70's finding showing its consequences. An ARM deployment is a record of an operation, not an owner of resources, so nothing owns the half-built output and nothing cleans it up.&lt;/p&gt;

&lt;p&gt;CloudFormation is the other way round. Day 47 failed on an IAM permission and rolled itself back, because &lt;code&gt;OnFailure&lt;/code&gt; defaults to &lt;code&gt;ROLLBACK&lt;/code&gt;. The price there was that the stack had to be deleted before retrying. The price here is cleaning up by hand.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: My own cleanup caused the next two failures
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;OperationNotAllowed: Operation 'Update VM' is not allowed on VM 'datacenter-vm'
since the VM is marked for deletion.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Caused by this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az vm delete &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; datacenter-vm &lt;span class="nt"&gt;--yes&lt;/span&gt; 2&amp;gt;/dev/null
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two mistakes on one line. &lt;code&gt;2&amp;gt;/dev/null&lt;/code&gt; hid whether it worked at all. And Azure's control-plane operations are asynchronous, so success means started, not finished: "Some Azure REST operations run asynchronously because the operation can't be completed quickly", returning a status code and a header to poll. I then ran &lt;code&gt;az vm create&lt;/code&gt; against a VM that was still being deleted.&lt;/p&gt;

&lt;p&gt;The obvious waiter did not help:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az vm &lt;span class="nb"&gt;wait&lt;/span&gt; &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; datacenter-vm &lt;span class="nt"&gt;--deleted&lt;/span&gt;
The operation failed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It reported failure while the VM was still listed. What &lt;code&gt;--deleted&lt;/code&gt; actually polls is not documented; the help says "Wait until deleted." and gives an interval and a timeout. What I can say is what it did here, which is give up on a resource that was already in a failed state.&lt;/p&gt;

&lt;p&gt;What worked was testing the only thing that matters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az vm delete &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; datacenter-vm &lt;span class="nt"&gt;--yes&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"delete exit=&lt;/span&gt;&lt;span class="nv"&gt;$?&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;until&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt; az vm show &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; datacenter-vm &lt;span class="o"&gt;&amp;gt;&lt;/span&gt;/dev/null 2&amp;gt;&amp;amp;1&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do &lt;/span&gt;&lt;span class="nb"&gt;sleep &lt;/span&gt;10&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;done&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;until ! az vm show&lt;/code&gt; asks whether the resource still exists, rather than asking a waiter for its opinion about state.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: The IP, which was the point
&lt;/h3&gt;

&lt;p&gt;Created as its own resource first, then referenced by name:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az network public-ip create &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; datacenter-pip &lt;span class="nt"&gt;--sku&lt;/span&gt; Standard &lt;span class="nt"&gt;--allocation-method&lt;/span&gt; Static
az vm create ... &lt;span class="nt"&gt;--public-ip-address&lt;/span&gt; datacenter-pip
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The address did not change across four attempts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Attempt 1 (policy failure):  20.119.41.127
Attempt 4 (success):         20.119.41.127
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because deleting a VM does not delete a public IP. Microsoft: "By default, disks, NICs, and Public IPs associated with a VM are persisted when the VM is deleted." And the CLI documents the dual behaviour of the flag: &lt;code&gt;--public-ip-address&lt;/code&gt; is the "Name of the public IP address when creating one (default) or referencing an existing one".&lt;/p&gt;

&lt;p&gt;Had I let &lt;code&gt;az vm create&lt;/code&gt; generate the IP, each failed attempt would have produced a different address, and every retry would have invalidated whatever DNS record or firewall allowlist pointed at the last one. Which is the problem the task exists to solve.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;--allocation-method Static&lt;/code&gt; is redundant with &lt;code&gt;--sku Standard&lt;/code&gt;, since Standard's allocation method is Static with no alternative. I passed it anyway, so the requirement is visible in the command rather than implied by a SKU.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the docs recommend
&lt;/h3&gt;

&lt;p&gt;Three resources survived my cleanup on purpose:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;datacenter-pip      the whole point, keep it
datacenter-vmVNET   az vm create reuses a VNet of this name
datacenter-vmNSG    az vm create reuses an NSG of this name
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;az vm create&lt;/code&gt; generates names from the VM name, &lt;code&gt;{vm}VNET&lt;/code&gt;, &lt;code&gt;{vm}Subnet&lt;/code&gt;, &lt;code&gt;{vm}NSG&lt;/code&gt;, &lt;code&gt;{vm}PublicIP&lt;/code&gt; and &lt;code&gt;{vm}VMNic&lt;/code&gt;, and for most of them it will reuse an existing resource of that name instead of failing. The reference says so for the NSG: "The name to use when creating a new Network Security Group (default) or referencing an existing one."&lt;/p&gt;

&lt;p&gt;The NIC is the exception, in the CLI's source and in practice. It holds the private IP reservation and the public IP association, so a new VM cannot adopt one already sitting there under the generated name. That is why only the VM, its NIC and its disk had to be removed between attempts.&lt;/p&gt;

&lt;p&gt;Two more small things that only show up on a retry. &lt;code&gt;--generate-ssh-keys&lt;/code&gt; printed its message on the first attempt, the one that failed, and the later attempts reused the keys, because it is documented to "Generate SSH public and private key files if missing" and stores them in &lt;code&gt;~/.ssh&lt;/code&gt;. And &lt;code&gt;azureuser&lt;/code&gt; rather than the local username, because the local user here is root, and &lt;code&gt;root&lt;/code&gt; is on Azure's list of disallowed Linux admin usernames. The CLI anticipates that: "Default value is current username of OS. If the default value is system reserved, then default value will be set to azureuser."&lt;/p&gt;

&lt;p&gt;Verification, as on Day 13:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh &lt;span class="nt"&gt;-o&lt;/span&gt; &lt;span class="nv"&gt;BatchMode&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;yes &lt;/span&gt;azureuser@20.119.41.127 &lt;span class="s1"&gt;'whoami; hostname'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;azureuser
datacenter-vm
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;BatchMode=yes&lt;/code&gt; makes a password prompt impossible, so that login could only have come from the key.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;p&gt;One line is not one unit of work, and both halves of today punished that assumption in different ways.&lt;/p&gt;

&lt;p&gt;The ssh command looked like one instruction to one machine and was two instructions to two machines, because &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt; is handled by the shell in front of ssh, not by ssh. Nothing errored loudly. Half the work simply happened on the wrong host, where the tool it needed does not exist.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;az vm create&lt;/code&gt; looked like one instruction and was a deployment of six resources, so failure is not a single event. Five resources existed, one sat in &lt;code&gt;Failed&lt;/code&gt; state, and the command meant to clean up started an operation it did not wait for, with its error output discarded.&lt;/p&gt;

&lt;p&gt;The static IP is the counterweight. By creating the address as its own resource and referring to it by name, the one thing that had to survive four rebuilds did, while everything around it was destroyed and recreated repeatedly.&lt;/p&gt;

&lt;p&gt;So here is the Day 71 question. In your scripts, which single line is actually doing its work on more than one machine, or on more than one resource, than you would say if asked?&lt;/p&gt;

&lt;p&gt;Day 71 down. Twenty-nine to go.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>jenkins</category>
      <category>azure</category>
      <category>cicd</category>
    </item>
    <item>
      <title>Day 70: A Read-Only User and a Template</title>
      <dc:creator>Nnamdi Felix Ibe</dc:creator>
      <pubDate>Sat, 24 Oct 2026 08:00:00 +0000</pubDate>
      <link>https://dev.to/ndcodes/day-70-a-read-only-user-and-a-template-1mf8</link>
      <guid>https://dev.to/ndcodes/day-70-a-read-only-user-and-a-template-1mf8</guid>
      <description>&lt;p&gt;Day 70 of DevOps, Day 20 of Azure. Both tasks today were about what a system does to the things you did not mention. In Jenkins, a permission you do not tick is denied, which is the easy half. In ARM, a property you leave out of a template is reset to its default on the next deployment, which is not what the word incremental led me to expect.&lt;/p&gt;

&lt;p&gt;One Jenkins task, one Azure task. Create a user and give them read-only access, then edit and deploy an ARM template. The tasks come from the KodeKloud Engineer platform.&lt;/p&gt;

&lt;h2&gt;
  
  
  Task 1: A read-only Jenkins user
&lt;/h2&gt;

&lt;p&gt;The task: create &lt;code&gt;kirsty&lt;/code&gt; with full name &lt;code&gt;Kirsty&lt;/code&gt; and password &lt;code&gt;BruCStnMT5&lt;/code&gt;, then grant read-only access using a project-based matrix authorisation strategy.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Create the user
&lt;/h3&gt;

&lt;p&gt;Manage Jenkins, Users, Create User, with the three values the task gave. That part is a form.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Install the strategy, because it is not in Jenkins
&lt;/h3&gt;

&lt;p&gt;This is the step the task's wording assumes you will work out. Matrix Authorisation Strategy is a plugin, not core, described on its own page as offering "matrix-based security authorisation strategies (global and per-project)". On a Jenkins where nobody has installed it, the Security page has no matrix option at all, so the authorisation half of the task cannot start until it is installed.&lt;/p&gt;

&lt;p&gt;Jenkins names the two strategies the plugin adds as "Matrix-based security" and "Project-based Matrix Authorisation Strategy", listing them alongside the core choices "Anyone can do anything", "Legacy mode" and "Logged in users can do anything". The plugin's own page calls the same things global and project-based matrix authorisation. Two vocabularies, one feature.&lt;/p&gt;

&lt;p&gt;The difference is whether per-item permissions exist at all. Matrix-based security is a single global grid. Project-based adds a second grid on every job and agent, which is what the requirement means by project-based.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Grant Overall/Read globally
&lt;/h3&gt;

&lt;p&gt;Manage Jenkins, Security, switch the strategy, add &lt;code&gt;kirsty&lt;/code&gt;, tick Overall/Read.&lt;/p&gt;

&lt;p&gt;This one goes first for a documented reason, with a caveat about how hard it is to state. The plugin says, "A user not granted read access to Jenkins in general will not be able to use most of the other permissions." Jenkins' permissions reference is more careful: "While Overall/Read is a prerequisite for more extensive access to Jenkins, some features are available without it."&lt;/p&gt;

&lt;p&gt;So "nothing works without Overall/Read" is too strong, and "most of it does not" is right.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Grant Read on the job
&lt;/h3&gt;

&lt;p&gt;Open the job, Configure, enable project-based security, add &lt;code&gt;kirsty&lt;/code&gt;, tick Read.&lt;/p&gt;

&lt;p&gt;Item-level grants add to the global ones, and that is a default rather than a property of the strategy. The plugin: "Permissions explicitly granted on individual items or agents will only add to permissions defined globally." Two other inheritance modes exist, "Inherit global configuration only" and "Do not inherit permissions", and under the last of those nothing is additive.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 5: Log in as the user, because the grid tells you nothing
&lt;/h3&gt;

&lt;p&gt;A matrix of ticks looks correct whether or not it is. The only check worth doing is logging out and back in as &lt;code&gt;kirsty&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The dashboard comes up with no Manage Jenkins entry. The job opens and offers nothing beyond reading it. That is the requirement expressed as behaviour rather than as configuration, and it is the difference between having set the permissions and having set the right ones.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the docs recommend
&lt;/h3&gt;

&lt;p&gt;Know the way out before you go in. Jenkins documents the lockout case plainly: administrators "may accidentally set up a security realm or authorisation strategy" such that they can no longer administer Jenkins. The recovery is to stop Jenkins, edit &lt;code&gt;config.xml&lt;/code&gt; in the Jenkins home directory, "Look for the elements &lt;code&gt;&amp;lt;securityRealm&amp;gt;&lt;/code&gt; and &lt;code&gt;&amp;lt;authorizationStrategy&amp;gt;&lt;/code&gt; and remove them", set &lt;code&gt;&amp;lt;useSecurity&amp;gt;&lt;/code&gt; to false, and restart.&lt;/p&gt;

&lt;p&gt;With a warning worth reading twice: "Jenkins will be in an entirely unsecured mode after it starts, allowing anyone full access." So the recovery is a window during which the controller is open to everyone, which is an argument for having a second administrator account rather than a recovery procedure.&lt;/p&gt;

&lt;p&gt;Put the matrix in a file. This is Day 69's Configuration as Code point arriving with a sharper reason. A security grid built by clicking has no diff and no history, so the state you locked yourself out of is also the state nobody can describe. In a configuration file, it is reviewable, and for a Configuration as Code installation, the recovery is editing the &lt;code&gt;authorizationStrategy&lt;/code&gt; directive rather than deleting elements from &lt;code&gt;config.xml&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Task 2: Edit and deploy an ARM template
&lt;/h2&gt;

&lt;p&gt;The task: change a template's VNet name, address space and tags, then deploy it with the CLI. The first infrastructure-as-code task on the Azure track, after CloudFormation on Days 47 and 48.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Edit by path, not by pattern
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cp &lt;/span&gt;vnet-deployment-template.json vnet-deployment-template.json.bak
jq &lt;span class="s1"&gt;'.resources[0].name = "arm-vnet-devops"
  | .resources[0].tags.displayName = "arm-vnet-devops"
  | .resources[0].tags.Environment = "KKE-devops"
  | .resources[0].properties.addressSpace.addressPrefixes = ["192.168.0.0/16"]'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  vnet-deployment-template.json.bak &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; vnet-deployment-template.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;sed&lt;/code&gt; would have worked on this file and breaks the moment whitespace, key ordering or a repeated string differs. &lt;code&gt;"virtualNetwork1"&lt;/code&gt; appears twice here, as the name and as the &lt;code&gt;displayName&lt;/code&gt;, and both needed the same new value, so a blind substitution would have been right by luck rather than by intent.&lt;/p&gt;

&lt;p&gt;Two details in that command matter. &lt;code&gt;jq file &amp;gt; file&lt;/code&gt; empties the file, because the shell truncates a redirect target before the command runs: "If the file does not exist it is created; if it does exist it is truncated to zero size." Reading from the backup gives the read and the write separate paths. And assigning &lt;code&gt;tags.Environment&lt;/code&gt; creates the key while leaving &lt;code&gt;displayName&lt;/code&gt; alone, whereas assigning &lt;code&gt;.tags = {...}&lt;/code&gt; would have replaced both.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Validate, then preview
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az deployment group validate &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;--template-file&lt;/span&gt; vnet-deployment-template.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Error"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Prov"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Succeeded"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is more than a syntax check. The command takes a &lt;code&gt;--validation-level&lt;/code&gt;, and "With a validation level of 'provider', ARM will perform full validation and check that you have sufficient permission to deploy all resources in the template". Only the &lt;code&gt;template&lt;/code&gt; level skips "preflight and permissions checks".&lt;/p&gt;

&lt;p&gt;Which is a real difference from the AWS equivalent. On Day 47, &lt;code&gt;aws cloudformation validate-template&lt;/code&gt; returned clean, and the stack then rolled back on an IAM permission. The limitation is documented, though not where you would look first: the API reference does not mention it, and the User Guide says, "The validate-template command is designed to check only the syntax of your template. It does not ensure that the property values that you have specified for a resource are valid for that resource."&lt;/p&gt;

&lt;p&gt;There is a better pre-flight check still:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az deployment group what-if &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;--template-file&lt;/span&gt; vnet-deployment-template.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"The what-if operation doesn't make any changes to existing resources. Instead, it predicts the changes if the specified template is deployed." So &lt;code&gt;validate&lt;/code&gt; answers whether this will run, and &lt;code&gt;what-if&lt;/code&gt; answers what it will do.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Deploy
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az deployment group create &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; arm-vnet-deploy &lt;span class="nt"&gt;--template-file&lt;/span&gt; vnet-deployment-template.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="nl"&gt;"Mode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Incremental"&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"provisioningState"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Succeeded"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The word incremental does not mean what I assumed
&lt;/h3&gt;

&lt;p&gt;I had been carrying a comfortable version of this: Incremental mode leaves unrelated resources alone, and how much of an existing resource's state survives a redeploy varies by type.&lt;/p&gt;

&lt;p&gt;Microsoft aims the warning directly at that assumption. "A common misunderstanding is to think properties that aren't specified in the template are left unchanged." What happens instead: "When redeploying an existing resource in incremental mode, all properties are reapplied. The properties aren't incrementally added", and "Properties that aren't included in the template are reset to the default values."&lt;/p&gt;

&lt;p&gt;So Incremental is incremental about resources, not properties. A resource the template never mentions is left alone. A resource the template does mention is rewritten to the template's definition, and everything the template omits goes back to its default.&lt;/p&gt;

&lt;p&gt;The type-specific caveat is real, and it is a short list of exceptions rather than a general hedge: "Two common examples are subnets on virtual networks and site configuration values for web apps." For subnets, "As long as the subnets are defined on the virtual network, you can redeploy the virtual network and not lose the subnets."&lt;/p&gt;

&lt;p&gt;Which turns a vague rule of thumb into a concrete one. Add a tag to this VNet later with &lt;code&gt;az tag update&lt;/code&gt;, as on Day 12, then redeploy this template unchanged, and the tag goes. Not maybe. Run &lt;code&gt;what-if&lt;/code&gt; first, and it will tell you.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the docs recommend
&lt;/h3&gt;

&lt;p&gt;Keep Complete mode away from a shared resource group, and know it is on its way out. Complete mode is where "Resource Manager deletes resources that exist in the resource group but aren't specified in the template", so this single-VNet template would empty a populated group. Microsoft now steers away from it entirely: "Use deployment stacks to perform resource deletions when using ARM templates or Bicep files, as the complete mode will be gradually deprecated."&lt;/p&gt;

&lt;p&gt;Do not expect a deployment to own anything. This is the core difference from CloudFormation and the reason Day 48's one-command teardown has no equivalent here:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;CloudFormation stack&lt;/th&gt;
&lt;th&gt;ARM deployment&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Deleting it&lt;/td&gt;
&lt;td&gt;"CloudFormation deletes all resources in that stack"&lt;/td&gt;
&lt;td&gt;"Deleting a template deployment does not affect the state of the resource group."&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;On a failed create&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;OnFailure&lt;/code&gt; defaults to &lt;code&gt;ROLLBACK&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;No automatic undo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Teardown&lt;/td&gt;
&lt;td&gt;&lt;code&gt;delete-stack&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Delete the resources, or the resource group&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Deployment Stacks are the newer answer and do add ownership: a stack "enables you to manage a group of Azure resources as a single, cohesive unit", and a resource removed from the template "can be detached or deleted based on the specified actionOnUnmanage behaviour". The default is still cautious, since stacks "detach and don't delete unmanaged resources".&lt;/p&gt;

&lt;p&gt;Parameterise it. The template's &lt;code&gt;parameters&lt;/code&gt; and &lt;code&gt;variables&lt;/code&gt; are both empty, with the name, address space and tags hardcoded, which is why this task was a text edit. It is also why the file is single-use. Three parameters and &lt;code&gt;"name": "[parameters('vnetName')]"&lt;/code&gt; turn the editing exercise into:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az deployment group create &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="nv"&gt;$RG&lt;/span&gt; &lt;span class="nt"&gt;--template-file&lt;/span&gt; ... &lt;span class="nt"&gt;--parameters&lt;/span&gt; &lt;span class="nv"&gt;vnetName&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;arm-vnet-devops
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep the two good habits the template already has. &lt;code&gt;"location": "[resourceGroup().location]"&lt;/code&gt; makes region inheritance explicit rather than an unstated CLI default, which is why this VNet landed in eastus without anyone naming a region. And &lt;code&gt;"apiVersion": "2023-11-01"&lt;/code&gt; pins the provider API, which the best-practice guidance wants hard-coded: "Set the apiVersion property to a hard-coded API version for the resource type", and specifically "Don't use a parameter for the API version."&lt;/p&gt;

&lt;p&gt;Finally, watch the history limit, because it fails rather than grooms. A scope "can store up to 800 deployments in its history", grooming starts above 700 and trims to 600, and "If your scope is already at the 800 limit, your next deployment fails with an error." The grooming runs as the deploying user and needs delete permission, and a CanNotDelete lock stops it. So a locked resource group with a busy pipeline can hit 800 and start failing deployments for a reason that has nothing to do with the deployment. Note that &lt;code&gt;what-if&lt;/code&gt; and &lt;code&gt;validate&lt;/code&gt; runs count too.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;p&gt;Jenkins is explicit about absence. If you don't grant a permission, it's denied, the grid shows every box you didn't tick, and the only surprise is that the strategy itself arrives as a plugin.&lt;/p&gt;

&lt;p&gt;ARM is the opposite, and the mode's name is part of the problem. Incremental sounds like it adds to what is there. For a resource the template names, it replaces, and the properties you left out go back to defaults. My notes had a hedged version of this, and the hedge was doing the work of a warning.&lt;/p&gt;

&lt;p&gt;So here is the Day 70 question. In your infrastructure code, what is currently true about a resource that the code itself does not say?&lt;/p&gt;

&lt;p&gt;Day 70 down. Thirty to go.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>jenkins</category>
      <category>azure</category>
      <category>cicd</category>
    </item>
    <item>
      <title>Day 69: Two Plugins and One Setting</title>
      <dc:creator>Nnamdi Felix Ibe</dc:creator>
      <pubDate>Fri, 23 Oct 2026 17:00:00 +0000</pubDate>
      <link>https://dev.to/ndcodes/day-69-two-plugins-and-one-setting-1b5l</link>
      <guid>https://dev.to/ndcodes/day-69-two-plugins-and-one-setting-1b5l</guid>
      <description>&lt;p&gt;Day 69 of DevOps, Day 19 of Azure. Both tasks today were about reaching for the narrow instrument when a broader one was sitting right next to it. Two named Jenkins plugins rather than whatever else looked useful, and one container closed rather than the whole storage account.&lt;/p&gt;

&lt;p&gt;The broader tool would have passed both tasks. That is what makes it worth writing down.&lt;/p&gt;

&lt;p&gt;One Jenkins task, one Azure task. Install the Git and GitLab plugins, then convert a public blob container to private without touching its neighbour. The tasks come from the KodeKloud Engineer platform.&lt;/p&gt;

&lt;h2&gt;
  
  
  Task 1: Install two plugins
&lt;/h2&gt;

&lt;p&gt;The task: log in as &lt;code&gt;admin&lt;/code&gt;, install the Git and GitLab plugins, restart Jenkins.&lt;/p&gt;

&lt;p&gt;There are no commands in this one. Manage Jenkins, then Plugins, then the Available tab, search for each, tick, install, restart. Which makes the interesting part everything the four clicks did on my behalf.&lt;/p&gt;

&lt;h3&gt;
  
  
  What ticking two boxes actually downloaded
&lt;/h3&gt;

&lt;p&gt;The progress list that follows is much longer than two entries, and the docs explain why: "Plugins can be automatically downloaded, with their dependencies, from the Update Centre."&lt;/p&gt;

&lt;p&gt;Git pulls in the Git client plugin, SCM API and several others. GitLab depends on Git, so the order I picked them in did not matter. It would matter if a dependency were unavailable, because the Update Centre resolves against what it is offering now rather than against what is already installed.&lt;/p&gt;

&lt;p&gt;That is the first reason to install two named plugins rather than a handful that look handy. Every plugin is code running with the controller's privileges, and the dependency tree means you install more of it than you chose.&lt;/p&gt;

&lt;h3&gt;
  
  
  The restart checkbox, and a sourcing problem
&lt;/h3&gt;

&lt;p&gt;The screen offers "Restart Jenkins when installation is complete and no jobs are running", and I ticked it.&lt;/p&gt;

&lt;p&gt;Going to cite that, I could not. The string is on the screen and is not in Jenkins' documentation anywhere. The labels the docs do use are "Install without restart" and "Download now and install after restart". So the checkbox is real, and quoting it as documentation would be putting words in Jenkins' mouth.&lt;/p&gt;

&lt;p&gt;A restart is not always required either. The docs: "Most plugins can be installed and used immediately by checking the box adjacent to the plugin." Where it is genuinely required is the manual route, dropping a &lt;code&gt;.hpi&lt;/code&gt; into &lt;code&gt;JENKINS_HOME/plugins&lt;/code&gt;, after which "The controller must be restarted before the plugin is loaded and made available in the Jenkins environment."&lt;/p&gt;

&lt;p&gt;The useful half of that checkbox is the second clause. A restart during a build loses the build. On a controller with anything scheduled, install without restart and take the restart in a quiet window.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the docs recommend
&lt;/h3&gt;

&lt;p&gt;Make the plugin set reproducible. Clicking works once; it does not survive a rebuild. Jenkins documents a CLI route:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;java &lt;span class="nt"&gt;-jar&lt;/span&gt; jenkins-cli.jar &lt;span class="nt"&gt;-s&lt;/span&gt; http://localhost:8080/ install-plugin SOURCE ... &lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;-deploy&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;-name&lt;/span&gt; VAL] &lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="nt"&gt;-restart&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;described as "Installs a plugin either from a file, an URL, or from the update centre", with &lt;code&gt;-restart&lt;/code&gt; to "Restart Jenkins upon successful installation".&lt;/p&gt;

&lt;p&gt;There is a second tool worth knowing about with a caveat attached. &lt;code&gt;jenkins-plugin-cli&lt;/code&gt;, the Plugin Installation Manager Tool, is what most images use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;RUN &lt;/span&gt;jenkins-plugin-cli &lt;span class="nt"&gt;--plugins&lt;/span&gt; &lt;span class="s2"&gt;"docker-workflow json-path-api"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That line is from Jenkins' Docker installation page, and that page is the only place in Jenkins' documentation it appears. The plugin management chapter does not describe the tool at all. Its own repository says it downloads "plugins and their dependencies into a folder" and uses "update center data to get the latest information about a plugin's dependencies".&lt;/p&gt;

&lt;p&gt;For the whole configuration rather than just plugins, Configuration as Code is the declarative answer: "an opinionated way to configure Jenkins based on human-readable declarative configuration files", which its page frames as "just translating into code a configuration process one is used to executing in the web UI". Worth noting it says declarative configuration files rather than YAML, so YAML is accurate in substance and is not a quotation.&lt;/p&gt;

&lt;p&gt;One honest gap. I wanted to cite a Jenkins instruction to keep plugins updated for security, and the plugin management chapter has none. What exists is the advisory machinery: "Security advisories are the primary way to publicly inform Jenkins users", and "Jenkins administrators are informed about published security issues directly in Jenkins". The advice to act on those is mine, not theirs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Task 2: Close one container
&lt;/h2&gt;

&lt;p&gt;The task: convert &lt;code&gt;devops-container-111929086&lt;/code&gt; to private, and leave &lt;code&gt;devops-priv-111929086&lt;/code&gt; alone.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Establish the negative first
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://&lt;/span&gt;&lt;span class="nv"&gt;$SA&lt;/span&gt;&lt;span class="s2"&gt;.blob.core.windows.net/&lt;/span&gt;&lt;span class="nv"&gt;$PUB&lt;/span&gt;&lt;span class="s2"&gt;?restype=container&amp;amp;comp=list"&lt;/span&gt;
curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; /dev/null &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="s2"&gt;"%{http_code}&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"https://&lt;/span&gt;&lt;span class="nv"&gt;$SA&lt;/span&gt;&lt;span class="s2"&gt;.blob.core.windows.net/&lt;/span&gt;&lt;span class="nv"&gt;$PRIV&lt;/span&gt;&lt;span class="s2"&gt;?restype=container&amp;amp;comp=list"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PUB before:  200
PRIV before: 404
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is the step that makes everything after it mean something. A &lt;code&gt;404&lt;/code&gt; at the end could be a wrong container name, a typo in the URL or an account that does not exist. A &lt;code&gt;404&lt;/code&gt; that was a &lt;code&gt;200&lt;/code&gt; five minutes ago, from the identical command, is the change itself.&lt;/p&gt;

&lt;p&gt;The second line earns its place too. It confirms the already-private container behaves as expected, and gives a known-good reference for what private looks like over HTTP from this client.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Pick the narrow control
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az storage container set-permission &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PUB&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--account-name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SA&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--account-key&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--public-access&lt;/span&gt; off
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The other option was one command away:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az storage account update &lt;span class="nt"&gt;-g&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SA&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--allow-blob-public-access&lt;/span&gt; &lt;span class="nb"&gt;false&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That one closes every container in the account, now and in future, and Microsoft is explicit about the relationship: "Disallowing anonymous access for a storage account overrides the access settings for all containers in that storage account", and "If you disallow anonymous access for the account, you don't also need to disable anonymous access for individual containers."&lt;/p&gt;

&lt;p&gt;It would also have passed the grader. The task said to leave the other container unchanged; it is already private, so nothing visible would change about it. The account flag still alters the conditions it operates under, and the requirement named one container.&lt;/p&gt;

&lt;p&gt;The principle is the one from the Jenkins half: match the scope of the change to the scope of the requirement. A control that also fixes things you were not asked about is not a better fix.&lt;/p&gt;

&lt;h3&gt;
  
  
  A claim I had to drop
&lt;/h3&gt;

&lt;p&gt;I had a second argument for the narrow control, and it does not hold up. I had written that closing at the account level leaves &lt;code&gt;publicAccess: container&lt;/code&gt; on the container itself, so the container's own property would still read public and the requirement would not be met.&lt;/p&gt;

&lt;p&gt;Microsoft describes the override entirely in terms of effective access, and never says whether the stored container property is retained or cleared. The nearest indirect evidence is that the same article still offers a per-container audit command, which only makes sense if the property persists independently of the account flag. That is an inference. It is not a documented behaviour and I should not have written it as one.&lt;/p&gt;

&lt;p&gt;The scope argument stands on its own and did not need the second one.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Prove it, and watch a column vanish
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PUB after:   404
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Then the table:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Name                        PublicAccess
--------------------------  --------------
devops-container-111929086  container
devops-priv-111929086
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;became:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Name
--------------------------
devops-container-111929086
devops-priv-111929086
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;PublicAccess&lt;/code&gt; column did not go blank. It disappeared. The CLI's table renderer only adds a key to a row when its value is not null, so once both containers were private, every value in that column was null and the column stopped existing.&lt;/p&gt;

&lt;p&gt;Which is uncomfortable, because the disappearance is the result. Both containers being private is exactly what was wanted, and the output announcing it looks like a broken query.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az storage container list &lt;span class="nt"&gt;--account-name&lt;/span&gt; &lt;span class="nv"&gt;$SA&lt;/span&gt; &lt;span class="nt"&gt;--account-key&lt;/span&gt; &lt;span class="nv"&gt;$KEY&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"[].{Name:name,PublicAccess:properties.publicAccess}"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That returns an explicit &lt;code&gt;"PublicAccess": null&lt;/code&gt; for each. Use &lt;code&gt;-o json&lt;/code&gt; when the expected answer is an absence, and &lt;code&gt;-o table&lt;/code&gt; only for scanning values you expect to be present.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the docs recommend
&lt;/h3&gt;

&lt;p&gt;Use the conditional headers this operation actually supports. I had recommended &lt;code&gt;--if-match&lt;/code&gt; to avoid racing another process adjusting permissions, and that header is not available here. The conditional headers table lists Set Container ACL with &lt;code&gt;If-Modified-Since&lt;/code&gt; and &lt;code&gt;If-Unmodified-Since&lt;/code&gt; only, and the CLI matches it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az storage container set-permission &lt;span class="nt"&gt;--name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PUB&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--account-name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SA&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--account-key&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--public-access&lt;/span&gt; off &lt;span class="nt"&gt;--if-unmodified-since&lt;/span&gt; 2026-09-28T00:00:00Z
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Do not read a null in the CLI output as an absence in the API. My response showed &lt;code&gt;"LastModified": null&lt;/code&gt;, and the REST reference lists both &lt;code&gt;ETag&lt;/code&gt; and &lt;code&gt;Last-Modified&lt;/code&gt; among Set Container ACL's response headers, with a sample response carrying both. The last-modified time genuinely advances here: "Any operation that modifies the container or its properties or metadata updates the last-modified time, including setting the container's permissions."&lt;/p&gt;

&lt;p&gt;Do not quote a propagation time for this. I had written that the change is immediate. Microsoft documents no propagation figure for a container-level public access change. There are two nearby 30-second figures, one for the account-level anonymous access setting and one for establishing a stored access policy, and neither is this. Borrowing one would be inventing a number.&lt;/p&gt;

&lt;p&gt;Plan for what breaks, because nothing warns you. Every blob URL that worked a moment ago now fails for anonymous callers: pages embedding images, documentation links, a partner integration, a CDN with a stale origin. Azure has no record of who was using those URLs, so there is no list to notify.&lt;/p&gt;

&lt;p&gt;The scoped replacement is a shared access signature:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az storage blob generate-sas &lt;span class="nt"&gt;--account-name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SA&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--container-name&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PUB&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; &amp;lt;blob&amp;gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--permissions&lt;/span&gt; r &lt;span class="nt"&gt;--expiry&lt;/span&gt; 2026-09-29T00:00:00Z &lt;span class="nt"&gt;--account-key&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$KEY&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--expiry&lt;/code&gt; "Specifies the UTC datetime (Y-m-d'T'H:M'Z') at which the SAS becomes invalid", which is the property a public container can never have.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;p&gt;Both halves had a bigger lever within reach, and in both cases pulling it would have passed. Installing a comfortable handful of plugins works. Disabling anonymous access account-wide works. Neither is what was asked for, and the difference only shows up later, when somebody wonders why a container they never touched behaves differently or why a controller is running code nobody chose.&lt;/p&gt;

&lt;p&gt;The part I did not expect was having to give up an argument I liked. My second reason for preferring the narrow Azure control turned out to be undocumented, which left the first reason doing all the work. It was enough on its own, which is the useful thing to find out before publishing rather than after.&lt;/p&gt;

&lt;p&gt;So here is the Day 69 question. The last time you fixed something, did you fix the thing you were asked about, or everything adjacent to it that looked wrong on the way past?&lt;/p&gt;

&lt;p&gt;Day 69 down. Thirty-one to go.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>jenkins</category>
      <category>azure</category>
      <category>cicd</category>
    </item>
    <item>
      <title>Day 68: Installing Jenkins and Copying a File</title>
      <dc:creator>Nnamdi Felix Ibe</dc:creator>
      <pubDate>Wed, 21 Oct 2026 17:00:00 +0000</pubDate>
      <link>https://dev.to/ndcodes/day-68-installing-jenkins-and-copying-a-file-28f8</link>
      <guid>https://dev.to/ndcodes/day-68-installing-jenkins-and-copying-a-file-28f8</guid>
      <description>&lt;p&gt;Day 68 of DevOps, Day 18 of Azure. Kubernetes closed yesterday at Day 67, and Jenkins opens here and runs to Day 81. Both of today's tasks ended the same way: the tool said it had succeeded, and the check that settled it was something else entirely.&lt;/p&gt;

&lt;p&gt;One Jenkins task, one Azure task. Install Jenkins and get through the setup wizard, then copy a single file into a blob container. The tasks come from the KodeKloud Engineer platform.&lt;/p&gt;

&lt;h2&gt;
  
  
  Task 1: Install Jenkins
&lt;/h2&gt;

&lt;p&gt;The task: install Jenkins on the &lt;code&gt;jenkins&lt;/code&gt; server, start it with the &lt;code&gt;service&lt;/code&gt; command, and create a named first administrator.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Java, because nothing else works without it
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;ssh root@jenkins
java &lt;span class="nt"&gt;-version&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;-bash: java: command not found
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;fontconfig openjdk-21-jre
java &lt;span class="nt"&gt;-version&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;openjdk version "21.0.10" 2026-01-20
OpenJDK Runtime Environment (build 21.0.10+7-Ubuntu-124.04)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The installation page lists "Java 21 or later" and gives that exact install line, &lt;code&gt;fontconfig openjdk-21-jre&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The version is worth a moment rather than a copy and paste. Jenkins' Java support policy lists Java 17 under unsupported versions, and names its final LTS release as 2.541.3. That is the version this lab installed. So this server is sitting on the last LTS that would still have accepted Java 17, and from the next one Java 21 is not a recommendation, it is the floor.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: The repository and the package
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;sudo &lt;/span&gt;wget &lt;span class="nt"&gt;-O&lt;/span&gt; /etc/apt/keyrings/jenkins-keyring.asc &lt;span class="se"&gt;\&lt;/span&gt;
  https://pkg.jenkins.io/debian-stable/jenkins.io-2026.key

&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"deb [signed-by=/etc/apt/keyrings/jenkins-keyring.asc]"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  https://pkg.jenkins.io/debian-stable binary/ | &lt;span class="nb"&gt;sudo tee&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  /etc/apt/sources.list.d/jenkins.list &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /dev/null

&lt;span class="nb"&gt;sudo &lt;/span&gt;apt update
&lt;span class="nb"&gt;sudo &lt;/span&gt;apt &lt;span class="nb"&gt;install &lt;/span&gt;jenkins
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Get:1 https://pkg.jenkins.io/debian-stable binary/ jenkins 2.541.3 [95.8 MB]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things in those four commands are easy to get wrong from an old tutorial. &lt;code&gt;debian-stable&lt;/code&gt; is the Jenkins LTS apt repository, not anything belonging to Debian or Ubuntu, so none of this comes from the distribution archives. And the key filename carries a year, &lt;code&gt;jenkins.io-2026.key&lt;/code&gt;, which is exactly the sort of value a copied script keeps long after it has moved on.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Read the install output before scrolling past it
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;invoke-rc.d: could not determine current runlevel
invoke-rc.d: policy-rc.d denied execution of start.
Created symlink /etc/systemd/system/multi-user.target.wants/jenkins.service -&amp;gt; /usr/lib/systemd/system/jenkins.service
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those first two lines say the package tried to start the service and the environment refused. That is a container convention rather than a failure, and it is why the next command reported Jenkins not running immediately after a clean install.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;service jenkins status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; * jenkins is not running
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;service jenkins start
service jenkins status
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; * Starting Jenkins Automation Server jenkins    Setting up max open files limit to 8192  [ OK ]
 * jenkins is running
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The task asked for &lt;code&gt;service&lt;/code&gt;. The documentation does not use it anywhere, and says why: "Beginning with Jenkins 2.335 and Jenkins 2.332.1, the package is configured with &lt;code&gt;systemd&lt;/code&gt; rather than the older System V &lt;code&gt;init&lt;/code&gt;." &lt;code&gt;service jenkins status&lt;/code&gt; works through systemd's compatibility shim. &lt;code&gt;systemctl status jenkins&lt;/code&gt; is what the docs would have me type.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Unlock, and notice where the password lives
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; /var/lib/jenkins/secrets/initialAdminPassword
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Jenkins listens on 8080 and refuses to do anything until that value is pasted into the browser. The design is deliberate: the only person who can finish the setup is someone who can already read the server's filesystem. Which is also the reason not to leave a half-configured Jenkins reachable while you go and find the file.&lt;/p&gt;

&lt;p&gt;Then "Install suggested plugins", described in the docs as "the recommended set of plugins, which are based on most common use cases", and the first administrator user. Day 69 is about what happens when the suggested set does not contain what you need.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the docs recommend
&lt;/h3&gt;

&lt;p&gt;Use &lt;code&gt;systemctl&lt;/code&gt;. The package has been a systemd unit since 2.332.1, and &lt;code&gt;systemctl cat jenkins&lt;/code&gt; shows you the unit the package installed, which is where the port and the Java options actually live.&lt;/p&gt;

&lt;p&gt;Know where &lt;code&gt;JENKINS_HOME&lt;/code&gt; is before you need it. "On Ubuntu by default, this is set to &lt;code&gt;/var/lib/jenkins&lt;/code&gt;", and it is "the root of the disk directory structure that Jenkins uses to perform builds and store archives". Everything that matters is in there: jobs, credentials, plugins, the lot. It is the backup target, and on Day 71 it is also where an SSH key generated by the &lt;code&gt;jenkins&lt;/code&gt; user ends up.&lt;/p&gt;

&lt;p&gt;Treat the suggested plugin set as a starting point rather than a decision. It is built for common cases, and the next two days are both about reaching past it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Task 2: Copy a file into a blob container
&lt;/h2&gt;

&lt;p&gt;The task: copy &lt;code&gt;/tmp/xfusion.txt&lt;/code&gt; into the existing container &lt;code&gt;xfusion-blob-398826849&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Upload
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az storage blob upload &lt;span class="nt"&gt;--account-name&lt;/span&gt; &lt;span class="nv"&gt;$SA&lt;/span&gt; &lt;span class="nt"&gt;--account-key&lt;/span&gt; &lt;span class="nv"&gt;$KEY&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--container-name&lt;/span&gt; &lt;span class="nv"&gt;$CONTAINER&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; xfusion.txt &lt;span class="nt"&gt;--file&lt;/span&gt; /tmp/xfusion.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Step 2: Do not stop at the listing
&lt;/h3&gt;

&lt;p&gt;The listing showed the right name and 33 bytes, matching the local file. That is good evidence and it is not proof. A file can be the right length with different bytes in it, and text tools hide trailing whitespace, line-ending changes and encoding shifts.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az storage blob download &lt;span class="nt"&gt;--account-name&lt;/span&gt; &lt;span class="nv"&gt;$SA&lt;/span&gt; &lt;span class="nt"&gt;--account-key&lt;/span&gt; &lt;span class="nv"&gt;$KEY&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--container-name&lt;/span&gt; &lt;span class="nv"&gt;$CONTAINER&lt;/span&gt; &lt;span class="nt"&gt;--name&lt;/span&gt; xfusion.txt &lt;span class="nt"&gt;--file&lt;/span&gt; /tmp/roundtrip.txt

cmp &lt;span class="nt"&gt;-s&lt;/span&gt; /tmp/xfusion.txt /tmp/roundtrip.txt &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"MATCH: byte-identical"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;MATCH: byte-identical
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;cmp&lt;/code&gt; compares bytes, which is the only check that means anything when the claim is that nothing changed. Cheap here at 33 bytes. For a large upload the equivalent is comparing checksums rather than re-downloading.&lt;/p&gt;

&lt;h3&gt;
  
  
  The checksum that was not where I looked
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"{ETag:etag,Md5:contentMd5}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"ETag"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;0x8DF1C7E15C4FC83&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Md5"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So I read it off the blob instead, which worked:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az storage blob show ... &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"properties.contentSettings.contentMd5"&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; tsv
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My conclusion at the time was that &lt;code&gt;contentMd5&lt;/code&gt; is not in the upload response. Checking it, that is wrong. The Put Blob REST reference lists &lt;code&gt;Content-MD5&lt;/code&gt; among its response headers, "Returned for a block blob so that the client can check the integrity of the message content", and adds that "In version 2012-02-12 and later, this header is returned even when the request doesn't include Content-MD5 or x-ms-blob-content-md5 headers".&lt;/p&gt;

&lt;p&gt;So the API did return it, and the CLI did not surface it under the name I queried. That makes the finding sharper rather than weaker: what a command prints is the command's own shape, and it is not a reliable guide to what the service sent back. The habit that follows is unchanged, and now rests on better ground:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az &amp;lt;&lt;span class="nb"&gt;command&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nt"&gt;-o&lt;/span&gt; json | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-i&lt;/span&gt; &amp;lt;fragment&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Hex and base64 are the same digest
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;md5sum&lt;/span&gt; /tmp/xfusion.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2eeef38a56ad6c682ecd2cf611c9f921
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;md5sum&lt;/code&gt; prints hex. Azure wants Base64, and the requirement is explicit in the error table: &lt;code&gt;InvalidMd5&lt;/code&gt; means "The MD5 value specified in the request is invalid. The MD5 value must be 128 bits and Base64-encoded." The neighbouring error, &lt;code&gt;Md5Mismatch&lt;/code&gt;, is what a well-formed but wrong digest gets.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;openssl md5 &lt;span class="nt"&gt;-binary&lt;/span&gt; /tmp/xfusion.txt | &lt;span class="nb"&gt;base64&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Worth knowing before deciding that a checksum mismatch means corruption.&lt;/p&gt;

&lt;h3&gt;
  
  
  What the docs recommend
&lt;/h3&gt;

&lt;p&gt;Use conditional headers rather than an overwrite flag. The ETag is the concurrency token: "Each version of the blob has a unique tag, called an ETag, that you can use with access conditions to assure you only change a specific instance of the blob."&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nt"&gt;--if-match&lt;/span&gt; &lt;span class="s1"&gt;'"0x8DF1C7E15C4FC83"'&lt;/span&gt;   &lt;span class="c"&gt;# only write if unchanged since I read it&lt;/span&gt;
&lt;span class="nt"&gt;--if-none-match&lt;/span&gt; &lt;span class="s1"&gt;'*'&lt;/span&gt;                &lt;span class="c"&gt;# only write if the blob does not exist&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The wildcard is documented directly: "Specify the wildcard character (*) to perform the operation only if the resource doesn't exist, and fail the operation if it does exist." An unmet condition returns 412.&lt;/p&gt;

&lt;p&gt;That beats relying on &lt;code&gt;--overwrite&lt;/code&gt;, whose default is not uniform. On &lt;code&gt;az storage blob upload&lt;/code&gt; it defaults to false and "the operation will fail with ResourceExistsError"; on &lt;code&gt;az storage blob download&lt;/code&gt; the docs give the default as true. Per command, again.&lt;/p&gt;

&lt;p&gt;Pick the blob type deliberately, because you only get one chance. "You specify the blob type when you create the blob. Once the blob has been created, its type cannot be changed." Block blobs for files, append blobs for logs, page blobs for virtual machine disks. A log sink created as a block blob cannot later be appended to efficiently.&lt;/p&gt;

&lt;p&gt;Remember the namespace is flat. "By default, the Blob service is based on a flat storage scheme, not a hierarchical scheme. However, you may specify a character or string delimiter within a blob name to create a virtual hierarchy." A slash in a blob name is a character. There is no empty folder, deleting the last blob under a prefix makes the folder disappear, and renaming a folder means copying everything under it.&lt;/p&gt;

&lt;p&gt;If you need real directories, the upgrade path now exists, which corrects something I had written down as fixed. Hierarchical namespace used to be a creation-time choice only. Microsoft now documents &lt;code&gt;az storage account hns-migration start --type upgrade&lt;/code&gt;, with the warning that "An upgrade is one-way. There's no way to revert your account once you've performed the upgrade", and preconditions: no page blobs in the account, and snapshots, encryption scopes, immutable storage and soft delete disabled first.&lt;/p&gt;

&lt;p&gt;And know where this command stops. For a directory there is &lt;code&gt;az storage blob upload-batch&lt;/code&gt;. For anything large, there is AzCopy, where resume is documented: &lt;code&gt;azcopy jobs resume&lt;/code&gt; "Resumes the existing job with the given job ID", and on resume it "attempts to transfer all of the files that are listed in the plan file and weren't already transferred".&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;p&gt;Both halves handed me a success message I could not use. &lt;code&gt;service jenkins status&lt;/code&gt; said it was running, which is true and says nothing about whether the setup wizard will load. The blob listing said 33 bytes, which is true and says nothing about the bytes.&lt;/p&gt;

&lt;p&gt;The second one also cost me a conclusion. I had written down that the upload response does not carry the checksum, when what actually happened is that the response carried it and the CLI printed something else. Those look identical in the terminal, and they are not the same fact.&lt;/p&gt;

&lt;p&gt;So here is the Day 68 question. When a command prints a field as null, do you check whether the service sent it, or do you just look somewhere else and move on?&lt;/p&gt;

&lt;p&gt;Day 68 down. Thirty-two to go.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>jenkins</category>
      <category>azure</category>
      <category>cicd</category>
    </item>
    <item>
      <title>Part 2: From Docker Compose up to Terraform on AWS, and the CI That Checks It</title>
      <dc:creator>Nnamdi Felix Ibe</dc:creator>
      <pubDate>Wed, 21 Oct 2026 12:00:00 +0000</pubDate>
      <link>https://dev.to/ndcodes/part-2-from-docker-compose-up-to-terraform-on-aws-and-the-ci-that-checks-it-1a4i</link>
      <guid>https://dev.to/ndcodes/part-2-from-docker-compose-up-to-terraform-on-aws-and-the-ci-that-checks-it-1a4i</guid>
      <description>&lt;p&gt;Part 1 ended with a pipeline that turns HL7 v2 into FHIR R4 and runs on a laptop with &lt;code&gt;make demo&lt;/code&gt;. This part is about the distance between that and something a trust could run, and about the checks that tell you whether you have crossed it.&lt;/p&gt;

&lt;p&gt;The short version: the distance is larger than the Terraform makes it look, and the first thing I did this week was build the pipeline that proves it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;docker compose up&lt;/code&gt; gives you a working system and almost no evidence. It tells you the code runs. It tells you nothing about whether the database is reachable from the internet, whether a credential is sitting in the history, or whether the thing you deploy is the thing you tested.&lt;/p&gt;

&lt;p&gt;In most settings that gap is a nuisance. In an NHS setting, it is the whole job. The data is patient-identifiable, auditors ask questions about provenance, and "it worked on my laptop" doesn't answer any of them.&lt;/p&gt;

&lt;p&gt;So the move from compose to AWS is not really a move from one runtime to another. It is a move from a system you have seen working to a system you can show is working. That needs two things: infrastructure described as code, and a pipeline that checks it on every change.&lt;/p&gt;

&lt;p&gt;I had the first. I did not have the second, and this week showed me what that was costing.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I built
&lt;/h2&gt;

&lt;p&gt;The repo describes an AWS deployment in Terraform: a VPC with public, private and database subnets, an EKS cluster with a managed node group, an RDS Postgres instance for the HAPI FHIR server, an S3 bucket for the dead-letter path, and KMS keys. &lt;code&gt;terraform plan&lt;/code&gt; reports 46 resources.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvbm0ko24ws88e2l50zny.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fvbm0ko24ws88e2l50zny.png" alt="The local stack on the left and the AWS target on the right, with the CI pipeline between them" width="800" height="496"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Two honest notes before anything else.&lt;/p&gt;

&lt;p&gt;The provider block in &lt;code&gt;infra/terraform/versions.tf&lt;/code&gt; is this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;provider&lt;/span&gt; &lt;span class="s2"&gt;"aws"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;region&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;var&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;aws_region&lt;/span&gt;

  &lt;span class="nx"&gt;access_key&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"mock"&lt;/span&gt;
  &lt;span class="nx"&gt;secret_key&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"mock"&lt;/span&gt;

  &lt;span class="nx"&gt;skip_credentials_validation&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="nx"&gt;skip_metadata_api_check&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="nx"&gt;skip_requesting_account_id&lt;/span&gt;  &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is how the plan runs in CI without an AWS account. It hasn't always been like that. In April I ran &lt;code&gt;apply&lt;/code&gt; against my own AWS account to see the stack come up, and that is where the EKS add-on version conflicts in &lt;code&gt;docs/lessons-learnt.md&lt;/code&gt; came from. Nothing from that run is still up, so the code you can clone today has been planned, not deployed. The mock credentials keep it free for anyone else to run.&lt;/p&gt;

&lt;p&gt;The second note is worse, and the pipeline found it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pipeline, and what it found
&lt;/h2&gt;

&lt;p&gt;Until this week the repo had a &lt;code&gt;.gitlab-ci.yml&lt;/code&gt; describing five stages, and nothing had ever run it. Porting it to GitHub Actions took an afternoon. Getting it green took longer, because it kept being right. Some of what it found turned up while I was porting it, and the rest arrived on its first real run.&lt;/p&gt;

&lt;h3&gt;
  
  
  The secret scan had never scanned anything
&lt;/h3&gt;

&lt;p&gt;The pipeline runs Gitleaks. The repo has a &lt;code&gt;.gitleaks.toml&lt;/code&gt;. The file held exactly this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="c"&gt;# Gitleaks config - ignore false positives in vendored terraform modules&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One comment. No rules, and no &lt;code&gt;[extend]&lt;/code&gt; block. Gitleaks' documentation describes two options for a custom config: "define your own configuration, default rules do not apply", or extend one, where "useDefault will extend the default gitleaks config built into the binary".&lt;/p&gt;

&lt;p&gt;Mine was the first kind. A configuration with no rules, replacing roughly a hundred built-in ones.&lt;/p&gt;

&lt;p&gt;I checked it rather than assuming. A directory with a planted Slack bot token, scanned three ways with gitleaks 8.28.0:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;no config file                  leaks found: 1    exit 1
the repo's .gitleaks.toml       no leaks found    exit 0
[extend] useDefault = true      leaks found: 1    exit 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The middle line is the one that matters. For months, a secret scan ran, found nothing, and the nothing meant nothing. The repo also carries &lt;code&gt;docs/verification/gitleaks.json&lt;/code&gt;, a stored run showing zero findings, which I had been treating as evidence that the repo was clean. It is evidence that the scanner was switched off.&lt;/p&gt;

&lt;p&gt;When I first wrote this section, the next line said I had no reason to think there was a secret in there. Then the fixed scanner ran over the git history and found one.&lt;/p&gt;

&lt;p&gt;On 26 April I committed a PDS API key to &lt;code&gt;.env.example&lt;/code&gt; while getting a demo working. The next commit blanked the value. The file looked clean from then on, and the key sat in the public history for six months. A scanner pointed at the current files would never have seen it. &lt;code&gt;gitleaks git&lt;/code&gt; reads every commit, and it did.&lt;/p&gt;

&lt;p&gt;The key is revoked now. It stays in the history because rewriting a public history breaks every clone and tag. Revoking it is what makes it harmless. The repo's &lt;code&gt;.gitleaksignore&lt;/code&gt; now holds that one finding's fingerprint, with a comment saying when the key was committed and when it was revoked. Any other secret still fails the build.&lt;/p&gt;

&lt;p&gt;So the stored report with zero findings wasn't just meaningless. It was wrong, and I had been treating it as evidence.&lt;/p&gt;

&lt;h3&gt;
  
  
  The test job could not have passed
&lt;/h3&gt;

&lt;p&gt;The GitLab test job runs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pytest &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="nt"&gt;--junitxml&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;junit.xml &lt;span class="nt"&gt;--cov&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;--cov-report&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;xml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;pytest-cov&lt;/code&gt; appears in none of the three &lt;code&gt;requirements-dev.txt&lt;/code&gt; files. I ran that command against each service in a clean virtualenv built from its pinned requirements, and got the same thing three times:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;error: unrecognised arguments: --cov=. --cov-report=xml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The tests themselves passed. Sixteen of them pass as soon as you drop the flags. It was the job around them that had never worked, and because nothing ran it, nothing said so.&lt;/p&gt;

&lt;p&gt;With &lt;code&gt;pytest-cov&lt;/code&gt; added, the numbers are ingest 98 per cent, transform 83 per cent, pds-client 93 per cent.&lt;/p&gt;

&lt;h3&gt;
  
  
  The database could call the internet
&lt;/h3&gt;

&lt;p&gt;The new pipeline runs Trivy against the Terraform. Its first finding was critical, in the security group in front of the FHIR database:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;&lt;span class="nx"&gt;resource&lt;/span&gt; &lt;span class="s2"&gt;"aws_security_group"&lt;/span&gt; &lt;span class="s2"&gt;"rds"&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;description&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"HAPI FHIR Postgres, ingress from EKS nodes only"&lt;/span&gt;

  &lt;span class="nx"&gt;ingress&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;description&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Postgres from EKS"&lt;/span&gt;
    &lt;span class="nx"&gt;from_port&lt;/span&gt;       &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5432&lt;/span&gt;
    &lt;span class="nx"&gt;to_port&lt;/span&gt;         &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5432&lt;/span&gt;
    &lt;span class="nx"&gt;protocol&lt;/span&gt;        &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"tcp"&lt;/span&gt;
    &lt;span class="nx"&gt;security_groups&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;module&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;eks&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;node_security_group_id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;egress&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;from_port&lt;/span&gt;   &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="nx"&gt;to_port&lt;/span&gt;     &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="nx"&gt;protocol&lt;/span&gt;    &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"-1"&lt;/span&gt;
    &lt;span class="nx"&gt;cidr_blocks&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"0.0.0.0/0"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at the care in the ingress rule and the shrug in the egress one. Inbound is locked to the node group's security group, port 5432, TCP only. Outbound is every port, every protocol, every address on the internet.&lt;/p&gt;

&lt;p&gt;The description even says "ingress from EKS nodes only", which is true and beside the point. I wrote a careful inbound rule, felt careful, and pasted the default outbound rule underneath it.&lt;/p&gt;

&lt;p&gt;The fix restricts egress to the VPC.&lt;/p&gt;

&lt;h3&gt;
  
  
  The cluster API was open to the internet
&lt;/h3&gt;

&lt;p&gt;My first count of Trivy findings was wrong. I ran it on a copy of the repo where it never downloaded the community EKS module, so it only scanned my own files. A clean checkout in CI downloads the module and scans what my inputs do to it. That found three more critical findings.&lt;/p&gt;

&lt;p&gt;Two were the same problem. The EKS API endpoint was public and open to &lt;code&gt;0.0.0.0/0&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight hcl"&gt;&lt;code&gt;  &lt;span class="c1"&gt;# API endpoint is private by default; grant access to the CI runner via&lt;/span&gt;
  &lt;span class="c1"&gt;# the public endpoint + CIDR allow-list when needed.&lt;/span&gt;
  &lt;span class="nx"&gt;cluster_endpoint_public_access&lt;/span&gt;       &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="nx"&gt;cluster_endpoint_public_access_cidrs&lt;/span&gt; &lt;span class="err"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"0.0.0.0/0"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="c1"&gt;# tighten in prod&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The comment says private. The code says public, to everyone. Nothing outside the VPC needs that endpoint, because Argo CD pulls from inside the cluster, so it is now private.&lt;/p&gt;

&lt;p&gt;The third was node egress to any address, the same rule as the database. This one I kept. The nodes need outbound internet to pull images and to call the PDS API. It sits in a &lt;code&gt;.trivyignore.yaml&lt;/code&gt;, scoped to the one module file and with the reason written next to it. I checked the scope by putting the database rule back the way it was: the build failed.&lt;/p&gt;

&lt;h3&gt;
  
  
  A dependency I never chose
&lt;/h3&gt;

&lt;p&gt;The image scan failed on all three services for the same reason. &lt;code&gt;fastapi==0.115.0&lt;/code&gt; pulls in starlette 0.38.6, which has CVE-2024-47874, rated high. I had pinned FastAPI and never looked at what it brought with it.&lt;/p&gt;

&lt;p&gt;Bumping FastAPI alone wasn't enough. The newer Starlette releases had high findings of their own, fixed only in 1.x, and &lt;code&gt;prometheus-fastapi-instrumentator&lt;/code&gt; 7.0.0 capped Starlette below 1.0. Both had to move. All sixteen tests still pass, and coverage hasn't changed.&lt;/p&gt;

&lt;p&gt;What is left on the Terraform is four findings, none above medium: IAM authentication and deletion protection on the database, a customer-managed key for Performance Insights, and access logging on the bucket. Those are real, and they are on the list for Part 5. Every run prints them, along with the accepted one, so they don't live only in my head.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmrecmvav89bxxuzhjzgc.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fmrecmvav89bxxuzhjzgc.png" alt="The seven CI jobs, which ones gate the merge, and what the first runs found" width="800" height="810"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What the pipeline actually checks
&lt;/h2&gt;

&lt;p&gt;Seven jobs run on every pull request into &lt;code&gt;master&lt;/code&gt;:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;code&gt;lint&lt;/code&gt;, ruff over each service.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;test&lt;/code&gt;, pytest with coverage over each service.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;secrets&lt;/code&gt;, Gitleaks over both the working tree and the git history.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;sast&lt;/code&gt;, Semgrep with the &lt;code&gt;p/ci&lt;/code&gt;, &lt;code&gt;p/python&lt;/code&gt; and &lt;code&gt;p/security-audit&lt;/code&gt; rulesets.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;terraform&lt;/code&gt;, &lt;code&gt;fmt -check&lt;/code&gt;, then &lt;code&gt;init -backend=false&lt;/code&gt; and &lt;code&gt;validate&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;iac-scan&lt;/code&gt;, Trivy against the Terraform.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;image&lt;/code&gt;, a Docker build per service, then a Trivy image scan and a Syft SBOM.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A few decisions inside that are worth more than the list.&lt;/p&gt;

&lt;p&gt;Every version is pinned, down to the scanner binaries. The gitleaks problem was a security tool that silently stopped checking, and a floating &lt;code&gt;latest&lt;/code&gt; tag is the same failure wearing a different hat. If a tool changes what it catches, I want that to arrive as a version bump I can read in a diff.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;terraform validate&lt;/code&gt; runs with &lt;code&gt;-backend=false&lt;/code&gt;, which initialises the providers without touching remote state, so the job needs no AWS credentials at all. Validation is a question about syntax and types, not about an account.&lt;/p&gt;

&lt;p&gt;The IaC scan reports everything and fails only on high and critical. A gate you have not triaged is a gate someone turns off in a week. Report first, then promote findings to blocking as you fix them.&lt;/p&gt;

&lt;p&gt;The image is built and scanned but not pushed. There is nowhere to push it to yet, and inventing a registry to make the diagram look complete would be the same instinct that produced the egress rule.&lt;/p&gt;

&lt;p&gt;And one job did not come across at all. The GitLab pipeline ended with a &lt;code&gt;deploy&lt;/code&gt; stage that bumped a Helm values file and let Argo CD sync it. Its rule was &lt;code&gt;if: $CI_COMMIT_BRANCH == "main"&lt;/code&gt;. This repo's branch is &lt;code&gt;master&lt;/code&gt;. That job could never have run on any commit, which is a tidy summary of the whole week: the pipeline described a system nobody had checked.&lt;/p&gt;

&lt;h2&gt;
  
  
  Best practices worth stealing
&lt;/h2&gt;

&lt;p&gt;Commit the Terraform lock file. HashiCorp's documentation says "You should include this file in your version control repository", because Terraform "remembers the decisions it made in a dependency lock file" so that "it can (by default) make the same decisions again in future". Mine was in &lt;code&gt;.gitignore&lt;/code&gt;. CI is exactly the second machine that needs to make the same decision.&lt;/p&gt;

&lt;p&gt;Make the local command and the CI command the same one. The repo's Makefile already had &lt;code&gt;make tf-validate&lt;/code&gt;, running &lt;code&gt;init -backend=false&lt;/code&gt;, &lt;code&gt;validate&lt;/code&gt; and &lt;code&gt;fmt -check&lt;/code&gt;. The CI job runs the same three. If it passes on your machine, it passes in CI, and you stop pushing commits to find out.&lt;/p&gt;

&lt;p&gt;Scan the history, not just the tree. &lt;code&gt;gitleaks dir&lt;/code&gt; checks what is there now. &lt;code&gt;gitleaks git&lt;/code&gt; checks what was ever there. A secret removed in a later commit is still published. While you are at it: &lt;code&gt;detect&lt;/code&gt; is deprecated, and the README says "v8.19.0 introduced a change that deprecated &lt;code&gt;detect&lt;/code&gt; and &lt;code&gt;protect&lt;/code&gt;". The modes are &lt;code&gt;git&lt;/code&gt;, &lt;code&gt;dir&lt;/code&gt; and &lt;code&gt;stdin&lt;/code&gt; now.&lt;/p&gt;

&lt;p&gt;Make the tool fail loudly or not at all. Semgrep prints its findings and exits 0 unless you pass &lt;code&gt;--error&lt;/code&gt;. Trivy does the same unless you pass &lt;code&gt;--exit-code 1&lt;/code&gt;, and it is in two of my jobs. So three of the seven would have been decorative by default: they would have printed the problem in a log nobody opens and gone green.&lt;/p&gt;

&lt;p&gt;Gitleaks is the exception, and I had it wrong until I tested it. Its help text reads &lt;code&gt;--exit-code int exit code when leaks have been encountered (default 1)&lt;/code&gt;, which I first skimmed as requiring the flag. It is not: gitleaks exits 1 on a finding whether you ask or not. I had already written the opposite into a draft of this article. Checking the three tools took one command each.&lt;/p&gt;

&lt;p&gt;Read why a gate went red. My first &lt;code&gt;iac-scan&lt;/code&gt; job passed &lt;code&gt;--no-progress&lt;/code&gt; to &lt;code&gt;trivy config&lt;/code&gt;. That flag works on &lt;code&gt;trivy image&lt;/code&gt; and doesn't exist on &lt;code&gt;trivy config&lt;/code&gt;, so the job failed before it scanned a single file. Red, for a reason that had nothing to do with Terraform.&lt;/p&gt;

&lt;p&gt;Fix the name, not the rule. Semgrep flagged &lt;code&gt;requests.append({... "http://snomed.info/sct" ...})&lt;/code&gt; as an unencrypted HTTP request. It was a Python list called &lt;code&gt;requests&lt;/code&gt;, holding FHIR MedicationRequests, and the URL is a SNOMED CT system identifier that FHIR requires in that exact form. Renaming the list to &lt;code&gt;medication_requests&lt;/code&gt; cleared it without switching anything off.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where AI helps here, and where it doesn't
&lt;/h2&gt;

&lt;p&gt;The port was a good use of AI. Translating a GitLab pipeline into Actions is mechanical, well-documented work, and the shape of the answer is known before you start.&lt;/p&gt;

&lt;p&gt;What it did not do is notice. The gitleaks config, the key in the history, the missing &lt;code&gt;pytest-cov&lt;/code&gt;, the egress rule and the open endpoint were all found by running things and reading the output, not by looking at the code and thinking about it. Every one of those files reads as correct. A config file with an allowlist comment looks like a config file. A requirements file without &lt;code&gt;pytest-cov&lt;/code&gt; looks like a requirements file. An egress rule set to &lt;code&gt;0.0.0.0/0&lt;/code&gt; is the most common four characters in any Terraform repo.&lt;/p&gt;

&lt;p&gt;The same thing happened to me twice in two weeks. In Part 1 it was a retry wrapper raising its own exception type, so the dead-letter path never ran. Here it is a scanner with its rules switched off. Both passed review, mine included, because reading code tells you what it says and running it tells you what it does.&lt;/p&gt;

&lt;p&gt;Running things has a catch of its own. The first Trivy count in this article came from an AI-assisted run on a copy of the repo that never downloaded the EKS module. It ran, it printed numbers, and the numbers were wrong. CI's clean checkout was the run that counted.&lt;/p&gt;

&lt;p&gt;So the rule from Part 1 survives contact with Part 2: AI drafts, and the specification, the tests and the running system decide. I would add a line to it now. The output of a check is only evidence if you have seen the check fail, in the same place it will run for real.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;I would have built the pipeline first, before the Terraform, before the Helm charts, probably before the third service. Not because CI is more important than the code, but because for eight weeks I had five stages of YAML that described a quality bar and enforced none of it. That is worse than having no pipeline, because it reads like one.&lt;/p&gt;

&lt;p&gt;I would also have protected &lt;code&gt;master&lt;/code&gt; before the first workflow landed. The pull request that added the pipeline merged with its own checks red, because nothing required them to pass. A required status check is one setting.&lt;/p&gt;

&lt;p&gt;The other thing is smaller and more annoying. &lt;code&gt;docs/lessons-learnt.md&lt;/code&gt; and &lt;code&gt;docs/security.md&lt;/code&gt; both say every AWS resource carries a &lt;code&gt;DataClass=patient-identifiable&lt;/code&gt; tag through Terraform's provider-level &lt;code&gt;default_tags&lt;/code&gt;, and describe the audit query that tag makes possible. The &lt;code&gt;default_tags&lt;/code&gt; block in &lt;code&gt;versions.tf&lt;/code&gt; is commented out. The documentation is describing a feature of the system I meant to build. Fixing that properly belongs with Part 5, on security and compliance, and it is going to be the theme of that article.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sources
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://developer.hashicorp.com/terraform/language/files/dependency-lock" rel="noopener noreferrer"&gt;HashiCorp: Dependency lock file&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.hashicorp.com/terraform/cli/commands/validate" rel="noopener noreferrer"&gt;HashiCorp: terraform validate&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://developer.hashicorp.com/terraform/cli/commands/fmt" rel="noopener noreferrer"&gt;HashiCorp: terraform fmt&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/gitleaks/gitleaks" rel="noopener noreferrer"&gt;Gitleaks&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://trivy.dev/latest/docs/scanner/misconfiguration/" rel="noopener noreferrer"&gt;Trivy: misconfiguration scanning&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://avd.aquasec.com/misconfig/aws-0104" rel="noopener noreferrer"&gt;Trivy AVD-AWS-0104: unrestricted egress&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://avd.aquasec.com/misconfig/aws-0040" rel="noopener noreferrer"&gt;Trivy AVD-AWS-0040: public EKS endpoint&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://trivy.dev/latest/docs/configuration/filtering/" rel="noopener noreferrer"&gt;Trivy: filtering and .trivyignore.yaml&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/advisories/GHSA-f96h-pmfr-66vw" rel="noopener noreferrer"&gt;GitHub advisory for CVE-2024-47874 in Starlette&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches/about-protected-branches" rel="noopener noreferrer"&gt;GitHub: about protected branches&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://semgrep.dev/docs/cli-reference" rel="noopener noreferrer"&gt;Semgrep CLI reference&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/anchore/syft" rel="noopener noreferrer"&gt;Anchore Syft&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions" rel="noopener noreferrer"&gt;GitHub Actions: workflow syntax&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/ND-codes/nhs-interop-platform/tree/part-2" rel="noopener noreferrer"&gt;The code for this article, tag part-2&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Next
&lt;/h2&gt;

&lt;p&gt;Part 3, on 28 October, is about the mapping itself: HL7 v2 ADT messages to FHIR R4, and the places it goes quietly wrong, several of them found by readers of Part 1.&lt;/p&gt;

&lt;p&gt;What does your pipeline check that you have never watched fail?&lt;/p&gt;




&lt;p&gt;This is a personal project. It is not affiliated with or endorsed by NHS England or any NHS organisation. It uses the public PDS FHIR sandbox and, earlier in the project, the PDS integration test environment, with synthetic test data only. Views are my own.&lt;/p&gt;

</description>
      <category>discuss</category>
      <category>ai</category>
      <category>aws</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
