Public/Import-HDTApplication.ps1
|
function Import-HDTApplication { <# .SYNOPSIS Promotes an installer into the workspace's application catalog. .DESCRIPTION Import-HDTApplication writes Applications\<id>\app.yaml, and it is the only writer of that document. DESIGN 2.1 fixes where it lands and DESIGN 8 fixes what it may hold; this command is the twin of Import-HDTOperatingSystem, down to the order it works in. THE DEFAULTS ARE NOT WRITTEN INTO THE FILE. successCodes, rebootCodes, runIn, uninstall, detect and dependencies are all optional, and every one of their defaults lives in exactly one place - ConvertTo-HDTApplicationCatalog. An importer that stamped 0/3010 into every app.yaml would make the file disagree with the projector the day either changes, so a key the caller did not ask for does not appear on disk at all. A four-key app.yaml is the normal shape. THE DOCUMENT IS VALIDATED BEFORE ANYTHING IS WRITTEN. It goes through Assert-HDTApplicationDocument - the same validator Get-HDTApplication reads through - so a refused import leaves no half-written entry, and the error names the file and the offending key rather than the parameter. That is what makes -Detect a hashtable rather than four parameter sets: the detection rule is a union of four shapes, the validator already knows all four, and duplicating that table in a param block would be a second place to keep it correct. EVERY PATH IS BUILT WITH Get-HDTWorkspacePath. That command exists because a plan once did not: Start-HDTResume built its path from the literal 'Sequences' while the layout said 'TaskSequences', the unit suite was green because nothing in it resolved a real workspace path, and a deployment would have died at its first reboot. No literal 'Applications' appears in this file. THE PAYLOAD COMES WITH IT, WHICH IS WHERE THIS PARTS COMPANY WITH THE OS IMPORTER. An operating system is registered where it stands because copying 4 GB is a real operation; an application is a folder of installer files, and a catalog entry whose source\ is empty is one the InstallApplications step cannot run - it fails at deployment time, which is the worst place to find out. So -SourcePath is mandatory, and the CONTENTS of that folder are copied into Applications\<id>\source: the folder's own name is discarded, because a payload nested one level deeper would break every relative install command. SourcePath in the returned catalog is that folder, which DESIGN 2.1 makes a convention rather than a key in the file. THERE IS NO -Force, on the reasoning that splits the sequence editor into Add-HDTStep and Set-HDTStepProperty: an importer that overwrote on a flag is one keystroke away from replacing a working catalog entry - and its payload - with a typo. Importing over an existing id is refused outright; changing one is Set-HDTApplication's job. .PARAMETER WorkspaceRoot The workspace root - a local path or a UNC share. .PARAMETER Id The catalog id. Becomes the folder name under Applications\, the key a sequence selects and a dependency names, so it must match ^[A-Za-z0-9][A-Za-z0-9_.-]*$. .PARAMETER Install The command line the InstallApplications step runs, resolved against the application's source folder. There is no default: an application HDT cannot install is a catalog entry with no purpose. .PARAMETER FileSystem An IFileSystem. Defaults to the real one, which is what an administrator wants; a test passes New-HDTFakeFileSystem instead and the whole importer is provable with no share and no disk. .PARAMETER Name The display name. Defaults to the id. .PARAMETER Description A free-text note for the console. .PARAMETER Publisher Who makes it. Workbench's first question about an application, and what makes two entries called Reader tellable apart. .PARAMETER Version Which version this entry installs, as text a technician reads. .PARAMETER Uninstall The command line that removes it. Omitted when not given. .PARAMETER SuccessCode The exit codes that count as success. Omitted when not given, which inherits 0 and 3010. .PARAMETER RebootCode The exit codes that oblige the sequence to restart. Omitted when not given, which inherits 3010. .PARAMETER RunIn The phase the installer runs in. Omitted when not given, which inherits FullOS - an application installer in WinPE is the unusual case. .PARAMETER Dependency Ids that must install first. Resolve-HDTApplicationOrder sorts them. .PARAMETER Detect The detection rule, as a hashtable in the shape app.yaml declares - @{ type = 'msiProduct'; productCode = '{...}' } and so on for file, registry and script. Omitted when not given, which is DESIGN 8's "install every time". .PARAMETER SourcePath The folder holding the installer. Its CONTENTS are copied into Applications\<id>\source, subfolders and all; its own name is discarded, so install commands stay relative to source\. .INPUTS None. This command does not accept pipeline input. .OUTPUTS System.Management.Automation.PSCustomObject - the catalog it wrote, in the shape Get-HDTApplication returns. .EXAMPLE Import-HDTApplication -WorkspaceRoot 'X:\Deploy' -Id '7Zip-24.09' ` -Install 'msiexec.exe /i "7z2409-x64.msi" /qn /norestart' ` -SourcePath 'C:\Downloads\7Zip' The four-key entry: everything else is inherited, including the filesystem. .EXAMPLE Import-HDTApplication -WorkspaceRoot 'X:\Deploy' -Id '7Zip-24.09' ` -Name '7-Zip 24.09 x64' -Install 'msiexec.exe /i "7z2409-x64.msi" /qn /norestart' ` -Detect @{ type = 'msiProduct'; productCode = '{23170F69-40C1-2702-2409-000001000000}' } ` -SourcePath 'C:\Downloads\7Zip' With a detection rule, so running the sequence twice installs it once. #> [CmdletBinding(SupportsShouldProcess = $true)] [OutputType([pscustomobject])] param( [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string] $WorkspaceRoot, [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string] $Id, [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string] $Install, # DEFAULTED, NOT MANDATORY, on New-HDTWorkspace's reasoning: this is a # command an administrator types, not one the engine's hot path calls, so # a working call is a short one. A test still passes the fake explicitly, # and must - every test in this command's suite does. [Parameter()] [ValidateNotNull()] [object] $FileSystem = (New-HDTFileSystem), [Parameter()] [string] $Name, [Parameter()] [string] $Description, # WORKBENCH'S OTHER TWO QUESTIONS. Neither is required - a package with # no version stated is still a package - and both are what # Get-HDTApplicationName composes the display name and the id from, so # the caller that asked for them has usually passed the result as -Id. [Parameter()] [string] $Publisher, [Parameter()] [string] $Version, [Parameter()] [string] $Uninstall, [Parameter()] [int[]] $SuccessCode, [Parameter()] [int[]] $RebootCode, [Parameter()] [ValidateSet('WinPE', 'FullOS', 'Any')] [string] $RunIn, [Parameter()] [string[]] $Dependency, [Parameter()] [System.Collections.IDictionary] $Detect, [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string] $SourcePath ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' if ($Id -notmatch '^[A-Za-z0-9][A-Za-z0-9_.-]*$') { $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -TargetObject $Id ` -Message ("'{0}' is not a legal application id. It becomes a folder name under the workspace's application folder, so it must start with a letter or a digit and hold only letters, digits, underscore, dot and hyphen." -f $Id))) } $appFolder = Get-HDTWorkspacePath -Root $WorkspaceRoot -Kind Applications -ChildPath $Id $catalogPath = Get-HDTWorkspacePath -Root $WorkspaceRoot -Kind Applications -ChildPath $Id, 'app.yaml' if ($FileSystem.TestPath($catalogPath)) { $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $catalogPath ` -Message ("an application with the id '{0}' is already in this workspace. Import registers a new entry; it does not replace one. Change the existing entry rather than importing over it." -f $Id))) } if (-not $FileSystem.TestPath($SourcePath)) { $PSCmdlet.ThrowTerminatingError((New-HDTErrorRecord -Path $SourcePath ` -Message 'the application source folder does not exist, so there is no payload to bring into the workspace.' ` -Category ObjectNotFound)) } $displayName = $Id if (-not [string]::IsNullOrWhiteSpace($Name)) { $displayName = $Name } $document = [System.Collections.Specialized.OrderedDictionary]::new() $document['schemaVersion'] = 1 $document['id'] = $Id $document['name'] = $displayName if (-not [string]::IsNullOrWhiteSpace($Description)) { $document['description'] = $Description } if (-not [string]::IsNullOrWhiteSpace($Publisher)) { $document['publisher'] = $Publisher } if (-not [string]::IsNullOrWhiteSpace($Version)) { $document['version'] = $Version } $document['install'] = $Install if (-not [string]::IsNullOrWhiteSpace($Uninstall)) { $document['uninstall'] = $Uninstall } if ($PSBoundParameters.ContainsKey('SuccessCode')) { $document['successCodes'] = [int[]] $SuccessCode } if ($PSBoundParameters.ContainsKey('RebootCode')) { $document['rebootCodes'] = [int[]] $RebootCode } if (-not [string]::IsNullOrWhiteSpace($RunIn)) { $document['runIn'] = $RunIn } if ($PSBoundParameters.ContainsKey('Detect')) { # THE RULE IS COPIED KEY BY KEY, NOT HANDED OVER WHOLE, for the reason # ConvertTo-HDTYaml states: a hashtable's key order differs between # Windows PowerShell 5.1 and pwsh 7, and a document that serialises in a # different order on each is a diff nobody can read. 'type' leads, and # what follows is the order Get-HDTApplicationDetectKey declares - the # same table the validator and the projector read. $rule = [System.Collections.Specialized.OrderedDictionary]::new() $type = '' if ($Detect.Contains('type')) { $type = [string] $Detect['type'] } $rule['type'] = $type $schema = Get-HDTApplicationDetectKey $ordered = @() # .Contains, not .ContainsKey: the detect table is an ordered dictionary, # which has only the former, and a hashtable answers to both. if ($schema.Contains($type)) { $ordered = @($schema[$type].Required + $schema[$type].Optional) } foreach ($key in $ordered) { if ($Detect.Contains($key)) { $rule[$key] = [string] $Detect[$key] } } # Anything the schema does not know about is carried across untouched so # the validator - not this command - is what refuses it, and names it. foreach ($key in @($Detect.Keys)) { if ((-not $rule.Contains([string] $key))) { $rule[[string] $key] = $Detect[$key] } } $document['detect'] = $rule } if ($PSBoundParameters.ContainsKey('Dependency')) { $document['dependencies'] = [string[]] $Dependency } # The writer is held to the validator, here, before anything is written. Assert-HDTApplicationDocument -Document $document -Path $catalogPath $text = ConvertTo-HDTYaml -Document $document -Path $catalogPath if (-not $PSCmdlet.ShouldProcess($catalogPath, ("Import application '{0}'" -f $Id))) { return $null } $FileSystem.CreateDirectory($appFolder) Copy-HDTContentTree -Source $SourcePath -Destination ([System.IO.Path]::Combine($appFolder, 'source')) -FileSystem $FileSystem | Out-Null $FileSystem.WriteAllText($catalogPath, $text) return (ConvertTo-HDTApplicationCatalog -Document $document -AppFolder $appFolder -CatalogPath $catalogPath) } |