Script to make Scripts - 夜莺博客

Script to make Scripts

原文:Script to make Scripts — theDXT (Daniel Keer)

Every managed-services shop eventually hits the same wall: one installer, dozens of customers, and a parameter that must be different in each deployment. BitDefender GravityZone makes this explicit — the silent install wrapper takes a package ID that maps the agent to the right customer tenant, so "the installer" is really one script per client. This post is the story of replacing that manual editing with a small Python script that generates the PowerShell scripts, and it is a pattern you can reuse for any per-customer, per-site or per-VLAN deployment artifact.

The Original Problem

The first version of the install script was a single PowerShell file that downloaded the BitDefender installer to a temp directory, extracted the archive, and ran msiexec silently with the GravityZone package ID supplied as an argument. It worked perfectly — for one customer. The script itself was the same everywhere except for two strings: the download URL and the package ID. With a handful of clients that is a five-minute edit; with forty clients it is an afternoon of copy-paste and a guaranteed typo somewhere in the middle.

Here is the original script as it was first written:

# url to get BitDefender installer
$BDURL = "Download URL"
# the BitDefender Gravity Zone ID to match the install to the correct customer
$GZID  = "GZ_PACKAGE_ID=GZID_here"
# check if temp exists if not make it
$temp = "temp"
if (-not (Test-Path $Env:SystemDrive\$temp))
{
    New-Item -ItemType Directory $Env:SystemDrive\$temp | out-null
}
# download the file to temp
Invoke-WebRequest -Uri $BDURL -OutFile "$Env:SystemDrive\$temp\eps_installer_signed.zip"
# extract files and overwrite if another version happens to be there
Expand-Archive -force "$Env:SystemDrive\$temp\eps_installer_signed.zip" -DestinationPath $Env:SystemDrive\$temp
# install BitDefender silently with no reboot and like with the Gravity Zone id
msiexec /i "$Env:SystemDrive\$temp\eps_installer_signed.msi" /qn $GZID
write-host "Bit Defender install is running"

Two things are worth noting about this early version. First, it assumes the download will succeed over whatever TLS version the endpoint negotiates by default — on older Windows builds that is TLS 1.0, which many CDNs and vendor portals now refuse outright. Second, it does not verify that the MSI actually exists before calling msiexec, so a failed download produces a confusing "installation running" message followed by nothing. Both issues were fixed in the next revision.

The Updated Install Script

This is what the updated install script looks like:

# Some Company Bit Defender Silent install
# the BitDefender Gravity Zone ID to match the install to the correct customer
$GZID = "GZ_PACKAGE_ID=GZID_HERE"
# url to get BitDefender installer
$BDURL = "BitDefender MSI Download URL here"
# check if temp exists if not make it
$temp = "temp"
if (-not (Test-Path $Env:SystemDrive\$temp))
{
    New-Item -ItemType Directory $Env:SystemDrive\$temp | out-null
}
# enable TLS 1.2
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
# download the file to temp
Invoke-WebRequest -Uri $BDURL -OutFile "$Env:SystemDrive\$temp\BEST_downloaderWrapper.msi"
# install BitDefender silently with no reboot and like with the gavity zone id
msiexec /i "$Env:SystemDrive\$temp\BEST_downloaderWrapper.msi" /qn $GZID
write-host "BitDefender install is running"

The change from a ZIP to the downloader-wrapper MSI removed the extract step entirely, and forcing TLS 1.2 removed the class of failures where the download silently produced a zero-byte file on older endpoints. From here the script is stable; only the package ID and URL change per customer, which is exactly what makes it a good candidate for generation rather than maintenance.

Why Manual Updates Did Not Scale

There were far too many scripts that needed to be updated that doing it manually was not realistic. Every time the vendor changed the download URL, every time a customer moved to a different GravityZone package, every time a new client was onboarded, the change had to be replicated across the entire set of files by hand. Worse, the scripts drift: someone fixes one client's script and forgets the other thirty-nine, and six months later nobody can say which scripts are current.

