Scripts/Install-OpenEndpointEventsUploader.ps1

<#
.SYNOPSIS
    Installs the OpenEndpointEvents Azure Blob uploader bootstrap.
 
.DESCRIPTION
    Installs or verifies the OpenEndpointEvents module, copies embedded runtime scripts
    to C:\ProgramData\OpenEndpointEvents\Scripts, writes config-refresh.json, locks down
    local config ACLs, then calls Update-OpenEndpointEventsConfig.ps1 to download and apply
    the remote uploader configuration.
 
    In v1.1, the remote uploader config is the source of truth for:
    - upload SAS
    - upload interval
    - config refresh interval
    - blob path layout
    - upload behaviour
 
.PARAMETER ConfigUri
    HTTPS URL to the protected remote uploader-config.json blob, including read-only SAS.
 
.PARAMETER StartNow
    Runs config refresh immediately, applies schedule, and performs an immediate upload.
 
.PARAMETER ForceInstall
    Rewrites local config-refresh.json if it already exists.
 
.EXAMPLE
    .\Install-OpenEndpointEventsUploader.ps1 `
        -ConfigUri "https://storageaccount.blob.core.windows.net/uploader-config/uploader-config.json?<read-only-config-sas>" `
        -StartNow `
        -Verbose
#>


[CmdletBinding()]
param(
    [string]$ConfigUri,
    [switch]$StartNow,
    [switch]$ForceInstall
)

$ErrorActionPreference = "Stop"
$script:OpenEndpointEventsModuleImported = $false

function Import-OpenEndpointEventsQuiet {
    if ($script:OpenEndpointEventsModuleImported) {
        return
    }

    if (Get-Command Write-EndpointInfo -ErrorAction SilentlyContinue) {
        $script:OpenEndpointEventsModuleImported = $true
        return
    }

    $module = Get-Module -ListAvailable OpenEndpointEvents |
        Sort-Object Version -Descending |
        Select-Object -First 1

    if ($module) {
        Import-Module $module.Path `
            -Force `
            -Global `
            -ErrorAction SilentlyContinue `
            -Verbose:$false | Out-Null

        $script:OpenEndpointEventsModuleImported = $true
    }
}


# ------------------------------------------------------------
# Validation
# ------------------------------------------------------------

if ([string]::IsNullOrWhiteSpace($ConfigUri)) {
    throw "ConfigUri is required."
}

if ($ConfigUri -notmatch "^https://") {
    throw "ConfigUri must use HTTPS."
}

$currentIdentity = [Security.Principal.WindowsIdentity]::GetCurrent()
$currentPrincipal = New-Object Security.Principal.WindowsPrincipal($currentIdentity)
$isAdmin = $currentPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)

if (-not $isAdmin) {
    throw "This installer must be run as Administrator."
}

# ------------------------------------------------------------
# Paths
# ------------------------------------------------------------

$BaseRoot = "C:\ProgramData\OpenEndpointEvents"
$LogRoot = Join-Path $BaseRoot "Logs"
$ConfigRoot = Join-Path $BaseRoot "Config"
$StateRoot = Join-Path $BaseRoot "State"
$ScriptRoot = Join-Path $BaseRoot "Scripts"

$RefreshConfigPath = Join-Path $ConfigRoot "config-refresh.json"
$UploaderConfigPath = Join-Path $ConfigRoot "uploader-config.json"
$ConfigRefreshStatePath = Join-Path $StateRoot "config-refresh-state.json"

$UploadScriptPath = Join-Path $ScriptRoot "Upload-EndpointEvents.ps1"
$ConfigRefreshScriptPath = Join-Path $ScriptRoot "Update-OpenEndpointEventsConfig.ps1"

$CorrelationId = "INSTALL-$((Get-Date).ToUniversalTime().ToString('yyyyMMddTHHmmssZ'))-$($env:COMPUTERNAME)-$(([guid]::NewGuid().ToString()).Substring(0,8))"

# ------------------------------------------------------------
# Helper functions
# ------------------------------------------------------------

function Set-OpenEndpointEventsConfigAcl {
    param(
        [string]$Path
    )

    if (-not (Test-Path -Path $Path)) {
        New-Item -Path $Path -ItemType Directory -Force | Out-Null
    }

    icacls $Path /inheritance:r | Out-Null
    icacls $Path /grant:r "SYSTEM:(OI)(CI)(F)" | Out-Null
    icacls $Path /grant:r "Administrators:(OI)(CI)(F)" | Out-Null
    icacls $Path /remove "Users" "Authenticated Users" "Everyone" 2>$null | Out-Null
}

function Write-StepInfo {
    param(
        [string]$EventName,
        [string]$Message,
        [hashtable]$Data
    )

    Write-Verbose "[OpenEndpointEventsInstaller] $Message"

    Import-OpenEndpointEventsQuiet

    if (Get-Command Write-EndpointInfo -ErrorAction SilentlyContinue) {
        if ($null -eq $Data) {
            $Data = @{}
        }

        $Data["StepStatus"] = "Success"

        Write-EndpointInfo `
            -Source "OpenEndpointEventsInstaller" `
            -EventName $EventName `
            -Message $Message `
            -CorrelationId $CorrelationId `
            -IncludeEndpointIdentity `
            -IncludeProcessInfo `
            -Data $Data | Out-Null
    }
}

