Skip to content
Merged
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
58 changes: 31 additions & 27 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -105,7 +79,7 @@ Connect-MgGraph -TenantId "<tenant-id>" -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 "<tenant-id>"
```
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
3 changes: 3 additions & 0 deletions powershell/OwnerLens/Private/New-OwnerLensStatusObject.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}
}
3 changes: 2 additions & 1 deletion powershell/OwnerLens/Private/Wait-OwnerLensServer.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
7 changes: 5 additions & 2 deletions powershell/OwnerLens/Public/Open-OwnerLens.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
91 changes: 88 additions & 3 deletions powershell/OwnerLens/Public/Start-OwnerLens.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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"
Expand Down
1 change: 1 addition & 0 deletions powershell/OwnerLens/Public/Stop-OwnerLens.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ function Stop-OwnerLens {
}
}

Unregister-OwnerLensRuntimeLogEvents -State $state
Remove-OwnerLensState
New-OwnerLensStatusObject -State $state -Running $false -Health "Stopped"
}
8 changes: 6 additions & 2 deletions powershell/OwnerLens/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions src/core/runtime/pagination.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
10 changes: 6 additions & 4 deletions src/core/runtime/pagination.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,11 @@ export function buildPage<Row>(
options: PageOptions,
defaults?: {
defaultPageSize?: number;
maxPageSize?: number;
}
): Page<Row> {
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 {
Expand All @@ -44,7 +42,6 @@ export function buildPaginatedCollection<CollectionId extends string, Row>(
options: PageOptions,
defaults?: {
defaultPageSize?: number;
maxPageSize?: number;
}
): PaginatedCollection<CollectionId, Row> {
return {
Expand All @@ -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);
}
4 changes: 3 additions & 1 deletion src/providers/azure/runtime/ExportService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ export class ExportService {

exportAzureResourceGroupOwnershipCsv(
rows: Record<string, unknown>[],
options: LocalReportCollectionQueryOptions
options: LocalReportCollectionQueryOptions,
columns?: readonly string[]
): RuntimeCollectionCsvExport<"azureResources.resourceGroupOwnership"> {
return buildRuntimeCollectionCsvExport({
collectionId: "azureResources.resourceGroupOwnership",
Expand All @@ -49,6 +50,7 @@ export class ExportService {
sortRules: options.sortRules,
selectedRowKeys: options.selectedRowKeys,
getRowKey: getResourceGroupOwnershipRowKey,
columns,
includeBom: true
});
}
Expand Down
Loading
Loading