Originally I tried to write a PowerShell script to write a PowerShell script, but that got messy and confusing quickly — quoting, escaping and heredocs inside strings turn a simple template into a debugging exercise. I gave up on the PowerShell route and decided to try using Python to write the PowerShell scripts instead. Python's string handling is exactly what you want for this job: no escaping surprises, no here-string bugs, and it is trivially easy to read a CSV row by row.

The Data File: One Row per Client

In order to do this I created a CSV that had each company name, company short name, and their GravityZone ID. The short name matters because it becomes the filename, so it must be filesystem-safe: no spaces, no slashes, no characters that require quoting in a later shell. Something like this:

client,shortname,Keycode
Acme Manufacturing,acme,GZ_PACKAGE_ID=8f3c1a20-1111-2222-3333-444455556666
Northwind Traders,northwind,GZ_PACKAGE_ID=9a7b2d41-aaaa-bbbb-cccc-ddddeeeeffff
Contoso Health,contoso,GZ_PACKAGE_ID=1c2d3e4f-1234-5678-9abc-def012345678

Keeping this as a CSV rather than inline in the generator is the single most important design decision in the whole approach. Onboarding a new client becomes one line added to a spreadsheet that a non-technical colleague can maintain; regenerating every script becomes a single command. The generator never needs to change when the client list changes, and the client list never needs to change when the template changes.

The Generator Script

I also created a text file that had the part of the PowerShell install script that did not change. Using Python I was able to import the CSV and loop it for each row to output a new PowerShell script for each company using the variables in the CSV file.

This is what the Python script looks like:

# the BD Install script maker script
# author theDXT
print("Starting the script to make scripts")
from csv import DictReader

# load the csv file with the variables and define it as the list
with open('clients.csv', 'r') as the_list:
    # define new variable to load the list as object mapping
    csv_dict_reader = DictReader(the_list)
    # run a loop for each item in the csv
    for item in csv_dict_reader:
        # make powershell files for each client using the shortname and define it as script
        with open((item['shortname']) + "_BD_Install.ps1", 'w') as script:
            # define the header comments and load in the client full name
            header_comments = ["# ", (item['client']), " Bit Defender Silent install\n \n"]
            # save the header comments to the file
            script.writelines(header_comments)
            # define the GZID stuff and load in the client keycode for the correct mappings
            GZID = ["# the BitDefender Gravity Zone ID to match the install to the correct customer\n", '$GZID = "GZ_PACKAGE_ID=', (item['Keycode']), '"']
            # save that to the file
            script.writelines(GZID)
            # load the rest of the install script that doesnt change
            source = open("template.txt", "r")
            # loop it for each line because I didnt find another way that worked
            for line in source:
                script.write(line)
print("all done")

Now all I have to do is run the script and it makes the scripts for me. Here's a link to the script on my GitHub: https://github.com/thedxt/Python#bd-script-maker.

The Template File

The template.txt file holds everything from the updated install script except the header comment and the $GZID line — those two are written by the generator so that each output file is individually identifiable. Keeping the template as plain text rather than a Python triple-quoted string means you can edit it in any editor, diff it in git, and hand it to a support engineer who has never seen the generator. The only rule is that the template must not contain the placeholder tokens again, or you will generate a script with two conflicting package IDs.

One refinement worth adding to the template is a variable for the installer filename, so a vendor-side rename becomes a one-line edit:

# template.txt (excerpt)
$BDURL = "https://downloads.example-vendor.net/BEST_downloaderWrapper.msi"
$InstallerName = "BEST_downloaderWrapper.msi"
$temp = "temp"

if (-not (Test-Path $Env:SystemDrive\$temp))
{
    New-Item -ItemType Directory $Env:SystemDrive\$temp | out-null
}

