#!/usr/bin/env pwsh <# .SYNOPSIS Bootstraps local GitLab credentials into the dev Vault for local integrations. .DESCRIPTION Reuses the current `secret/gitlab` credentials when they still pass the local GitLab API probe (and optional registry token exchange probe). Otherwise, the script signs in to the local GitLab CE instance as the configured root user, revokes any earlier bootstrap personal access tokens with the same name, creates a replacement token, verifies it, and stores: - `access-token` - `registry-basic` under the configured Vault KV v2 path. .PARAMETER GitLabUrl Base URL of the local GitLab CE web service. .PARAMETER GitLabRegistryUrl Base URL of the local GitLab container registry endpoint. .PARAMETER GitLabUsername Local GitLab admin username used for the bootstrap flow. .PARAMETER GitLabPassword Local GitLab admin password used for the bootstrap flow. .PARAMETER VaultUrl Base URL of the local Vault dev server. .PARAMETER VaultToken Vault token used to read and write the local secret path. .PARAMETER VaultSecretPath KV v2 path in `/` form. Defaults to `secret/gitlab`. .PARAMETER TokenName GitLab personal access token name used for the local integration bootstrap. .PARAMETER TokenLifetimeDays Lifetime of the generated GitLab personal access token. .PARAMETER Rotate Forces PAT rotation even when the current Vault secret still verifies cleanly. .PARAMETER VerifyRegistry Also verify that the stored or generated `registry-basic` secret can exchange against GitLab's `/jwt/auth` registry token endpoint. #> [CmdletBinding()] param( [string]$GitLabUrl = 'http://gitlab.stella-ops.local:8929', [string]$GitLabRegistryUrl = 'http://gitlab.stella-ops.local:5050', [string]$GitLabUsername = 'root', [string]$GitLabPassword = 'Stella2026!', [string]$VaultUrl = 'http://vault.stella-ops.local:8200', [string]$VaultToken = 'stellaops-dev-root-token-2026', [string]$VaultSecretPath = 'secret/gitlab', [string]$TokenName = 'stella-local-integration', [ValidateRange(1, 365)] [int]$TokenLifetimeDays = 30, [ValidateRange(10, 900)] [int]$ReadinessTimeoutSeconds = 600, [switch]$Rotate, [switch]$VerifyRegistry ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $GitLabUrl = $GitLabUrl.TrimEnd('/') $GitLabRegistryUrl = $GitLabRegistryUrl.TrimEnd('/') $VaultUrl = $VaultUrl.TrimEnd('/') function Get-HttpStatusCode { param([Parameter(Mandatory)]$ErrorRecord) $response = $ErrorRecord.Exception.Response if ($null -eq $response) { return $null } if ($response.PSObject.Properties.Name -contains 'StatusCode') { $statusCode = $response.StatusCode if ($statusCode -is [int]) { return $statusCode } try { return [int]$statusCode } catch { return $null } } return $null } function Wait-HttpReady { param( [Parameter(Mandatory)][string]$Name, [Parameter(Mandatory)][string]$Uri, [hashtable]$Headers, [int]$TimeoutSeconds = 300 ) $deadline = (Get-Date).ToUniversalTime().AddSeconds($TimeoutSeconds) do { try { Invoke-RestMethod -Method GET -Uri $Uri -Headers $Headers -TimeoutSec 30 -ErrorAction Stop | Out-Null return } catch { if ((Get-Date).ToUniversalTime() -ge $deadline) { throw "Timed out waiting for $Name at $Uri. Last error: $($_.Exception.Message)" } Start-Sleep -Seconds 5 } } while ($true) } function Split-VaultSecretPath { param([Parameter(Mandatory)][string]$Path) $normalized = $Path.Trim('/') $segments = $normalized -split '/', 2 if ($segments.Count -ne 2 -or [string]::IsNullOrWhiteSpace($segments[0]) -or [string]::IsNullOrWhiteSpace($segments[1])) { throw "VaultSecretPath must be in '/' form. Received '$Path'." } return [pscustomobject]@{ Mount = $segments[0] Path = $segments[1] } } function Invoke-VaultJson { param( [Parameter(Mandatory)][ValidateSet('GET', 'POST')][string]$Method, [Parameter(Mandatory)][string]$Path, [object]$Body ) $parameters = @{ Method = $Method Uri = "$VaultUrl/v1/$Path" Headers = @{ 'X-Vault-Token' = $VaultToken } TimeoutSec = 30 ErrorAction = 'Stop' } if ($null -ne $Body) { $parameters['ContentType'] = 'application/json' $parameters['Body'] = $Body | ConvertTo-Json -Depth 10 } return Invoke-RestMethod @parameters } function Get-VaultGitLabSecret { param([Parameter(Mandatory)]$VaultPathParts) try { $response = Invoke-VaultJson -Method GET -Path "$($VaultPathParts.Mount)/data/$($VaultPathParts.Path)" return $response.data.data } catch { $statusCode = Get-HttpStatusCode -ErrorRecord $_ if ($statusCode -eq 404) { return $null } throw } } function Set-VaultGitLabSecret { param( [Parameter(Mandatory)]$VaultPathParts, [Parameter(Mandatory)][hashtable]$Data ) Invoke-VaultJson -Method POST -Path "$($VaultPathParts.Mount)/data/$($VaultPathParts.Path)" -Body @{ data = $Data } | Out-Null } function Get-GitLabOAuthToken { $response = Invoke-RestMethod -Method POST -Uri "$GitLabUrl/oauth/token" -ContentType 'application/x-www-form-urlencoded' -Body @{ grant_type = 'password' username = $GitLabUsername password = $GitLabPassword } -TimeoutSec 60 -ErrorAction Stop if ([string]::IsNullOrWhiteSpace($response.access_token)) { throw "GitLab OAuth password grant did not return an access token." } return "$($response.access_token)" } function Get-GitLabAdminHeaders { $deadline = (Get-Date).ToUniversalTime().AddSeconds($ReadinessTimeoutSeconds) do { try { $oauthToken = Get-GitLabOAuthToken return @{ Authorization = "Bearer $oauthToken" } } catch { if ((Get-Date).ToUniversalTime() -ge $deadline) { throw "Timed out waiting for GitLab admin login at $GitLabUrl. Last error: $($_.Exception.Message)" } Start-Sleep -Seconds 5 } } while ($true) } function Get-GitLabCurrentUser { param([Parameter(Mandatory)][hashtable]$Headers) return Invoke-RestMethod -Method GET -Uri "$GitLabUrl/api/v4/user" -Headers $Headers -TimeoutSec 30 -ErrorAction Stop } function Get-GitLabPersonalAccessTokens { param( [Parameter(Mandatory)][hashtable]$Headers, [Parameter(Mandatory)][int]$UserId ) $response = Invoke-RestMethod -Method GET -Uri "$GitLabUrl/api/v4/personal_access_tokens?user_id=$UserId" -Headers $Headers -TimeoutSec 30 -ErrorAction Stop return @($response) } function Revoke-GitLabPersonalAccessToken { param( [Parameter(Mandatory)][hashtable]$Headers, [Parameter(Mandatory)][int]$TokenId ) Invoke-RestMethod -Method DELETE -Uri "$GitLabUrl/api/v4/personal_access_tokens/$TokenId" -Headers $Headers -TimeoutSec 30 -ErrorAction Stop | Out-Null } function New-GitLabPersonalAccessToken { param( [Parameter(Mandatory)][hashtable]$Headers, [Parameter(Mandatory)][int]$UserId, [Parameter(Mandatory)][string]$Name, [Parameter(Mandatory)][string[]]$Scopes, [Parameter(Mandatory)][string]$ExpiresAt ) $body = @{ name = $Name description = 'Stella Ops local integration bootstrap' expires_at = $ExpiresAt scopes = $Scopes } | ConvertTo-Json -Depth 5 return Invoke-RestMethod -Method POST -Uri "$GitLabUrl/api/v4/users/$UserId/personal_access_tokens" -Headers $Headers -ContentType 'application/json' -Body $body -TimeoutSec 30 -ErrorAction Stop } function Test-GitLabApiToken { param([Parameter(Mandatory)][string]$Token) if ([string]::IsNullOrWhiteSpace($Token)) { return $false } try { $response = Invoke-RestMethod -Method GET -Uri "$GitLabUrl/api/v4/version" -Headers @{ 'PRIVATE-TOKEN' = $Token } -TimeoutSec 30 -ErrorAction Stop return -not [string]::IsNullOrWhiteSpace($response.version) } catch { return $false } } function Test-GitLabRegistryCredential { param([Parameter(Mandatory)][string]$RegistryBasic) if ([string]::IsNullOrWhiteSpace($RegistryBasic) -or -not $RegistryBasic.Contains(':')) { return $false } try { Invoke-WebRequest -Method GET -Uri "$GitLabRegistryUrl/v2/" -TimeoutSec 30 -ErrorAction Stop | Out-Null } catch { $statusCode = Get-HttpStatusCode -ErrorRecord $_ if ($statusCode -ne 401) { return $false } } try { $encoded = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes($RegistryBasic)) $response = Invoke-RestMethod -Method GET -Uri "$GitLabUrl/jwt/auth?service=container_registry&scope=registry:catalog:*" -Headers @{ Authorization = "Basic $encoded" } -TimeoutSec 30 -ErrorAction Stop return -not [string]::IsNullOrWhiteSpace($response.token) } catch { return $false } } $vaultPathParts = Split-VaultSecretPath -Path $VaultSecretPath Write-Host "Waiting for Vault and GitLab to become ready..." -ForegroundColor Cyan Wait-HttpReady -Name 'Vault' -Uri "$VaultUrl/v1/sys/health" -TimeoutSeconds $ReadinessTimeoutSeconds $existingSecret = Get-VaultGitLabSecret -VaultPathParts $vaultPathParts if (-not $Rotate -and $null -ne $existingSecret) { $apiValid = Test-GitLabApiToken -Token "$($existingSecret.'access-token')" $registryValid = $true if ($VerifyRegistry) { $registryValid = Test-GitLabRegistryCredential -RegistryBasic "$($existingSecret.'registry-basic')" } if ($apiValid -and $registryValid) { Write-Host "Existing GitLab bootstrap secret at '$VaultSecretPath' is still valid; reusing it." -ForegroundColor Green Write-Host "AuthRefs: authref://vault/gitlab#access-token, authref://vault/gitlab#registry-basic" return } Write-Host "Existing GitLab bootstrap secret at '$VaultSecretPath' is stale or incomplete; rotating it." -ForegroundColor Yellow } Write-Host "Signing in to GitLab and reconciling the local bootstrap PAT..." -ForegroundColor Cyan $adminHeaders = Get-GitLabAdminHeaders $currentUser = Get-GitLabCurrentUser -Headers $adminHeaders $expiresAt = (Get-Date).ToUniversalTime().AddDays($TokenLifetimeDays).ToString('yyyy-MM-dd') $tokensToRevoke = Get-GitLabPersonalAccessTokens -Headers $adminHeaders -UserId ([int]$currentUser.id) | Where-Object { $_.name -eq $TokenName -and $_.active -and -not $_.revoked } foreach ($token in $tokensToRevoke) { Revoke-GitLabPersonalAccessToken -Headers $adminHeaders -TokenId ([int]$token.id) } $newToken = New-GitLabPersonalAccessToken -Headers $adminHeaders -UserId ([int]$currentUser.id) -Name $TokenName -Scopes @('api', 'read_registry') -ExpiresAt $expiresAt $registryBasic = "${GitLabUsername}:$($newToken.token)" Write-Host "Verifying the new GitLab PAT against the API and registry surfaces..." -ForegroundColor Cyan if (-not (Test-GitLabApiToken -Token "$($newToken.token)")) { throw "The newly created GitLab personal access token failed the API probe." } if ($VerifyRegistry -and -not (Test-GitLabRegistryCredential -RegistryBasic $registryBasic)) { throw "The newly created GitLab personal access token failed the registry token exchange probe. Ensure the GitLab registry surface is enabled." } $secretData = @{ 'access-token' = "$($newToken.token)" 'registry-basic' = $registryBasic 'bootstrap-user' = $GitLabUsername 'token-name' = $TokenName 'expires-at' = "$($newToken.expires_at)" 'rotated-at' = (Get-Date).ToUniversalTime().ToString('o') } Set-VaultGitLabSecret -VaultPathParts $vaultPathParts -Data $secretData Write-Host "Bootstrapped GitLab PAT material into Vault path '$VaultSecretPath'." -ForegroundColor Green Write-Host "AuthRefs: authref://vault/gitlab#access-token, authref://vault/gitlab#registry-basic" Write-Host "Token name: $TokenName" Write-Host "Expires at: $($newToken.expires_at)"