diff --git a/FromGitToGalleryWithOpenSource-MacInally/readme.md b/FromGitToGalleryWithOpenSource-MacInally/readme.md new file mode 100644 index 0000000..0c19c0a --- /dev/null +++ b/FromGitToGalleryWithOpenSource-MacInally/readme.md @@ -0,0 +1,11 @@ +# Everything On-Prem: From Git to Gallery with Open Source + +Slides and supporting material for my PowerShell + DevOps Global Summit 2026 session. + +This folder contains the slide deck only. The full demo environment — Vagrantfile, provisioning scripts, nginx/Gitea/Nexus/lldap configs, the example `Certificates` PowerShell module, and the complete 40+ step walkthrough — lives in a separate repository: + +**👉 https://github.com/lindnerbrewery/PsSummit2026_GitToGallery** + +Head over there if you want to spin up the VM, follow along step by step, or rebuild the whole on-prem pipeline (Gitea + Actions + Nexus + LDAP + nginx) on your own infrastructure. + +— Emrys MacInally (@lindnerbrewery) \ No newline at end of file diff --git a/FromGitToGalleryWithOpenSource-MacInally/slides/FromGitToGalleryWithOpenSource-MacInally.pptx b/FromGitToGalleryWithOpenSource-MacInally/slides/FromGitToGalleryWithOpenSource-MacInally.pptx new file mode 100644 index 0000000..e1b15b6 Binary files /dev/null and b/FromGitToGalleryWithOpenSource-MacInally/slides/FromGitToGalleryWithOpenSource-MacInally.pptx differ diff --git a/PimpYourParameterValidationClasses-MacInally/demo/character.schema.json b/PimpYourParameterValidationClasses-MacInally/demo/character.schema.json new file mode 100644 index 0000000..29c3faa --- /dev/null +++ b/PimpYourParameterValidationClasses-MacInally/demo/character.schema.json @@ -0,0 +1,24 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "D&D Character", + "type": "object", + "properties": { + "name": { "type": "string", "minLength": 2 }, + "class": { "type": "string", "enum": ["Barbarian", "Bard", "Cleric", "Druid", "Fighter", "Monk", "Paladin", "Ranger", "Rogue", "Sorcerer", "Warlock", "Wizard"] }, + "level": { "type": "integer", "minimum": 1, "maximum": 20 }, + "race": { "type": "string" }, + "stats": { + "type": "object", + "properties": { + "strength": { "type": "integer", "minimum": 1, "maximum": 30 }, + "dexterity": { "type": "integer", "minimum": 1, "maximum": 30 }, + "constitution": { "type": "integer", "minimum": 1, "maximum": 30 }, + "intelligence": { "type": "integer", "minimum": 1, "maximum": 30 }, + "wisdom": { "type": "integer", "minimum": 1, "maximum": 30 }, + "charisma": { "type": "integer", "minimum": 1, "maximum": 30 } + }, + "required": ["strength", "dexterity", "constitution", "intelligence", "wisdom", "charisma"] + } + }, + "required": ["name", "class", "level", "race", "stats"] +} diff --git a/PimpYourParameterValidationClasses-MacInally/demo/validationClassesDemo.ps1 b/PimpYourParameterValidationClasses-MacInally/demo/validationClassesDemo.ps1 new file mode 100644 index 0000000..19fe090 --- /dev/null +++ b/PimpYourParameterValidationClasses-MacInally/demo/validationClassesDemo.ps1 @@ -0,0 +1,505 @@ +#region ---- Built-in Validation Attributes (Quick Recap) ---- + +# ValidateNotNullOrEmpty - the basics +function Get-User { + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string]$Username + ) + "Looking up user: $Username" +} + +Get-User -Username '' +Get-User -Username 'emrys' + +# ValidateSet - static list +function Set-Environment { + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [ValidateSet('Dev', 'Test', 'Staging', 'Prod')] + [string]$Environment + ) + "Deploying to: $Environment" +} + +Set-Environment -Environment 'Dev' +Set-Environment -Environment 'QA' # Fails + +# ValidatePattern - regex +function Get-Server { + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [ValidatePattern('^SRV-\d{3,5}$')] + [string]$ServerName + ) + "Connecting to: $ServerName" +} + +Get-Server -ServerName 'SRV-001' +Get-Server -ServerName 'whatever' # Ugly error message! + +# ValidateScript - flexible but messy +function Get-Config { + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [ValidateScript({ + if ($_ -match '^[A-Z]+-\d+$') { $true } + else { throw "'$_' is not a valid ticket ID. Expected format: PROJ-1234" } + })] + [string]$TicketId + ) + "Loading config for ticket: $TicketId" +} + +Get-Config -TicketId 'PROJ-1234' +Get-Config -TicketId 'bad' # Works but... copy-paste this to 10 functions? + +#endregion + +#region ---- The Problem: Why Custom Validation Classes? ---- + +# Imagine you have 15 functions that all need to validate a ticket ID... +# Copy-paste ValidateScript everywhere? No thanks. +# What if the format changes? Update 15 functions? +# What about friendly error messages? + +# Enter: Custom Validation Attributes! + +#endregion + +#region ---- Anatomy of a Custom Validation Attribute ---- + +# The skeleton - this is ALL you need to know: + +class ValidateIsNotFridayAttribute : System.Management.Automation.ValidateArgumentsAttribute { + [void] Validate([object]$arguments, [System.Management.Automation.EngineIntrinsics]$engineIntrinsics) { + # If something is wrong: throw an exception + # If everything is fine: do nothing (return void) + if ([datetime]$arguments -is [datetime] -and ([datetime]$arguments).DayOfWeek -eq 'Friday') { + throw "No deployments on Fridays! '$arguments' falls on a Friday. Go home." + } + } +} + +function New-Deployment { + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [ValidateIsNotFriday()] + [datetime]$ScheduledDate + ) + "Deployment scheduled for: $ScheduledDate" +} + +New-Deployment -ScheduledDate '2026-03-20' # A Friday - DENIED! +New-Deployment -ScheduledDate '2026-03-23' # A Monday - Welcome! + +#endregion + +#region ---- Example 1: ValidateTicketId (JIRA-style) ---- + +class ValidateTicketIdAttribute : System.Management.Automation.ValidateArgumentsAttribute { + [void] Validate([object]$arguments, [System.Management.Automation.EngineIntrinsics]$engineIntrinsics) { + $ticketId = [string]$arguments + + if ([string]::IsNullOrWhiteSpace($ticketId)) { + throw [System.ArgumentNullException]::new('TicketId', 'Ticket ID cannot be null or empty.') + } + + # Pattern: 2-5 uppercase letters, dash, 1-6 digits (e.g., PROJ-1234, DEV-56789) + if ($ticketId -notmatch '^[A-Z]{2,5}-\d{1,6}$') { + throw "Invalid ticket ID: '$ticketId'. Expected format like PROJ-1234 (2-5 uppercase letters, dash, 1-6 digits)." + } + } +} + +function Get-TicketInfo { + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [ValidateTicketId()] + [string]$TicketId + ) + "Fetching details for ticket: $TicketId" +} + +function Close-Ticket { + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [ValidateTicketId()] + [string]$TicketId, + + [Parameter(Mandatory)] + [string]$Reason + ) + "Closing ticket $TicketId - Reason: $Reason" +} + +Get-TicketInfo -TicketId 'PROJ-1234' +Get-TicketInfo -TicketId 'DEV-567' +Get-TicketInfo -TicketId 'nope' # Friendly error! +Get-TicketInfo -TicketId 'toolong-1234' # Friendly error! +Close-Ticket -TicketId 'PROJ-1234' -Reason 'Fixed' +Close-Ticket -TicketId 'garbage' -Reason 'Fixed' # Same validation, zero duplication! + +#endregion + +#region ---- Example 2: ValidateSemVer (Semantic Versioning) ---- + +class ValidateSemVerAttribute : System.Management.Automation.ValidateArgumentsAttribute { + [void] Validate([object]$arguments, [System.Management.Automation.EngineIntrinsics]$engineIntrinsics) { + $version = [string]$arguments + + if ([string]::IsNullOrWhiteSpace($version)) { + throw [System.ArgumentNullException]::new('Version', 'Version string cannot be null or empty.') + } + + # Remove leading 'v' if present (v1.2.3 -> 1.2.3) + $version = $version.TrimStart('v', 'V') + + # Match semantic versioning: Major.Minor.Patch with optional pre-release + if ($version -notmatch '^\d+\.\d+\.\d+(-[a-zA-Z0-9]+(\.[a-zA-Z0-9]+)*)?$') { + throw "Invalid semantic version: '$arguments'. Expected format: Major.Minor.Patch (e.g., 1.2.3, v2.0.0, 1.0.0-beta.1)" + } + + # Extract parts and validate ranges + $parts = $version.Split('-')[0].Split('.') + $major = [int]$parts[0] + $minor = [int]$parts[1] + $patch = [int]$parts[2] + + if ($major -gt 999 -or $minor -gt 999 -or $patch -gt 999) { + throw "Version numbers cannot exceed 999. Got: $major.$minor.$patch" + } + } +} + +function Deploy-Module { + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [string]$ModuleName, + + [Parameter(Mandatory)] + [ValidateSemVer()] + [string]$Version + ) + "Deploying $ModuleName version $Version" +} + +Deploy-Module -ModuleName 'MyModule' -Version '1.2.3' +Deploy-Module -ModuleName 'MyModule' -Version 'v2.0.0' +Deploy-Module -ModuleName 'MyModule' -Version '1.0.0-beta.1' +Deploy-Module -ModuleName 'MyModule' -Version '1.0.0-rc.2' +Deploy-Module -ModuleName 'MyModule' -Version 'not.a.version' # Fails +Deploy-Module -ModuleName 'MyModule' -Version '1.2' # Fails - missing patch + +#endregion + +#region ---- Example 3: ValidateHogwartsHouse (Fun + Constructor Parameters!) ---- + +# What if you want the SAME validator class but with DIFFERENT allowed values? +# Constructor parameters to the rescue! + +class ValidateIsOneOfAttribute : System.Management.Automation.ValidateArgumentsAttribute { + [string[]]$AllowedValues + [string]$ErrorContext + + ValidateIsOneOfAttribute([string[]]$allowedValues) { + $this.AllowedValues = $allowedValues + $this.ErrorContext = 'value' + } + + ValidateIsOneOfAttribute([string[]]$allowedValues, [string]$errorContext) { + $this.AllowedValues = $allowedValues + $this.ErrorContext = $errorContext + } + + [void] Validate([object]$arguments, [System.Management.Automation.EngineIntrinsics]$engineIntrinsics) { + if ($arguments -notin $this.AllowedValues) { + $allowed = $this.AllowedValues -join ', ' + throw "Invalid $($this.ErrorContext): '$arguments'. Allowed values: $allowed" + } + } +} + +function Get-HogwartsStudent { + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [ValidateIsOneOf(('Gryffindor', 'Hufflepuff', 'Ravenclaw', 'Slytherin'), 'Hogwarts House')] + [string]$House + ) + "Fetching students from $House..." +} + +function Get-PizzaOrder { + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [ValidateIsOneOf(('Small', 'Medium', 'Large', 'Family'), 'pizza size')] + [string]$Size, + + [Parameter(Mandatory)] + [ValidateIsOneOf(('Margherita', 'Pepperoni', 'Hawaiian', 'Quattro Formaggi'), 'pizza type')] + [string]$Type + ) + "Order placed: $Size $Type pizza" +} + +Get-HogwartsStudent -House 'Gryffindor' +Get-HogwartsStudent -House 'Durmstrang' # "Invalid Hogwarts House: 'Durmstrang'" + +Get-PizzaOrder -Size 'Large' -Type 'Pepperoni' +Get-PizzaOrder -Size 'Huge' -Type 'Pepperoni' # "Invalid pizza size: 'Huge'" +Get-PizzaOrder -Size 'Large' -Type 'Anchovy' # "Invalid pizza type: 'Anchovy'" +Get-PizzaOrder -Size 'Huge' -Type 'Anchovy' # "Order matters: only the first parameter validation error is shown. Fix size first, then type. This keeps error messages focused and actionable." +Get-PizzaOrder -Type 'Anchovy' -Size 'Huge' # "Order matters: only Type is validated first here, so the error is about pizza type" + +# Same class, different contexts! That's the power of constructor parameters. + +#endregion + +#region ---- Example 4: ValidateSafeFilePath (Security!) ---- + +class ValidateSafeFilePathAttribute : System.Management.Automation.ValidateArgumentsAttribute { + [string]$AllowedRoot + + ValidateSafeFilePathAttribute() { + $this.AllowedRoot = $null # Any path, just safety checks + } + + ValidateSafeFilePathAttribute([string]$allowedRoot) { + $this.AllowedRoot = $allowedRoot + } + + [void] Validate([object]$arguments, [System.Management.Automation.EngineIntrinsics]$engineIntrinsics) { + $path = [string]$arguments + + if ([string]::IsNullOrWhiteSpace($path)) { + throw [System.ArgumentNullException]::new('Path', 'File path cannot be null or empty.') + } + + # Block path traversal attacks + if ($path -match '\.\.[\\/]') { + throw "Path traversal detected in '$path'. Nice try! Relative parent paths (..) are not allowed." + } + + # Block UNC paths (network shares) + if ($path -match '^\\\\') { + throw "UNC paths are not allowed: '$path'. Only local paths are permitted." + } + + # If AllowedRoot is set, enforce it + if ($this.AllowedRoot) { + $resolvedPath = $engineIntrinsics.SessionState.Path.GetUnresolvedProviderPathFromPSPath($path) + $resolvedRoot = $engineIntrinsics.SessionState.Path.GetUnresolvedProviderPathFromPSPath($this.AllowedRoot) + + if (-not $resolvedPath.StartsWith($resolvedRoot, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Path '$path' is outside the allowed directory '$($this.AllowedRoot)'. Access denied." + } + } + } +} + +function Export-Report { + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [ValidateSafeFilePath('C:\Reports')] + [string]$OutputPath, + + [Parameter()] + [string]$Content = 'Sample report content' + ) + "Exporting report to: $OutputPath" +} + +function Read-ConfigFile { + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [ValidateSafeFilePath()] + [string]$Path + ) + "Reading config from: $Path" +} + +Export-Report -OutputPath 'C:\Reports\monthly.csv' +Export-Report -OutputPath 'C:\Reports\2026\q1.csv' +Export-Report -OutputPath 'C:\Temp\sneaky.csv' # Outside allowed root! +Export-Report -OutputPath 'C:\Reports\..\Windows\evil.exe' # Path traversal blocked! +Read-ConfigFile -Path '..\..\etc\passwd' # Traversal blocked! +Read-ConfigFile -Path '\\server\share\config.json' # UNC blocked! + +#endregion + +#region ---- Example 5: ValidatePort (Numeric Range with Context) ---- + +class ValidatePortAttribute : System.Management.Automation.ValidateArgumentsAttribute { + [bool]$AllowWellKnown = $false + [bool]$AllowRegistered = $true + [bool]$AllowDynamic = $true + + ValidatePortAttribute() {} + + ValidatePortAttribute([bool]$allowWellKnown) { + $this.AllowWellKnown = $allowWellKnown + } + + [void] Validate([object]$arguments, [System.Management.Automation.EngineIntrinsics]$engineIntrinsics) { + $port = [int]$arguments + + if ($port -lt 0 -or $port -gt 65535) { + throw "Port $port is out of range. Valid ports: 0-65535." + } + + if ($port -le 1023 -and -not $this.AllowWellKnown) { + throw "Port $port is a well-known port (0-1023). Use ports 1024-65535 or explicitly allow well-known ports." + } + + if ($port -ge 1024 -and $port -le 49151 -and -not $this.AllowRegistered) { + throw "Port $port is a registered port (1024-49151). This range is not allowed." + } + + if ($port -ge 49152 -and -not $this.AllowDynamic) { + throw "Port $port is a dynamic/ephemeral port (49152-65535). This range is not allowed." + } + } +} + +function Start-WebServer { + [CmdletBinding()] + param ( + [Parameter()] + [ValidatePort()] # No well-known ports by default + [int]$Port = 8080 + ) + "Starting web server on port $Port..." +} + +function Test-Connection { + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [string]$Hostname, + + [Parameter()] + [ValidatePort($true)] # Allow well-known ports (80, 443, etc.) + [int]$Port = 443 + ) + "Testing connection to ${Hostname}:${Port}..." +} + +Start-WebServer -Port 8080 +Start-WebServer -Port 80 # Blocked! Well-known port +Start-WebServer -Port 70000 # Out of range! +Test-Connection -Hostname 'google.com' -Port 443 # Allowed - well-known OK here +Test-Connection -Hostname 'google.com' -Port 80 # Also allowed + +#endregion + +#region ---- The Trilogy: Completers + Transformers + Validators = Magic ---- + +# Let's combine ALL THREE from the "Pimp Your Parameters" series! +# Argument Completer: suggests valid values (tab completion) +# Transformation Attribute: converts input to the right format +# Validation Attribute: rejects bad data before the function runs + +# Step 1: The Validator - ensures the environment is valid +class ValidateDeployEnvironmentAttribute : System.Management.Automation.ValidateArgumentsAttribute { + [void] Validate([object]$arguments, [System.Management.Automation.EngineIntrinsics]$engineIntrinsics) { + $validEnvironments = @('Dev', 'Test', 'Staging', 'Prod') + if ($arguments -notin $validEnvironments) { + throw "Invalid environment: '$arguments'. Must be one of: $($validEnvironments -join ', ')" + } + } +} + +# Step 2: The Transformer - normalizes input (dev -> Dev, PROD -> Prod) +class EnvironmentTransformationAttribute : System.Management.Automation.ArgumentTransformationAttribute { + [object] Transform([System.Management.Automation.EngineIntrinsics]$engineIntrinsics, [object]$inputData) { + $envMap = @{ + 'dev' = 'Dev'; 'development' = 'Dev' + 'test' = 'Test'; 'testing' = 'Test'; 'qa' = 'Test' + 'staging' = 'Staging'; 'stage' = 'Staging'; 'uat' = 'Staging' + 'prod' = 'Prod'; 'production' = 'Prod'; 'live' = 'Prod' + } + + $key = ([string]$inputData).ToLower() + if ($envMap.ContainsKey($key)) { + return $envMap[$key] + } + # Return as-is and let the validator catch it + return $inputData + } +} + +# Step 3: The Completer - tab completion for environments +$environmentCompleter = { + param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) + @('Dev', 'Test', 'Staging', 'Prod') | Where-Object { $_ -like "$wordToComplete*" } | ForEach-Object { + [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', "Deploy to $_ environment") + } +} + +# Step 4: The Function - all three working together! +function Start-Deploy { + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [string]$Application, + + [Parameter(Mandatory)] + [EnvironmentTransformation()] + [ValidateDeployEnvironment()] + [string]$Environment, + + [Parameter(Mandatory)] + [ValidateSemVer()] + [string]$Version, + + [Parameter(Mandatory)] + [ValidateTicketId()] + [string]$TicketId + ) + "Deploying $Application v$Version to $Environment (Ticket: $TicketId)" +} +Register-ArgumentCompleter -CommandName Start-Deploy -ParameterName Environment -ScriptBlock $environmentCompleter + +# The full trilogy in action: +# Start-Deploy -Application 'WebApp' -Version '2.1.0' -TicketId 'PROJ-4567' -Environment 'production' +# ↑ Tab completes! ↑ 'production' transforms to 'Prod'! ↑ Validated! ↑ Validated! + +# Start-Deploy -Application 'WebApp' -Environment 'qa' -Version '1.0.0' -TicketId 'DEV-123' +# ↑ 'qa' transforms to 'Test'! + +# Start-Deploy -Application 'WebApp' -Environment 'yolo' -Version 'nope' -TicketId 'bad' +# ↑ Everything fails with friendly messages! + +#endregion + +#region ---- Bonus: Using Validation Attributes on Variables ---- + +# You can use validation attributes on regular variables too! +# The validation runs on EVERY assignment! + +[ValidateSemVer()][string]$appVersion = '1.0.0' +$appVersion # 1.0.0 + +# $appVersion = '2.0.0' # Works! +# $appVersion = 'banana' # Fails! Validation runs on assignment! + +[ValidateTicketId()][string]$currentTicket = 'PROJ-001' +$currentTicket # PROJ-001 + +# $currentTicket = 'DEV-999' # Works! +# $currentTicket = 'nope' # Fails! + +#endregion diff --git a/PimpYourParameterValidationClasses-MacInally/demo/validationClassesDemo_JsonSchema.ps1 b/PimpYourParameterValidationClasses-MacInally/demo/validationClassesDemo_JsonSchema.ps1 new file mode 100644 index 0000000..9a2b07d --- /dev/null +++ b/PimpYourParameterValidationClasses-MacInally/demo/validationClassesDemo_JsonSchema.ps1 @@ -0,0 +1,679 @@ +#region ---- Example 1: ValidateJsonSchema - Static Inline Schema ---- + +# The simplest case: hardcode the schema right into the class +# Great when the schema is small and never changes + +class ValidatePotionRecipeAttribute : System.Management.Automation.ValidateArgumentsAttribute { + [void] Validate([object]$arguments, [System.Management.Automation.EngineIntrinsics]$engineIntrinsics) { + $json = [string]$arguments + + $schema = @' +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "name": { "type": "string", "minLength": 1 }, + "brewer": { "type": "string" }, + "effect": { "type": "string" }, + "ingredients": { "type": "array", "items": { "type": "string" }, "minItems": 1 }, + "potency": { "type": "integer", "minimum": 1, "maximum": 10 }, + "dangerous": { "type": "boolean" } + }, + "required": ["name", "effect", "ingredients", "potency"], + "additionalProperties": false +} +'@ + + try { + $isValid = $json | Test-Json -Schema $schema -ErrorAction Stop + } + catch { + throw "Invalid potion recipe! $($_.Exception.Message)" + } + } +} + +function Brew-Potion { + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [ValidatePotionRecipe()] + [string]$Recipe + ) + $potion = $Recipe | ConvertFrom-Json + "Brewing '$($potion.name)' with $($potion.ingredients.Count) ingredients... Potency: $($potion.potency)/10" +} + +# Valid potion +$goodPotion = @' +{ + "name": "Felix Felicis", + "brewer": "Horace Slughorn", + "effect": "Liquid Luck - everything goes your way", + "ingredients": ["Ashwinder egg", "Squill bulb", "Murtlap tentacle", "Tincture of thyme", "Occamy eggshell"], + "potency": 9, + "dangerous": false +} +'@ + +# Brew-Potion -Recipe $goodPotion # Works! + +# Missing required field 'potency' +$badPotion = @' +{ + "name": "Polyjuice Potion", + "effect": "Transform into another person", + "ingredients": ["Lacewing flies", "Leeches", "Powdered bicorn horn"] +} +'@ + +# Brew-Potion -Recipe $badPotion # Fails! Missing 'potency' + +# Wrong type for potency (string instead of integer) +$wrongType = @' +{ + "name": "Amortentia", + "effect": "Love potion", + "ingredients": ["Pearl dust", "Rose thorns"], + "potency": "very strong" +} +'@ + +# Brew-Potion -Recipe $wrongType # Fails! potency must be integer + +# Potency out of range +$tooStrong = @' +{ + "name": "Draught of Living Death", + "effect": "Extremely powerful sleeping potion", + "ingredients": ["Asphodel", "Wormwood", "Valerian root", "Sopophorous bean"], + "potency": 42 +} +'@ + +# Brew-Potion -Recipe $tooStrong # Fails! potency max is 10 + +#endregion + +#region ---- Example 2: ValidateDeployConfig - Real-World Static Schema ---- + +class ValidateDeployConfigAttribute : System.Management.Automation.ValidateArgumentsAttribute { + [void] Validate([object]$arguments, [System.Management.Automation.EngineIntrinsics]$engineIntrinsics) { + $json = [string]$arguments + + $schema = @' +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "appName": { "type": "string", "pattern": "^[a-zA-Z][a-zA-Z0-9-]{1,62}$" }, + "environment": { "type": "string", "enum": ["Dev", "Test", "Staging", "Prod"] }, + "version": { "type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+$" }, + "replicas": { "type": "integer", "minimum": 1, "maximum": 50 }, + "healthCheck": { + "type": "object", + "properties": { + "endpoint": { "type": "string", "pattern": "^/" }, + "intervalSec": { "type": "integer", "minimum": 5, "maximum": 300 }, + "timeoutSec": { "type": "integer", "minimum": 1, "maximum": 60 } + }, + "required": ["endpoint", "intervalSec"] + }, + "tags": { + "type": "array", + "items": { "type": "string" }, + "uniqueItems": true + } + }, + "required": ["appName", "environment", "version", "replicas"], + "additionalProperties": false +} +'@ + + try { + $json | Test-Json -Schema $schema -ErrorAction Stop | Out-Null + } + catch { + throw "Invalid deployment config! $($_.Exception.Message)" + } + } +} + +function Start-AppDeployment { + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [ValidateDeployConfig()] + [string]$Config + ) + $cfg = $Config | ConvertFrom-Json + "Deploying $($cfg.appName) v$($cfg.version) to $($cfg.environment) with $($cfg.replicas) replicas" +} + +# Valid config +$goodConfig = @' +{ + "appName": "my-web-api", + "environment": "Prod", + "version": "2.1.0", + "replicas": 3, + "healthCheck": { + "endpoint": "/health", + "intervalSec": 30, + "timeoutSec": 5 + }, + "tags": ["backend", "critical"] +} +'@ + +# Start-AppDeployment -Config $goodConfig # Works! + +# Invalid environment +$badEnv = @' +{ + "appName": "my-web-api", + "environment": "YOLO", + "version": "1.0.0", + "replicas": 2 +} +'@ + +# Start-AppDeployment -Config $badEnv # Fails! 'YOLO' not in enum + +# Bad app name (starts with number) +$badName = @' +{ + "appName": "123-bad-name", + "environment": "Dev", + "version": "1.0.0", + "replicas": 1 +} +'@ + +# Start-AppDeployment -Config $badName # Fails! Pattern doesn't match + +# Too many replicas +$tooMany = @' +{ + "appName": "my-app", + "environment": "Dev", + "version": "1.0.0", + "replicas": 9001 +} +'@ + +# Start-AppDeployment -Config $tooMany # Fails! Max replicas is 50 + +#endregion + +#region ---- Example 3: ValidateJsonSchemaFile - Point to a Schema File ---- + +# Now the schema lives in an external file! +# Much cleaner for large schemas, and you can version them in git + +class ValidateJsonSchemaFileAttribute : System.Management.Automation.ValidateArgumentsAttribute { + [string]$SchemaPath + + ValidateJsonSchemaFileAttribute([string]$schemaPath) { + $this.SchemaPath = $schemaPath + } + + [void] Validate([object]$arguments, [System.Management.Automation.EngineIntrinsics]$engineIntrinsics) { + $json = [string]$arguments + + if (-not (Test-Path -Path $this.SchemaPath)) { + throw "Schema file not found: '$($this.SchemaPath)'. Cannot validate." + } + + try { + $json | Test-Json -SchemaFile $this.SchemaPath -ErrorAction Stop | Out-Null + } + catch { + $schemaName = [System.IO.Path]::GetFileNameWithoutExtension($this.SchemaPath) + throw "JSON does not match '$schemaName' schema! $($_.Exception.Message)" + } + } +} + +# First, let's create some schema files to use +$characterSchema = @' +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "D&D Character", + "type": "object", + "properties": { + "name": { "type": "string", "minLength": 2 }, + "class": { "type": "string", "enum": ["Barbarian", "Bard", "Cleric", "Druid", "Fighter", "Monk", "Paladin", "Ranger", "Rogue", "Sorcerer", "Warlock", "Wizard"] }, + "level": { "type": "integer", "minimum": 1, "maximum": 20 }, + "race": { "type": "string" }, + "stats": { + "type": "object", + "properties": { + "strength": { "type": "integer", "minimum": 1, "maximum": 30 }, + "dexterity": { "type": "integer", "minimum": 1, "maximum": 30 }, + "constitution": { "type": "integer", "minimum": 1, "maximum": 30 }, + "intelligence": { "type": "integer", "minimum": 1, "maximum": 30 }, + "wisdom": { "type": "integer", "minimum": 1, "maximum": 30 }, + "charisma": { "type": "integer", "minimum": 1, "maximum": 30 } + }, + "required": ["strength", "dexterity", "constitution", "intelligence", "wisdom", "charisma"] + } + }, + "required": ["name", "class", "level", "race", "stats"] +} +'@ + +# Save schema to file (run this first!) +# $characterSchema | Set-Content -Path ".\character.schema.json" -Force + +function Register-Character { + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [ValidateJsonSchemaFile(".\character.schema.json")] + [string]$CharacterJson + ) + $char = $CharacterJson | ConvertFrom-Json + "Registered: $($char.name) the Level $($char.level) $($char.race) $($char.class)" +} + +$validCharacter = @' +{ + "name": "Drizzt Do'Urden", + "class": "Ranger", + "level": 15, + "race": "Drow Elf", + "stats": { + "strength": 13, + "dexterity": 20, + "constitution": 15, + "intelligence": 17, + "wisdom": 17, + "charisma": 14 + } +} +'@ + +# Register-Character -CharacterJson $validCharacter # Works! + +$invalidCharacter = @' +{ + "name": "Steve", + "class": "Accountant", + "level": 999, + "race": "Human", + "stats": { + "strength": 6, + "dexterity": 8, + "constitution": 7, + "intelligence": 14, + "wisdom": 10, + "charisma": 5 + } +} +'@ + +# Register-Character -CharacterJson $invalidCharacter # Fails! 'Accountant' not a valid class, level max 20 + +#endregion + +#region ---- Example 4: ValidateJsonSchemaUri - Load Schema from a URL ---- + +# Schema lives on the web! Great for shared schemas across teams, +# API contract validation, or industry-standard schemas + +class ValidateJsonSchemaUriAttribute : System.Management.Automation.ValidateArgumentsAttribute { + [string]$SchemaUri + [int]$TimeoutSec = 10 + + ValidateJsonSchemaUriAttribute([string]$schemaUri) { + $this.SchemaUri = $schemaUri + } + + ValidateJsonSchemaUriAttribute([string]$schemaUri, [int]$timeoutSec) { + $this.SchemaUri = $schemaUri + $this.TimeoutSec = $timeoutSec + } + + [void] Validate([object]$arguments, [System.Management.Automation.EngineIntrinsics]$engineIntrinsics) { + $json = [string]$arguments + + try { + $schema = (Invoke-WebRequest -Uri $this.SchemaUri -TimeoutSec $this.TimeoutSec -ErrorAction Stop).Content + } + catch { + throw "Failed to fetch schema from '$($this.SchemaUri)': $($_.Exception.Message)" + } + + try { + $json | Test-Json -Schema $schema -ErrorAction Stop | Out-Null + } + catch { + throw "JSON does not match remote schema! $($_.Exception.Message)" + } + } +} + +# Example: Validate against a GeoJSON schema +function Import-MapData { + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [ValidateJsonSchemaUri('https://geojson.org/schema/Point.json')] + [string]$GeoJson + ) + $geo = $GeoJson | ConvertFrom-Json + "Imported map point: [$($geo.coordinates[1]), $($geo.coordinates[0])]" +} + +$validGeoJson = @' +{ + "type": "Point", + "coordinates": [-122.1955, 47.6101] +} +'@ + +# Import-MapData -GeoJson $validGeoJson # Works! (Bellevue, WA coordinates) + +$invalidGeoJson = @' +{ + "type": "Polygon", + "coordinates": "not an array" +} +'@ + +# Import-MapData -GeoJson $invalidGeoJson # Fails! + +#endregion + +#region ---- Example 5: ValidateJsonSchema - The Swiss Army Knife ---- + +# The ultimate flexible validator: +# - Inline schema string +# - File path to a .json schema +# - URL to a remote schema +# Figures out which one you gave it automatically! + +class ValidateJsonSchemaAttribute : System.Management.Automation.ValidateArgumentsAttribute { + [string]$SchemaSource + + ValidateJsonSchemaAttribute([string]$schemaSource) { + $this.SchemaSource = $schemaSource + } + + [void] Validate([object]$arguments, [System.Management.Automation.EngineIntrinsics]$engineIntrinsics) { + $json = [string]$arguments + $schema = $null + + # Determine the source type + if ($this.SchemaSource -match '^https?://') { + # It's a URL - fetch it + try { + $schema = (Invoke-WebRequest -Uri $this.SchemaSource -TimeoutSec 10 -ErrorAction Stop).Content + } + catch { + throw "Failed to fetch schema from '$($this.SchemaSource)': $($_.Exception.Message)" + } + } + elseif (Test-Path -Path $this.SchemaSource -ErrorAction SilentlyContinue) { + # It's a file path - read it + $schema = Get-Content -Path $this.SchemaSource -Raw -ErrorAction Stop + } + else { + # Assume it's an inline schema string + $schema = $this.SchemaSource + } + + try { + $json | Test-Json -Schema $schema -ErrorAction Stop | Out-Null + } + catch { + throw "JSON schema validation failed! $($_.Exception.Message)" + } + } +} + +# --- Use with inline schema --- +$pizzaOrderSchema = @' +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "size": { "type": "string", "enum": ["Small", "Medium", "Large", "Family"] }, + "crust": { "type": "string", "enum": ["Thin", "Thick", "Stuffed", "Cauliflower"] }, + "toppings": { "type": "array", "items": { "type": "string" }, "minItems": 1, "maxItems": 8 }, + "quantity": { "type": "integer", "minimum": 1, "maximum": 20 }, + "notes": { "type": "string", "maxLength": 200 } + }, + "required": ["size", "crust", "toppings", "quantity"] +} +'@ + +function Submit-PizzaOrder { + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [ValidateJsonSchema('{ "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": { "size": { "type": "string", "enum": ["Small", "Medium", "Large", "Family"] }, "crust": { "type": "string", "enum": ["Thin", "Thick", "Stuffed", "Cauliflower"] }, "toppings": { "type": "array", "items": { "type": "string" }, "minItems": 1, "maxItems": 8 }, "quantity": { "type": "integer", "minimum": 1, "maximum": 20 }, "notes": { "type": "string", "maxLength": 200 } }, "required": ["size", "crust", "toppings", "quantity"] }')] + [string]$OrderJson + ) + $order = $OrderJson | ConvertFrom-Json + $toppingList = $order.toppings -join ', ' + "Order placed: $($order.quantity)x $($order.size) $($order.crust) crust with $toppingList" +} + +$goodPizza = @' +{ + "size": "Large", + "crust": "Thin", + "toppings": ["Mozzarella", "Pepperoni", "Mushrooms", "Olives"], + "quantity": 2, + "notes": "Extra crispy please" +} +'@ + +# Submit-PizzaOrder -OrderJson $goodPizza # Works! + +$badPizza = @' +{ + "size": "XXXL", + "crust": "Cardboard", + "toppings": [], + "quantity": 0 +} +'@ + +# Submit-PizzaOrder -OrderJson $badPizza # Fails! Multiple violations + +# --- Use with file path --- +function Import-ServerConfig { + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [ValidateJsonSchema("$PSScriptRoot\schemas\server-config.schema.json")] + [string]$ConfigJson + ) + $cfg = $ConfigJson | ConvertFrom-Json + "Loaded config for server: $($cfg.hostname)" +} + +# --- Use with URL --- +function Import-GeoPoint { + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [ValidateJsonSchema('https://geojson.org/schema/Point.json')] + [string]$GeoJson + ) + $point = $GeoJson | ConvertFrom-Json + "Point imported: $($point.coordinates -join ', ')" +} + +#endregion + +#region ---- Example 6: ValidateJsonFile - Validate a File Path, Not a String ---- + +# Sometimes you don't pass JSON as a string - you pass a FILE PATH +# This validator reads the file AND validates it against a schema + +class ValidateJsonFileAttribute : System.Management.Automation.ValidateArgumentsAttribute { + [string]$SchemaSource + + ValidateJsonFileAttribute([string]$schemaSource) { + $this.SchemaSource = $schemaSource + } + + [void] Validate([object]$arguments, [System.Management.Automation.EngineIntrinsics]$engineIntrinsics) { + $filePath = [string]$arguments + + # First, check the file exists + if (-not (Test-Path -Path $filePath)) { + throw "JSON file not found: '$filePath'" + } + + # Check it's actually a .json file + if ([System.IO.Path]::GetExtension($filePath) -ne '.json') { + throw "Expected a .json file, got: '$([System.IO.Path]::GetExtension($filePath))'" + } + + # Read the file + $json = Get-Content -Path $filePath -Raw -ErrorAction Stop + + # Resolve the schema + $schema = $null + if ($this.SchemaSource -match '^https?://') { + try { + $schema = (Invoke-WebRequest -Uri $this.SchemaSource -TimeoutSec 10 -ErrorAction Stop).Content + } + catch { + throw "Failed to fetch schema from '$($this.SchemaSource)': $($_.Exception.Message)" + } + } + elseif (Test-Path -Path $this.SchemaSource -ErrorAction SilentlyContinue) { + $schema = Get-Content -Path $this.SchemaSource -Raw -ErrorAction Stop + } + else { + $schema = $this.SchemaSource + } + + try { + $json | Test-Json -Schema $schema -ErrorAction Stop | Out-Null + } + catch { + throw "File '$([System.IO.Path]::GetFileName($filePath))' does not match schema! $($_.Exception.Message)" + } + } +} + +# Validate a CI/CD pipeline config file +$pipelineSchema = @' +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "properties": { + "pipeline": { "type": "string" }, + "trigger": { "type": "string", "enum": ["push", "pull_request", "schedule", "manual"] }, + "stages": { + "type": "array", + "items": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "image": { "type": "string" }, + "script": { "type": "array", "items": { "type": "string" }, "minItems": 1 } + }, + "required": ["name", "script"] + }, + "minItems": 1 + } + }, + "required": ["pipeline", "trigger", "stages"] +} +'@ + +function Import-PipelineConfig { + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [ValidateJsonFile('{ "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": { "pipeline": { "type": "string" }, "trigger": { "type": "string", "enum": ["push", "pull_request", "schedule", "manual"] }, "stages": { "type": "array", "items": { "type": "object", "properties": { "name": { "type": "string" }, "image": { "type": "string" }, "script": { "type": "array", "items": { "type": "string" }, "minItems": 1 } }, "required": ["name", "script"] }, "minItems": 1 } }, "required": ["pipeline", "trigger", "stages"] }')] + [string]$ConfigPath + ) + $config = Get-Content -Path $ConfigPath -Raw | ConvertFrom-Json + "Pipeline '$($config.pipeline)' loaded with $($config.stages.Count) stages (trigger: $($config.trigger))" +} + +# Create a test file to try it +# @' +# { +# "pipeline": "deploy-web-app", +# "trigger": "push", +# "stages": [ +# { "name": "build", "image": "mcr.microsoft.com/dotnet/sdk:8.0", "script": ["dotnet build", "dotnet test"] }, +# { "name": "deploy", "script": ["./deploy.ps1 -Environment Prod"] } +# ] +# } +# '@ | Set-Content -Path "$PSScriptRoot\pipeline.json" + +# Import-PipelineConfig -ConfigPath "$PSScriptRoot\pipeline.json" # Works! +# Import-PipelineConfig -ConfigPath "$PSScriptRoot\demo.ps1" # Fails! Not a .json file! +# Import-PipelineConfig -ConfigPath "$PSScriptRoot\nope.json" # Fails! File not found! + +#endregion + +#region ---- Setup: Create Schema Files for File-Based Examples ---- + +# Run this region first to set up the schema files needed for Examples 3 and 5 + +$schemasDir = "$PSScriptRoot\schemas" +if (-not (Test-Path $schemasDir)) { + New-Item -Path $schemasDir -ItemType Directory -Force | Out-Null +} + +# D&D Character schema (for Example 3) +@' +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "D&D Character", + "type": "object", + "properties": { + "name": { "type": "string", "minLength": 2 }, + "class": { "type": "string", "enum": ["Barbarian", "Bard", "Cleric", "Druid", "Fighter", "Monk", "Paladin", "Ranger", "Rogue", "Sorcerer", "Warlock", "Wizard"] }, + "level": { "type": "integer", "minimum": 1, "maximum": 20 }, + "race": { "type": "string" }, + "stats": { + "type": "object", + "properties": { + "strength": { "type": "integer", "minimum": 1, "maximum": 30 }, + "dexterity": { "type": "integer", "minimum": 1, "maximum": 30 }, + "constitution": { "type": "integer", "minimum": 1, "maximum": 30 }, + "intelligence": { "type": "integer", "minimum": 1, "maximum": 30 }, + "wisdom": { "type": "integer", "minimum": 1, "maximum": 30 }, + "charisma": { "type": "integer", "minimum": 1, "maximum": 30 } + }, + "required": ["strength", "dexterity", "constitution", "intelligence", "wisdom", "charisma"] + } + }, + "required": ["name", "class", "level", "race", "stats"] +} +'@ | Set-Content -Path "$schemasDir\character.schema.json" -Force + +# Server config schema (for Example 5) +@' +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Server Configuration", + "type": "object", + "properties": { + "hostname": { "type": "string", "pattern": "^[a-zA-Z][a-zA-Z0-9.-]+$" }, + "ip": { "type": "string", "format": "ipv4" }, + "port": { "type": "integer", "minimum": 1, "maximum": 65535 }, + "environment": { "type": "string", "enum": ["Dev", "Test", "Staging", "Prod"] }, + "services": { "type": "array", "items": { "type": "string" } }, + "monitoring": { "type": "boolean" } + }, + "required": ["hostname", "ip", "port", "environment"] +} +'@ | Set-Content -Path "$schemasDir\server-config.schema.json" -Force + +Write-Host "Schema files created in $schemasDir" -ForegroundColor Green + +#endregion diff --git a/PimpYourParameterValidationClasses-MacInally/slides/PimpYourParametersValidationClasses-MacInally.pptx b/PimpYourParameterValidationClasses-MacInally/slides/PimpYourParametersValidationClasses-MacInally.pptx new file mode 100644 index 0000000..ee7b1cb Binary files /dev/null and b/PimpYourParameterValidationClasses-MacInally/slides/PimpYourParametersValidationClasses-MacInally.pptx differ