function Write-StepError {
    param(
        [string]$EventName,
        [string]$Message,
        [System.Management.Automation.ErrorRecord]$ErrorRecord,
        [hashtable]$Data
    )

    Write-Verbose "[OpenEndpointEventsInstaller] ERROR: $Message"

    Import-OpenEndpointEventsQuiet

    if (Get-Command Write-EndpointError -ErrorAction SilentlyContinue) {
        if ($null -eq $Data) {
            $Data = @{}
        }

        $Data["StepStatus"] = "Failed"

        Write-EndpointError `
            -Source "OpenEndpointEventsInstaller" `
            -EventName $EventName `
            -Message $Message `
            -CorrelationId $CorrelationId `
            -ErrorRecord $ErrorRecord `
            -IncludeEndpointIdentity `
            -IncludeProcessInfo `
            -Data $Data | Out-Null
    }
}

# ------------------------------------------------------------
# Main
# ------------------------------------------------------------

try {
    [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12

    Write-Verbose "[OpenEndpointEventsInstaller] CorrelationId: $CorrelationId"

    Write-StepInfo `
        -EventName "InstallStarted" `
        -Message "OpenEndpointEvents uploader installation started" `
        -Data @{
            ConfigUriConfigured = $true
            BaseRoot            = $BaseRoot
            StartNow            = [bool]$StartNow
            ForceInstall        = [bool]$ForceInstall
        }

    Write-StepInfo `
        -EventName "CreateFolderStructure" `
        -Message "Creating OpenEndpointEvents folder structure" `
        -Data @{
            BaseRoot   = $BaseRoot
            LogRoot    = $LogRoot
            ConfigRoot = $ConfigRoot
            StateRoot  = $StateRoot
            ScriptRoot = $ScriptRoot
        }

    foreach ($path in @($BaseRoot, $LogRoot, $ConfigRoot, $StateRoot, $ScriptRoot)) {
        if (-not (Test-Path -Path $path)) {
            New-Item -Path $path -ItemType Directory -Force | Out-Null
        }
    }

    Write-StepInfo `
        -EventName "LocateModule" `
        -Message "Locating or installing OpenEndpointEvents module" `
        -Data @{}

    $module = Get-Module -ListAvailable OpenEndpointEvents |
        Sort-Object Version -Descending |
        Select-Object -First 1

    if (-not $module) {
        Install-Module `
            -Name OpenEndpointEvents `
            -Scope AllUsers `
            -Force

        $module = Get-Module -ListAvailable OpenEndpointEvents |
            Sort-Object Version -Descending |
            Select-Object -First 1
    }

    if (-not $module) {
        throw "OpenEndpointEvents module could not be found or installed."
    }

    Import-OpenEndpointEventsQuiet

    Write-StepInfo `
        -EventName "ModuleLocated" `
        -Message "OpenEndpointEvents module located" `
        -Data @{
            ModuleVersion = $module.Version.ToString()
            ModuleBase    = $module.ModuleBase
        }

    $embeddedScriptRoot = Join-Path $module.ModuleBase "Scripts"

    if (-not (Test-Path -Path $embeddedScriptRoot)) {
        throw "Embedded Scripts folder not found in module package: $embeddedScriptRoot"
    }

    $sourceUploadScript = Join-Path $embeddedScriptRoot "Upload-EndpointEvents.ps1"
    $sourceConfigRefreshScript = Join-Path $embeddedScriptRoot "Update-OpenEndpointEventsConfig.ps1"

    if (-not (Test-Path -Path $sourceUploadScript)) {
        throw "Upload script missing from module package: $sourceUploadScript"
    }

    if (-not (Test-Path -Path $sourceConfigRefreshScript)) {
        throw "Config refresh script missing from module package: $sourceConfigRefreshScript"
    }

    Write-StepInfo `
        -EventName "CopyRuntimeScripts" `
        -Message "Copying runtime uploader scripts to ProgramData" `
        -Data @{
            SourceUploadScript        = $sourceUploadScript
            SourceConfigRefreshScript = $sourceConfigRefreshScript
            UploadScriptPath          = $UploadScriptPath
            ConfigRefreshScriptPath   = $ConfigRefreshScriptPath
        }

    Copy-Item -Path $sourceUploadScript -Destination $UploadScriptPath -Force
    Copy-Item -Path $sourceConfigRefreshScript -Destination $ConfigRefreshScriptPath -Force

    Write-StepInfo `
        -EventName "WriteRefreshConfig" `
        -Message "Writing local config-refresh.json" `
        -Data @{
            RefreshConfigPath = $RefreshConfigPath
            ForceInstall      = [bool]$ForceInstall
        }

    if (-not (Test-Path -Path $RefreshConfigPath) -or $ForceInstall) {
        $refreshConfig = [ordered]@{
            ConfigUri       = $ConfigUri
            LocalConfigPath = $UploaderConfigPath
            StatePath       = $ConfigRefreshStatePath
        }

        $refreshConfig |
            ConvertTo-Json -Depth 10 |
            Set-Content -Path $RefreshConfigPath -Encoding UTF8
    }

    Write-StepInfo `
        -EventName "ApplyConfigAcl" `
        -Message "Applying ACLs to config folder" `
        -Data @{
            ConfigRoot = $ConfigRoot
        }

    Set-OpenEndpointEventsConfigAcl -Path $ConfigRoot

    Write-StepInfo `
        -EventName "RunConfigRefresh" `
        -Message "Running config refresh and applying schedule from remote config" `
        -Data @{
            ConfigRefreshScriptPath = $ConfigRefreshScriptPath
        }

    $updateArgs = @(
        "-NoProfile",
        "-ExecutionPolicy", "Bypass",
        "-File", $ConfigRefreshScriptPath,
        "-Force",
        "-ApplySchedule",
        "-CorrelationId", $CorrelationId
    )

    if ($VerbosePreference -eq "Continue") {
        $updateArgs += "-Verbose"
    }

    & powershell.exe @updateArgs

    if ($LASTEXITCODE -ne 0) {
        throw "Config refresh failed with exit code $LASTEXITCODE."
    }

    Write-StepInfo `
        -EventName "ConfigRefreshCompleted" `
        -Message "Config refresh completed and schedule was applied" `
        -Data @{
            UploaderConfigPath = $UploaderConfigPath
        }

    Write-StepInfo `
        -EventName "InstallCompleted" `
        -Message "OpenEndpointEvents uploader installation completed" `
        -Data @{
            RefreshConfigPath        = $RefreshConfigPath
            UploaderConfigPath       = $UploaderConfigPath
            UploadScriptPath         = $UploadScriptPath
            ConfigRefreshScriptPath  = $ConfigRefreshScriptPath
            Status                   = "Success"
        }

    if ($StartNow) {
        Write-StepInfo `
            -EventName "InitialUploadStarted" `
            -Message "Running immediate upload after installation" `
            -Data @{
                UploadScriptPath = $UploadScriptPath
            }

        $uploadArgs = @(
            "-NoProfile",
            "-ExecutionPolicy", "Bypass",
            "-File", $UploadScriptPath,
            "-Now",
            "-Window", "AllChanged",
            "-ForceUpload",
            "-CorrelationId", $CorrelationId
        )

        if ($VerbosePreference -eq "Continue") {
            $uploadArgs += "-Verbose"
        }

        & powershell.exe @uploadArgs

        if ($LASTEXITCODE -ne 0) {
            throw "Initial upload failed with exit code $LASTEXITCODE."
        }

        Write-StepInfo `
            -EventName "InitialUploadCompleted" `
            -Message "Immediate upload completed after installation" `
            -Data @{
                UploadScriptPath = $UploadScriptPath
                Status           = "Success"
            }
    }

    Write-Host "OpenEndpointEvents uploader installed."
    Write-Host "CorrelationId: $CorrelationId"
    Write-Host "Refresh config path: $RefreshConfigPath"
    Write-Host "Uploader config path: $UploaderConfigPath"
    Write-Host "Upload script path: $UploadScriptPath"
    Write-Host "Config refresh script path: $ConfigRefreshScriptPath"

    exit 0
}
catch {
    Write-StepError `
        -EventName "InstallFailed" `
        -Message "OpenEndpointEvents uploader installation failed" `
        -ErrorRecord $_ `
        -Data @{
            ConfigUriConfigured = -not [string]::IsNullOrWhiteSpace($ConfigUri)
            BaseRoot            = $BaseRoot
            Status              = "Failed"
        }

    throw
}