[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
Invoke-WebRequest -Uri $BDURL -OutFile "$Env:SystemDrive\$temp\$InstallerName"
msiexec /i "$Env:SystemDrive\$temp\$InstallerName" /qn $GZID
write-host "BitDefender install is running"

Running It and Checking the Output

The workflow is three commands: drop clients.csv, template.txt and the generator into a working directory, run python make_scripts.py from that directory, and read the all done line. Every run overwrites the previous output, which is the behaviour you want — the generated files are build artifacts, not source. That leads to the one discipline that keeps this pattern healthy: never hand-edit a generated _BD_Install.ps1. If a client needs a deviation, add a column to the CSV and a conditional in the generator, or the next regeneration will silently discard the manual change.

A quick sanity check after generation is worth the ten seconds it costs:

ls *_BD_Install.ps1 | wc -l          # should equal the number of CSV rows minus the header
grep -L 'GZ_PACKAGE_ID=GZ_PACKAGE_ID' *_BD_Install.ps1   # any file listed here got no ID
Select-String -Path .\*_BD_Install.ps1 -Pattern '\$GZID' | Select-Object Filename, Line

The grep -L check is the important one: if a CSV row has an empty Keycode, the generated script will contain the literal template default and the agent will register to the wrong tenant. Failing loudly at generation time is far cheaper than discovering it on a customer's endpoint weeks later.

Hardening the Generated Scripts

Generation solves the drift problem, but the generated script still runs as SYSTEM on an endpoint and still downloads an executable from the internet. A few additions to the template pay for themselves immediately:

  • Signature check — after downloading, verify the MSI's Authenticode signature before executing it: Get-AuthenticodeSignature and a check for Status -eq 'Valid'.
  • Hash pinning — carry a SHA-256 hash as a CSV column alongside the URL and verify with Get-FileHash; vendor CDNs do occasionally serve truncated files.
  • Exit-code handling — msiexec returns 3010 for "success, reboot required" and 1603 for a fatal install error. Capture $LASTEXITCODE and write it to the log so your RMM has something to act on.
  • Logging — msiexec /i ... /qn /l*v "$env:TEMP\bd_install.log" gives you a verbose log without changing the silent behaviour.
  • Idempotency — check whether the agent is already installed and exit early, so a repeated push does not reinstall over a working endpoint.

None of these need to live in the generator; they belong in the template so that every client benefits the moment you change one file.

Extending the Pattern Beyond One Vendor

The same generator shape — CSV of per-site variables, one template, one loop — applies to almost any fleet-wide deployment artifact. Configuration files for network devices, VPN profiles, monitoring agent configs and package-repo definitions all follow the same structure: a fixed body plus a small number of site-specific values. If you are already automating network devices with Python, the neighbouring problems are the same ones covered in our Junos PyEZ automation guide, the NAPALM getters and config-diff workflow, and the declarative approach in Terraform for network automation. In each case the win comes from extracting the variable parts into data and keeping the logic in one place.

Two upgrades turn a personal script into something a team can rely on. First, add argparse so the input CSV and output directory are arguments rather than hard-coded paths, which makes the generator usable from CI. Second, emit a manifest — a CSV or JSON file listing every generated script with its package ID and a hash of the template used — so that later you can answer "which clients are still on the old installer?" without opening a single file.

Lessons Worth Keeping

  • Generate, do not duplicate. Anything that differs only in a handful of values should be produced from a template, not copied.
  • Put the variables in data. A CSV that a colleague can edit beats a dictionary buried in code.
  • Pick the language that makes templating boring. PowerShell is the right runtime for the output; Python was the right runtime for the generator.
  • Treat generated files as build artifacts. Never hand-edit them, and never treat them as the source of truth.
  • Fail at generation time. Validate every row before writing a script, so a bad input never becomes a bad deployment.

That is the whole pattern: a template, a data file, and a loop. It is unglamorous, it takes an hour to write, and it removes an entire category of repetitive work from the queue — permanently.