#Requires -Version 7.0 # Invardia guided install - bootstrap (docs/DESIGN.md §8, phase 1). # Script version: 2026.11.01.1 # Requirements: # PowerShell 7 (pwsh) - Azure Cloud Shell (PowerShell) has it; Windows PowerShell 5.1 is refused at once. Install it from # https://aka.ms/powershell. No modules, no Azure CLI. # Rights: the person who installs, signed in to the tenant the installation is for, with the rights the product's own # installer needs (listed with the install command under "Before you start" and in the product's documentation). # Network (HTTPS): invardia.com, login.microsoftonline.com, graph.microsoft.com, management.azure.com, # database.windows.net, the product release's download host (Invardia's update platform or github.com), and # http://localhost (the browser sign-in's answer, outside Cloud Shell). # Documentation: https://invardia.com/docs/invardia/scripts/Install-Invardia/ (what it does, every change, -WhatIf, undo) # Save, verify, run (never pipe-to-execute first): download it, check its SHA-256 against # https://invardia.com/install/SHA256SUMS.txt, then dot-source it (. ./bootstrap.ps1) and run Install-Invardia. # Run in Azure Cloud Shell (PowerShell) or PowerShell 7, signed in as the person who installs: # . ./bootstrap.ps1; Install-Invardia -Code K7Q-29F-XM4 (preview first: add -WhatIf - it writes nothing) # (convenience only, no checksum check: irm https://invardia.com/install/bootstrap.ps1 | iex; Install-Invardia -Code ...) # What it does: # 1. exchanges the one-time install code for the bundle (your wizard answers, your licence file, an install token); # 2. writes them to a state folder (Cloud Shell: ~/clouddrive/invardia/, which persists); # 3. runs the product's own unattended installer, which reports each step back to your status page. # The install token can ONLY report progress for this installation. Nothing here asks for, or sends, a password or a # token of yours: the product installer uses your current Azure sign-in. # Resume after a failure: run the same command again, or Install-Invardia -Resume -InstallId . # When a step fails, the run sends its own log files (secrets taken out first) to the support ticket the failure opened - # see Send-InvardiaInstallLogs below. # # With an enrollment key instead of a code (framework 8.6; owner 2026-10-08): # . ./bootstrap.ps1 (saved and verified as above) # Install-Invardia -EnrollmentKey ek-... -Product pim-manager|securityinsight [-SubscriptionId ] [-Location ] # [-LegalName 'Example School'] [-EnvironmentName PROD] # [-Answers @{ location = 'northeurope'; resourceGroup = 'rg-securityinsight'; mailTo = 'secops@contoso.com' }] (the product's own answers) # The managed customer's names (072, owner 2026-10-11): an MSP's managed tenant is a customer of its own under the MSP, # named by its legal name; its environment has a name people can rename. Sent with the claim when given; a pre-staged # environment's names are known at Invardia already. Nothing is read from the tenant for them. Only when the claim's answer # says a name is still missing, an interactive run asks for it and sends it with the install key (PUT # /api/enrollments/names); a non-interactive run never asks (the names can be set in the customer portal afterwards). # The bootstrap is the ONLY claimant: it reads the signed-in tenant, claims the key ONCE for it (POST /api/enrollments/claim # - the tenant's environment, licence and install key), keeps the claim's answer in state.json and never claims again on # a resume (each claim issues a new install key and stops the previous one). config.json gets installKey (PIM: exactly its # minimal key set, no enrollmentKey; SI: enrollmentKey too) and - for an MSP managed tenant - mspRole 'managed', # managing {bundleStorage, container, signingKeyIds, tenantId}; it asks only for what is # missing (the subscription; super admins / alert recipients default to the signed-in admin). # PowerShell 7 only (owner 2026-11-01: "we are now in ps7 everywhere"). #Requires stops a saved file at once; this line # says the same in plain words when the text is run another way (irm ... | iex). if ($PSVersionTable.PSVersion.Major -lt 7) { throw 'This script needs PowerShell 7 - install it from https://aka.ms/powershell and run it with pwsh' } # Who is signed in - without the Azure CLI or any module (owner 2026-10-10: "we can NOT have any az cli or ps modules ... # except if they are approved"; chose "Browser sign-in"). Azure Cloud Shell: its tenant (ACC_TID) and its own token endpoint # (MSI_ENDPOINT - REST, the signed-in user's token). Anywhere else: the browser sign-in (authorization code + PKCE, the same # as New-InvardiaSupportApp; never device code) with the Microsoft Azure CLI's public client id - an app id over REST, not # the az program. ONE sign-in per run (kept in $script:InvSignIn). -NoSignIn (a -WhatIf preview): never opens a browser. # Tests set $global:InvardiaTestSignedInTenant / ...User / ...Subscription ('skip' = cannot be told). $script:InvSignIn = $null function ConvertFrom-InvardiaJwt([string]$Jwt) { $p = $Jwt.Split('.')[1].Replace('-', '+').Replace('_', '/') switch ($p.Length % 4) { 2 { $p += '==' } 3 { $p += '=' } } [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($p)) | ConvertFrom-Json } function Wait-InvardiaAuthCode([string]$AuthorizeUrl, [string]$Redirect, [int]$Seconds = 300) { $listener = New-Object System.Net.HttpListener $listener.Prefixes.Add($Redirect); $listener.Start() try { try { Start-Process $AuthorizeUrl } catch { } Write-Host 'SIGN IN: your browser opens the Microsoft sign-in page - sign in as an administrator of the tenant to install into.' -ForegroundColor Yellow Write-Host " If no browser opens, open this address on this machine:`n $AuthorizeUrl" # The browser can ask more than once (a preload, then the real page; an icon) - owner 2026-10-30 ("This site can't be # reached"): only a request with code or error is the answer; every request gets a page, and the listener keeps # answering for a few seconds after the answer, so a second load of the address shows "Signed in" too. $answer = $null; $html = $null $deadline = [DateTime]::UtcNow.AddSeconds($Seconds); $graceEnd = $null while ($true) { $left = if ($answer) { ($graceEnd - [DateTime]::UtcNow).TotalMilliseconds } else { ($deadline - [DateTime]::UtcNow).TotalMilliseconds } if ($left -le 0) { break } $pending = $listener.BeginGetContext($null, $null) if (-not $pending.AsyncWaitHandle.WaitOne([int][Math]::Ceiling($left))) { break } $ctx = $listener.EndGetContext($pending) $q = [System.Web.HttpUtility]::ParseQueryString($ctx.Request.Url.Query) if (-not $answer -and ($q['code'] -or $q['error'])) { $answer = @{ code = $q['code']; state = $q['state']; error = $q['error']; error_description = $q['error_description'] } $html = if (-not $q['error']) { '

Signed in.

You can close this tab and return to PowerShell.

' } else { "

Sign-in failed

$([System.Net.WebUtility]::HtmlEncode($q['error_description']))

