mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Align Windows agent onboarding with seamless install flow
This commit is contained in:
@@ -127,11 +127,21 @@ reverse.
|
||||
15. `frontend-modern/src/components/Settings/useInfrastructureOperationsState.tsx` shared with `api-contracts`: the shared infrastructure operations state hook is both an agent fleet lifecycle control surface and an API token, lookup, assignment, and reporting/install contract boundary.
|
||||
16. `frontend-modern/src/components/Settings/useNodeModalState.ts` shared with `api-contracts`: the node setup modal state hook is both an agent lifecycle control surface and a shared API-backed install/setup contract boundary.
|
||||
17. `frontend-modern/src/utils/agentInstallCommand.ts` shared with `api-contracts`: the shared frontend install-command helper is both an agent lifecycle control surface and a canonical API/install transport contract boundary.
|
||||
Operator-facing agent install commands must preflight the selected
|
||||
OS/architecture download before they mutate the target host, and generated
|
||||
commands must pass enrollment secrets through short-lived token files
|
||||
rather than long-lived service/environment arguments. Windows, macOS, and
|
||||
Linux commands must keep custom CA, insecure/plain-HTTP, and optional-auth
|
||||
behavior aligned so the Machines onboarding path does not diverge by OS.
|
||||
18. `frontend-modern/src/utils/infrastructureSettingsPresentation.ts` shared with `api-contracts`: the infrastructure settings presentation helper is both an agent lifecycle control surface and an API-backed direct-node/discovery settings boundary.
|
||||
19. `internal/api/agent_install_command_shared.go` shared with `api-contracts`: agent install command assembly is both an agent lifecycle control surface and a canonical API payload contract boundary.
|
||||
20. `internal/api/config_setup_handlers.go` shared with `api-contracts`: auto-register and setup handlers are both an agent lifecycle control surface and a canonical API payload contract boundary.
|
||||
21. `internal/api/unified_agent.go` shared with `api-contracts`: unified agent download and installer handlers are both an agent lifecycle control surface and a canonical API payload contract boundary.
|
||||
22. `scripts/install.ps1` shared with `deployment-installability`: the Windows installer is both a deployment installability entry point and a canonical agent lifecycle runtime continuity boundary.
|
||||
The Windows installer must support a non-mutating download preflight that
|
||||
can run before Administrator-only install work, must accept token-file
|
||||
enrollment input, and must persist plain-HTTP/insecure runtime continuity
|
||||
consistently with the Unix installer.
|
||||
23. `scripts/install.sh` shared with `deployment-installability`: the shell installer is both a deployment installability entry point and a canonical agent lifecycle runtime continuity boundary.
|
||||
|
||||
Agent lifecycle and fleet-operation surfaces may consume
|
||||
|
||||
@@ -261,6 +261,12 @@ platform page needs source-native backup columns.
|
||||
rather than generic `container` copy, because those labels surface directly
|
||||
in token presets, custom scopes, and inventory badges.
|
||||
28. `frontend-modern/src/utils/agentInstallCommand.ts` shared with `agent-lifecycle`: the shared frontend install-command helper is both an agent lifecycle control surface and a canonical API/install transport contract boundary.
|
||||
Generated install commands are part of the API contract because they bind
|
||||
the UI-selected Pulse URL, token source, custom CA, insecure/plain-HTTP
|
||||
behavior, and `/download/pulse-agent?arch=...` availability proof into the
|
||||
installer invocation. Windows, macOS, and Linux commands must therefore
|
||||
preflight the exact platform artifact and avoid raw token process
|
||||
arguments.
|
||||
29. `frontend-modern/src/utils/apiTokenPresentation.ts` shared with `security-privacy`: the API token presentation helper is both a security/privacy control surface and a canonical API token management boundary.
|
||||
It owns the operator-facing Docker / Podman token vocabulary used by API
|
||||
Access, token presets, usage summaries, and revoke warnings.
|
||||
|
||||
@@ -116,6 +116,10 @@ server-side update execution surfaces.
|
||||
host storage while they remain running.
|
||||
5. `internal/cloudcp/tenant_runtime_rollout.go` shared with `cloud-paid`: hosted tenant runtime rollout is both a Pulse Cloud runtime contract boundary and a deployment-installability release-rollout boundary.
|
||||
6. `scripts/install.ps1` shared with `agent-lifecycle`: the Windows installer is both a deployment installability entry point and a canonical agent lifecycle runtime continuity boundary.
|
||||
It must expose a non-mutating preflight for the exact Windows agent
|
||||
architecture before Administrator-only install changes, accept token-file
|
||||
enrollment input, and avoid interactive download-failure prompts when
|
||||
launched by generated non-interactive onboarding commands.
|
||||
7. `scripts/install.sh` shared with `agent-lifecycle`: the shell installer is both a deployment installability entry point and a canonical agent lifecycle runtime continuity boundary.
|
||||
|
||||
## Extension Points
|
||||
|
||||
+14
@@ -277,6 +277,10 @@ describe('infrastructure operations model', () => {
|
||||
expect(infrastructureOperationsModelSource).toContain(
|
||||
'preflights this Pulse URL, verifies the matching agent binary is available',
|
||||
);
|
||||
expect(infrastructureOperationsModelSource).toContain(
|
||||
'verifies the matching Windows agent binary is available',
|
||||
);
|
||||
expect(infrastructureOperationsModelSource).toContain('token-file handoff');
|
||||
expect(infrastructureOperationsModelSource).toContain('macOS may ask for your');
|
||||
expect(infrastructureOperationsModelSource).toContain('admin password');
|
||||
});
|
||||
@@ -331,6 +335,16 @@ describe('infrastructure operations model', () => {
|
||||
expect(operationsStateSource).not.toContain('useInfrastructureReportingState');
|
||||
});
|
||||
|
||||
it('routes Windows upgrade commands through the shared seamless installer command builder', async () => {
|
||||
const operationsStateSource = await import('../useInfrastructureOperationsState?raw').then(
|
||||
(mod) => (mod as { default: string }).default,
|
||||
);
|
||||
|
||||
expect(operationsStateSource).toContain('buildWindowsAgentInstallCommand({');
|
||||
expect(operationsStateSource).toContain('extraEnvAssignments: envAssignments');
|
||||
expect(operationsStateSource).not.toContain('const tokenEnv = token ?');
|
||||
});
|
||||
|
||||
it('keeps discovered-node filtering anchored to canonical represented-host dedupe', async () => {
|
||||
const discoveryStateSource = await import('../useInfrastructureDiscoveryRuntimeState?raw').then(
|
||||
(mod) => (mod as { default: string }).default,
|
||||
|
||||
@@ -392,7 +392,7 @@ export const buildCommandsByPlatform = (
|
||||
windows: {
|
||||
title: 'Install on Windows',
|
||||
description:
|
||||
'Run the PowerShell script to install and configure the unified agent as a Windows service with automatic startup.',
|
||||
'The PowerShell installer preflights this Pulse URL, verifies the matching Windows agent binary is available, and configures the agent as a Windows service with automatic startup.',
|
||||
snippets: [
|
||||
{
|
||||
label: 'Install as Windows Service (PowerShell)',
|
||||
@@ -400,8 +400,8 @@ export const buildCommandsByPlatform = (
|
||||
note: (
|
||||
<span>
|
||||
Run in PowerShell as Administrator. The script will prompt for the Pulse URL and API
|
||||
token, download the agent binary, and install it as a Windows service with automatic
|
||||
startup.
|
||||
token, preflight the matching agent binary, and install it as a Windows service with
|
||||
automatic startup.
|
||||
</span>
|
||||
),
|
||||
},
|
||||
@@ -410,7 +410,8 @@ export const buildCommandsByPlatform = (
|
||||
command: windowsParameterizedCommand,
|
||||
note: (
|
||||
<span>
|
||||
Non-interactive installation. Set environment variables before running to skip prompts.
|
||||
Non-interactive installation with token-file handoff and the same download preflight as
|
||||
the interactive command.
|
||||
</span>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createContext, useContext, type ParentComponent } from 'solid-js';
|
||||
import {
|
||||
buildPowerShellInstallScriptBootstrap,
|
||||
buildWindowsAgentInstallCommand,
|
||||
powerShellQuote,
|
||||
} from '@/utils/agentInstallCommand';
|
||||
import {
|
||||
@@ -129,9 +130,13 @@ export const useInfrastructureOperationsState = (
|
||||
if (hostname) {
|
||||
envAssignments.push(`$env:PULSE_HOSTNAME="${powerShellQuote(hostname)}"`);
|
||||
}
|
||||
const prefix = envAssignments.length > 0 ? `${envAssignments.join('; ')}; ` : '';
|
||||
const tokenEnv = token ? `$env:PULSE_TOKEN="${powerShellQuote(token)}"; ` : '';
|
||||
return `${prefix}$env:PULSE_URL="${powerShellQuote(url)}"; ${tokenEnv}${buildPowerShellInstallScriptBootstrap(url)}`;
|
||||
return buildWindowsAgentInstallCommand({
|
||||
baseUrl: url,
|
||||
token,
|
||||
insecure: installState.insecureMode(),
|
||||
caCertPath: selectedCustomCaPath(),
|
||||
extraEnvAssignments: envAssignments,
|
||||
});
|
||||
}
|
||||
let command = `curl ${getCurlFlags()}${getShellCustomCaCurlFlag()} ${shellQuoteArg(`${url}/install.sh`)} | bash -s -- --url ${shellQuoteArg(url)}`;
|
||||
if (token) {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
buildPowerShellInstallScriptBootstrap,
|
||||
buildUnixAgentInstallCommand,
|
||||
buildWindowsAgentInstallCommand,
|
||||
normalizeInstallerBaseUrl,
|
||||
@@ -127,11 +126,21 @@ describe('agentInstallCommand', () => {
|
||||
caCertPath: 'C:\\Pulse\\custom-ca.cer',
|
||||
});
|
||||
|
||||
expect(command).toContain('$env:PULSE_URL="https://pulse.example/base"');
|
||||
expect(command).toContain('$env:PULSE_TOKEN="token-123"');
|
||||
expect(command).toContain('$env:PULSE_INSECURE_SKIP_VERIFY="true"');
|
||||
expect(command).toContain('$env:PULSE_CACERT="C:\\Pulse\\custom-ca.cer"');
|
||||
expect(command).toContain(
|
||||
'$pulseTmp=Join-Path ([System.IO.Path]::GetTempPath()) ("pulse-agent-install-"+[System.Guid]::NewGuid().ToString("N"))',
|
||||
);
|
||||
expect(command).toContain('$pulseScriptUrl="https://pulse.example/base/install.ps1"');
|
||||
expect(command).toContain(
|
||||
'[System.IO.File]::WriteAllText($pulseTokenFile, "token-123", [System.Text.Encoding]::ASCII)',
|
||||
);
|
||||
expect(command).toContain('-TokenFile $pulseTokenFile');
|
||||
expect(command).toContain('-PreflightOnly $true');
|
||||
expect(command).toContain('-Output "json"');
|
||||
expect(command).toContain('-NonInteractive $true');
|
||||
expect(command).toContain('-Insecure $true');
|
||||
expect(command).toContain('-CACertPath "C:\\Pulse\\custom-ca.cer"');
|
||||
expect(command).toContain('Invoke-WebRequest -Uri $pulseScriptUrl -UseBasicParsing -OutFile $pulseInstallScript');
|
||||
expect(command).not.toContain('$env:PULSE_TOKEN=');
|
||||
});
|
||||
|
||||
it('supports tokenless shared Windows install transport for optional auth', () => {
|
||||
@@ -140,9 +149,11 @@ describe('agentInstallCommand', () => {
|
||||
token: null,
|
||||
});
|
||||
|
||||
expect(command).toContain('$env:PULSE_URL="https://pulse.example"');
|
||||
expect(command).toContain('$pulseScriptUrl="https://pulse.example/install.ps1"');
|
||||
expect(command).not.toContain('$env:PULSE_TOKEN=');
|
||||
expect(command).toContain(buildPowerShellInstallScriptBootstrap('https://pulse.example'));
|
||||
expect(command).not.toContain('-TokenFile $pulseTokenFile');
|
||||
expect(command).toContain('-PreflightOnly $true');
|
||||
expect(command).toContain('-NonInteractive $true');
|
||||
});
|
||||
|
||||
it('fails closed when the install endpoint URL is blank', () => {
|
||||
@@ -183,9 +194,22 @@ describe('agentInstallCommand', () => {
|
||||
],
|
||||
});
|
||||
|
||||
expect(command).toContain('$env:PULSE_TOKEN="token-123"');
|
||||
expect(command).not.toContain('$env:PULSE_TOKEN="token-123"');
|
||||
expect(command).toContain('-TokenFile $pulseTokenFile');
|
||||
expect(command).toContain('$env:PULSE_ENABLE_PROXMOX="true"');
|
||||
expect(command).toContain('$env:PULSE_PROXMOX_TYPE="pbs"');
|
||||
expect(command).toContain('$env:PULSE_ENABLE_COMMANDS="true"');
|
||||
});
|
||||
|
||||
it('passes insecure runtime continuity for plain-http Windows installs', () => {
|
||||
const command = buildWindowsAgentInstallCommand({
|
||||
baseUrl: 'http://pulse.example:7655',
|
||||
token: 'token-123',
|
||||
});
|
||||
|
||||
expect(command).toContain('-Url "http://pulse.example:7655"');
|
||||
expect(command).toContain('-Insecure $true');
|
||||
expect(command).toContain('-PreflightOnly $true');
|
||||
expect(command).not.toContain('$env:PULSE_TOKEN=');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -139,18 +139,48 @@ export const buildWindowsAgentInstallCommand = ({
|
||||
}
|
||||
const normalizedToken = (token || '').trim();
|
||||
const normalizedCaCertPath = (caCertPath || '').trim();
|
||||
const envAssignments = [`$env:PULSE_URL="${powerShellQuote(normalizedBaseUrl)}"`];
|
||||
const installRequiresInsecure = insecure || normalizedBaseUrl.startsWith('http://');
|
||||
const normalizedExtraEnvAssignments = extraEnvAssignments.filter(
|
||||
(assignment) => assignment.trim().length > 0,
|
||||
);
|
||||
const installerFetchRequiresCustomTrust = insecure || Boolean(normalizedCaCertPath);
|
||||
const scriptUrl = powerShellQuote(`${normalizedBaseUrl}/install.ps1`);
|
||||
const installArgs = [
|
||||
`-Url "${powerShellQuote(normalizedBaseUrl)}"`,
|
||||
...(normalizedToken ? ['-TokenFile $pulseTokenFile'] : []),
|
||||
...(installRequiresInsecure ? ['-Insecure $true'] : []),
|
||||
...(normalizedCaCertPath ? [`-CACertPath "${powerShellQuote(normalizedCaCertPath)}"`] : []),
|
||||
'-NonInteractive $true',
|
||||
];
|
||||
const preflightArgs = [...installArgs, '-PreflightOnly $true', '-Output "json"'];
|
||||
const customTrustFetch = installerFetchRequiresCustomTrust
|
||||
? `$pulseCustomCa=$null; if (-not [string]::IsNullOrWhiteSpace($pulseCaCertPath)) { $pulseCustomCaBytes=[System.IO.File]::ReadAllBytes($pulseCaCertPath); $pulseCustomCaText=[System.Text.Encoding]::ASCII.GetString($pulseCustomCaBytes); if ($pulseCustomCaText.Contains("-----BEGIN CERTIFICATE-----")) { $pulseCustomCa=[System.Security.Cryptography.X509Certificates.X509Certificate2]::CreateFromPem($pulseCustomCaText) } else { $pulseCustomCa=[System.Security.Cryptography.X509Certificates.X509Certificate2]::new($pulseCustomCaBytes) } }; $pulsePrev=[System.Net.ServicePointManager]::ServerCertificateValidationCallback; try { [System.Net.ServicePointManager]::ServerCertificateValidationCallback={ param($sender,$certificate,$chain,$sslPolicyErrors) if ($pulseAllowInsecure) { return $true }; if ($null -eq $pulseCustomCa) { return $sslPolicyErrors -eq [System.Net.Security.SslPolicyErrors]::None }; if ($null -eq $certificate) { return $false }; $pulseChain=[System.Security.Cryptography.X509Certificates.X509Chain]::new(); $pulseChain.ChainPolicy.RevocationMode=[System.Security.Cryptography.X509Certificates.X509RevocationMode]::NoCheck; $null=$pulseChain.ChainPolicy.ExtraStore.Add($pulseCustomCa); $null=$pulseChain.Build($certificate); foreach ($pulseElement in $pulseChain.ChainElements) { if ($pulseElement.Certificate.Thumbprint -eq $pulseCustomCa.Thumbprint) { return $true } }; return $false }; Invoke-WebRequest -Uri $pulseScriptUrl -UseBasicParsing -OutFile $pulseInstallScript } finally { [System.Net.ServicePointManager]::ServerCertificateValidationCallback=$pulsePrev }`
|
||||
: `Invoke-WebRequest -Uri $pulseScriptUrl -UseBasicParsing -OutFile $pulseInstallScript`;
|
||||
|
||||
if (normalizedToken) {
|
||||
envAssignments.push(`$env:PULSE_TOKEN="${powerShellQuote(normalizedToken)}"`);
|
||||
}
|
||||
if (insecure) {
|
||||
envAssignments.push('$env:PULSE_INSECURE_SKIP_VERIFY="true"');
|
||||
}
|
||||
if (normalizedCaCertPath) {
|
||||
envAssignments.push(`$env:PULSE_CACERT="${powerShellQuote(normalizedCaCertPath)}"`);
|
||||
}
|
||||
envAssignments.push(...extraEnvAssignments.filter((assignment) => assignment.trim().length > 0));
|
||||
const tokenBootstrap = normalizedToken
|
||||
? `$pulseTokenFile=Join-Path $pulseTmp "token"; [System.IO.File]::WriteAllText($pulseTokenFile, "${powerShellQuote(normalizedToken)}", [System.Text.Encoding]::ASCII); `
|
||||
: '';
|
||||
const extraEnvBootstrap = normalizedExtraEnvAssignments.length
|
||||
? `${normalizedExtraEnvAssignments.join('; ')}; `
|
||||
: '';
|
||||
|
||||
return `${envAssignments.join('; ')}; ${buildPowerShellInstallScriptBootstrap(normalizedBaseUrl)}`;
|
||||
return (
|
||||
`& { $ErrorActionPreference="Stop"; ` +
|
||||
`$pulseTmp=Join-Path ([System.IO.Path]::GetTempPath()) ("pulse-agent-install-"+[System.Guid]::NewGuid().ToString("N")); ` +
|
||||
`New-Item -ItemType Directory -Force -Path $pulseTmp | Out-Null; ` +
|
||||
`$pulseInstallScript=Join-Path $pulseTmp "install.ps1"; ` +
|
||||
`$pulseScriptUrl="${scriptUrl}"; ` +
|
||||
`$pulseAllowInsecure=${insecure ? '$true' : '$false'}; ` +
|
||||
`$pulseCaCertPath="${powerShellQuote(normalizedCaCertPath)}"; ` +
|
||||
`try { ` +
|
||||
`${customTrustFetch}; ` +
|
||||
`${tokenBootstrap}` +
|
||||
`${extraEnvBootstrap}` +
|
||||
`$pulsePowerShell=(Get-Process -Id $PID).Path; ` +
|
||||
`& $pulsePowerShell -NoProfile -ExecutionPolicy Bypass -File $pulseInstallScript ${preflightArgs.join(' ')}; ` +
|
||||
`if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }; ` +
|
||||
`& $pulsePowerShell -NoProfile -ExecutionPolicy Bypass -File $pulseInstallScript ${installArgs.join(' ')}; ` +
|
||||
`if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } ` +
|
||||
`} finally { Remove-Item -LiteralPath $pulseTmp -Recurse -Force -ErrorAction SilentlyContinue } }`
|
||||
);
|
||||
};
|
||||
|
||||
+79
-5
@@ -20,7 +20,11 @@ param (
|
||||
[bool]$Uninstall = $false,
|
||||
[string]$CACertPath = $env:PULSE_CACERT,
|
||||
[string]$AgentId = $env:PULSE_AGENT_ID,
|
||||
[string]$Hostname = $env:PULSE_HOSTNAME
|
||||
[string]$Hostname = $env:PULSE_HOSTNAME,
|
||||
[string]$TokenFile = $env:PULSE_TOKEN_FILE,
|
||||
[bool]$PreflightOnly = $false,
|
||||
[string]$Output = $env:PULSE_OUTPUT,
|
||||
[bool]$NonInteractive = $false
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
@@ -85,6 +89,12 @@ if (-not $PSBoundParameters.ContainsKey('Insecure') -and -not [string]::IsNullOr
|
||||
if (-not $PSBoundParameters.ContainsKey('Uninstall') -and -not [string]::IsNullOrWhiteSpace($env:PULSE_UNINSTALL)) {
|
||||
$Uninstall = Parse-Bool $env:PULSE_UNINSTALL $Uninstall
|
||||
}
|
||||
if (-not $PSBoundParameters.ContainsKey('PreflightOnly') -and -not [string]::IsNullOrWhiteSpace($env:PULSE_PREFLIGHT_ONLY)) {
|
||||
$PreflightOnly = Parse-Bool $env:PULSE_PREFLIGHT_ONLY $PreflightOnly
|
||||
}
|
||||
if (-not $PSBoundParameters.ContainsKey('NonInteractive') -and -not [string]::IsNullOrWhiteSpace($env:PULSE_NON_INTERACTIVE)) {
|
||||
$NonInteractive = Parse-Bool $env:PULSE_NON_INTERACTIVE $NonInteractive
|
||||
}
|
||||
|
||||
# Docker-only installs should not silently fall back to host metrics unless the
|
||||
# caller explicitly opts back in.
|
||||
@@ -94,7 +104,7 @@ if ($EnableDocker -and -not $PSBoundParameters.ContainsKey('EnableHost') -and [s
|
||||
|
||||
# --- Administrator Check ---
|
||||
$isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
|
||||
if (-not $isAdmin) {
|
||||
if (-not $isAdmin -and -not $PreflightOnly) {
|
||||
Write-Host "ERROR: This script must be run as Administrator" -ForegroundColor Red
|
||||
Write-Host "Right-click PowerShell and select 'Run as Administrator'" -ForegroundColor Yellow
|
||||
Exit 1
|
||||
@@ -126,6 +136,22 @@ function Show-Error {
|
||||
}
|
||||
}
|
||||
|
||||
function Write-InstallerEvent {
|
||||
param(
|
||||
[string]$Phase,
|
||||
[string]$Code,
|
||||
[string]$Message,
|
||||
[int]$ExitCode = 0
|
||||
)
|
||||
|
||||
if ($Output -eq "json") {
|
||||
@{ phase = $Phase; code = $Code; message = $Message; exitCode = $ExitCode } | ConvertTo-Json -Compress
|
||||
return
|
||||
}
|
||||
|
||||
Write-Host $Message
|
||||
}
|
||||
|
||||
function Test-ValidUrl {
|
||||
param([string]$TestUrl)
|
||||
if ([string]::IsNullOrWhiteSpace($TestUrl)) { return $false }
|
||||
@@ -515,6 +541,20 @@ if ($Uninstall) {
|
||||
Exit 0
|
||||
}
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($Token) -and -not [string]::IsNullOrWhiteSpace($TokenFile)) {
|
||||
try {
|
||||
$resolvedTokenFile = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($TokenFile)
|
||||
if (-not (Test-Path $resolvedTokenFile)) {
|
||||
Show-Error "Invalid token file. File does not exist.`nProvided: $TokenFile"
|
||||
Exit 1
|
||||
}
|
||||
$Token = (Get-Content -Path $resolvedTokenFile -Raw -ErrorAction Stop).Trim()
|
||||
} catch {
|
||||
Show-Error "Failed to read token file.`nProvided: $TokenFile`nError: $_"
|
||||
Exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# --- Input Validation ---
|
||||
Write-Host "Validating parameters..." -ForegroundColor Cyan
|
||||
|
||||
@@ -559,6 +599,10 @@ if (-not [string]::IsNullOrWhiteSpace($NormalizedProxmoxType) -and $NormalizedPr
|
||||
|
||||
# Normalize URL (remove trailing slash)
|
||||
$Url = $Url.TrimEnd('/')
|
||||
if ($Url.ToLowerInvariant().StartsWith("http://") -and -not $Insecure) {
|
||||
Write-Host "Plain HTTP Pulse URL detected; enabling insecure mode for persisted agent update checks." -ForegroundColor Yellow
|
||||
$Insecure = $true
|
||||
}
|
||||
|
||||
# --- Download ---
|
||||
# Determine architecture
|
||||
@@ -573,6 +617,34 @@ if ($processorArch -eq "ARM64" -or $processorArch64 -eq "ARM64") {
|
||||
}
|
||||
$ArchParam = "windows-$Arch"
|
||||
$DownloadUrl = "$Url/download/pulse-agent?arch=$ArchParam"
|
||||
|
||||
function Invoke-AgentDownloadPreflight {
|
||||
param([string]$Uri)
|
||||
|
||||
try {
|
||||
$preflightResponse = $null
|
||||
Invoke-WithOptionalInsecureTls -AllowInsecure $Insecure -CustomCaCertificate $CustomCaCertificate -Action {
|
||||
$preflightResponse = Invoke-WebRequest -Uri $Uri -Method Head -UseBasicParsing -TimeoutSec 10 -ErrorAction Stop
|
||||
}
|
||||
|
||||
$checksum = $preflightResponse.Headers["X-Checksum-Sha256"]
|
||||
if ([string]::IsNullOrWhiteSpace($checksum)) {
|
||||
Write-InstallerEvent -Phase "preflight" -Code "agent_download_checksum_missing" -Message "Agent download exists but did not include a checksum header: $Uri" -ExitCode 12
|
||||
Exit 12
|
||||
}
|
||||
|
||||
Write-InstallerEvent -Phase "preflight" -Code "agent_download_available" -Message "Agent download is available for $ArchParam." -ExitCode 0
|
||||
} catch {
|
||||
Write-InstallerEvent -Phase "preflight" -Code "agent_download_unavailable" -Message "Agent download is not available for $ArchParam at $Uri. $_" -ExitCode 11
|
||||
Exit 11
|
||||
}
|
||||
}
|
||||
|
||||
if ($PreflightOnly) {
|
||||
Invoke-AgentDownloadPreflight $DownloadUrl
|
||||
return
|
||||
}
|
||||
|
||||
Write-Host "Downloading agent from $DownloadUrl..." -ForegroundColor Cyan
|
||||
|
||||
if (-not (Test-Path $InstallDir)) {
|
||||
@@ -613,9 +685,11 @@ try {
|
||||
} catch {
|
||||
Cleanup
|
||||
Show-Error "Failed to download agent: $_"
|
||||
Write-Host ""
|
||||
Write-Host "Press Enter to exit..." -ForegroundColor Yellow
|
||||
Read-Host
|
||||
if (-not $NonInteractive) {
|
||||
Write-Host ""
|
||||
Write-Host "Press Enter to exit..." -ForegroundColor Yellow
|
||||
Read-Host
|
||||
}
|
||||
Exit 1
|
||||
} finally {
|
||||
if ($webClient) { $webClient.Dispose() }
|
||||
|
||||
@@ -54,7 +54,10 @@ func TestInstallPS1AllowsMissingTokenForOptionalAuth(t *testing.T) {
|
||||
|
||||
script := string(content)
|
||||
required := []string{
|
||||
`[string]$TokenFile = $env:PULSE_TOKEN_FILE,`,
|
||||
`if (-not [string]::IsNullOrWhiteSpace($Token) -and -not (Test-ValidToken $Token)) {`,
|
||||
`if ([string]::IsNullOrWhiteSpace($Token) -and -not [string]::IsNullOrWhiteSpace($TokenFile)) {`,
|
||||
`$Token = (Get-Content -Path $resolvedTokenFile -Raw -ErrorAction Stop).Trim()`,
|
||||
`function Write-RuntimeTokenFile {`,
|
||||
`if (-not [string]::IsNullOrWhiteSpace($Token)) { $ServiceArgs += @("--token-file", "` + "`" + `"$TokenFilePath` + "`" + `"") }`,
|
||||
}
|
||||
@@ -142,6 +145,8 @@ func TestInstallPS1UsesInsecureTlsForRuntimeTransport(t *testing.T) {
|
||||
`if ($AllowInsecure -or $null -ne $CustomCaCertificate) {`,
|
||||
`if ($AllowInsecure) {`,
|
||||
`return Test-CertificateTrustedByCustomCa -Certificate $certificate -CustomCaCertificate $CustomCaCertificate`,
|
||||
`if ($Url.ToLowerInvariant().StartsWith("http://") -and -not $Insecure) {`,
|
||||
`Plain HTTP Pulse URL detected; enabling insecure mode for persisted agent update checks.`,
|
||||
`Invoke-WithOptionalInsecureTls -AllowInsecure $Insecure -CustomCaCertificate $CustomCaCertificate -Action {`,
|
||||
`Invoke-RestMethod @invokeArgs | Out-Null`,
|
||||
`$downloadTask = $webClient.DownloadFileTaskAsync($DownloadUrl, $TempPath)`,
|
||||
@@ -153,6 +158,36 @@ func TestInstallPS1UsesInsecureTlsForRuntimeTransport(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallPS1SupportsDownloadPreflightBeforeAdministratorInstall(t *testing.T) {
|
||||
content, err := os.ReadFile(repoFile("scripts", "install.ps1"))
|
||||
if err != nil {
|
||||
t.Fatalf("read install.ps1: %v", err)
|
||||
}
|
||||
|
||||
script := string(content)
|
||||
required := []string{
|
||||
`[bool]$PreflightOnly = $false,`,
|
||||
`[string]$Output = $env:PULSE_OUTPUT,`,
|
||||
`[bool]$NonInteractive = $false`,
|
||||
`if (-not $isAdmin -and -not $PreflightOnly) {`,
|
||||
`function Write-InstallerEvent {`,
|
||||
`function Invoke-AgentDownloadPreflight {`,
|
||||
`Invoke-WebRequest -Uri $Uri -Method Head -UseBasicParsing -TimeoutSec 10 -ErrorAction Stop`,
|
||||
`$checksum = $preflightResponse.Headers["X-Checksum-Sha256"]`,
|
||||
`agent_download_checksum_missing`,
|
||||
`agent_download_available`,
|
||||
`agent_download_unavailable`,
|
||||
`if ($PreflightOnly) {`,
|
||||
`Invoke-AgentDownloadPreflight $DownloadUrl`,
|
||||
`if (-not $NonInteractive) {`,
|
||||
}
|
||||
for _, needle := range required {
|
||||
if !strings.Contains(script, needle) {
|
||||
t.Fatalf("install.ps1 missing download preflight handling: %s", needle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallPS1ReadsAgentIdentityFromEnvironment(t *testing.T) {
|
||||
content, err := os.ReadFile(repoFile("scripts", "install.ps1"))
|
||||
if err != nil {
|
||||
|
||||
@@ -3529,7 +3529,7 @@ class SubsystemLookupTest(unittest.TestCase):
|
||||
{
|
||||
"heading": "## Shared Boundaries",
|
||||
"path": "internal/api/access_control_handlers.go",
|
||||
"line": 268,
|
||||
"line": 274,
|
||||
"heading_line": 112,
|
||||
}
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user