🚀 Mastering DevOps: HashiCorp Tool Installation with Cross-Platform PowerShell and the Perfect "Golden Image" Architecture 💻⚙️
Managing Infrastructure as Code (IaC) is no longer a luxury but a necessity in the modern technology world. IaC merges infrastructure management with software development disciplines, ensuring repeatability, version control, and agility. The pioneers of this revolution are undoubtedly Packer and Terraform.
While we create fundamental, reliable virtual machine images with Packer, we use those images with Terraform to provision complex infrastructures in minutes. However, the manual overhead of installing and keeping these critical tools up-to-date across multiple operating systems (Windows 🗔, Linux 🐧, macOS 🍎) fundamentally contradicts the philosophy of automation. Installation automation is the first and most foundational step of a successful IaC lifecycle.
In this article, I have prepared a script that fully automates this installation process by leveraging PowerShell's cross-platform capabilities.
In this comprehensive guide, we will delve into the depths of installation automation, explain why we chose this path, and analyze step-by-step how we use the Packer we installed to create a real-world scenario: an Ubuntu "Golden Image" loaded with PowerShell Core.
Part 1: The Foundation of IaC - The Installation Automation Script 🐍🛠️
For a DevOps engineer, consistency and reproducibility are essential. Installation automation is the first link in this chain of consistency; it guarantees that the same tools, with the same versions, exist across Development, Test, and Production environments.
A. Our Cross-Platform Installation Script (PowerShell) 📜
The following PowerShell script utilizes cross-platform management capabilities to the fullest extent when installing HashiCorp tools (Packer and Terraform).
# Clear the console screen
Clear-Host
Set-Location -Path $PSScriptRoot
# List of apps to install (packer commented out, terraform active)
$apps = @('packer', 'terraform')
if ($IsWindows) {
# Set the installation directory for Windows
$installPath = "C:\hashicorp"
# Loop through each app to download and install
$apps | ForEach-Object {
$app = $_
# Get metadata JSON from HashiCorp releases API
$metadataUrl = "https://releases.hashicorp.com/$($app)/index.json"
$metadata = Invoke-RestMethod -Uri $metadataUrl
# Extract available version strings
$versionStrings = $metadata.versions.PSObject.Properties.Name
# Filter only versions matching semantic versioning pattern (digit.digit.digit)
$standardVersions = $versionStrings | Where-Object { $_ -match '^\d+\.\d+\.\d+$' }
# Convert versions to [version] objects and sort descending
$versions = $standardVersions | ForEach-Object { [version]$_ } | Sort-Object -Descending
# Select the latest stable version
$version = ($versions | Select-Object -First 1).ToString()
# Exit with error if no version found
if (!$version) {
Write-Error "Could not get the latest version"
exit 1
}
# Set parameters to download the zip archive for the app
$param = @{
uri = "https://releases.hashicorp.com/$($app)/$($version)/$($app)_$($version)_windows_$([System.Environment]::Is64BitOperatingSystem ? 'amd64' : '386').zip"
OutFile = "$($env:Temp)\$($app).zip"
}
Invoke-WebRequest @param
# Extract the downloaded zip file to the installation path
$param = @{
path = $param.OutFile
DestinationPath = $installPath
}
Expand-Archive @param -Force
# Remove the downloaded zip file after extraction
Remove-Item -Path $param.path -Force
}
# Get the current user PATH environment variable
$currentPath = [Environment]::GetEnvironmentVariable("Path", "User")
# Add installPath to PATH if not already present
if ($currentPath -notlike "*$installPath*") {
# Reminder: This script must be run as Administrator on Windows!
if (-not ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator")) {
Write-Warning "⚠️ Please run this script as Administrator to install applications and modify system settings."
exit 1
}
Write-Host "Adding $installPath to system PATH..."
[Environment]::SetEnvironmentVariable("Path", "$currentPath;$installPath", "User")
}
else {
Write-Host "PATH already contains $installPath"
}
}
elseif ($IsLinux) {
# Detect Linux distribution info from /etc/os-release
$os = @{ }
Get-Content /etc/os-release | ForEach-Object { if ($_ -match '^(.*?)=(.*)$') { $os[$matches[1]] = $matches[2].Trim('"') } }
if ($os.Name -match 'Ubuntu|Debian') {
# Add HashiCorp GPG key and repository for Debian-based systems
wget -O - https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(grep -oP '(?<=UBUNTU_CODENAME=).*' /etc/os-release || lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
# Update package lists and install each app
$apps | ForEach-Object {
sudo apt update && sudo apt install $_
}
}
elseif ($os.Name -match 'Centos|RHEL') {
# Add HashiCorp repo and install packages on CentOS/RHEL
sudo yum install -y yum-utils
sudo yum-config-manager --add-repo https://rpm.releases.hashicorp.com/RHEL/hashicorp.repo
$apps | ForEach-Object {
sudo yum -y install $_
}
}
else {
Write-Error "Unsupported OS: $($os["NAME"])"
}
}
elseif ($IsMacOS) {
# Use Homebrew to tap and install Terraform on macOS
brew tap hashicorp/tap
brew install hashicorp/tap/terraform
}
else {
Write-Host 'Unknown operating system'
exit 1
}
B. Technical Analysis of the PowerShell Script: Platform Separation and Reliability 💡
1. Windows Installation: API Dynamism and Secure PATH Management 🗔
This approach eliminates dependency on static installation files:
2. Linux Installation: Native Package Management Integration 🐧
The most correct and sustainable method on Linux is to use the distribution's own package manager:
3. macOS Installation: Simplification with Homebrew 🍎
The industry standard for macOS, Homebrew, is utilized. The brew tap hashicorp/tap command installs HashiCorp tools quickly and securely.
Part 2: IaC Excellence in Practice - Automated "Golden Image" Production 🌟🏭
By using the Packer we automatically installed, we can now create our base image that will be ready for deployment with Terraform. This image is an Ubuntu 24.04 server loaded with PowerShell Core, which is necessary for automation.
A. Required Folder Structure 📁
Clean management of scripts and configuration files is critical for the project structure. All HCL and script files should reside in the same root directory, with the ISO file in a separate subdirectory:
📦 /packer-project
┣ 📜 packer-build.ps1 (Management script that runs Packer)
┣ 📜 ubuntu.pkr.hcl (Packer configuration file)
┣ 📜 install_or_update_pwsh.sh (PowerShell installation script running inside the VM)
┣ 📄 meta-data (cloud-init file)
┣ 📄 user-data (cloud-init file)
┗ 📂 ISO (Folder containing the ISO file)
┗ 💿 ubuntu-24.04.3-live-server-amd64.iso
B. The Complete Packer Configuration: ubuntu.pkr.hcl 🧱
The following Packer HCL file has been updated to reflect this change in the folder structure by modifying the iso_url parameter (it uses ./ISO/... instead of C:/ISO/... or your specific Windows file path):
packer {
required_version = ">= 1.14.0"
required_plugins {
vmware-iso = {
version = ">= 1.2.0"
source = "github.com/hashicorp/vmware"
}
}
}
source "vmware-iso" "ubuntu" {
iso_url = "C:/ISO/ubuntu-24.04.3-live-server-amd64.iso" # Your file path should be reflected here. The Windows path is kept in the example.
iso_checksum = "none" # For security, it is highly recommended to fill this section with the SHA256 value!
vm_name = "ubuntu"
cpus = 8
memory = 16384
headless = false
cd_files = ["./meta-data", "./user-data"] # cloud-init files are transferred from the root directory to the VM
cd_label = "cidata"
boot_wait = "5s"
# Boot Command: Triggers the automated installation
boot_command = [
"<wait>",
"c<wait>",
"linux /casper/vmlinuz autoinstall ds=nocloud;s=/cdrom/ ---<enter><wait>",
"initrd /casper/initrd<enter><wait>",
"boot<enter>"
]
ssh_username = "ubuntu"
ssh_password = "1"
ssh_timeout = "20m"
ssh_handshake_attempts = 50
}
build {
sources = ["source.vmware-iso.ubuntu"]
# Provisioner: Runs the PowerShell installation script after the VM is set up
provisioner "shell" {
script = "./install_or_update_pwsh.sh"
# Secure Sudo Command: Executes the command with administrative privileges by piping the password.
execute_command = "echo '{{ .Password }}' | sudo -S sh '{{ .Path }}'"
}
}
C. Cloud-init Files: meta-data and user-data Content ☁️
These are the most critical files that activate Packer's autoinstall feature and ensure the installation proceeds without manual intervention. The command cd_files = ["./meta-data", "./user-data"] presents these files to the virtual machine as a CD-ROM.
1. meta-data (Simple Content)
It is usually empty but can contain basic information like the instance-id. This simple format is sufficient for automated installation:
Recommended by LinkedIn
instance-id: ubuntu
local-hostname: ubuntu
2. user-data (Ubuntu Installation Configuration)
This file defines Ubuntu's installation process (language, disk partitioning, user account, etc.) in YAML format. Remember that the username and password in this file must match the values used by Packer for the SSH connection.
#cloud-config
autoinstall:
version: 1
identity:
hostname: ubuntu
username: ubuntu
# Important: We were using a plain text password ("1") for Packer's SSH connection in the HCL file.
# For a true Golden Image, the SHA-512 hash generated with mkpasswd should go here!
password: $6$YCCM0tjftFGJ.E1C$Qn1HJGdVraSMJpozoqX1WOt8s61/XRXCCmNQPkK6IJWgEAfDQ/m12m9WLsXCNGnIVALukD/QFL6hXtUp2mgPG0
ssh:
install-server: true
allow-pw: true
storage:
layout:
name: direct
packages:
- open-vm-tools
late-commands:
- curtin in-target -- systemctl enable ssh
D. Provisioning Script: PowerShell Core Installation (install_or_update_pwsh.sh) 🎯
This Bash script operates according to the principle of Idempotence (repeatability). In every execution, the script:
#!/bin/bash
# Define package and GitHub repo information
PACKAGE_NAME="powershell"
GITHUB_REPO="PowerShell/PowerShell"
# Check if the script is run as root
if [ "$EUID" -ne 0 ]; then
echo "Please run this script as root (use sudo)."
exit 1
fi
# Check if pwsh is already installed
if command -v pwsh >/dev/null 2>&1; then
echo "PowerShell is already installed. Checking for updates..."
# Get the current installed version
CURRENT_VERSION=$(pwsh -Command '$PSVersionTable.PSVersion.ToString()')
# Fetch the latest release version from GitHub
LATEST_VERSION=$(curl -s "https://api.github.com/repos/$GITHUB_REPO/releases/latest" | grep tag_name | cut -d '"' -f 4 | sed 's/^v//')
# Compare versions
if [ "$CURRENT_VERSION" != "$LATEST_VERSION" ]; then
echo "Latest version: $LATEST_VERSION (current: $CURRENT_VERSION)"
echo "Updating PowerShell..."
# Update PowerShell using apt
apt-get update
apt-get install --only-upgrade -y $PACKAGE_NAME
else
echo "PowerShell is up to date (version: $CURRENT_VERSION)."
fi
else
echo "PowerShell is not installed. Installing..."
# Add Microsoft package repository and install dependencies
apt-get update && apt-get install -y wget apt-transport-https software-properties-common
wget -q https://packages.microsoft.com/config/ubuntu/$(lsb_release -rs)/packages-microsoft-prod.deb
dpkg -i packages-microsoft-prod.deb
rm packages-microsoft-prod.deb
# Install PowerShell
apt-get update
apt-get install -y $PACKAGE_NAME
fi
# Print installed version
echo "Done. Installed PowerShell version: $(pwsh -Command '$PSVersionTable.PSVersion.ToString()')"
Script Analysis: Idempotence and Reliability Mechanisms
E. Packer Management Flow: The packer-build.ps1 Script 📜
This PowerShell script manages the complex Packer process as a workflow. This is crucial for both early error detection and ensuring a clean start.
# Clear the console screen
Clear-Host
Write-Host "--- PACKER GOLDEN IMAGE MANAGEMENT STARTED ---" -ForegroundColor Green
# 1. Environment Cleanup: Ensures a clean start by removing previous outputs.
if (Test-Path .\output-ubuntu) {
Write-Host "Cleaning up previous image outputs..." -ForegroundColor Yellow
Remove-Item .\output-ubuntu -Recurse -Force
}
# Set the directory where the script is running
Set-Location -Path $PSScriptRoot
# 2. Preparation: Install Packer plugins
Write-Host "Packer init (Plugins) is loading..." -ForegroundColor Cyan
packer init .
# 3. Validation: Check the syntax and format of the HCL file
Write-Host "Packer validate (Configuration Check) is running..." -ForegroundColor Cyan
packer validate .
# 4. Start: Initiate the image creation process
Write-Host "Packer build (Image creation) process is starting..." -ForegroundColor Red
packer build .
Write-Host "--- PACKER GOLDEN IMAGE MANAGEMENT COMPLETED ---" -ForegroundColor Green
Script Analysis: The Four Steps of Management
F. Analysis of the Packer Build Output (Log) 🔍
The complete log output produced by Packer when running the packer build . command is provided below. This output shows both the successful steps of the process and any warnings that require attention:
The configuration is valid.
Warning: A checksum of 'none' was specified. Since ISO files are so big,
a checksum is highly recommended.
on ubuntu.pkr.hcl line 11:
(source code not available)
Warning: A shutdown_command was not specified. Without a shutdown command, Packer
will forcibly halt the virtual machine, which may result in data loss.
on ubuntu.pkr.hcl line 11:
(source code not available)
... (Start of the Process) ...
==> vmware-iso.ubuntu: Connected to SSH!
==> vmware-iso.ubuntu: Provisioning with shell script: ./install_or_update_pwsh.sh
==> vmware-iso.ubuntu: [sudo] password for ubuntu: /tmp/script_5512.sh: 8: [: Illegal number:
==> vmware-iso.ubuntu: PowerShell is not installed. Installing...
... (Package download and installation logs) ...
==> vmware-iso.ubuntu: Selecting previously unselected package powershell.
...
==> vmware-iso.ubuntu: Setting up powershell (7.5.3-1.deb) ...
...
==> vmware-iso.ubuntu: Done. Installed PowerShell version: 7.5.3
==> vmware-iso.ubuntu: Forcibly halting virtual machine...
... (Cleanup and VMX operations) ...
Build 'vmware-iso.ubuntu' finished after 7 minutes 58 seconds.
==> Wait completed after 7 minutes 58 seconds
==> Builds finished. The artifacts of successful builds are:
--> vmware-iso.ubuntu: VM files in directory: output-ubuntu
Detailed Analysis of the Log Output 🔬
Every step in this output validates a critical part of our IaC process:
1. Critical Warnings:
2. SSH Connection: The line Connected to SSH! indicates that the non-interactive installation performed with cloud-init was completed successfully and that Packer has moved on to the Provisioning stage.
3. Provisioning Success:
Conclusion: Bootstrapping Automation with Automation 🔗
The unified automation model we presented in this article represents the pinnacle of modern infrastructure management and achieves fundamental DevOps goals:
This cycle allows your team to build faster, more reliable, and more repeatable infrastructures. By bootstrapping automation with automation, you take a significant step toward eliminating human error.
Have you used PowerShell's cross-platform capabilities in your IaC projects like this? What was the most interesting technical challenge you faced with Packer and cloud-init integration? I'd love to hear your experiences and comments! 👇