" } $graceEnd = [DateTime]::UtcNow.AddSeconds(10) } $page = if ($html) { $html } else { '

Waiting for the sign-in...

Finish the sign-in in the other tab.

' } try { $bytes = [Text.Encoding]::UTF8.GetBytes($page) $ctx.Response.ContentType = 'text/html; charset=utf-8'; $ctx.Response.ContentLength64 = $bytes.Length $ctx.Response.OutputStream.Write($bytes, 0, $bytes.Length); $ctx.Response.Close() } catch { } } if (-not $answer) { throw "No sign-in within $([int]($Seconds / 60)) minutes - run the same command again." } $answer } finally { $listener.Stop(); $listener.Close() } } function Get-InvardiaSignIn([switch]$NoSignIn) { if ($script:InvSignIn) { return $script:InvSignIn } $arm = 'https://management.azure.com/' # -ViaSupportApp (owner 2026-10-21 "test trial with support app today"): Invardia staff install for a customer through the # Invardia Support app session of THIS shell (tools/Connect-InvardiaSupport.ps1) - its app token, each one recorded in the # customer's audit log by that session (fail-closed). No person signs in; the token has no user (idtyp app). if ($script:InvViaSupport) { $t = Get-InvardiaSupportToken -Resource $arm $script:InvSignIn = [pscustomobject]@{ token = $t; claims = ConvertFrom-InvardiaJwt $t; app = $true } return $script:InvSignIn } if ($env:ACC_TID -and $env:MSI_ENDPOINT) { # Azure Cloud Shell try { $t = Invoke-RestMethod -Uri "$($env:MSI_ENDPOINT)?resource=$([uri]::EscapeDataString($arm))&api-version=2019-08-01" -Headers @{ Metadata = 'true' } $script:InvSignIn = [pscustomobject]@{ token = $t.access_token; claims = ConvertFrom-InvardiaJwt $t.access_token } return $script:InvSignIn } catch { Write-Verbose "Invardia: Cloud Shell token endpoint did not answer ($($_.Exception.Message))" } } if ($NoSignIn) { return $null } Add-Type -AssemblyName System.Web -ErrorAction SilentlyContinue $client = '04b07795-8ddb-461a-bbee-02f9e1bf7b46' # Microsoft Azure CLI (public client id; the az program is never used) # -TenantId pins the browser sign-in to that tenant (else: any work account, the one you pick) $login = "https://login.microsoftonline.com/$(if ($script:InvPinnedTenant) { $script:InvPinnedTenant } else { 'organizations' })/oauth2/v2.0" $tcp = New-Object System.Net.Sockets.TcpListener([System.Net.IPAddress]::Loopback, 0); $tcp.Start(); $port = $tcp.LocalEndpoint.Port; $tcp.Stop() $redirect = "http://localhost:$port/" $b64 = { param([byte[]]$b) [Convert]::ToBase64String($b).TrimEnd('=').Replace('+', '-').Replace('/', '_') } $verifier = & $b64 ([byte[]](1..32 | ForEach-Object { Get-Random -Minimum 0 -Maximum 256 })) $challenge = & $b64 ([System.Security.Cryptography.SHA256]::Create().ComputeHash([Text.Encoding]::ASCII.GetBytes($verifier))) $state = [guid]::NewGuid().ToString('N'); $scope = "$($arm).default openid profile offline_access" $url = "$login/authorize?client_id=$client&response_type=code&response_mode=query&prompt=select_account" + "&redirect_uri=$([uri]::EscapeDataString($redirect))&scope=$([uri]::EscapeDataString($scope))&code_challenge=$challenge&code_challenge_method=S256&state=$state" $r = if ($global:InvardiaTestWaitAuthCode) { & $global:InvardiaTestWaitAuthCode $url $redirect } else { Wait-InvardiaAuthCode $url $redirect } if ($r.error) { throw "Sign-in failed: $($r.error) - $($r.error_description)" } if ($r.state -ne $state) { throw 'Sign-in answer did not match this request (state) - run the same command again.' } $t = Invoke-RestMethod -Method Post -Uri "$login/token" -Body @{ grant_type = 'authorization_code'; client_id = $client; code = $r.code; redirect_uri = $redirect; code_verifier = $verifier; scope = $scope } # The refresh token stays IN MEMORY in this run only (it mints the hand-over audiences below); never written or passed on. $script:InvSignIn = [pscustomobject]@{ token = $t.access_token; claims = ConvertFrom-InvardiaJwt $t.access_token; refresh = $t.refresh_token; client = $client } $script:InvSignIn } # One sign-in for the whole install (agreed with PIM 2026-10-10, framework §8 install handoff): the product installer runs in # THIS process and gets the signed-in admin's ACCESS tokens - Azure Resource Manager, Microsoft Graph and Azure SQL, for the # customer's tenant - in $env:PIM_HANDOFF_TOKENS, so it does not open a second browser sign-in. Access tokens only (60-90 # min; the product signs in again for an expired audience); the refresh token never crosses over. An audience that cannot be # had is left out (the product falls back to its own sign-in for it). Never printed, logged or written to disk. $script:InvHandoffAudiences = @('https://management.azure.com/', 'https://graph.microsoft.com/', 'https://database.windows.net/') function Get-InvardiaHandoffTokens { if ($global:InvardiaTestHandoffTokens) { return $global:InvardiaTestHandoffTokens } $out = [ordered]@{} $s = Get-InvardiaSignIn -NoSignIn foreach ($aud in $script:InvHandoffAudiences) { try { if ($script:InvViaSupport) { # -ViaSupportApp: the Support app session's token per resource (audited by the session) $tk = Get-InvardiaSupportToken -Resource $aud $out[$aud] = @{ access_token = $tk; expires_on = [long](ConvertFrom-InvardiaJwt $tk).exp } } elseif ($env:ACC_TID -and $env:MSI_ENDPOINT) { # Azure Cloud Shell: its token endpoint, per resource $t = Invoke-RestMethod -Uri "$($env:MSI_ENDPOINT)?resource=$([uri]::EscapeDataString($aud))&api-version=2019-08-01" -Headers @{ Metadata = 'true' } $out[$aud] = @{ access_token = $t.access_token; expires_on = [long]$t.expires_on } } elseif ($s -and $s.refresh -and $s.claims.tid) { # the browser sign-in: minted from the in-memory refresh token $t = Invoke-RestMethod -Method Post -Uri "https://login.microsoftonline.com/$($s.claims.tid)/oauth2/v2.0/token" -Body @{ grant_type = 'refresh_token'; client_id = $s.client; refresh_token = $s.refresh; scope = "$($aud).default offline_access" } if ($t.refresh_token) { $s.refresh = $t.refresh_token } $out[$aud] = @{ access_token = $t.access_token; expires_on = [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() + [long]$t.expires_in } } } catch { Write-Verbose "Invardia: no hand-over token for $aud - the product signs in for it itself" } } $out } # The tenant this shell is signed in to (lower case), or $null when it cannot be told. function Get-InvardiaSignedInTenant([switch]$NoSignIn) { if ($null -ne $global:InvardiaTestSignedInTenant) { $t = "$global:InvardiaTestSignedInTenant".Trim(); return $(if (-not $t -or $t -eq 'skip') { $null } else { $t.ToLowerInvariant() }) } if ($script:InvViaSupport) { return "$($global:InvardiaSupportState.TenantId)".Trim().ToLowerInvariant() } if ($env:ACC_TID) { return $env:ACC_TID.Trim().ToLowerInvariant() } $s = Get-InvardiaSignIn -NoSignIn:$NoSignIn if ($s -and $s.claims.tid) { "$($s.claims.tid)".ToLowerInvariant() } else { $null } } # The signed-in account (UPN) and its subscription (the only one it can see, else $null - then it is asked), or $nulls - # the defaults an enrollment install asks with. function Get-InvardiaSignedInAccount([switch]$NoSignIn) { if ($null -ne $global:InvardiaTestSignedInUser -or $null -ne $global:InvardiaTestSubscription) { $u = "$global:InvardiaTestSignedInUser".Trim(); $s = "$global:InvardiaTestSubscription".Trim() return [pscustomobject]@{ user = $(if ($u -and $u -ne 'skip') { $u } else { $null }); subscriptionId = $(if ($s -and $s -ne 'skip') { $s.ToLowerInvariant() } else { $null }) } } $s = Get-InvardiaSignIn -NoSignIn:$NoSignIn if (-not $s) { return [pscustomobject]@{ user = $null; subscriptionId = $null } } $user = @($s.claims.upn, $s.claims.unique_name, $s.claims.preferred_username) | Where-Object { $_ } | Select-Object -First 1 $sub = $null try { $subs = @((Invoke-RestMethod -Uri 'https://management.azure.com/subscriptions?api-version=2022-12-01' -Headers @{ Authorization = "Bearer $($s.token)" }).value | Where-Object { $_.state -eq 'Enabled' }) if ($subs.Count -eq 1) { $sub = "$($subs[0].subscriptionId)".ToLowerInvariant() } } catch { Write-Verbose "Invardia: could not list subscriptions ($($_.Exception.Message))" } [pscustomobject]@{ user = $user; subscriptionId = $sub } } # A value the install still needs: the default when there is one (said out loud), else asked. -NonInteractive: never asks. function Read-InvardiaValue([string]$Prompt, $Default) { if ($Default) { Write-Host " $Prompt`: $Default" -ForegroundColor DarkGray; return $Default } if ([Environment]::GetCommandLineArgs() -contains '-NonInteractive') { return $null } $v = Read-Host $Prompt if ($v) { $v.Trim() } else { $null } } # Whether this run may ask (072). Tests set $global:InvardiaTestInteractive. function Test-InvardiaInteractive { if ($null -ne $global:InvardiaTestInteractive) { return [bool]$global:InvardiaTestInteractive } [Environment]::UserInteractive -and -not ([Environment]::GetCommandLineArgs() -contains '-NonInteractive') } # A name the person types (nothing is read from the tenant for it): Enter leaves it empty - it can be set in the portal later. function Read-InvardiaName([string]$Prompt) { $v = Read-Host "$Prompt (Enter to leave empty)" if ($v -and $v.Trim()) { $v.Trim() } else { $null } } # The run sends its own log files (owner 2026-10-13: "automatic upload log files"; "i dont want to have to get logs per mail"). # When a step failed, the run - at its very end, after the transcript is closed - sends its newest log files to the support # ticket that the failure opened at Invardia: this run's transcript, and the .log / .txt files the product's installer # wrote in its own state folder (/product) since the last upload. A resume that fails again sends the newer # files. Over REST with the install token the run already reports its steps with (POST /api/install/logs) - no new secret, # no module. state.json, config.json and the licence file are NEVER sent. # SECRETS ARE TAKEN OUT BEFORE ANYTHING LEAVES THIS MACHINE (Protect-InvardiaLogText): the install token, the install key # and the enrollment key of this installation wherever they stand, and anything shaped like a key, a token (JWT, Bearer), # a private key, or a value written as password= / secret= / key= / token= / sig=. Invardia's server applies the same # rules again before it stores a file. An older server without the route: nothing is sent, the install is unchanged. function Protect-InvardiaLogText([string]$Text, [string[]]$Known) { $r = '[redacted]' foreach ($k in $Known) { if ($k -and $k.Length -ge 8) { $Text = $Text.Replace($k, $r) } } foreach ($p in @( '-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----', '\bit-[A-Za-z0-9_-]{43}(?![A-Za-z0-9_-])', '\bek-[A-Za-z0-9_-]{43}(?![A-Za-z0-9_-])', '\binv-[A-Za-z0-9_-]{20,100}', '\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]*')) { $Text = [regex]::Replace($Text, $p, $r) } $Text = [regex]::Replace($Text, '\b(Bearer|Basic)(\s+)[A-Za-z0-9._~+/=-]{16,}', '${1}${2}[redacted]', 'IgnoreCase') [regex]::Replace($Text, '\b((?:client[_-]?secret|secret|password|passwd|pwd|api[_-]?key|access[_-]?key|account[_-]?key|shared[_-]?access[_-]?key|install[_-]?key|enrollment[_-]?key|access[_-]?token|refresh[_-]?token|id[_-]?token|token|sig)["'']?\s*[:=]\s*["'']?)([^\s"'';,&]{6,})', '${1}[redacted]', 'IgnoreCase') } # A log's text (UTF-8, or UTF-16 by its byte order mark - Windows PowerShell transcripts); of a long one, the END. function Get-InvardiaLogTail([string]$Path, [int]$MaxChars = 1000000) { $fs = [IO.File]::Open($Path, [IO.FileMode]::Open, [IO.FileAccess]::Read, [IO.FileShare]::ReadWrite) try { $t = (New-Object IO.StreamReader($fs, [Text.Encoding]::UTF8, $true)).ReadToEnd() } finally { $fs.Dispose() } if ($t.Length -gt $MaxChars) { $cut = $t.Length - $MaxChars; $t = "[... the first $cut characters of this log were left out ...]`n" + $t.Substring($cut) } $t } # Never stops or fails the install: whatever goes wrong here is a verbose line. function Send-InvardiaInstallLogs([string]$Origin, [string]$Dir, [string]$Transcript) { try { $statePath = Join-Path $Dir 'state.json' if (-not (Test-Path $statePath)) { return } $state = Get-Content $statePath -Raw | ConvertFrom-Json if (-not $state.token) { return } $marker = Join-Path $Dir 'logs-sent.txt' # when this installation's logs were last sent (UTC) $since = [datetime]::MinValue if (Test-Path $marker) { try { $since = [datetime]::Parse("$(Get-Content $marker -Raw)".Trim(), [Globalization.CultureInfo]::InvariantCulture, [Globalization.DateTimeStyles]::RoundtripKind).ToUniversalTime() } catch { } } $now = [datetime]::UtcNow $files = @() if ($Transcript -and (Test-Path $Transcript)) { $files += Get-Item $Transcript } $productDir = Join-Path $Dir 'product' if (Test-Path $productDir) { $files += @(Get-ChildItem $productDir -Recurse -File -ErrorAction SilentlyContinue | Where-Object { ('.log', '.txt') -contains $_.Extension.ToLowerInvariant() -and $_.LastWriteTimeUtc -gt $since }) } $pick = @($files | Sort-Object LastWriteTimeUtc -Descending | Select-Object -First 5) if (-not $pick.Count) { return } $known = @("$($state.token)", "$($state.installKey)", "$($state.enrollmentKey)") $configPath = Join-Path $Dir 'config.json' if (Test-Path $configPath) { try { $known += "$((Get-Content $configPath -Raw | ConvertFrom-Json).installKey)" } catch { } } $send = @($pick | ForEach-Object { @{ name = $_.Name; text = (Protect-InvardiaLogText (Get-InvardiaLogTail $_.FullName) $known) } }) $json = @{ files = $send } | ConvertTo-Json -Depth 4 -Compress # the body as UTF-8 bytes: Windows PowerShell 5.1 would send a string body in another encoding $head = @{ 'x-invardia-client' = 'bootstrap/1'; 'x-invardia-install-token' = "$($state.token)" } $r = Invoke-RestMethod -Method Post -Uri "$Origin/api/install/logs" -Headers $head -ContentType 'application/json; charset=utf-8' -Body ([Text.Encoding]::UTF8.GetBytes($json)) $now.ToString('o') | Set-Content $marker # the server's own "what to do when it stops" lines (the same text everywhere: packages/core installsupport.ts) # $true = the server's lines with the resume line among them were printed: the run does not print its own resume line too if ($r.next) { foreach ($l in @($r.next)) { if ($l) { Write-Host "Invardia: $l" -ForegroundColor Yellow } } return (@(@($r.next) | Where-Object { "$_" -like '*-Resume*' }).Count -gt 0) } elseif ($r.ticketNumber) { Write-Host "Invardia: your logs were sent to ticket $($r.ticketNumber)." -ForegroundColor Yellow } } catch { Write-Verbose "Invardia: the log files were not sent ($($_.Exception.Message))" } return $false } function Install-Invardia { <# .SYNOPSIS Installs PIM Manager or SecurityInsight from an Invardia install code or enrollment key, and reports each step to your installation page on invardia.com. .DESCRIPTION Exchanges a one-time install code (or claims an enrollment key for the tenant this shell is signed in to), writes the answers, the licence file and the install token to a state folder, and runs the product's own unattended installer, which makes the Azure and Entra changes. -WhatIf writes nothing: it says what it would do and stops before the first step that cannot be previewed. .LINK https://invardia.com/docs/invardia/scripts/Install-Invardia/ #> [CmdletBinding(DefaultParameterSetName = 'Code', SupportsShouldProcess)] param( [Parameter(ParameterSetName = 'Code', Mandatory)][string]$Code, [Parameter(ParameterSetName = 'Resume', Mandatory)][switch]$Resume, [Parameter(ParameterSetName = 'Resume', Mandatory)][string]$InstallId, [Parameter(ParameterSetName = 'Enroll', Mandatory)][ValidatePattern('^ek-[A-Za-z0-9_-]{43}$')][string]$EnrollmentKey, [Parameter(ParameterSetName = 'Enroll', Mandatory)][ValidateSet('pim-manager', 'securityinsight')][string]$Product, [Parameter(ParameterSetName = 'Enroll')][ValidatePattern('^[0-9a-fA-F-]{36}$')][string]$SubscriptionId, [Parameter(ParameterSetName = 'Enroll')][ValidatePattern('^[a-z0-9]{2,40}$')][string]$Location, [Parameter(ParameterSetName = 'Enroll')][ValidateLength(1, 200)][string]$LegalName, [Parameter(ParameterSetName = 'Enroll')][ValidateLength(1, 120)][string]$EnvironmentName, # the tenant the product goes into (owner 2026-10-11: the command carries every detail) - the sign-in is pinned to it [Parameter(ParameterSetName = 'Enroll')][ValidatePattern('^[0-9a-fA-F-]{36}$')][string]$TenantId, # the product's own install answers (e.g. @{ location = 'northeurope'; resourceGroup = 'rg-securityinsight'; mailTo = 'secops@contoso.com' }) [Parameter(ParameterSetName = 'Enroll')][hashtable]$Answers, [string]$StateRoot, [switch]$PreflightOnly, # Invardia staff only: install through the Invardia Support app session of this shell instead of a person's sign-in [switch]$ViaSupportApp, [string]$Origin = 'https://invardia.com', # for testing only [int]$PollSeconds = 15 ) $ErrorActionPreference = 'Stop' # RETIRED 2026-11-02 (owner: "i only support install of products using the cmdlets from the solutions, newer invardia"): # Install-Invardia installs nothing any more - every form (-Code, -Resume, -EnrollmentKey) stops here, before anything is # written or sent. throw "Invardia install commands are no longer used. Products are installed with the product's own install command, every value written out." $origin = $Origin.TrimEnd('/') if (-not $StateRoot) { $cloud = Join-Path $HOME 'clouddrive' $StateRoot = Join-Path ($(if (Test-Path $cloud) { $cloud } else { $HOME })) 'invardia' } $headers = @{ 'x-invardia-client' = 'bootstrap/1' } # Framework 12.7 SCRIPT-DOC-1: the documentation page is the first line; 12.10: a local transcript of the run (no secret # is ever printed), its path at the start and the end. Once - a resume calls this function again. -WhatIf: in the temp # folder; otherwise next to the state folder (-logs, never inside it: its sub-folders are installations). $top = @(Get-PSCallStack)[1].Command -ne 'Install-Invardia' $transcript = $null # this run (a resume it starts itself included): did a step fail, and in which installation's folder - for the log upload if ($top) { $script:InvRun = @{ failed = $false; dir = $null } } if ($top) { $script:InvViaSupport = [bool]$ViaSupportApp; $script:InvSignIn = $null if ($ViaSupportApp -and (-not ($global:InvardiaSupportState -is [hashtable]) -or -not $global:InvardiaSupportState.TenantId -or -not (Get-Command Get-InvardiaSupportToken -ErrorAction SilentlyContinue))) { throw '-ViaSupportApp is for Invardia staff: it runs only in a shell with an Invardia Support session (. tools\Connect-InvardiaSupport.ps1 -Environment -Reason ). Customers run the command without it.' } if ($ViaSupportApp) { Write-Host "Invardia: installing through the Invardia Support app for tenant $($global:InvardiaSupportState.TenantId) - no person signs in; every token is recorded in the customer's audit log." -ForegroundColor Yellow } } if ($top) { Write-Host 'Install-Invardia version 2026.11.01.1 - documentation: https://invardia.com/docs/invardia/scripts/Install-Invardia/' $logDir = if ($WhatIfPreference) { [IO.Path]::GetTempPath() } else { "$($StateRoot.TrimEnd('/', '\'))-logs" } try { New-Item -ItemType Directory -Force -Path $logDir -WhatIf:$false | Out-Null $transcript = Join-Path $logDir ('Install-Invardia-{0:yyyyMMdd-HHmmss-fff}-{1}.log' -f (Get-Date), $PID) Start-Transcript -Path $transcript -WhatIf:$false -Confirm:$false | Out-Null Write-Host "Transcript: $transcript" } catch { $transcript = $null; Write-Warning "Invardia: no transcript of this run ($($_.Exception.Message))" } } try { # -WhatIf: reads only (the signed-in tenant, the saved state, the bundle of a resumed install) and says what it would do. # No claim, no code exchange, no file, no step reported, no download, no product installer. if ($WhatIfPreference) { Write-Host 'What if: nothing is written - no claim, no code exchange, no file, no step reported, no installer run.' -ForegroundColor Yellow $files = 'state.json (the install token), config.json (the answers), the licence file' if ($PSCmdlet.ParameterSetName -eq 'Enroll') { $tenant = Get-InvardiaSignedInTenant -NoSignIn if (-not $tenant) { Write-Host 'What if: could not read which tenant this shell is signed in to - a real run would stop here (sign in first).'; return } $acct = Get-InvardiaSignedInAccount -NoSignIn $sub = if ($SubscriptionId) { $SubscriptionId.ToLowerInvariant() } elseif ($acct.subscriptionId) { $acct.subscriptionId } else { '' } Write-Host "What if: claim the enrollment key ONCE for tenant $tenant, subscription $sub, $Product (POST $origin/api/enrollments/claim)." Write-Host "What if: then write $files to $StateRoot\, download the product's release (SHA-256 checked) and run its installer." Write-Host 'What if: a claim issues an install key and stops any earlier one, so it cannot be previewed - stopping here.' } elseif ($PSCmdlet.ParameterSetName -eq 'Code') { Write-Host "What if: exchange the install code for the bundle (POST $origin/api/install/exchange)." Write-Host "What if: then write $files to $StateRoot\, download the product's release (SHA-256 checked) and run its installer." Write-Host 'What if: an install code works only once, so the exchange cannot be previewed - stopping here.' } else { $dir = Join-Path $StateRoot $InstallId if (-not (Test-Path (Join-Path $dir 'state.json'))) { Write-Host "What if: no saved state for installation $InstallId in $StateRoot - a real run would stop here."; return } $state = Get-Content (Join-Path $dir 'state.json') -Raw | ConvertFrom-Json # Fetching the bundle again changes nothing at Invardia (the same answer as before; no new token). $bundle = Invoke-RestMethod -Method Post -Uri "$origin/api/install/bundle" -Headers ($headers + @{ 'x-invardia-install-token' = $state.token }) -ContentType 'application/json' -Body '{}' Write-Host "What if: resume installation $InstallId - $($bundle.product) $($bundle.version) ($($bundle.kind)) for tenant $($bundle.config.tenantId)." Write-Host "What if: write config.json$(if ($bundle.licence) { " and $($bundle.licence.fileName)" }) in $dir$(if ($bundle.licencePending) { ' (the licence is still being signed: it would wait up to 10 minutes)' })." $have = Get-InvardiaSignedInTenant -NoSignIn $want = "$($bundle.config.tenantId)".Trim().ToLowerInvariant() if ($want -and $have -and $have -ne $want) { Write-Host "What if: this shell is signed in to tenant $have, not $want - a real run would stop before the installer." } if ($bundle.package) { Write-Host "What if: download $($bundle.package.url) (SHA-256 $($bundle.package.sha256)) and run $($bundle.package.entryScript)$(if ($PreflightOnly) { ' -PreflightOnly' }) -Resume." } Write-Host "What if: the product's installer makes the Azure and Entra changes; its own documentation lists them. Status page: $($bundle.endpoints.status)" } return } if ($PSCmdlet.ParameterSetName -eq 'Enroll') { # The key is claimed for the tenant THIS shell is signed in to - so that tenant must be known. -TenantId pins it. if ($TenantId) { $script:InvPinnedTenant = $TenantId.ToLowerInvariant() } $tenant = Get-InvardiaSignedInTenant if ($TenantId -and $tenant -and $tenant -ne $TenantId.ToLowerInvariant()) { throw "This shell is signed in to tenant $tenant, but the command is for tenant $($TenantId.ToLowerInvariant()). Sign in to the right tenant (in Cloud Shell: switch directory), then run the same command again." } if (-not $tenant) { throw 'Could not read which tenant this shell is signed in to. Sign in when the browser asks (or run it in Azure Cloud Shell), then run the same command again.' } $keyHash = [BitConverter]::ToString([Security.Cryptography.SHA256]::Create().ComputeHash([Text.Encoding]::UTF8.GetBytes($EnrollmentKey))).Replace('-', '').ToLowerInvariant() # Claimed before on this machine for this tenant + product: resume THAT install - never claim again (a claim issues a # new install key and stops the previous one). $prior = Get-ChildItem $StateRoot -Directory -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Where-Object { $f = Join-Path $_.FullName 'state.json' if (-not (Test-Path $f)) { return $false } $s = try { Get-Content $f -Raw | ConvertFrom-Json } catch { $null } $s -and $s.enrollmentKeyHash -eq $keyHash -and $s.tenantId -eq $tenant -and $s.product -eq $Product } | Select-Object -First 1 if ($prior) { Write-Host "Invardia: this key was already claimed for tenant $tenant on this machine. Resuming installation $($prior.Name)..." -ForegroundColor Yellow return Install-Invardia -Resume -InstallId $prior.Name -StateRoot $StateRoot -PreflightOnly:$PreflightOnly -Origin $Origin -PollSeconds $PollSeconds } $acct = Get-InvardiaSignedInAccount $sub = if ($SubscriptionId) { $SubscriptionId.ToLowerInvariant() } else { Read-InvardiaValue 'Azure subscription id to install into' $acct.subscriptionId } if (-not $sub) { throw 'Name the Azure subscription to install into: -SubscriptionId .' } Write-Host "Invardia: claiming the enrollment key for tenant $tenant ($Product)..." -ForegroundColor Cyan try { $claimBody = @{ enrollmentKey = $EnrollmentKey; tenantId = $tenant; subscriptionId = $sub; product = $Product } if ($LegalName) { $claimBody.legalName = $LegalName.Trim() } if ($EnvironmentName) { $claimBody.environmentName = $EnvironmentName.Trim() } $claim = Invoke-RestMethod -Method Post -Uri "$origin/api/enrollments/claim" -Headers $headers -ContentType 'application/json' -Body ($claimBody | ConvertTo-Json) } catch { $status = $_.Exception.Response.StatusCode.value__ $err = try { ($_.ErrorDetails.Message | ConvertFrom-Json).error } catch { $null } $why = switch ($err) { 'key' { 'The enrollment key is not known. Copy it again from your customer portal (portal.invardia.com > Enrollment key).' } 'keyRevoked' { 'This enrollment key was re-issued or revoked - use the newest one from your customer portal (portal.invardia.com > Enrollment key).' } 'keyExpired' { 'This enrollment key has expired - use the newest one from your customer portal (portal.invardia.com > Enrollment key).' } 'productMismatch' { "The enrollment key is not for $Product." } 'keyLimit' { 'This enrollment key has reached the tenant limit it was issued with - re-issue it in your customer portal (portal.invardia.com > Enrollment key).' } 'tenantLimit' { 'The MSP licence has no tenants left. A larger MSP order is needed.' } 'managingTenant' { 'This is the MSP managing tenant itself - install it with its own install code, not the managed-tenant key.' } 'tenantBelongsToAnotherCompany' { "Tenant $tenant belongs to another company at Invardia. Contact support@invardia.com." } 'noMspLicence' { 'There is no live MSP licence for this key. Contact support@invardia.com.' } 'businessNotEnforced' { 'Pro Business cannot be issued for this product version yet. Contact support@invardia.com.' } default { "HTTP $status $err" } } throw "Could not claim the enrollment key: $why Nothing was installed." } if ($claim.alreadyInstalled) { Write-Host "Invardia: $($claim.message)" -ForegroundColor Yellow return } # 072: the names still missing (not given, not pre-staged) - asked only in an interactive run, sent with the install key $n = $claim.names if ($n -and -not $n.complete -and $claim.installKey -and (Test-InvardiaInteractive)) { Write-Host 'Invardia: name the customer this tenant belongs to and this environment:' -ForegroundColor Cyan $legal = if ($n.legalNameSet -or -not $n.managed) { $n.legalName } else { Read-InvardiaName 'Legal name of the customer' } $envName = if ($n.environmentNameSet) { $n.environmentName } else { Read-InvardiaName 'Environment name (e.g. PROD)' } $put = @{} if ($legal -and -not $n.legalNameSet -and $n.managed) { $put.legalName = $legal } if ($envName -and -not $n.environmentNameSet) { $put.environmentName = $envName } if ($put.Count) { try { $named = Invoke-RestMethod -Method Put -Uri "$origin/api/enrollments/names" -Headers ($headers + @{ 'x-invardia-install-key' = $claim.installKey }) -ContentType 'application/json' -Body ($put | ConvertTo-Json) if ($named.names.environmentName) { Write-Host "Invardia: saved - environment $($named.names.environmentName)." -ForegroundColor Cyan } } catch { Write-Warning "Invardia: the names were not saved ($($_.Exception.Message)) - set them in your customer portal (Managed customers)." } } } $inst = $claim.installation $dir = Join-Path $StateRoot $inst.installId New-Item -ItemType Directory -Force -Path $dir | Out-Null # The claim's answer, kept for every resume: the token, the install key, the managing facts (managed only). @{ installId = $inst.installId; token = $inst.token; product = $Product; seq = 0; tenantId = $tenant; enrollmentKeyHash = $keyHash installKey = $claim.installKey; enrollmentKey = $EnrollmentKey; managing = $claim.managing; environmentHandle = $claim.environmentHandle location = $(if ($Location) { $Location } else { $null }); answers = $(if ($Answers) { $Answers } else { $null }) } | ConvertTo-Json -Depth 6 | Set-Content (Join-Path $dir 'state.json') if ($IsLinux -or $IsMacOS) { chmod 600 (Join-Path $dir 'state.json') } Write-Host "Invardia: enrolled as $($claim.environmentHandle). Installation $($inst.installId) - resume it with: Install-Invardia -Resume -InstallId $($inst.installId)" -ForegroundColor Cyan $bundle = Invoke-RestMethod -Method Post -Uri "$origin/api/install/bundle" -Headers ($headers + @{ 'x-invardia-install-token' = $inst.token }) -ContentType 'application/json' -Body '{}' } elseif ($PSCmdlet.ParameterSetName -eq 'Code') { Write-Host 'Invardia: exchanging the install code...' -ForegroundColor Cyan try { $bundle = Invoke-RestMethod -Method Post -Uri "$origin/api/install/exchange" -Headers $headers -ContentType 'application/json' -Body (@{ code = $Code } | ConvertTo-Json) } catch { $status = $_.Exception.Response.StatusCode.value__ if ($status -eq 410) { # Already used: resume the installation this machine started, if any. $last = Get-ChildItem $StateRoot -Directory -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1 if ($last -and (Test-Path (Join-Path $last.FullName 'state.json'))) { Write-Host "This code was already used. Resuming installation $($last.Name)..." -ForegroundColor Yellow return Install-Invardia -Resume -InstallId $last.Name -StateRoot $StateRoot -PreflightOnly:$PreflightOnly -Origin $Origin -PollSeconds $PollSeconds } throw 'This install code was already used or has expired. Get a new code on your installation page on invardia.com.' } throw "Could not exchange the install code: $($_.Exception.Message)" } $dir = Join-Path $StateRoot $bundle.installId New-Item -ItemType Directory -Force -Path $dir | Out-Null # The token stays on this machine (state.json), readable by the current user only where the OS supports it. @{ installId = $bundle.installId; token = $bundle.token; product = $bundle.product; seq = 0 } | ConvertTo-Json | Set-Content (Join-Path $dir 'state.json') if ($IsLinux -or $IsMacOS) { chmod 600 (Join-Path $dir 'state.json') } } else { $dir = Join-Path $StateRoot $InstallId if (-not (Test-Path (Join-Path $dir 'state.json'))) { throw "No saved state for installation $InstallId in $StateRoot." } $state = Get-Content (Join-Path $dir 'state.json') -Raw | ConvertFrom-Json $bundle = Invoke-RestMethod -Method Post -Uri "$origin/api/install/bundle" -Headers ($headers + @{ 'x-invardia-install-token' = $state.token }) -ContentType 'application/json' -Body '{}' } $state = Get-Content (Join-Path $dir 'state.json') -Raw | ConvertFrom-Json # A trial started from the wizard: its licence is signed within moments - wait for it (up to 10 minutes). # An enrollment's licence (the tenant's own, or the MSP licence re-signed with the tenant) likewise. $what = if ($bundle.kind -eq 'enroll') { 'licence' } else { 'trial licence' } for ($n = 0; -not $bundle.licence -and $bundle.licencePending -and $n -lt [Math]::Ceiling(600 / $PollSeconds); $n++) { if ($n -eq 0) { Write-Host "Invardia: waiting for your $what to be signed..." -ForegroundColor Cyan } Start-Sleep -Seconds $PollSeconds $bundle = Invoke-RestMethod -Method Post -Uri "$origin/api/install/bundle" -Headers ($headers + @{ 'x-invardia-install-token' = $state.token }) -ContentType 'application/json' -Body '{}' } if (-not $bundle.licence -and $bundle.licencePending) { throw "Your $what is not signed yet. Run again in a few minutes: Install-Invardia -Resume -InstallId $($bundle.installId)" } # Config and licence for the product installer. $configPath = Join-Path $dir 'config.json' # The install key (telemetry + updates) comes ONLY in the exchange answer: it goes into config.json as installKey (the # product stores it in its own vault and does not claim one), and a later -Resume keeps it (SI 2026-10-06: without it the # product claimed and got 409 "key exists"). $config = $bundle.config $prevKey = if (Test-Path $configPath) { try { (Get-Content $configPath -Raw | ConvertFrom-Json).installKey } catch { $null } } else { $null } $key = if ($bundle.installKey -and $bundle.installKey.key) { $bundle.installKey.key } elseif ($state.installKey) { $state.installKey } else { $prevKey } if ($key) { $config | Add-Member -NotePropertyName installKey -NotePropertyValue $key -Force } if ($state.enrollmentKeyHash) { # An enrollment install: the claim's facts (PIM's Install-PimManager does not claim when installKey + licence are # there), and what is still missing - asked, or the signed-in admin. PIM gets NO enrollmentKey (with one it claims). $pim = $bundle.product -eq 'pim-manager' if (-not $pim) { $config | Add-Member -NotePropertyName enrollmentKey -NotePropertyValue $state.enrollmentKey -Force } if ($state.managing) { $config | Add-Member -NotePropertyName managing -NotePropertyValue $state.managing -Force } if ($state.location) { $config | Add-Member -NotePropertyName location -NotePropertyValue $state.location -Force } $prev = if (Test-Path $configPath) { try { Get-Content $configPath -Raw | ConvertFrom-Json } catch { $null } } else { $null } $me = (Get-InvardiaSignedInAccount).user $lists = if ($bundle.product -eq 'pim-manager') { @('superAdmins', 'alertRecipients') } else { @('webAppAdmins') } foreach ($k in $lists) { $have = if ($prev -and $prev.$k) { $prev.$k } else { $config.$k } if (-not $have -or ($have -is [array] -and -not $have.Count)) { $u = Read-InvardiaValue "$k (comma separated)" $me if ($u) { $config | Add-Member -NotePropertyName $k -NotePropertyValue @("$u".Split(',') | ForEach-Object { $_.Trim() } | Where-Object { $_ }) -Force } } elseif ($prev -and $prev.$k) { $config | Add-Member -NotePropertyName $k -NotePropertyValue $prev.$k -Force } } if (-not $config.subscriptionId) { $s = Read-InvardiaValue 'Azure subscription id to install into' $null if ($s) { $config | Add-Member -NotePropertyName subscriptionId -NotePropertyValue $s -Force } } # The product's minimal config.json for this install type, from its install contract (bundle.configKeys = PIM 2.4.536 # minimalConfig.singleClaimedByInvardia / managedClaimedByInvardia): exactly these keys. Without one, PIM's own set. $keep = if ($bundle.configKeys) { @($bundle.configKeys) } elseif ($pim) { @('installId', 'tenantId', 'subscriptionId', 'location', 'superAdmins', 'alertRecipients', 'installKey') + $(if ($state.managing) { @('mspRole', 'managing') } else { @() }) } else { $null } if ($keep) { if ($state.managing -and $keep -contains 'mspRole') { $config | Add-Member -NotePropertyName mspRole -NotePropertyValue 'managed' -Force } # adminPrefixes is never part of an install (owner 2026-10-10: "it is NOT required to deploy a new tenant ... that is # handled by solution" - each product's Get started): never asked, never derived, never passed on. $min = [ordered]@{} foreach ($k in $keep) { if ($null -ne $config.$k) { $min[$k] = $config.$k } } $config = [pscustomobject]$min } # -Answers (owner 2026-10-29: "install cmdlets must also improve pro install experience"): the product's own install # answers on the command line - the same choices as its Community samples (region, resource group, mail recipients, # scope, private network...) - added after the minimal set. The product checks them as it checks its config file; # the identity of the install is never taken from here. if ($state.answers) { $fixed = @('installId', 'tenantId', 'installKey', 'enrollmentKey', 'managing', 'mspRole', 'adminPrefixes') foreach ($p in $state.answers.PSObject.Properties) { if ($p.Name -match '^[A-Za-z][A-Za-z0-9]{0,60}$' -and $fixed -notcontains $p.Name) { $config | Add-Member -NotePropertyName $p.Name -NotePropertyValue $p.Value -Force } } } } $config | ConvertTo-Json -Depth 10 | Set-Content $configPath if ($IsLinux -or $IsMacOS) { chmod 600 $configPath } $licencePath = $null if ($bundle.licence) { $licencePath = Join-Path $dir $bundle.licence.fileName [IO.File]::WriteAllBytes($licencePath, [Convert]::FromBase64String($bundle.licence.contentBase64)) $hash = (Get-FileHash $licencePath -Algorithm SHA256).Hash.ToLowerInvariant() if ($hash -ne $bundle.licence.sha256) { throw 'The licence file does not match its checksum. Nothing was installed.' } } # The reporter: the product calls it at the start and end of every step. Failures to report never stop the install. $statePath = Join-Path $dir 'state.json' $token = $state.token $eventsUri = "$origin/api/install/events" $run = $script:InvRun # the same object in the reporter's closure: a failed step marks the run for the log upload $reporter = { param($event) if ($run -and "$($event.state)" -eq 'failed') { $run.failed = $true; $run.dir = $dir } $s = Get-Content $statePath -Raw | ConvertFrom-Json $s.seq = [int]$s.seq + 1 $s | ConvertTo-Json | Set-Content $statePath $e = @{} + $event if (-not $e.ContainsKey('seq')) { $e.seq = $s.seq } if (-not $e.ContainsKey('at')) { $e.at = (Get-Date).ToUniversalTime().ToString('o') } $mark = switch ($e.state) { 'ok' { '[ok]' } 'completed' { '[done]' } 'failed' { '[FAILED]' } 'warning' { '[warning]' } 'skipped' { '[skipped]' } default { '[..]' } } Write-Host ("{0} {1} {2}" -f $mark, $(if ($e.step.title) { $e.step.title } else { $e.step.id }), $e.message) if ($e.action -and $e.action.command) { Write-Host " $($e.action.text)`n $($e.action.command)" -ForegroundColor Yellow } try { Invoke-RestMethod -Method Post -Uri $eventsUri -Headers @{ 'x-invardia-install-token' = $token } -ContentType 'application/json' -Body ($e | ConvertTo-Json -Depth 8) | Out-Null } catch { Write-Verbose "Invardia: could not report the step ($($_.Exception.Message))" } }.GetNewClosure() # Tenant guard (EFIF 2026-10-08: a wrong tenant id was registered): this shell must be signed in to the tenant the # installation and its licence are for - otherwise stop BEFORE the product installer creates anything. The state is kept, # so the same install resumes once the right tenant is signed in. $want = "$($bundle.config.tenantId)".Trim().ToLowerInvariant() $have = Get-InvardiaSignedInTenant if ($want -and $have -and $have -ne $want) { & $reporter @{ step = @{ id = 'tenant-check'; title = 'Check the signed-in tenant' }; state = 'waiting' message = "This shell is signed in to tenant $have, but this installation and its licence are for tenant $want. Nothing was installed." action = @{ text = "Run it again and sign in to tenant $want when the browser asks (Cloud Shell: switch directory in the portal first):"; command = "Install-Invardia -Resume -InstallId $($bundle.installId)" } } Write-Host "Invardia: WRONG TENANT - signed in to $have, the installation is for $want. Nothing was installed." -ForegroundColor Red return } if ($want -and -not $have) { Write-Host "Invardia: could not read which tenant this shell is signed in to - make sure it is $want." -ForegroundColor Yellow } Write-Host "Invardia: installing $($bundle.product) $(if ($bundle.version) { $bundle.version } elseif ($bundle.package) { $bundle.package.version } else { '(version not known yet)' }) ($($bundle.kind)). Status page: $($bundle.endpoints.status)" -ForegroundColor Cyan # The product's unattended entry point (design 4.2). Each product publishes it; until it is available in this shell, # the bootstrap stops here with the files ready. $entry = switch ($bundle.product) { 'pim-manager' { 'Install-PimManager' } 'securityinsight' { 'Install-SecurityInsight' } default { $null } } # Not loaded in this shell: get the product's own release (pinned by SHA-256 in the bundle), unpack it, run its script. if ($entry -and -not (Get-Command $entry -ErrorAction SilentlyContinue) -and $bundle.package) { $pkg = $bundle.package $zip = Join-Path $dir "package-$($pkg.version).zip" $unpacked = Join-Path $dir "package-$($pkg.version)" if (-not (Test-Path (Join-Path $unpacked $pkg.entryScript))) { Write-Host "Invardia: downloading $($bundle.product) $($pkg.version)..." -ForegroundColor Cyan Invoke-WebRequest -Uri $pkg.url -OutFile $zip -UseBasicParsing $got = (Get-FileHash $zip -Algorithm SHA256).Hash.ToLowerInvariant() if ($got -ne $pkg.sha256) { Remove-Item $zip -Force; throw "The downloaded release does not match its SHA-256 ($got). Nothing was installed." } Expand-Archive -Path $zip -DestinationPath $unpacked -Force } $script = Join-Path $unpacked $pkg.entryScript if (Test-Path $script) { $entry = $script } } if (-not $entry -or -not (Get-Command $entry -ErrorAction SilentlyContinue)) { & $reporter @{ step = @{ id = 'bootstrap'; title = 'Prepare the install' }; state = 'waiting' message = "Config and licence are ready in $dir. The product installer ($entry) is not available in this shell yet." action = @{ text = 'Load the product installer, then run:'; command = "Install-Invardia -Resume -InstallId $($bundle.installId)" } } return } # The product keeps its own state in a sub-folder: it must never overwrite the bootstrap's state.json (the install # token) - an installer writing \state.json made the next -Resume fail with "token" (SI, 2026-10-06). $productState = Join-Path $dir 'product' New-Item -ItemType Directory -Force -Path $productState | Out-Null $run = @{ ConfigPath = $configPath; Reporter = $reporter; StatePath = $productState } if ($licencePath) { $run.LicencePath = $licencePath } if ($PreflightOnly) { $run.PreflightOnly = $true } if ($PSCmdlet.ParameterSetName -eq 'Resume') { $run.Resume = $true } # A crash in the product installer (a terminating error, e.g. after its last step event) is reported as a failed step, # so the install page and Invardia's feed always show the cause - never a silent install (2026-10-10). Only the # exception's message (its first lines) is sent; nothing else from this shell. # The single sign-in hand-over (PIM only; SI may adopt SI_HANDOFF_TOKENS on the same pattern): set just before the call, # removed in finally right after it, so no later process inherits it. Not for -WhatIf (never reaches here) or -NoSignIn. # SecurityInsight takes the same hand-over in SI_HANDOFF_TOKENS (same JSON shape; SI 2026-10-21, for -ViaSupportApp). $handoff = $null $handoffVar = @{ 'pim-manager' = 'PIM_HANDOFF_TOKENS'; 'securityinsight' = 'SI_HANDOFF_TOKENS' }[$bundle.product] if ($handoffVar) { $tokens = Get-InvardiaHandoffTokens if ($tokens -and $tokens.Count) { $handoff = $handoffVar; Set-Item -Path "env:$handoff" -Value ($tokens | ConvertTo-Json -Compress -Depth 3) } } try { & $entry @run } catch { $why = @("$($_.Exception.Message)" -split "`r?`n" | Where-Object { $_.Trim() } | Select-Object -First 5) -join ' / ' if ($why.Length -gt 800) { $why = $why.Substring(0, 800) + '...' } if (-not $why) { $why = $_.Exception.GetType().Name } & $reporter @{ step = @{ id = 'installer'; title = 'Run the product installer' }; state = 'failed' message = "The product installer stopped with an error: $why" action = @{ text = 'Fix the cause (see above), then run:'; command = "Install-Invardia -Resume -InstallId $($bundle.installId)" } } throw } finally { if ($handoff) { Remove-Item -Path "env:$handoff" -ErrorAction SilentlyContinue } } $code = $LASTEXITCODE switch ($code) { 0 { Write-Host 'Invardia: the install finished.' -ForegroundColor Green } 1 { # printed at the end of the run, and only when the server did not print its own "what to do when it stops" lines # (with the resume line) after the log upload - an older server still gets this line $stopLine = "Invardia: a step failed. Fix it (see above), then run: Install-Invardia -Resume -InstallId $($bundle.installId)" if ($script:InvRun) { $script:InvRun.stopLine = $stopLine } else { Write-Host $stopLine -ForegroundColor Red } } 2 { Write-Host 'Invardia: the checks before installing found a problem (see above). Nothing was changed.' -ForegroundColor Red } 3 { Write-Host 'Invardia: the installer refused the configuration. Open your installation page to correct the answers.' -ForegroundColor Red } } } finally { if ($transcript) { try { Stop-Transcript | Out-Null } catch { }; Write-Host "Transcript: $transcript" } # a step failed in this run: its log files go to the ticket the failure opened (see Send-InvardiaInstallLogs above) $nextShown = $false if ($top -and $script:InvRun -and $script:InvRun.failed -and $script:InvRun.dir) { $nextShown = Send-InvardiaInstallLogs -Origin $origin -Dir $script:InvRun.dir -Transcript $transcript } if ($top -and $script:InvRun -and $script:InvRun.stopLine -and -not $nextShown) { Write-Host $script:InvRun.stopLine -ForegroundColor Red } } }