Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 14 additions & 9 deletions cmd/nvfleetint/alert.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
package main

import (
"errors"
"fmt"
"io"
"strings"
Expand Down Expand Up @@ -212,8 +211,13 @@ func runAlertTimeline(cmd *cobra.Command, flags alertTimelineFlags, common resol
return err
}

nodeUUID := strings.TrimSpace(flags.node)
if nodeUUID != "" {
// An omitted --node lists every node with timeline history, so only a
// supplied value is validated as a path identifier.
if strings.TrimSpace(flags.node) != "" {
nodeUUID, err := nvfleetint.ValidateResourceID("--node", flags.node)
if err != nil {
return err
}
return runNodeAlertTimeline(cmd, client, flags, nodeUUID, common)
}
return runAlertTimelineNodes(cmd, client, flags, common)
Expand Down Expand Up @@ -330,13 +334,14 @@ func runAlertDescribe(cmd *cobra.Command, alertUUID string, flags alertDescribeF
return err
}

nodeUUID := strings.TrimSpace(flags.node)
alertUUID = strings.TrimSpace(alertUUID)
if nodeUUID == "" {
return errors.New("--node is required")
// Named for the flag so an omitted value reports "--node is required".
nodeUUID, err := nvfleetint.ValidateResourceID("--node", flags.node)
if err != nil {
return err
}
if alertUUID == "" {
return errors.New("alert UUID is required")
alertUUID, err = nvfleetint.ValidateResourceID("alert UUID", alertUUID)
if err != nil {
return err
}

client, err := newConfiguredClient(common)
Expand Down
6 changes: 3 additions & 3 deletions cmd/nvfleetint/node.go
Original file line number Diff line number Diff line change
Expand Up @@ -264,9 +264,9 @@ func runNodeDescribe(cmd *cobra.Command, nodeUUID string, common resolvedCommonF
return err
}

nodeUUID = strings.TrimSpace(nodeUUID)
if nodeUUID == "" {
return errors.New("node UUID is required")
nodeUUID, err := nvfleetint.ValidateResourceID("node UUID", nodeUUID)
if err != nil {
return err
}

client, err := newConfiguredClient(common)
Expand Down
6 changes: 3 additions & 3 deletions cmd/nvfleetint/node_health.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,9 @@ func runNodeHealth(cmd *cobra.Command, nodeUUID string, flags nodeHealthFlags, c
return err
}

nodeUUID = strings.TrimSpace(nodeUUID)
if nodeUUID == "" {
return errors.New("node UUID is required")
nodeUUID, err := nvfleetint.ValidateResourceID("node UUID", nodeUUID)
if err != nil {
return err
}

start := strings.TrimSpace(flags.start)
Expand Down
49 changes: 49 additions & 0 deletions cmd/nvfleetint/node_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -473,3 +473,52 @@ func TestListAllRejectsPage(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
}

// Verifies the CLI rejects an identifier that would re-target the request
// before any call reaches the API. The SDK enforces this too; the check is
// duplicated here so a hostile argument never reaches a configured client.
func TestResourceIDArgsRejectedBeforeRequest(t *testing.T) {
t.Setenv("HOME", t.TempDir())

var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
requests.Add(1)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{}`))
}))
defer server.Close()

saveTestConfig(t, server.URL, "test-key")

invocations := [][]string{
{"node", "describe", ".."},
{"node", "describe", "../../v1/tags"},
{"node", "health", "..", "--start", "2026-04-07T00:00:00Z", "--end", "2026-04-14T00:00:00Z"},
{"alert", "describe", "alert-1", "--node", ".."},
{"alert", "describe", "..", "--node", "node-1"},
{"alert", "timeline", "--node", ".."},
}

for _, args := range invocations {
t.Run(strings.Join(args, " "), func(t *testing.T) {
var out bytes.Buffer
cmd := newRootCmd()
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs(args)

err := cmd.Execute()
if err == nil {
t.Fatalf("expected an error, got output %q", out.String())
}
if !strings.Contains(err.Error(), "different API path") &&
!strings.Contains(err.Error(), "single path segment") {
t.Fatalf("unexpected error: %v", err)
}
})
}

if requests.Load() != 0 {
t.Fatalf("expected no requests to be issued, server saw %d", requests.Load())
}
}
165 changes: 160 additions & 5 deletions install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,120 @@ param(
} else {
Join-Path $env:LOCALAPPDATA "Programs\nvfleetint\bin"
}),
[switch]$NoModifyPath
[switch]$NoModifyPath,

# Download resilience. Every request carries an explicit timeout and a
# bounded number of attempts, so a dropped or throttled network fails the
# install instead of hanging a provisioning pipeline indefinitely.
[ValidateRange(1, 3600)]
[int]$TimeoutSeconds = $(if ($env:NVFLEETINT_MAX_TIME) { $env:NVFLEETINT_MAX_TIME } else { 120 }),
[ValidateRange(1, 100)]
[int]$RetryAttempts = $(if ($env:NVFLEETINT_RETRY_ATTEMPTS) { $env:NVFLEETINT_RETRY_ATTEMPTS } else { 4 }),
[ValidateRange(1, 3600)]
[int]$RetryDelaySeconds = $(if ($env:NVFLEETINT_RETRY_DELAY) { $env:NVFLEETINT_RETRY_DELAY } else { 2 }),
[ValidateRange(1, 3600)]
[int]$RetryMaxDelaySeconds = $(if ($env:NVFLEETINT_RETRY_MAX_DELAY) { $env:NVFLEETINT_RETRY_MAX_DELAY } else { 30 }),

# Fallback sources. BaseUrl replaces the default download root, assets are
# read from <root>/<tag>/<asset>; FallbackBaseUrl is tried only after the
# primary is exhausted; CacheDir is consulted before the network and
# populated after a successful checksum verification.
[string]$BaseUrl = $(if ($env:NVFLEETINT_BASE_URL) { $env:NVFLEETINT_BASE_URL } else { "" }),
[string]$FallbackBaseUrl = $(if ($env:NVFLEETINT_FALLBACK_BASE_URL) { $env:NVFLEETINT_FALLBACK_BASE_URL } else { "" }),
[string]$CacheDir = $(if ($env:NVFLEETINT_CACHE_DIR) { $env:NVFLEETINT_CACHE_DIR } else { "" })
)

$ErrorActionPreference = "Stop"
$ProgressPreference = "SilentlyContinue"
$repository = "NVIDIA/fleet-intelligence-client"
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12

if (-not $BaseUrl) {
$BaseUrl = "https://github.com/$repository/releases/download"
}

# Keeps a caller-supplied mirror from downgrading the transport to plaintext.
# Plain http is accepted only for loopback, matching the rule the SDK applies to
# its own base URL (nvfleetint/baseurl.go) so local mock servers keep working.
function Assert-SecureUrl {
param([string]$Name, [string]$Value)

$uri = $null
if (-not [Uri]::TryCreate($Value, [UriKind]::Absolute, [ref]$uri)) {
throw "$Name must be an absolute URL, got: $Value"
}
if ($uri.Scheme -eq "https") { return }
if ($uri.Scheme -eq "http" -and $uri.IsLoopback) { return }
throw "$Name must be an https:// URL (plain http is allowed only for localhost), got: $Value"
}

# Extracts the HTTP status from a failed request, or 0 when the request never
# got a response at all (DNS failure, refused connection, timeout).
function Get-HttpStatusCode {
param($ErrorRecord)

$response = $ErrorRecord.Exception.Response
if (-not $response) { return 0 }
try {
return [int]$response.StatusCode
} catch {
return 0
}
}

# Reports whether a failure is worth another attempt. A transport-level failure
# has no status and is always transient enough to retry; a 404 means the release
# or asset does not exist, so retrying only delays a certain failure.
function Test-RetryableFailure {
param($ErrorRecord)

$code = Get-HttpStatusCode $ErrorRecord
if ($code -eq 0) { return $true }
return @(408, 425, 429, 500, 502, 503, 504) -contains $code
}

# Runs a request with bounded retries and exponential backoff, throwing a clear
# message once the attempts are exhausted or the failure is deterministic.
function Invoke-WithRetry {
param([string]$Description, [scriptblock]$Action)

$attempt = 1
$delay = $RetryDelaySeconds
while ($true) {
try {
return & $Action
} catch {
$record = $_
$code = Get-HttpStatusCode $record
$reason = if ($code -ne 0) { "HTTP $code" } else { $record.Exception.Message }

if (-not (Test-RetryableFailure $record)) {
throw "$Description failed ($reason); not retryable."
}
if ($attempt -ge $RetryAttempts) {
throw "$Description failed after $RetryAttempts attempts ($reason)."
}

Write-Warning "$Description failed ($reason); retrying in ${delay}s (attempt $($attempt + 1)/$RetryAttempts)."
Start-Sleep -Seconds $delay
$attempt++
$delay = [Math]::Min($delay * 2, $RetryMaxDelaySeconds)
}
}
}

Assert-SecureUrl -Name "BaseUrl" -Value $BaseUrl
$BaseUrl = $BaseUrl.TrimEnd("/")
if ($FallbackBaseUrl) {
Assert-SecureUrl -Name "FallbackBaseUrl" -Value $FallbackBaseUrl
$FallbackBaseUrl = $FallbackBaseUrl.TrimEnd("/")
}

if ($Version -eq "latest") {
$release = Invoke-RestMethod -Uri "https://api.github.com/repos/$repository/releases/latest"
$release = Invoke-WithRetry -Description "latest release lookup" -Action {
Invoke-RestMethod -Uri "https://api.github.com/repos/$repository/releases/latest" `
-TimeoutSec $TimeoutSeconds
}
$Version = $release.tag_name
}

Expand All @@ -44,17 +148,60 @@ $architecture = switch ($machineArchitecture.ToUpperInvariant()) {
}

$asset = "nvfleetint_${releaseVersion}_windows_${architecture}.zip"
$baseUrl = "https://github.com/$repository/releases/download/$tag"
$workDir = Join-Path ([System.IO.Path]::GetTempPath()) ("nvfleetint-install-" + [guid]::NewGuid())
$archive = Join-Path $workDir $asset
$checksumPath = Join-Path $workDir "checksums.txt"
$extractDir = Join-Path $workDir "extract"

# Resolves one release file into the work directory: the cache first, then each
# configured download root in turn. Every source is fully retried before the
# next is tried.
function Get-ReleaseFile {
param([string]$Name, [string]$Destination)

if ($CacheDir) {
$cached = Join-Path (Join-Path $CacheDir $tag) $Name
if (Test-Path -LiteralPath $cached) {
Write-Host "Using cached $Name from $(Join-Path $CacheDir $tag)"
Copy-Item -LiteralPath $cached -Destination $Destination -Force
return
}
}

$roots = @($BaseUrl)
if ($FallbackBaseUrl) { $roots += $FallbackBaseUrl }

foreach ($root in $roots) {
try {
Invoke-WithRetry -Description "download of $Name from $root" -Action {
Invoke-WebRequest -Uri "$root/$tag/$Name" -OutFile $Destination `
-UseBasicParsing -TimeoutSec $TimeoutSeconds
}
return
} catch {
Write-Warning "Giving up on $root for ${Name}: $($_.Exception.Message)"
}
}

throw "Could not obtain $Name from any configured source."
}

# Stores a verified file in the cache. Only called after checksum verification,
# so a later run never reuses an artifact this run could not vouch for.
function Save-CachedFile {
param([string]$Name, [string]$Path)

if (-not $CacheDir) { return }
$target = Join-Path $CacheDir $tag
New-Item -ItemType Directory -Path $target -Force | Out-Null
Copy-Item -LiteralPath $Path -Destination (Join-Path $target $Name) -Force
}

try {
New-Item -ItemType Directory -Path $workDir | Out-Null
Write-Host "Downloading nvfleetint $tag for windows/$architecture"
Invoke-WebRequest -Uri "$baseUrl/$asset" -OutFile $archive -UseBasicParsing
Invoke-WebRequest -Uri "$baseUrl/checksums.txt" -OutFile $checksumPath -UseBasicParsing
Get-ReleaseFile -Name $asset -Destination $archive
Get-ReleaseFile -Name "checksums.txt" -Destination $checksumPath

$escapedAsset = [regex]::Escape($asset)
$checksumLine = Get-Content $checksumPath | Where-Object {
Expand All @@ -70,6 +217,9 @@ try {
throw "Checksum verification failed for $asset"
}

Save-CachedFile -Name $asset -Path $archive
Save-CachedFile -Name "checksums.txt" -Path $checksumPath

New-Item -ItemType Directory -Path $extractDir | Out-Null
Expand-Archive -Path $archive -DestinationPath $extractDir
$binary = Get-ChildItem -Path $extractDir -Filter "nvfleetint.exe" -File -Recurse |
Expand Down Expand Up @@ -100,6 +250,11 @@ try {

Write-Host "Installed nvfleetint to $destination"
& $destination version
} catch {
# Fail deterministically: an automated caller sees a non-zero exit code and
# one clear reason, rather than a partially installed tree and exit 0.
$Host.UI.WriteErrorLine("Error: $($_.Exception.Message)")
exit 1
} finally {
if (Test-Path -LiteralPath $workDir) {
Remove-Item -LiteralPath $workDir -Recurse -Force
Expand Down
Loading
Loading