# Sentinel V2 prebuilt-image installer for Windows PowerShell 5+. # Piped installs use environment variables because irm | iex cannot bind param(). $ErrorActionPreference = "Stop" $Image = if ($env:SENTINEL_IMAGE) { $env:SENTINEL_IMAGE } else { "sentinel-v2:latest" } $ImageTar = $env:SENTINEL_IMAGE_TAR $ImageSha256 = $env:SENTINEL_IMAGE_SHA256 $AllowUnverifiedImage = $env:SENTINEL_ALLOW_UNVERIFIED_IMAGE -eq "1" $HomeDir = if ($env:SENTINEL_HOME) { $env:SENTINEL_HOME } else { Join-Path $env:USERPROFILE "sentinel-v2" } $PublicHost = $env:SENTINEL_PUBLIC_HOST $Port = if ($env:SENTINEL_PORT) { $env:SENTINEL_PORT } else { "4317" } $OciDir = $env:SENTINEL_OCI_DIR $Force = $env:SENTINEL_FORCE -eq "1" $HealthTimeout = if ($env:SENTINEL_HEALTH_TIMEOUT) { $env:SENTINEL_HEALTH_TIMEOUT } else { "120" } $DockerDesktopUrl = "https://docs.docker.com/desktop/setup/install/windows-install/" $InstallerArgs = @($args) function Write-Info([string] $Message) { Write-Host $Message } function Stop-Install([string] $Message) { throw "Sentinel installer: $Message" } function Show-Usage { @" Install a prebuilt Sentinel V2 image and start it with Docker Compose. Usage: powershell -File scripts\install.ps1 [options] Options: --public-host HOST Exact hostname/IP used in the browser URL --image-tar PATH|URL docker save tar to load --image-sha256 HASH Expected SHA-256 (required for remote URLs) --image NAME Image name (default sentinel-v2:latest) --home DIR Install directory (default ~\sentinel-v2) --oci-dir DIR Host .oci directory to mount read-only --port PORT Published and container port (default 4317) --force Replace generated files instead of merging .env Set SENTINEL_INSTALL_DOCKER=1 to opt into winget installation. Set SENTINEL_ALLOW_UNVERIFIED_IMAGE=1 only for a trusted URL without a hash. "@ | Write-Host } function Get-RequiredArgument( [string[]] $Values, [int] $Index, [string] $Option ) { if ($Index -ge $Values.Count -or [string]::IsNullOrWhiteSpace($Values[$Index]) -or $Values[$Index].StartsWith("--")) { Stop-Install "$Option requires a value" } return $Values[$Index] } for ($i = 0; $i -lt $InstallerArgs.Count; $i++) { $option = $InstallerArgs[$i] switch ($option) { "--public-host" { $PublicHost = Get-RequiredArgument $InstallerArgs (++$i) $option } "--image-tar" { $ImageTar = Get-RequiredArgument $InstallerArgs (++$i) $option } "--image-sha256" { $ImageSha256 = Get-RequiredArgument $InstallerArgs (++$i) $option } "--image" { $Image = Get-RequiredArgument $InstallerArgs (++$i) $option } "--home" { $HomeDir = Get-RequiredArgument $InstallerArgs (++$i) $option } "--oci-dir" { $OciDir = Get-RequiredArgument $InstallerArgs (++$i) $option } "--port" { $Port = Get-RequiredArgument $InstallerArgs (++$i) $option } "--force" { $Force = $true } "-h" { Show-Usage; exit 0 } "--help" { Show-Usage; exit 0 } default { Stop-Install "unknown option: $option" } } } function Assert-SingleLine([string] $Name, [AllowEmptyString()][string] $Value) { if ($Value -match "[`r`n]") { Stop-Install "$Name must not contain line breaks" } } function Assert-Inputs { Assert-SingleLine "SENTINEL_PUBLIC_HOST" $PublicHost Assert-SingleLine "SENTINEL_IMAGE" $Image Assert-SingleLine "SENTINEL_IMAGE_TAR" $ImageTar Assert-SingleLine "SENTINEL_HOME" $HomeDir Assert-SingleLine "SENTINEL_OCI_DIR" $OciDir Assert-SingleLine "SENTINEL_OCI_CONFIG_PATH" $env:SENTINEL_OCI_CONFIG_PATH $parsedPort = 0 if (-not [int]::TryParse($Port, [ref] $parsedPort) -or $parsedPort -lt 1 -or $parsedPort -gt 65535) { Stop-Install "SENTINEL_PORT must be an integer from 1 to 65535" } $parsedTimeout = 0 if (-not [int]::TryParse($HealthTimeout, [ref] $parsedTimeout) -or $parsedTimeout -lt 1) { Stop-Install "SENTINEL_HEALTH_TIMEOUT must be a positive integer" } if ([string]::IsNullOrWhiteSpace($PublicHost)) { Stop-Install "SENTINEL_PUBLIC_HOST is required" } if ($PublicHost.Length -gt 253 -or $PublicHost -eq "0.0.0.0" -or $PublicHost -notmatch "^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$") { Stop-Install "SENTINEL_PUBLIC_HOST must be a hostname or IPv4 address without scheme, path, or port" } if ($PublicHost.Contains("..") -or $PublicHost.Contains(".-") -or $PublicHost.Contains("-.")) { Stop-Install "SENTINEL_PUBLIC_HOST contains an invalid DNS label" } foreach ($label in $PublicHost.Split(".")) { if ($label.Length -gt 63) { Stop-Install "SENTINEL_PUBLIC_HOST contains a DNS label longer than 63 characters" } } if ($Image -notmatch "^[A-Za-z0-9][A-Za-z0-9._/:@-]*$") { Stop-Install "SENTINEL_IMAGE contains unsupported characters" } if ($ImageSha256) { $script:ImageSha256 = $ImageSha256.ToLowerInvariant() if ($ImageSha256 -notmatch "^[a-f0-9]{64}$") { Stop-Install "SENTINEL_IMAGE_SHA256 must be 64 hexadecimal characters" } } } function Invoke-Docker { param( [Parameter(Mandatory = $true)][string[]] $Arguments, [switch] $Capture, [switch] $AllowFailure ) $output = & docker @Arguments 2>&1 $code = $LASTEXITCODE if ($code -ne 0 -and -not $AllowFailure) { $detail = ($output | Out-String).Trim() Stop-Install "docker $($Arguments -join ' ') failed with exit code $code`n$detail" } if ($Capture) { return [pscustomobject]@{ ExitCode = $code Output = ($output | Out-String).TrimEnd() } } if ($output) { $output | Write-Host } return $code } function Test-ImageExists([string] $Name) { $result = Invoke-Docker -Arguments @("image", "inspect", $Name) -Capture -AllowFailure return $result.ExitCode -eq 0 } function Initialize-Docker { if (-not (Get-Command docker -ErrorAction SilentlyContinue)) { Write-Info "Docker is missing. Manual install:" Write-Info " winget install Docker.DockerDesktop" Write-Info " $DockerDesktopUrl" if ($env:SENTINEL_INSTALL_DOCKER -ne "1") { Stop-Install "install/start Docker Desktop and retry, or explicitly set SENTINEL_INSTALL_DOCKER=1" } if (-not (Get-Command winget -ErrorAction SilentlyContinue)) { Stop-Install "winget is unavailable; install Docker Desktop manually" } & winget install Docker.DockerDesktop --accept-package-agreements --accept-source-agreements if ($LASTEXITCODE -ne 0) { Stop-Install "winget failed with exit code $LASTEXITCODE" } Stop-Install "Docker Desktop was installed. Start it (or reboot), then rerun the installer" } $compose = Invoke-Docker -Arguments @("compose", "version") -Capture -AllowFailure if ($compose.ExitCode -ne 0) { Stop-Install "Docker is installed but 'docker compose' is unavailable" } $info = Invoke-Docker -Arguments @("info") -Capture -AllowFailure if ($info.ExitCode -ne 0) { Stop-Install "Docker is installed but its daemon is unavailable; start Docker Desktop and retry" } } function Get-ImageTar { if ($ImageTar -notmatch "^https?://") { if (-not (Test-Path -LiteralPath $ImageTar -PathType Leaf)) { Stop-Install "image tar not found: $ImageTar" } return [pscustomobject]@{ Path = (Resolve-Path $ImageTar).Path; Temporary = $false } } if (-not $ImageSha256 -and -not $AllowUnverifiedImage) { Stop-Install "SENTINEL_IMAGE_SHA256 is required for a remote image tar" } $temp = Join-Path ([IO.Path]::GetTempPath()) ( "sentinel-image-{0}.tar" -f [guid]::NewGuid().ToString("N") ) Write-Info "Downloading image tar" $lastError = $null for ($attempt = 1; $attempt -le 3; $attempt++) { try { Invoke-WebRequest -UseBasicParsing -Uri $ImageTar -OutFile $temp -TimeoutSec 1800 $lastError = $null break } catch { $lastError = $_ Remove-Item -LiteralPath $temp -Force -ErrorAction SilentlyContinue if ($attempt -lt 3) { Start-Sleep -Seconds (2 * $attempt) } } } if ($lastError) { Stop-Install "image download failed after 3 attempts: $lastError" } return [pscustomobject]@{ Path = $temp; Temporary = $true } } function Confirm-ImageTar([string] $Path) { $file = Get-Item -LiteralPath $Path if ($file.Length -eq 0) { Stop-Install "image tar is empty" } if ($ImageSha256) { $actual = (Get-FileHash -Algorithm SHA256 -LiteralPath $Path).Hash.ToLowerInvariant() if ($actual -ne $ImageSha256) { Stop-Install "image SHA-256 mismatch (expected $ImageSha256, got $actual)" } Write-Info "Image SHA-256 verified" } elseif ($AllowUnverifiedImage) { Write-Warning "Loading an unverified image tar" } } function Import-Image([string] $Path) { Write-Info "Loading image from $Path" $result = Invoke-Docker -Arguments @("load", "--input", $Path) -Capture Write-Info $result.Output $loaded = @() foreach ($line in $result.Output -split "`r?`n") { if ($line -match "^Loaded image(?: ID)?:\s+(.+)$") { $loaded += $Matches[1].Trim() } } if ($result.Output -match [regex]::Escape("Loaded image: $Image") -and (Test-ImageExists $Image)) { return } if ($loaded.Count -eq 0) { Stop-Install "docker load did not report a loaded image reference" } if ($loaded.Count -ne 1) { Stop-Install "image archive contains multiple image references; set SENTINEL_IMAGE to a tag contained in the archive" } $source = $loaded[-1] [void](Invoke-Docker -Arguments @("tag", $source, $Image)) if (-not (Test-ImageExists $Image)) { Stop-Install "failed to tag loaded image as $Image" } } function ConvertTo-NormalizedArchitecture([string] $Value) { switch ($Value.ToLowerInvariant()) { "x86_64" { return "amd64" } "amd64" { return "amd64" } "aarch64" { return "arm64" } "arm64" { return "arm64" } "armv7l" { return "arm" } default { return $Value.ToLowerInvariant() } } } function Confirm-Architecture { $serverResult = Invoke-Docker -Arguments @("info", "--format", "{{.Architecture}}") -Capture $imageResult = Invoke-Docker -Arguments @( "image", "inspect", $Image, "--format", "{{.Architecture}}" ) -Capture $server = ConvertTo-NormalizedArchitecture $serverResult.Output.Trim() $imageArch = ConvertTo-NormalizedArchitecture $imageResult.Output.Trim() if (-not $server -or -not $imageArch) { Stop-Install "could not determine Docker/image architecture" } if ($server -ne $imageArch) { Stop-Install "image architecture $imageArch does not match Docker host $server" } } function Get-ExistingEnvValue([string] $Path, [string] $Name) { if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return "" } $prefix = "$Name=" $value = "" foreach ($line in [IO.File]::ReadAllLines($Path)) { if ($line.StartsWith($prefix, [StringComparison]::Ordinal)) { $value = $line.Substring($prefix.Length) } } return $value } function Confirm-OciConfiguration { if (-not $OciDir) { return } if (-not (Test-Path -LiteralPath $OciDir -PathType Container)) { Stop-Install "SENTINEL_OCI_DIR is not a directory: $OciDir" } $config = Join-Path $OciDir "config" if (-not (Test-Path -LiteralPath $config -PathType Leaf)) { Stop-Install "OCI config is missing: $config" } $keyFile = "" foreach ($line in [IO.File]::ReadAllLines($config)) { if ($line -match "^\s*key_file\s*=\s*(.+?)\s*$") { $keyFile = $Matches[1] } } if (-not $keyFile) { Stop-Install "OCI config has no key_file" } if (-not $keyFile.StartsWith("/home/node/.oci/", [StringComparison]::Ordinal)) { Stop-Install "OCI key_file must use /home/node/.oci/.pem inside the container" } $relativeKey = $keyFile.Substring("/home/node/.oci/".Length) -replace "/", "\" $hostKey = Join-Path $OciDir $relativeKey if (-not (Test-Path -LiteralPath $hostKey -PathType Leaf)) { Stop-Install "OCI key is missing: $hostKey" } } function ConvertTo-YamlQuotedValue([string] $Value) { return "'" + $Value.Replace("'", "''") + "'" } function Write-Utf8NoBom([string] $Path, [string[]] $Lines) { $encoding = New-Object Text.UTF8Encoding($false) [IO.File]::WriteAllLines($Path, $Lines, $encoding) } function New-RequestedEnvironment { $values = [ordered]@{ SENTINEL_HOST = "0.0.0.0" SENTINEL_PUBLIC_HOST = $PublicHost SENTINEL_PORT = $Port SENTINEL_MODE = "production" SENTINEL_DATA_DIR = "/data" SENTINEL_IMAGE = $Image } $optional = @( "SENTINEL_AGENT_INVOCATIONS", "SENTINEL_REMOTE_MUTATIONS", "SENTINEL_SCHEDULE_CREATION", "SENTINEL_DESTRUCTIVE_OPS", "SENTINEL_AIDP_REGION", "SENTINEL_AIDP_DATA_LAKE_OCID", "SENTINEL_AIDP_API_VERSION", "SENTINEL_AIDP_WORKBENCH_API_VERSION", "SENTINEL_AIDP_WORKSPACE_KEY", "SENTINEL_AIDP_REGISTRY_OPS_WORKSPACE_KEY", "SENTINEL_AIDP_API_BASE_DOMAIN", "SENTINEL_AIDP_REQUEST_TIMEOUT_MS", "SENTINEL_OCI_PROFILE", "SENTINEL_OCI_COMPARTMENT_OCID", "SENTINEL_REGISTRY_BEARER_TOKENS_FILE" ) foreach ($name in $optional) { $value = [Environment]::GetEnvironmentVariable($name) if (-not [string]::IsNullOrWhiteSpace($value)) { Assert-SingleLine $name $value $values[$name] = $value } } if ($OciDir) { $values["SENTINEL_OCI_DIR"] = $OciDir $values["SENTINEL_OCI_CONFIG_PATH"] = if ($env:SENTINEL_OCI_CONFIG_PATH) { $env:SENTINEL_OCI_CONFIG_PATH } else { "/home/node/.oci/config" } } return $values } function Install-ConfigurationFiles { New-Item -ItemType Directory -Force -Path $HomeDir | Out-Null $envFile = Join-Path $HomeDir ".env" $composeFile = Join-Path $HomeDir "docker-compose.yml" $requested = New-RequestedEnvironment $output = New-Object Collections.Generic.List[string] $used = @{} if ((Test-Path -LiteralPath $envFile) -and -not $Force) { foreach ($line in [IO.File]::ReadAllLines($envFile)) { if ($line -match "^([A-Za-z_][A-Za-z0-9_]*)=" -and $requested.Contains($Matches[1])) { $key = $Matches[1] $output.Add("$key=$($requested[$key])") $used[$key] = $true } else { $output.Add($line) } } } else { $output.Add("# Generated by Sentinel installer. Firewall-internal only.") } foreach ($entry in $requested.GetEnumerator()) { if (-not $used.ContainsKey($entry.Key)) { $output.Add("$($entry.Key)=$($entry.Value)") } } $timestamp = Get-Date -Format "yyyyMMddHHmmss" if (Test-Path -LiteralPath $envFile) { Copy-Item -LiteralPath $envFile -Destination "$envFile.backup.$timestamp" } if (Test-Path -LiteralPath $composeFile) { Copy-Item -LiteralPath $composeFile -Destination "$composeFile.backup.$timestamp" } Write-Utf8NoBom $envFile $output.ToArray() $compose = New-Object Collections.Generic.List[string] @( "services:", " sentinel:", " image: $(ConvertTo-YamlQuotedValue $Image)", " ports:", " - `"${Port}:${Port}`"", " environment:", " SENTINEL_HOST: `"0.0.0.0`"", " SENTINEL_PUBLIC_HOST: $(ConvertTo-YamlQuotedValue $PublicHost)", " SENTINEL_PORT: `"$Port`"", " SENTINEL_MODE: production", " SENTINEL_DATA_DIR: /data", " env_file: .env", " volumes:", " - sentinel-data:/data" ) | ForEach-Object { $compose.Add($_) } if ($OciDir) { $compose.Add(" - type: bind") $compose.Add(" source: $(ConvertTo-YamlQuotedValue (($OciDir -replace '\\', '/')))") $compose.Add(" target: /home/node/.oci") $compose.Add(" read_only: true") } $compose.Add("") $compose.Add("volumes:") $compose.Add(" sentinel-data:") Write-Utf8NoBom $composeFile $compose.ToArray() } function Show-ComposeDiagnostics { Push-Location $HomeDir try { [void](Invoke-Docker -Arguments @("compose", "ps") -AllowFailure) [void](Invoke-Docker -Arguments @( "compose", "logs", "--no-color", "--tail", "100", "sentinel" ) -AllowFailure) } finally { Pop-Location } } function Wait-SentinelHealthy { Push-Location $HomeDir try { $idResult = Invoke-Docker -Arguments @("compose", "ps", "-q", "sentinel") -Capture $containerId = $idResult.Output.Trim() if (-not $containerId) { Stop-Install "Compose did not create the Sentinel container" } $deadline = [DateTime]::UtcNow.AddSeconds([int] $HealthTimeout) while ([DateTime]::UtcNow -lt $deadline) { $state = Invoke-Docker -Arguments @( "inspect", "--format", "{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}", $containerId ) -Capture switch ($state.Output.Trim()) { "healthy" { return } "running" { return } "unhealthy" { Stop-Install "Sentinel container is unhealthy" } "exited" { Stop-Install "Sentinel container exited" } "dead" { Stop-Install "Sentinel container is dead" } } Start-Sleep -Seconds 2 } Stop-Install "Sentinel did not become healthy within $HealthTimeout seconds" } finally { Pop-Location } } try { Initialize-Docker if ([string]::IsNullOrWhiteSpace($PublicHost)) { $PublicHost = Read-Host "Public host clients will open [127.0.0.1]" if ([string]::IsNullOrWhiteSpace($PublicHost)) { $PublicHost = "127.0.0.1" } } if (-not $OciDir) { $OciDir = Get-ExistingEnvValue (Join-Path $HomeDir ".env") "SENTINEL_OCI_DIR" } if (-not $ImageTar -and -not (Test-ImageExists $Image)) { $ImageTar = Read-Host "Path or URL of docker save tar" } Assert-Inputs Confirm-OciConfiguration # Supplying a tar always updates the image, even if the tag already exists. if ($ImageTar) { $tar = Get-ImageTar try { Confirm-ImageTar $tar.Path Import-Image $tar.Path } finally { if ($tar -and $tar.Temporary) { Remove-Item -LiteralPath $tar.Path -Force -ErrorAction SilentlyContinue } } } elseif (-not (Test-ImageExists $Image)) { Stop-Install "image $Image is absent; set SENTINEL_IMAGE_TAR or run docker load -i " } Confirm-Architecture Install-ConfigurationFiles Write-Info "Starting Sentinel in $HomeDir" Push-Location $HomeDir try { [void](Invoke-Docker -Arguments @("compose", "up", "--detach")) } finally { Pop-Location } Wait-SentinelHealthy Write-Info "" Write-Info "Sentinel is healthy: http://${PublicHost}:${Port}" Write-Info "Firewall-internal only: anyone who loads GET / receives a session cookie." } catch { [Console]::Error.WriteLine($_.ToString()) if ((Get-Command docker -ErrorAction SilentlyContinue) -and (Test-Path -LiteralPath (Join-Path $HomeDir "docker-compose.yml") -PathType Leaf)) { Show-ComposeDiagnostics } exit 1 }