diff --git a/README.md b/README.md index 49d22e8..fe0a3b3 100644 --- a/README.md +++ b/README.md @@ -60,32 +60,6 @@ Install-Module Microsoft.Graph -Scope CurrentUser Run all PowerShell commands in `pwsh`. -## Run - -Build the PowerShell module from a source checkout: - -```powershell -pwsh ./scripts/build-windows-runtime.ps1 -pwsh ./scripts/build-powershell-module.ps1 -``` - -Start the local app from `pwsh`: - -```powershell -Import-Module ./artifacts/OwnerLens/OwnerLens.psd1 -Force -Start-OwnerLens -DataPath ./data -Open-OwnerLens -``` - -`Start-OwnerLens` binds to `127.0.0.1`, chooses a free port, creates the data -directory, and stores runtime state under `$env:LOCALAPPDATA\OwnerLens`. - -Use an explicit port or data directory when needed: - -```powershell -Start-OwnerLens -Port 4174 -DataPath C:\OwnerLensData -``` - ## Create Snapshots Collectors write these files by default: @@ -105,7 +79,7 @@ Connect-MgGraph -TenantId "" -Scopes "Application.Read.All","Group.Re Collect snapshots from `pwsh`: ```powershell -Import-Module ./artifacts/OwnerLens/OwnerLens.psd1 -Force +Install-Module OwnerLens -Scope CurrentUser -AllowPrerelease Invoke-OwnerLensCollectAzure -SubscriptionIds "sub-id-1,sub-id-2" Invoke-OwnerLensCollectEntra -TenantId "" ``` @@ -116,6 +90,36 @@ Snapshot files can contain sensitive tenant, subscription, identity, group, credential, and activity-log metadata. Review them before sharing. Files matching `data/*snapshot.json` are ignored by git. + +## Run + +Build the PowerShell module from a source checkout: + +```powershell +pwsh ./scripts/build-windows-runtime.ps1 +pwsh ./scripts/build-powershell-module.ps1 +``` + +Start the local app from `pwsh`: + +```powershell +Install-Module OwnerLens -Scope CurrentUser -AllowPrerelease +Start-OwnerLens -DataPath ./data +Open-OwnerLens +``` + +`Start-OwnerLens` binds to `127.0.0.1`, chooses a free port, creates the data +directory, waits up to 180 seconds for the runtime API to become ready, stores +runtime state under `$env:LOCALAPPDATA\OwnerLens`, and writes server stdout/stderr +logs under `$env:LOCALAPPDATA\OwnerLens\logs`. + +Use an explicit port or data directory when needed: + +```powershell +Start-OwnerLens -Port 4174 -DataPath C:\OwnerLensData +Start-OwnerLens -StartupTimeoutSeconds 240 +``` + ## Development See [DEVELOPMENT.md](DEVELOPMENT.md) for local development, testing, dependency diff --git a/powershell/OwnerLens/Private/Invoke-OwnerLensPrepareResourceSnapshot.ps1 b/powershell/OwnerLens/Private/Invoke-OwnerLensPrepareResourceSnapshot.ps1 index 2bbfd58..44ef85d 100644 --- a/powershell/OwnerLens/Private/Invoke-OwnerLensPrepareResourceSnapshot.ps1 +++ b/powershell/OwnerLens/Private/Invoke-OwnerLensPrepareResourceSnapshot.ps1 @@ -114,7 +114,7 @@ $subscriptionFilters = @() if ([string]::IsNullOrWhiteSpace($SubscriptionIds)) { $subscriptionFilters = @($context.Subscription.Id) } else { - $subscriptionFilters = $SubscriptionIds.Split(",") | ForEach-Object { $_.Trim() } | Where-Object { $_ } + $subscriptionFilters = @($SubscriptionIds.Split(",") | ForEach-Object { $_.Trim() } | Where-Object { $_ }) } Write-SnapshotProgress "Preparing Azure resource snapshot" diff --git a/powershell/OwnerLens/Private/New-OwnerLensStatusObject.ps1 b/powershell/OwnerLens/Private/New-OwnerLensStatusObject.ps1 index 46d6e4d..f9897f3 100644 --- a/powershell/OwnerLens/Private/New-OwnerLensStatusObject.ps1 +++ b/powershell/OwnerLens/Private/New-OwnerLensStatusObject.ps1 @@ -22,5 +22,8 @@ function New-OwnerLensStatusObject { DataPath = if ($State) { [string]$State.DataPath } else { $null } StartedAt = if ($State) { [datetimeoffset]::Parse([string]$State.StartedAt) } else { $null } Health = $Health + LogDirectory = if ($State -and $State.LogDirectory) { [string]$State.LogDirectory } else { $null } + StdoutLogPath = if ($State -and $State.StdoutLogPath) { [string]$State.StdoutLogPath } else { $null } + StderrLogPath = if ($State -and $State.StderrLogPath) { [string]$State.StderrLogPath } else { $null } } } diff --git a/powershell/OwnerLens/Private/Wait-OwnerLensServer.ps1 b/powershell/OwnerLens/Private/Wait-OwnerLensServer.ps1 index d699187..16a6fa5 100644 --- a/powershell/OwnerLens/Private/Wait-OwnerLensServer.ps1 +++ b/powershell/OwnerLens/Private/Wait-OwnerLensServer.ps1 @@ -15,7 +15,8 @@ function Wait-OwnerLensServer { [Parameter(Mandatory)] [string]$Token, - [int]$TimeoutSeconds = 30 + [ValidateRange(1, [int]::MaxValue)] + [int]$TimeoutSeconds = 180 ) $deadline = (Get-Date).AddSeconds($TimeoutSeconds) diff --git a/powershell/OwnerLens/Public/Open-OwnerLens.ps1 b/powershell/OwnerLens/Public/Open-OwnerLens.ps1 index f9f7296..00ac792 100644 --- a/powershell/OwnerLens/Public/Open-OwnerLens.ps1 +++ b/powershell/OwnerLens/Public/Open-OwnerLens.ps1 @@ -15,12 +15,15 @@ function Open-OwnerLens { [ValidateNotNullOrEmpty()] [string]$DataPath = (Join-Path (Get-Location) "data"), - [string]$RuntimePath = "" + [string]$RuntimePath = "", + + [ValidateRange(1, [int]::MaxValue)] + [int]$StartupTimeoutSeconds = 180 ) $state = Read-OwnerLensState if (-not $state -or -not (Test-OwnerLensTrackedProcess -State $state)) { - Start-OwnerLens -Port $Port -DataPath $DataPath -RuntimePath $RuntimePath | Out-Null + Start-OwnerLens -Port $Port -DataPath $DataPath -RuntimePath $RuntimePath -StartupTimeoutSeconds $StartupTimeoutSeconds | Out-Null $state = Read-OwnerLensState } diff --git a/powershell/OwnerLens/Public/Start-OwnerLens.ps1 b/powershell/OwnerLens/Public/Start-OwnerLens.ps1 index 56efc94..043f12e 100644 --- a/powershell/OwnerLens/Public/Start-OwnerLens.ps1 +++ b/powershell/OwnerLens/Public/Start-OwnerLens.ps1 @@ -6,6 +6,51 @@ Starts the local OwnerLens runtime server. Launches the packaged OwnerLens runtime server on loopback, creates a runtime token, persists process state, and returns the runtime status. #> +function Get-OwnerLensStartupLogTail { + param( + [string]$StdoutLogPath, + [string]$StderrLogPath + ) + + $sections = @() + foreach ($log in @( + @{ Label = "stderr"; Path = $StderrLogPath }, + @{ Label = "stdout"; Path = $StdoutLogPath } + )) { + if (-not (Test-Path -LiteralPath $log.Path)) { + continue + } + + $tail = Get-Content -LiteralPath $log.Path -Tail 20 -ErrorAction SilentlyContinue + if ($tail) { + $sections += "`nLast OwnerLens $($log.Label) log lines ($($log.Path)):`n$($tail -join "`n")" + } + } + + if ($sections.Count -eq 0) { + return "`nOwnerLens log files: stdout=$StdoutLogPath stderr=$StderrLogPath" + } + + return $sections -join "`n" +} + +function Unregister-OwnerLensRuntimeLogEvents { + param( + [object]$State + ) + + foreach ($sourceIdentifier in @($State.StdoutEventSourceIdentifier, $State.StderrEventSourceIdentifier)) { + if ([string]::IsNullOrWhiteSpace([string]$sourceIdentifier)) { + continue + } + + Unregister-Event -SourceIdentifier ([string]$sourceIdentifier) -ErrorAction SilentlyContinue + Get-Job | + Where-Object { $_.Name -eq [string]$sourceIdentifier } | + Remove-Job -Force -ErrorAction SilentlyContinue + } +} + function Start-OwnerLens { [CmdletBinding()] param( @@ -15,7 +60,10 @@ function Start-OwnerLens { [ValidateNotNullOrEmpty()] [string]$DataPath = (Join-Path (Get-Location) "data"), - [string]$RuntimePath = "" + [string]$RuntimePath = "", + + [ValidateRange(1, [int]::MaxValue)] + [int]$StartupTimeoutSeconds = 180 ) $existingState = Read-OwnerLensState @@ -31,6 +79,16 @@ function Start-OwnerLens { $serverPort = if ($Port -gt 0) { $Port } else { Get-OwnerLensFreePort } $token = New-OwnerLensRuntimeToken $serverUrl = "http://127.0.0.1:$serverPort" + $paths = Get-OwnerLensPaths + $logDirectory = Join-Path $paths.AppDataRoot "logs" + New-Item -ItemType Directory -Path $logDirectory -Force | Out-Null + $logTimestamp = [datetimeoffset]::UtcNow.ToString("yyyyMMdd-HHmmss") + $stdoutLogPath = Join-Path $logDirectory "ownerlens-server.out.log" + $stderrLogPath = Join-Path $logDirectory "ownerlens-server.err.log" + $sessionLogPath = Join-Path $logDirectory "ownerlens-server-$logTimestamp.log" + "OwnerLens server start $([datetimeoffset]::UtcNow.ToString("o"))" | Set-Content -LiteralPath $sessionLogPath -Encoding UTF8 + "OwnerLens server stdout $([datetimeoffset]::UtcNow.ToString("o"))" | Set-Content -LiteralPath $stdoutLogPath -Encoding UTF8 + "OwnerLens server stderr $([datetimeoffset]::UtcNow.ToString("o"))" | Set-Content -LiteralPath $stderrLogPath -Encoding UTF8 $startInfo = [System.Diagnostics.ProcessStartInfo]::new() $startInfo.FileName = $runtime.NodePath @@ -48,10 +106,29 @@ function Start-OwnerLens { $startInfo.Environment["OWNERLENS_DATA_DIR"] = $resolvedDataPath $startInfo.Environment["OWNERLENS_RUNTIME_TOKEN"] = $token + Write-Host "Starting OwnerLens at $serverUrl" + Write-Host "Server logs:" + Write-Host " stdout: $stdoutLogPath" + Write-Host " stderr: $stderrLogPath" + $process = [System.Diagnostics.Process]::Start($startInfo) if (-not $process) { throw "Failed to start OwnerLens server process." } + $stdoutEventSourceIdentifier = "OwnerLens.Stdout.$($process.Id)" + $stderrEventSourceIdentifier = "OwnerLens.Stderr.$($process.Id)" + Register-ObjectEvent -InputObject $process -EventName OutputDataReceived -SourceIdentifier $stdoutEventSourceIdentifier -MessageData @{ Path = $stdoutLogPath } -Action { + if ($EventArgs.Data) { + Add-Content -LiteralPath $Event.MessageData.Path -Value $EventArgs.Data -Encoding UTF8 + } + } | Out-Null + Register-ObjectEvent -InputObject $process -EventName ErrorDataReceived -SourceIdentifier $stderrEventSourceIdentifier -MessageData @{ Path = $stderrLogPath } -Action { + if ($EventArgs.Data) { + Add-Content -LiteralPath $Event.MessageData.Path -Value $EventArgs.Data -Encoding UTF8 + } + } | Out-Null + $process.BeginOutputReadLine() + $process.BeginErrorReadLine() $state = [pscustomobject]@{ ProcessId = $process.Id @@ -63,18 +140,26 @@ function Start-OwnerLens { RuntimeRoot = $runtime.RuntimeRoot NodePath = $runtime.NodePath ServerScript = $runtime.ServerScript + LogDirectory = $logDirectory + StdoutLogPath = $stdoutLogPath + StderrLogPath = $stderrLogPath + SessionLogPath = $sessionLogPath + StdoutEventSourceIdentifier = $stdoutEventSourceIdentifier + StderrEventSourceIdentifier = $stderrEventSourceIdentifier } Write-OwnerLensState -State $state try { - Wait-OwnerLensServer -ServerUrl $serverUrl -Token $token + Wait-OwnerLensServer -ServerUrl $serverUrl -Token $token -TimeoutSeconds $StartupTimeoutSeconds } catch { if (-not $process.HasExited) { $process.Kill() $process.WaitForExit(5000) | Out-Null } + Unregister-OwnerLensRuntimeLogEvents -State $state Remove-OwnerLensState - throw "OwnerLens server failed to start: $($_.Exception.Message)" + $logTail = Get-OwnerLensStartupLogTail -StdoutLogPath $stdoutLogPath -StderrLogPath $stderrLogPath + throw "OwnerLens server failed to start: $($_.Exception.Message)$logTail" } Write-Verbose "OwnerLens runtime token: $token" diff --git a/powershell/OwnerLens/Public/Stop-OwnerLens.ps1 b/powershell/OwnerLens/Public/Stop-OwnerLens.ps1 index 061eb1a..c2fca3d 100644 --- a/powershell/OwnerLens/Public/Stop-OwnerLens.ps1 +++ b/powershell/OwnerLens/Public/Stop-OwnerLens.ps1 @@ -32,6 +32,7 @@ function Stop-OwnerLens { } } + Unregister-OwnerLensRuntimeLogEvents -State $state Remove-OwnerLensState New-OwnerLensStatusObject -State $state -Running $false -Health "Stopped" } diff --git a/powershell/OwnerLens/README.md b/powershell/OwnerLens/README.md index 91b6393..cee288f 100644 --- a/powershell/OwnerLens/README.md +++ b/powershell/OwnerLens/README.md @@ -30,13 +30,17 @@ Get-OwnerLensStatus Stop-OwnerLens ``` -`Start-OwnerLens` binds to `127.0.0.1`, chooses a random free port by default, writes state to -`$env:LOCALAPPDATA\OwnerLens\runtime-state.json`, and keeps the runtime token out of normal output. +`Start-OwnerLens` binds to `127.0.0.1`, chooses a random free port by default, waits up to 180 seconds +for the runtime API to become ready, writes state to `$env:LOCALAPPDATA\OwnerLens\runtime-state.json`, +and keeps the runtime token out of normal output. +Server stdout and stderr are written under `$env:LOCALAPPDATA\OwnerLens\logs`; `Start-OwnerLens` +prints those paths, and `Get-OwnerLensStatus` returns them as `StdoutLogPath` and `StderrLogPath`. Use explicit paths when needed: ```powershell Start-OwnerLens -Port 4174 -DataPath C:\OwnerLensData +Start-OwnerLens -StartupTimeoutSeconds 240 ``` ## Collect Entra diff --git a/src/core/runtime/pagination.test.ts b/src/core/runtime/pagination.test.ts index e3e8f84..029ab20 100644 --- a/src/core/runtime/pagination.test.ts +++ b/src/core/runtime/pagination.test.ts @@ -6,11 +6,11 @@ test("uses default page size of 50", () => { expect(buildPage(rows, {}).pageSize).toBe(50); }); -test("clamps page size to max 500", () => { - const page = buildPage(Array.from({ length: 600 }, (_, index) => index), { pageSize: 1000 }); +test("does not cap requested page size", () => { + const page = buildPage(Array.from({ length: 1000 }, (_, index) => index), { pageSize: 1000 }); - expect(page.pageSize).toBe(500); - expect(page.rows).toHaveLength(500); + expect(page.pageSize).toBe(1000); + expect(page.rows).toHaveLength(1000); }); test("clamps page to available range", () => { diff --git a/src/core/runtime/pagination.ts b/src/core/runtime/pagination.ts index 6c894ed..f0cfa7f 100644 --- a/src/core/runtime/pagination.ts +++ b/src/core/runtime/pagination.ts @@ -20,13 +20,11 @@ export function buildPage( options: PageOptions, defaults?: { defaultPageSize?: number; - maxPageSize?: number; } ): Page { const defaultPageSize = defaults?.defaultPageSize ?? 50; - const maxPageSize = defaults?.maxPageSize ?? 500; - const pageSize = clampInteger(options.pageSize ?? defaultPageSize, 1, maxPageSize); + const pageSize = clampMinInteger(options.pageSize ?? defaultPageSize, 1); const page = clampInteger(options.page ?? 1, 1, Math.max(1, Math.ceil(rows.length / pageSize))); return { @@ -44,7 +42,6 @@ export function buildPaginatedCollection( options: PageOptions, defaults?: { defaultPageSize?: number; - maxPageSize?: number; } ): PaginatedCollection { return { @@ -58,3 +55,8 @@ function clampInteger(value: number, min: number, max: number): number { const integer = Number.isFinite(value) ? Math.trunc(value) : min; return Math.min(Math.max(integer, min), max); } + +function clampMinInteger(value: number, min: number): number { + const integer = Number.isFinite(value) ? Math.trunc(value) : min; + return Math.max(integer, min); +} diff --git a/src/providers/azure/runtime/ExportService.ts b/src/providers/azure/runtime/ExportService.ts index 0d0a2ba..266f15e 100644 --- a/src/providers/azure/runtime/ExportService.ts +++ b/src/providers/azure/runtime/ExportService.ts @@ -39,7 +39,8 @@ export class ExportService { exportAzureResourceGroupOwnershipCsv( rows: Record[], - options: LocalReportCollectionQueryOptions + options: LocalReportCollectionQueryOptions, + columns?: readonly string[] ): RuntimeCollectionCsvExport<"azureResources.resourceGroupOwnership"> { return buildRuntimeCollectionCsvExport({ collectionId: "azureResources.resourceGroupOwnership", @@ -49,6 +50,7 @@ export class ExportService { sortRules: options.sortRules, selectedRowKeys: options.selectedRowKeys, getRowKey: getResourceGroupOwnershipRowKey, + columns, includeBom: true }); } diff --git a/src/providers/azure/runtime/SnapshotImporter.test.ts b/src/providers/azure/runtime/SnapshotImporter.test.ts new file mode 100644 index 0000000..d06e248 --- /dev/null +++ b/src/providers/azure/runtime/SnapshotImporter.test.ts @@ -0,0 +1,49 @@ +import type { SnapshotImportStatus } from "../../../core/runtime/snapshotImportRegistry"; +import { SnapshotImporter, type SnapshotImportRuntime } from "./SnapshotImporter"; +import type { LocalEntraReportRuntime } from "./entra/LocalEntraReportRuntime"; +import type { LocalAzureResourcesReportRuntime } from "./resources/LocalAzureResourcesReportRuntime"; + +function createImportRuntime(imported: boolean): SnapshotImportRuntime { + const initiallyImported = imported; + return { + getStatus(): SnapshotImportStatus { + return { + imported, + fileName: "snapshot.json", + name: imported ? "snapshot" : null, + lastModifiedDate: imported ? "2026-06-25T00:00:00.000Z" : null, + sizeBytes: imported ? 2 : null, + contentHash: imported ? "hash" : null, + importedAt: null, + skipped: initiallyImported + }; + }, + importSnapshot: jest.fn(async () => { + imported = true; + }) + }; +} + +test("SnapshotImporter logs import progress for startup diagnostics", async () => { + const logger = { log: jest.fn() }; + const entra = createImportRuntime(false); + const azureResources = createImportRuntime(true); + const zeroTrustAssessment = createImportRuntime(false); + const importer = new SnapshotImporter({ + entra: entra as LocalEntraReportRuntime, + azureResources: azureResources as LocalAzureResourcesReportRuntime, + zeroTrustAssessment, + logger + }); + + await importer.importSnapshots(); + + expect(logger.log.mock.calls.map(([message]) => message)).toEqual([ + "Importing Entra snapshot...", + "Imported Entra snapshot.", + "Checking Azure resources snapshot...", + "Azure resources snapshot is already current.", + "Importing Zero Trust Assessment snapshot...", + "Imported Zero Trust Assessment snapshot." + ]); +}); diff --git a/src/providers/azure/runtime/SnapshotImporter.ts b/src/providers/azure/runtime/SnapshotImporter.ts index e8aa2dc..accb1b1 100644 --- a/src/providers/azure/runtime/SnapshotImporter.ts +++ b/src/providers/azure/runtime/SnapshotImporter.ts @@ -15,6 +15,7 @@ export type SnapshotImporterOptions = { entra: LocalEntraReportRuntime; azureResources: LocalAzureResourcesReportRuntime; zeroTrustAssessment: SnapshotImportRuntime; + logger?: Pick | null; }; export type SnapshotImporterStatus = { @@ -39,11 +40,13 @@ export class SnapshotImporter { private readonly entra: LocalEntraReportRuntime; private readonly azureResources: LocalAzureResourcesReportRuntime; private readonly zeroTrustAssessment: SnapshotImportRuntime; + private readonly logger: Pick | null; constructor(options: SnapshotImporterOptions) { this.entra = options.entra; this.azureResources = options.azureResources; this.zeroTrustAssessment = options.zeroTrustAssessment; + this.logger = options.logger ?? (process.env.NODE_ENV === "test" ? null : console); } getStatus(): SnapshotImporterStatus { @@ -55,8 +58,32 @@ export class SnapshotImporter { } async importSnapshots(): Promise { - await this.entra.importSnapshot(); - await this.azureResources.importSnapshot(); - await this.zeroTrustAssessment.importSnapshot(); + await this.importSnapshotWithLogging("Entra", this.entra); + await this.importSnapshotWithLogging("Azure resources", this.azureResources); + await this.importSnapshotWithLogging("Zero Trust Assessment", this.zeroTrustAssessment); + } + + private async importSnapshotWithLogging(label: string, runtime: SnapshotImportRuntime): Promise { + const previousStatus = runtime.getStatus(); + this.logger?.log( + previousStatus.imported + ? `Checking ${label} snapshot...` + : `Importing ${label} snapshot...` + ); + + await runtime.importSnapshot(); + + const status = runtime.getStatus(); + if (!status.imported) { + this.logger?.log(`No ${label} snapshot found.`); + return; + } + + if (status.skipped) { + this.logger?.log(`${label} snapshot is already current.`); + return; + } + + this.logger?.log(`Imported ${label} snapshot.`); } } diff --git a/src/providers/azure/runtime/resources/AzureResourcesCollectionQueryService.test.ts b/src/providers/azure/runtime/resources/AzureResourcesCollectionQueryService.test.ts new file mode 100644 index 0000000..6571ff5 --- /dev/null +++ b/src/providers/azure/runtime/resources/AzureResourcesCollectionQueryService.test.ts @@ -0,0 +1,52 @@ +import { ExportService } from "../ExportService"; +import { AzureResourcesCollectionQueryService } from "./AzureResourcesCollectionQueryService"; +import type { AzureResourceGroupOwnershipSqlRow } from "./tables"; + +test("exports resource group ownership CSV from a large paginated ownership query", async () => { + const readAzureResourceGroupOwnershipCollectionSqlRows = jest.fn().mockResolvedValue([ + ownershipSqlRow("sub-1", "rg-a", "alice@example.test"), + ownershipSqlRow("sub-1", "rg-b", "bob@example.test") + ]); + const service = new AzureResourcesCollectionQueryService({ + entra: {} as never, + azureResources: { + readAzureResourceGroupOwnershipCollectionSqlRows + } as never, + disabledEvidenceStore: {} as never, + exportService: new ExportService() + }); + + const csv = await service.exportResourceGroupOwnershipCsv({ + page: 1, + pageSize: 1, + sortRules: [{ columnId: "resourceGroup", direction: "asc" }] + }); + + expect(readAzureResourceGroupOwnershipCollectionSqlRows).toHaveBeenCalledWith(10000); + expect(csv.count).toBe(2); + expect(csv.body).toContain("rg-a"); + expect(csv.body).toContain("rg-b"); +}); + +function ownershipSqlRow( + subscriptionId: string, + resourceGroup: string, + owner: string +): AzureResourceGroupOwnershipSqlRow { + return { + subscriptionId, + subscriptionName: "Subscription 1", + resourceGroup, + location: "westeurope", + tags: null, + targetKey: `${subscriptionId}:${resourceGroup}`, + kind: "resourceGroup", + owner, + ownerCandidate: owner, + ownerDisplayName: owner, + principalId: null, + confidence: "high", + source: "tag.owner", + evidence: [{ user: owner, date: null }] + }; +} diff --git a/src/providers/azure/runtime/resources/AzureResourcesCollectionQueryService.ts b/src/providers/azure/runtime/resources/AzureResourcesCollectionQueryService.ts index f3e195c..a0b88f1 100644 --- a/src/providers/azure/runtime/resources/AzureResourcesCollectionQueryService.ts +++ b/src/providers/azure/runtime/resources/AzureResourcesCollectionQueryService.ts @@ -22,6 +22,8 @@ import { import { mapEntraServicePrincipalsToCore } from "../entra/entraServicePrincipalMapper"; import type { AzureResourceGroupOwnershipSqlRow } from "./tables"; +const csvExportPageSize = 10000; + export type AzureResourcesCollectionQueryServiceOptions = { entra: LocalEntraReportRuntime; azureResources: LocalAzureResourcesReportRuntime; @@ -75,9 +77,18 @@ export class AzureResourcesCollectionQueryService { async exportResourceGroupOwnershipCsv( options: LocalReportCollectionQueryOptions ): Promise> { + const collection = await this.queryResourceGroupOwnership({ + ...options, + page: 1, + pageSize: csvExportPageSize + }); + return this.exportService.exportAzureResourceGroupOwnershipCsv( - await this.readResourceGroupOwnershipRows(), - options + collection.rows as unknown as Record[], + { + selectedRowKeys: options.selectedRowKeys + }, + collection.columns ); } diff --git a/tests/powershell/OwnerLens.Tests.ps1 b/tests/powershell/OwnerLens.Tests.ps1 index 69b1276..b29ce8b 100644 --- a/tests/powershell/OwnerLens.Tests.ps1 +++ b/tests/powershell/OwnerLens.Tests.ps1 @@ -130,6 +130,57 @@ Describe "Azure Monitor activity log collection" { } } +Describe "Azure resource snapshot collection" { + BeforeEach { + . (Join-Path $PSScriptRoot "..\..\powershell\OwnerLens\Private\Invoke-OwnerLensPrepareResourceSnapshot.ps1") + + function Get-AzContext { + [pscustomobject]@{ + Subscription = [pscustomobject]@{ + Id = "current-sub" + } + } + } + + function Invoke-AzRestMethod {} + + function Get-AzSubscription { + @( + [pscustomobject]@{ + Id = "single-sub" + Name = "Test subscription" + TenantId = "tenant-1" + State = "Enabled" + } + ) + } + + function Get-AzUserAssignedIdentity { + @() + } + + function Set-AzContext {} + function Get-AzResourceGroup { @() } + function Get-AzResource { @() } + function Get-AzRoleAssignment { @() } + } + + It "writes requestedSubscriptions as an array for a single subscription filter" { + $outputPath = Join-Path $TestDrive "snapshot.json" + + Invoke-OwnerLensPrepareResourceSnapshot ` + -OutputPath $outputPath ` + -SubscriptionIds "single-sub" ` + -SkipAuditLogsExport + + $snapshot = Get-Content -LiteralPath $outputPath -Raw | ConvertFrom-Json -AsHashtable + + $requestedSubscriptions = $snapshot.meta.requestedSubscriptions + $requestedSubscriptions.GetType().FullName | Should -Be "System.Object[]" + $requestedSubscriptions | Should -Be @("single-sub") + } +} + Describe "OwnerLens REST request retry" { BeforeEach { . (Join-Path $PSScriptRoot "..\..\powershell\OwnerLens\Private\Invoke-OwnerLensRestRequestWithRetry.ps1") diff --git a/tools/ownerlens-powershell-runtime.test.ts b/tools/ownerlens-powershell-runtime.test.ts new file mode 100644 index 0000000..28387ab --- /dev/null +++ b/tools/ownerlens-powershell-runtime.test.ts @@ -0,0 +1,27 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +const startScript = readFileSync( + join(process.cwd(), "powershell/OwnerLens/Public/Start-OwnerLens.ps1"), + "utf8" +); +const statusScript = readFileSync( + join(process.cwd(), "powershell/OwnerLens/Private/New-OwnerLensStatusObject.ps1"), + "utf8" +); + +test("PowerShell runtime start persists server output to discoverable log files", () => { + expect(startScript).toContain("$logDirectory"); + expect(startScript).toContain("ownerlens-server.out.log"); + expect(startScript).toContain("ownerlens-server.err.log"); + expect(startScript).toContain("RedirectStandardOutput = $true"); + expect(startScript).toContain("RedirectStandardError = $true"); + expect(startScript).toContain("Register-ObjectEvent -InputObject $process -EventName OutputDataReceived"); + expect(startScript).toContain("Register-ObjectEvent -InputObject $process -EventName ErrorDataReceived"); + expect(startScript).toContain("$process.BeginOutputReadLine()"); + expect(startScript).toContain("$process.BeginErrorReadLine()"); + expect(startScript).toContain("LogDirectory = $logDirectory"); + expect(statusScript).toContain("LogDirectory"); + expect(statusScript).toContain("StdoutLogPath"); + expect(statusScript).toContain("StderrLogPath"); +}); diff --git a/tools/ownerlens-startup-timeout.test.ts b/tools/ownerlens-startup-timeout.test.ts new file mode 100644 index 0000000..6d7135f --- /dev/null +++ b/tools/ownerlens-startup-timeout.test.ts @@ -0,0 +1,23 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +const startScript = readFileSync( + join(process.cwd(), "powershell/OwnerLens/Public/Start-OwnerLens.ps1"), + "utf8" +); +const openScript = readFileSync( + join(process.cwd(), "powershell/OwnerLens/Public/Open-OwnerLens.ps1"), + "utf8" +); +const waitScript = readFileSync( + join(process.cwd(), "powershell/OwnerLens/Private/Wait-OwnerLensServer.ps1"), + "utf8" +); + +test("PowerShell runtime startup timeout defaults to 180 seconds and is configurable", () => { + expect(waitScript).toContain("[int]$TimeoutSeconds = 180"); + expect(startScript).toContain("[int]$StartupTimeoutSeconds = 180"); + expect(startScript).toContain("-TimeoutSeconds $StartupTimeoutSeconds"); + expect(openScript).toContain("[int]$StartupTimeoutSeconds = 180"); + expect(openScript).toContain("-StartupTimeoutSeconds $StartupTimeoutSeconds"); +});