Files
git.stella-ops.org/scripts/bootstrap-local-gitlab-secrets.ps1
master a19987979d feat(devops): local GitLab secret bootstrap + integration registration scripts
Adds PowerShell helpers to seed the local Stella Ops stack with a working
GitLab + integrations configuration:
- bootstrap-local-gitlab-secrets.ps1 provisions GitLab's JWT signing secret
  and admin PAT into Vault/Authority.
- register-local-integrations.ps1 POSTs the canonical integration records
  (GitLab, Jenkins, Harbor, Gitea, Nexus, etc.) against the Integrations
  service for first-run local environments.

Docs: INSTALL_GUIDE.md + integrations/LOCAL_SERVICES.md document the new
helpers. devops/compose README and router-gateway-local.json get the
corresponding route wiring. Two new sprint files track the follow-on work
(SPRINT_20260413_002, SPRINT_20260413_003).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 21:59:13 +03:00

352 lines
12 KiB
PowerShell

#!/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 `<mount>/<path>` 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 '<mount>/<path>' 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)"