DEV Community

Cover image for Day 72: A Parameter and a Boot Script
Nnamdi Felix Ibe
Nnamdi Felix Ibe

Posted on AI-assisted

Day 72: A Parameter and a Boot Script

Day 72 of DevOps, Day 22 of Azure. Both tasks today were about a value crossing from outside into something that runs it, and what happens at the boundary.

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

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

Task 1: Two build parameters

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

Step 1: Add them, and notice the documentation gap

Both go under This project is parameterised. That part is a form.

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

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

So nothing in the script declares anything. Jenkins exports Stage and env into the build's environment, and the shell expands them.

Step 2: The Choice parameter is yesterday's recommendation

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

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

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

Step 3: Why this is safer than Pipeline, by accident

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

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

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

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

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

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

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

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

What the docs recommend

None of that makes an unquoted expansion fine. Word splitting and filename expansion do still apply, and they are the next two steps in that same list:

echo "Stage is $Stage, environment is $env"
Enter fullscreen mode Exit fullscreen mode

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

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

Task 2: A VM that configures itself

The task: an Ubuntu VM named nautilus-vm that installs and starts nginx on first boot, reachable on port 80.

Step 1: Pass the disk flags, and a correction about why

--storage-sku Standard_LRS --os-disk-size-gb 30
Enter fullscreen mode Exit fullscreen mode

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

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

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

The finding survives, the mechanism does not: omit --storage-sku in this subscription and you get a Premium disk and a policy refusal. Specify it.

Step 2: The CLI encodes the script for you

--custom-data /root/cloud-init.sh
Enter fullscreen mode Exit fullscreen mode

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

Worth setting beside the AWS equivalent, because the asymmetry bit me on Day 44:

Call Encoding
aws ec2 run-instances --user-data file://... Automatic
aws ec2 create-launch-template UserData Manual. Raw text succeeds and never runs
az vm create --custom-data Automatic

The launch template case is the dangerous one, because nothing fails. The template is created, the instance launches, the script simply never executes.

Step 3: Custom data or user data

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

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

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

One warning attaches to custom data and not to user data: "We advise not to store sensitive data in custom data."

Step 4: Port 80 needs its own command

--nsg-rule cannot do it. Its accepted values are {NONE, RDP, SSH}, full stop, so an arbitrary port goes through a second call:

az vm open-port -g $RG -n nautilus-vm --port 80 --priority 900
Enter fullscreen mode Exit fullscreen mode
open-port-80       80   900   Allow
default-allow-ssh  22   1000  Allow
Enter fullscreen mode Exit fullscreen mode

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

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

What the docs recommend

The script is four lines, and its first line is the one that decides whether any of it happens:

#!/bin/bash
apt-get update -y
apt-get install -y nginx
systemctl enable --now nginx
Enter fullscreen mode Exit fullscreen mode

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

Then the thing that actually caught me:

attempt 1: 000
attempt 2: 000
attempt 3: 200
Enter fullscreen mode Exit fullscreen mode

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

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

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

What I learned

Both halves came down to the same question: at what point does a value stop being text and start being structure?

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

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

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

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

Day 72 down. Twenty-eight to go.

Top comments (0)