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>
352 lines
12 KiB
PowerShell
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)"
|