iT邦幫忙

2026 iThome 鐵人賽

DAY 8
0

本文從一個空的 CRC 叢集開始,部署 Gitea,並在 Windows 本機完成一次 git push,終點是從 Gitea API 回讀的 commit SHA 與本機一致

每一步都附上目的與預期輸出。輸出取自實測,換版本或換機器不保證重現。

測試環境

項目 版本
CRC / OpenShift 2.61.0+6eb443 / 4.21.14(8 cpu、18432 MB、disk 180 GB)
Chart / Gitea gitea-charts/gitea 12.7.0 / app 1.27.0
PowerShell 5.1(Windows 內建)
git 2.54.0.windows.1,http.sslbackend(system) = schannel
curl 系統 8.21.0(Schannel)、Git 附帶 8.19.0(Schannel)、Git\usr\bin\openssl.exe 3.5.6

0. 清場(首次執行可略過)

目的:讓重跑從乾淨狀態開始。StorageClass 的 reclaimPolicyRetain,刪掉 PVC 與 PV 之後,節點上的資料夾仍會留著,而且不會出現在任何 oc get 輸出裡。每重跑一輪固定累積 3 個目錄、約 70 MB。

& $OC --kubeconfig $kubeconfig delete namespace gitea --ignore-not-found
& $OC --kubeconfig $kubeconfig get pv | Select-String "Released"
# 逐一刪除上列 Released 的 PV

清點節點上的殘留:目錄名即 PV 名稱,與現存 PV 清單交叉比對後才是孤兒。

$livePv = @((& $OC --kubeconfig $kubeconfig get pv -o jsonpath='{.items[*].metadata.name}') -split '\s+' |
            Where-Object { $_ })
$dirs = @(& $OC --kubeconfig $kubeconfig debug node/crc --quiet -- `
            chroot /host ls -1 /var/lib/csi-hostpath-data) | Where-Object { $_ -match '^pvc-' }
$orphans = @($dirs | Where-Object { $livePv -notcontains $_ })
"現存 PV = $($livePv.Count);節點目錄 = $($dirs.Count);孤兒 = $($orphans.Count)"
$orphans

現存 PV = 2;節點目錄 = 5;孤兒 = 3

確認清單無誤後才刪除。逐一指名,不使用萬用字元。

$paths = ($orphans | ForEach-Object { "/var/lib/csi-hostpath-data/$_" }) -join ' '
& $OC --kubeconfig $kubeconfig debug node/crc --quiet -- chroot /host sh -c "rm -rf $paths"
& $OC --kubeconfig $kubeconfig debug node/crc --quiet -- chroot /host du -sh /var/lib/csi-hostpath-data

74M → 約 4M

清掉本機的 git 設定,讓第 11 步的基準測試有意義。目標鍵不存在時退出碼為 5,屬正常。

git config --global --unset-all http."https://gitea-gitea.apps-crc.testing/".sslCAInfo 2>$null
git config --global --unset-all http."https://gitea-gitea.apps-crc.testing/".sslbackend 2>$null

變數

目的:集中定義後續共用的名稱與路徑。$caPath 不放在 $workDir 底下——git 設定指向固定路徑,不應隨每次執行的時間戳變動。

GIT_TERMINAL_PROMPT 只擋 git 自己的終端機提示,擋不住 Git Credential Manager 的圖形視窗,兩者都要關。

$OC = (Get-ChildItem "$env:USERPROFILE\.crc\cache" -Recurse -Filter "oc.exe" | Select-Object -First 1).FullName
$kubeconfig = "$env:USERPROFILE\.crc\machines\crc\kubeconfig"
$apiServer  = "https://api.crc.testing:6443"

$gtNs         = "gitea"
$gtDeployerSa = "gitea-deployer"
$gtChartVer   = "12.7.0"
$gtUser       = "gitea_admin"
$caPath       = "$env:USERPROFILE\certs\crc-ingress-ca.crt"

$env:GIT_TERMINAL_PROMPT = "0"
$env:GCM_INTERACTIVE     = "Never"

$gtWorkDir = Join-Path "$env:USERPROFILE\gitea-run" (Get-Date -Format "yyyyMMdd-HHmmss")
New-Item -ItemType Directory -Path $gtWorkDir -Force | Out-Null
New-Item -ItemType Directory -Path (Split-Path $caPath) -Force | Out-Null
"workDir = $gtWorkDir"

1. 建 namespace 與部署帳號

目的:建立限縮於單一 namespace 的部署身分,取代全程使用 system:admin。edit 涵蓋後續需要的 Secret、Deployment、Service、Route 與 Helm release 記錄。

不論先前的 context 是什麼,一律以 --kubeconfig 取得 system:admin——crc start 會改寫預設 kubeconfig。

& $OC --kubeconfig $kubeconfig whoami
& $OC --kubeconfig $kubeconfig create namespace $gtNs
& $OC --kubeconfig $kubeconfig create sa $gtDeployerSa -n $gtNs
& $OC --kubeconfig $kubeconfig adm policy add-role-to-user edit -z $gtDeployerSa -n $gtNs

system:admin
namespace/gitea created
serviceaccount/gitea-deployer created
clusterrole.rbac.authorization.k8s.io/edit added: "gitea-deployer"

不授權 anyuid。 此 Chart 在 OpenShift 上安裝時會移除寫死的 UID,三個 Pod 皆以 restricted-v2 進場,第 7 步會驗證這件事。

2. 切換身分

目的:取得部署帳號的 token 並登入。第 3–8 步以該身分執行。

$gtToken = & $OC --kubeconfig $kubeconfig create token $gtDeployerSa -n $gtNs --duration=8h
& $OC login --token=$gtToken --server=$apiServer --insecure-skip-tls-verify=true
& $OC whoami

WARNING: Using insecure TLS client config. Setting this option is not supported!
Logged into "https://api.crc.testing:6443" as "system:serviceaccount:gitea:gitea-deployer"
You have one project on this server: "gitea" / Using project "gitea".
system:serviceaccount:gitea:gitea-deployer

token 效期 8 小時,跨日重跑需重新執行本步。

3. 確認權限

目的:確認部署帳號足以完成第 4–8 步,且未取得叢集層級權限。以權限查詢驗證,不以第 1 步的輸出訊息驗證。

& $OC auth can-i create deployment -n $gtNs
& $OC auth can-i create route -n $gtNs
& $OC auth can-i get secret -n openshift-ingress-operator
& $OC auth can-i create namespace

yes
yes
no
Warning: resource 'namespaces' is not namespace scoped / no

第三行的 no 是刻意查的:CA 憑證在該 namespace,此身分讀不到,所以第 9 步會跳回 --kubeconfig。整段退出碼是 1。

4. Chart 與 storageClass

目的:留下本版 Chart 的 values 參考檔,並確認 storageClass 名稱。名稱錯誤時 PVC 會停在 Pending,而該狀態要到第 6 步之後才顯現。

helm repo add gitea-charts https://dl.gitea.com/charts/
helm repo update

$gtRefPath = Join-Path $gtWorkDir "chart-values-ref.yaml"
[System.IO.File]::WriteAllText($gtRefPath,
    (helm show values gitea-charts/gitea --version $gtChartVer | Out-String),
    (New-Object System.Text.UTF8Encoding $false))

& $OC get storageclass -o custom-columns=NAME:.metadata.name,DEFAULT:.metadata.annotations."storageclass\.kubernetes\.io/is-default-class",RECLAIM:.reclaimPolicy

chart-values-ref.yaml 37,395 bytes
crc-csi-hostpath-provisioner true Retain

離線渲染不能用來預測 securityContext。 helm templatehelm install --dry-run=client 都不查叢集,.Capabilities.APIVersions 不含 security.openshift.io/v1,Chart 的 OpenShift 判斷不成立,會渲染出寫死的 runAsUser: 1000/1001。要與線上一致必須查叢集——本文實測的是 helm install --dry-run=server,helm template --dry-run=server 未測(上游回報該旗標在 helm template 上只影響 lookup):

helm install gitea gitea-charts/gitea -n $gtNs --version $gtChartVer --dry-run=server |
    Out-File (Join-Path $gtWorkDir "rendered-ha-server.yaml")
Select-String -Path (Join-Path $gtWorkDir "rendered-ha-server.yaml") -Pattern "runAsUser"

無命中(檔案 180,031 bytes)

5. values 與 Secret

目的:寫入 values 與 admin 憑證。Chart 只引用 Secret 不生成,需先存在。寫檔用 WriteAllText 以避免 BOM,values.yaml 要交給 YAML parser。

Chart 預設是 HA 組合(postgresql-ha + valkey-cluster),PVC 宣告總量 64 Gi。單機叢集改用單副本,降到 28 Gi、三個 Pod。

gitea.config.* 底下的設定會由 Helm 渲染進 gitea-inline-config Secret,initContainer 每次啟動照它重新產生 app.ini。設在這裡才會持久;直接改容器裡的 app.ini 撐不過下一次重啟。

$gtValues = @"
persistence:
  enabled: true
  storageClass: crc-csi-hostpath-provisioner
  size: 10Gi

gitea:
  admin:
    existingSecret: gitea-admin
  config:
    webhook:
      ALLOWED_HOST_LIST: loopback,private,external

postgresql-ha:
  enabled: false
postgresql:
  enabled: true
valkey-cluster:
  enabled: false
valkey:
  enabled: true
"@
$gtValuesPath = Join-Path $gtWorkDir "values.yaml"
[System.IO.File]::WriteAllText($gtValuesPath, $gtValues, (New-Object System.Text.UTF8Encoding $false))

$b = [System.IO.File]::ReadAllBytes($gtValuesPath)
"bytes = $($b.Length); first4 = $(($b[0..3] | ForEach-Object { $_.ToString('X2') }) -join ' ')"

bytes = 327; first4 = 70 65 72 73

327 對應 LF 換行且無結尾換行。存成 CRLF 的 .ps1 會得到不同數字,不是錯誤。

webhook.ALLOWED_HOST_LIST 這個位置已被 Gitea 標為棄用,啟動時會在 container log 留下一則 [E] 訊息,指出應改用 [security] 區段,且該 fallback 將在 v28.0.0 移除。目前仍生效(實測 webhook 投遞成功)。改成下列寫法尚未實測,一併會改動本步的 bytes 與第 7 步的 key 名稱:

  config:
    security:
      ALLOWED_HOST_LIST: loopback,private,external

密碼用 GUID:只含十六進位字元與連字號,第 14 步放進 credential store 檔案時不需 URL 編碼。

$gtPass = [System.Guid]::NewGuid().ToString()
@"
apiVersion: v1
kind: Secret
metadata:
  name: gitea-admin
  namespace: $gtNs
type: Opaque
data:
  username: $([Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($gtUser)))
  password: $([Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($gtPass)))
"@ | & $OC apply -f -

$back = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String(
    (& $OC get secret gitea-admin -n $gtNs -o jsonpath='{.data.password}')))
"round-trip match = $($back -eq $gtPass)"

secret/gitea-admin created
round-trip match = True

密碼可以事後輪替:改 Secret 之後執行 oc rollout restart deployment/gitea -n gitea,initContainer 會呼叫 gitea admin user change-password 同步,舊密碼隨即失效(實測 new: 200 / old: 401)。

只改 Secret 而不重啟,不會有任何效果,也不會有錯誤訊息——Gitea 不在執行期讀取 Secret,密碼是啟動時寫進資料庫的。

此行為取決於 gitea.admin.passwordMode,預設 keepUpdated。該模式的語意是每次 Pod 重建都把密碼重設為所定義的值,因此在網頁 UI 自行修改的 admin 密碼也會在下一次重啟被覆蓋回 Secret 裡的值。

6. 安裝

目的:部署 Gitea 並等待就緒。就緒判定看 Pod 的 Ready condition,不看 status.phase,也不比對 Pod 名稱——Chart 12.6.0 起由 StatefulSet 改為 Deployment,Pod 名稱含隨機 hash。

containerStatuses 只能用來顯示進度,不能用來判定:restartPolicy: Always 的 native sidecar 出現在 initContainerStatuses,只看前者會提早回報就緒。

$env:KUBECONFIG = ""
$t0 = Get-Date
helm install gitea gitea-charts/gitea -n $gtNs -f $gtValuesPath --version $gtChartVer

STATUS: deployed,REVISION 1,chart 12.7.0 / app 1.27.0

$deadline = (Get-Date).AddMinutes(10)
$last = ""
while ($true) {
    $raw = & $OC get pod -n $gtNs -o json 2>$null
    if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($raw)) {
        "[{0:HH:mm:ss}] (查詢失敗,重試)" -f (Get-Date)
    } else {
        $pods = ($raw | ConvertFrom-Json).items
        if ($pods.Count -eq 0) {
            $snap = "(namespace 內尚無 Pod)"; $allReady = $false
        } else {
            $snap = ($pods | ForEach-Object {
                $r = @($_.status.containerStatuses | Where-Object { $_.ready }).Count
                $t = @($_.status.containerStatuses).Count
                "$($_.metadata.name) $r/$t $($_.status.phase)"
            }) -join " | "
            $allReady = -not ($pods | Where-Object {
                -not ($_.status.conditions | Where-Object { $_.type -eq 'Ready' -and $_.status -eq 'True' })
            })
        }
        if ($snap -ne $last) { "[{0:HH:mm:ss}] {1}" -f (Get-Date), $snap; $last = $snap }
        if ($allReady) { "ALL READY"; break }
    }
    if ((Get-Date) -gt $deadline) { throw "等待 Pod 就緒逾時(10 分鐘)" }
    Start-Sleep -Seconds 10
}
"全就緒耗時 = $([int]((Get-Date) - $t0).TotalSeconds) 秒"

三個 Pod(gitea / postgresql / valkey-primary)依序就緒 → ALL READY

耗時不具重現性:節點無映像快取時約 3 分 27 秒,已快取時約 40–75 秒(六輪實測)。引用此數字須一併標明映像狀態。

7. 檢核

目的:確認 PVC、SCC、執行身分、憑證來源、config 五項與預期一致。

& $OC get pvc -n $gtNs
& $OC get pod -n $gtNs -o custom-columns=NAME:.metadata.name,SCC:.metadata.annotations."openshift\.io/scc"

$gtPod = & $OC get pod -n $gtNs -l app.kubernetes.io/name=gitea -o jsonpath='{.items[0].metadata.name}'
& $OC exec $gtPod -n $gtNs -- id
& $OC get namespace $gtNs -o jsonpath='{.metadata.annotations.openshift\.io/sa\.scc\.uid-range}'

三個 PVC Bound(CAPACITY 顯示的是節點磁碟容量,不是宣告值)
三個 Pod 的 SCC 皆 restricted-v2
uid= 落在同時印出的 uid-range 之內

UID 由叢集從區間池配發,namespace 刪除後該區間會回收再利用,數值不可預期(六輪實測依序為 1000660000、670000、680000、690000、700000,第六輪跳回 660000)。不要寫死,與同時印出的 uid-range 比對即可。

憑證注在 initContainer,不在 containers——只查後者會得到空結果,並把「什麼都沒驗到」記成通過。

$p = & $OC get pod $gtPod -n $gtNs -o json | ConvertFrom-Json
@($p.spec.initContainers) + @($p.spec.containers) | ForEach-Object {
    $c = $_.name
    $_.env | Where-Object { $_.valueFrom.secretKeyRef } | ForEach-Object {
        "[$c] $($_.name)=<secretKeyRef: $($_.valueFrom.secretKeyRef.name)/$($_.valueFrom.secretKeyRef.key)>"
    }
}

[configure-gitea] GITEA_ADMIN_USERNAME=<secretKeyRef: gitea-admin/username>
[configure-gitea] GITEA_ADMIN_PASSWORD=<secretKeyRef: gitea-admin/password>

查工作負載的 spec 而不是 Pod 的 spec,才能分辨 UID 是「Chart 沒渲染」還是「SCC admission 改寫」。admission 只驗證或補值,不刪欄位;欄位不存在就是 Chart 沒產生。

& $OC get deployment,statefulset -n $gtNs -o json | ConvertFrom-Json |
    ForEach-Object { $_.items } |
    ForEach-Object { "$($_.kind)/$($_.metadata.name) runAsUser=$($_.spec.template.spec.securityContext.runAsUser)" }

& $OC get secret gitea-inline-config -n $gtNs -o json | ConvertFrom-Json |
    ForEach-Object { $_.data.PSObject.Properties } |
    ForEach-Object { "$($_.Name) = $([System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($_.Value)))" }

三個工作負載的 runAsUser= 皆為空
webhook = ALLOWED_HOST_LIST=loopback,private,external

8. Route

目的:建立對外入口。host 從 Route 讀出,不自行拼接。

& $OC create route edge gitea --service=gitea-http --port=3000 `
    --insecure-policy=Redirect -n $gtNs
$gtHost = & $OC get route gitea -n $gtNs -o jsonpath='{.spec.host}'
$gtUrl  = "https://$gtHost"
$gtUrl

route.route.openshift.io/gitea created
https://gitea-gitea.apps-crc.testing

gitea-http 是 headless Service,Route 直接指向 endpoints,不受影響。

9. 導出 Ingress CA

目的:取出叢集的自簽 CA,供後續 curl 與 git 使用。憑證在 openshift-ingress-operator,第 3 步已確認部署帳號讀不到,此步帶 --kubeconfig

WriteAllBytes 而不是 WriteAllText:來源是 base64 解出的位元組,直接落盤,不經過任何字串編碼決策。

$b64 = & $OC --kubeconfig $kubeconfig get secret router-ca -n openshift-ingress-operator -o jsonpath='{.data.tls\.crt}'
[System.IO.File]::WriteAllBytes($caPath, [Convert]::FromBase64String($b64))
$ca = [System.IO.File]::ReadAllBytes($caPath)
"bytes = $($ca.Length); first5 = $(($ca[0..4] | ForEach-Object { $_.ToString('X2') }) -join ' ')"

bytes = 1119; first5 = 2D 2D 2D 2D 2D

2D-,PEM 開頭的 -----。帶 BOM 會是 EF BB BF,UTF-16 會是 FF FE

10. 驗證 CA

目的:確認 CA 檔本身有效。這一步先做,後面 git 若失敗就能排除憑證檔的因素。

Windows 上的系統 curl 與 Git 附帶的 curl 都是 Schannel,與 git 預設後端相同,因此它們的成功不構成獨立驗證。Schannel 會查 Windows 憑證存放區,無法區分「CA 檔有效」與「這台機器本來就信任」。

curl.exe --cacert $caPath --ssl-revoke-best-effort -s -o NUL -w "with-ca: %{http_code}`n" "$gtUrl/api/v1/version"
curl.exe -k -s -o NUL -w "insecure: %{http_code}`n" "$gtUrl/api/v1/version"

with-ca: 200 / insecure: 200

--ssl-revoke-best-effort 是必要的:CRC 的自簽 CA 沒有 CRL/OCSP 端點,Schannel 會走到查撤銷狀態才失敗,回 exit 60 revocation status is unknown

真正獨立的驗證要用 OpenSSL——它不走 Schannel,也不查 Windows 存放區:

$osslExe = "C:\Program Files\Git\usr\bin\openssl.exe"
"Q" | & $osslExe s_client -connect "${gtHost}:443" -CAfile $caPath 2>&1 |
    Select-String "Verify return code"
"Q" | & $osslExe s_client -connect "${gtHost}:443" 2>&1 |
    Select-String "Verify return code"

Verify return code: 0 (ok)
Verify return code: 19 (self-signed certificate in certificate chain)

第一行證明 CA 檔有效,第二行證明不讀 Windows 存放區的實作確實不信任這張憑證。兩行一起看,才把「CA 檔有效」與「機器本來就信任」分開。

11. git 信任

目的:讓本機 git 信任這張自簽憑證,且不影響其他站台。

先測基準,判斷這台機器屬於哪種情況。判定用否定式:倉庫尚未建立,所以認證層一定失敗;要看的是失敗的種類,不是失敗的字串。認證層的訊息會隨 Credential Manager 有沒有介入而改變(實測出現過三種),不能寫死。

git config --system http.sslbackend
$probe = "$gtUrl/$gtUser/hello.git"

function Test-Tls {
    param([string]$Label)
    $out  = & git -c credential.helper= -c credential.interactive=false ls-remote $probe 2>&1
    $code = $LASTEXITCODE
    $txt  = ($out | Out-String)
    $fail = $txt -match 'SEC_E_UNTRUSTED_ROOT|self-signed certificate|unable to get local issuer|trust anchors|Could not resolve host|Failed to connect'
    "{0}: exit={1} TLS={2} :: {3}" -f $Label, $code, $(if ($fail) { "FAIL" } else { "PASS" }),
        (($txt -split "`n" | Where-Object { $_.Trim() } | Select-Object -First 1).Trim())
}

Test-Tls "基準"

基準: exit=128 TLS=PASS :: git.exe : fatal: unable to get password from user

訊息裡的 git.exe : 前綴來自 2>&1——PowerShell 5.1 會把原生程式的 stderr 包裝成 NativeCommandError 物件,字串化時附上程式名。判定比對的是訊息內容,前綴不影響結果。

TLS=PASS 代表 git 已完成交握、收到 401 才來要密碼——TLS 真的失敗時會在索取憑證之前就短路,回報 TLS 專屬訊息。

基準若已通過,表示 CRC 已把憑證裝進 Windows 憑證存放區:

Get-ChildItem Cert:\CurrentUser\Root | Where-Object { $_.Subject -match "ingress-operator|apps-crc" } |
    Select-Object Subject, NotAfter

CN=ingress-operator@... / CN=*.apps-crc.testing,皆有效至 2028-05-13

即使如此仍建議明確設定,理由是不依賴看不見的機器狀態。兩行必須同進退——切到 openssl 後端之後,它看不到 Windows 存放區,sslCAInfo 就從多餘變成必要。

git config --global http."$gtUrl/".sslCAInfo $caPath
git config --global http."$gtUrl/".sslbackend openssl
Test-Tls "設定後"
git config --global --get http."$gtUrl/".sslCAInfo

設定後: exit=128 TLS=PASS :: git.exe : fatal: unable to get password from user
C:\Users\...\certs\crc-ingress-ca.crt

兩行都是 URL-scoped,只作用於這個網域,其他站台維持預設。若基準測試失敗(SEC_E_UNTRUSTED_ROOT),這兩行就是唯一的解法,不是可選項。

12. API helper

目的:定義後續共用的呼叫函式。認證走設定檔不進指令列,暫存檔無 BOM 且刪除於 finally。密碼從 Secret 重新讀取,不沿用第 5 步的變數。

$gtPass = [System.Text.Encoding]::UTF8.GetString([Convert]::FromBase64String(
    (& $OC get secret gitea-admin -n $gtNs -o jsonpath='{.data.password}')))
$gtCred = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes("${gtUser}:${gtPass}"))
"cred length = $($gtCred.Length)"

function Invoke-GiteaApi {
    param([string]$Url, [string]$Method = "GET", [string]$JsonBody = $null)
    $cfg = Join-Path $env:TEMP ("gt-{0}.cfg" -f [guid]::NewGuid())
    $tmp = $null
    try {
        [System.IO.File]::WriteAllText($cfg, "header = `"Authorization: Basic $gtCred`"",
                                       (New-Object System.Text.UTF8Encoding $false))
        $a = @("-K", $cfg, "--cacert", $caPath, "--ssl-revoke-best-effort",
               "-s", "--fail-with-body", "-X", $Method,
               "-H", "Content-Type: application/json", $Url)
        if ($JsonBody) {
            $tmp = Join-Path $env:TEMP ("gt-{0}.json" -f [guid]::NewGuid())
            [System.IO.File]::WriteAllText($tmp, $JsonBody, (New-Object System.Text.UTF8Encoding $false))
            $a += @("--data-binary", "@$tmp")
        }
        $raw = & curl.exe @a
        $script:lastExit = $LASTEXITCODE
        return ($raw -join "`n")
    } finally {
        if (Test-Path $cfg) { Remove-Item $cfg -Force }
        if ($tmp -and (Test-Path $tmp)) { Remove-Item $tmp -Force }
    }
}

Invoke-GiteaApi -Url "$gtUrl/api/v1/version"; "exit=$lastExit"
(Invoke-GiteaApi -Url "$gtUrl/api/v1/user" | ConvertFrom-Json) | Select-Object login, is_admin

cred length = 64
{"version":"1.27.0"},exit=0
gitea_admin / True

最後一行先確認憑證本身可用。第 14 步若失敗,可據此立刻排除憑證因素。

13. 建 repo

目的:建立測試倉庫。

$repoBody = [ordered]@{ name = "hello"; private = $true; auto_init = $false } |
            ConvertTo-Json -Depth 10 -Compress
$repoRaw = Invoke-GiteaApi -Url "$gtUrl/api/v1/user/repos" -Method POST -JsonBody $repoBody
"exit=$lastExit"; ($repoRaw | ConvertFrom-Json).full_name

exit=0
gitea_admin/hello

14. push

目的:建立本機倉庫並推送。認證走 credential store 暫存檔,不寫進 remote URL,避免密碼留在 .git/config

這一步有兩個容易踩的地方,兩個都不會給出有用的錯誤訊息:

  • credential.helper 的值含空白時,git 會把整串交給 shell 執行。Windows 路徑的反斜線會被 shell 當逸出字元吃掉,helper 拿到不存在的路徑,靜默失敗。改用正斜線,路徑含空白時再加單引號。
  • credential.helper多值設定,-c 是追加不是取代。系統層通常已設 manager,兩個 helper 都會被查詢。先用空值重設清單,再加入自己的。
$repoDir = Join-Path $gtWorkDir "hello"
New-Item -ItemType Directory -Path $repoDir -Force | Out-Null
Push-Location $repoDir
$credFile = Join-Path $env:TEMP ("gt-cred-{0}" -f [guid]::NewGuid())
$credArg  = "store --file='" + $credFile.Replace([char]92, [char]47) + "'"

try {
    [System.IO.File]::WriteAllText($credFile,
        "$gtUrl".Replace("https://", "https://${gtUser}:${gtPass}@") + "`n",
        (New-Object System.Text.UTF8Encoding $false))
    [System.IO.File]::WriteAllText((Join-Path $repoDir "README.md"), "# hello`n",
        (New-Object System.Text.UTF8Encoding $false))

    git init -q
    git checkout -q -b main
    git -c user.name="deployer" -c user.email="deployer@local" add README.md
    git -c user.name="deployer" -c user.email="deployer@local" commit -q -m "init"
    git remote add origin "$gtUrl/$gtUser/hello.git"

    git -c credential.helper= -c credential.interactive=false `
        -c credential.helper="$credArg" push -u origin main
    "push exit = $LASTEXITCODE"

    $localSha = git rev-parse HEAD
    "local SHA = $localSha"
} finally {
    if (Test-Path $credFile) { Remove-Item $credFile -Force }
    Pop-Location
}

* [new branch] main -> main
push exit = 0

15. 回讀核對

目的:以獨立的一次 API 讀取確認推送內容已落地。不採用 git push 自身的輸出——該輸出與寫入屬同一次操作。

push 完成後立刻讀取,實測撞過一次 Gitea 內部 panic(HTTP 500,回應為 Go 追蹤而非 JSON),等約一分鐘重試同一端點即正常。堆疊與上游已知的 nil pointer 問題同一條鏈(repo_objectrepo_commit_nogogitrepo_commit),該問題被歸因於資料庫壓力下 GitRepo 為 nil;本次發生時 PostgreSQL 正在初始寫入。屬偶發,成因未實地驗證,因此帶重試。

$branchUrl = "$gtUrl/api/v1/repos/$gtUser/hello/branches/main"
$branchRaw = $null; $remoteSha = $null

for ($i = 1; $i -le 3; $i++) {
    $branchRaw = Invoke-GiteaApi -Url $branchUrl
    try {
        $remoteSha = ($branchRaw | ConvertFrom-Json).commit.id
        if ($remoteSha) { break }
    } catch {
        "第 $i 次讀取失敗(exit=$lastExit):$($_.Exception.Message)"
    }
    $remoteSha = $null
    Start-Sleep -Seconds 5
}
if (-not $remoteSha) { throw "讀取 branches/main 連續 3 次失敗" }

[System.IO.File]::WriteAllText((Join-Path $gtWorkDir "branch.json"), $branchRaw,
                               (New-Object System.Text.UTF8Encoding $false))
"local  = $localSha"; "remote = $remoteSha"
if ($localSha -ne $remoteSha) { throw "SHA 不符" }
"SHA match = True"

SHA match = True,branch.json 705 bytes

失敗那次的 branch.json 是 4,716 bytes 的 panic 追蹤,ConvertFrom-JsonInvalid JSON primitive: PANIC。重試前不寫檔,避免把失敗的回應留成產物。

16. 收工

目的:確認產物齊全、密碼未落地、無暫存殘留,並記錄本輪新增的節點目錄容量作為下次清場的基準。

Get-ChildItem $gtWorkDir | Select-Object Name, Length
Select-String -Path (Join-Path $repoDir ".git\config") -Pattern "@" -SimpleMatch
Test-Path (git config --global --get http."$gtUrl/".sslCAInfo)
Get-ChildItem $env:TEMP -Filter "gt-*" | Select-Object Name
& $OC config get-contexts
& $OC --kubeconfig $kubeconfig debug node/crc --quiet -- chroot /host du -sh /var/lib/csi-hostpath-data

chart-values-ref.yaml 37,395 / rendered-ha-server.yaml 180,031 / values.yaml 327 / branch.json 705 / hello\
第二行無輸出——有輸出代表密碼寫進了 .git/config
True
gt-* 查無結果
current context 為 gitea/api-crc-testing:6443/system:serviceaccount:gitea:gitea-deployer
74M(本輪新增一組,下次第 0 步清)

最後一項需要沉澱時間:PostgreSQL 的初始寫入尚未完成時量測會偏低(實測第 15 步一結束為 58M,約一分鐘後才是穩態的 74M)。差異全在 PostgreSQL 的資料目錄,其餘四個目錄合計不到 4M。


技術說明

目的:記錄快樂路徑中八處設計的實測依據,供升版或改寫腳本時核對。操作步驟見快樂路徑本身。

腳本中的作法 對應步驟 省略後的結果
curl 帶 --ssl-revoke-best-effort 10、12 exit 60,所有 curl 呼叫失敗
另以 openssl.exe 驗一次 CA 10 無法區分「CA 檔有效」與「機器本來就信任」
helm install --dry-run=server 而非 helm template 4 誤判需要 anyuid
不授權 anyuid 1 多授一組永不生效的權限
設定寫在 gitea.config.* 5 改動撐不過下一次容器重啟
輪替密碼後重啟 5 Secret 已更新但實際密碼未變,且無錯誤訊息
initContainers 而非只查 containers 7 輸出為空,卻被記成通過
節點目錄與現存 PV 交叉比對 0 每輪累積約 70 MB,任何 oc 查詢都看不到

測試環境:CRC 2.61.0 / OpenShift 4.21.14、Chart gitea-charts/gitea 12.7.0、Gitea 1.27.0、PowerShell 5.1、git 2.54.0.windows.1。快樂路徑實跑六輪,以下標明各項的觀測次數。


一、Windows 上只有一套 TLS 實作

原始構想是用 curl 驗證 CA 檔再交給 git,理由是兩者獨立。該前提在 Windows 上不成立:

系統 curl.exe   libcurl/8.21.0 Schannel
Git 附帶 curl   libcurl/8.19.0 Schannel
git 預設後端    http.sslbackend (system) = schannel

.NETSslStreamInvoke-WebRequest 同樣走 Schannel。作業系統只提供一套堆疊,獨立路徑必須來自靜態連結 OpenSSL 的執行檔。

撤銷檢查

CRC 的自簽 CA 沒有 CRL/OCSP 端點,Schannel 走到查撤銷才失敗:

curl.exe --cacert <ca> https://gitea-gitea.apps-crc.testing/api/v1/version
→ curl: (60) schannel: the revocation status is unknown

憑證鏈本身沒問題。加 --ssl-revoke-best-effort 即回 200,此旗標為 Schannel 專屬。

三組對照(單輪):--cacert + 旗標 → 200;只有旗標、不給 CA → 200;兩者皆無 → exit 35。第二列表示 CA 檔對 curl 不是必要的,因為 Schannel 查的是 Windows 存放區,而 CRC 已把兩張憑證裝在 Cert:\CurrentUser\Root(CN=ingress-operator@…CN=*.apps-crc.testing)。

唯一的獨立路徑

C:\Program Files\Git\usr\bin\openssl.exe(3.5.6)不走 Schannel,也不查 Windows 存放區:

s_client -CAfile <ca>   → Verify return code: 0 (ok)
s_client(不給 CAfile)  → Verify return code: 19 (self-signed certificate in certificate chain)

這兩行把先前混在一起的兩件事分開:第一行證明 CA 檔有效,第二行證明不讀存放區的實作確實不信任它。

原本的假設

原本假設 schannel 不讀 sslCAInfo,因此只設該行會失敗,必須補上 sslbackend openssl 才成功。以三格加反證重驗(四輪):

設定 結果
A 兩行都不設 通過
B 只設 sslCAInfo 通過
C sslbackend openssl 通過
B′ schannel + 無效 CA 檔 仍通過
C′ openssl + 無效 CA 檔 error adding trust anchors from file

三格全通過時無法分辨「被讀取但多餘」與「根本沒被讀取」;給一個壞檔就分開了。

假設中的機制成立——schannel 不讀 sslCAInfo,openssl 會讀。必要性不成立——CRC 已把憑證裝進存放區,schannel 本來就信任該站台。

正確的敘述是:這兩行是自洽但非必要的替代路徑。切到 openssl 後端之後 sslCAInfo 就從多餘變成必要。在憑證不在存放區的機器上(乾淨機器、或不同使用者帳戶),狀態 A 會失敗,這兩行才是唯一解法。

快樂路徑仍設定它們,理由是不依賴看不見的機器狀態。


二、離線渲染看不到叢集

初次部署前依 helm template 的輸出判定需要授權 anyuid(渲染結果含 runAsUser: 1000/1001)。該判定錯誤:實際部署後三個 Pod 全部以 restricted-v2 進場,授權完全未被使用(五輪)。

要分辨 UID 是「Chart 沒渲染」還是「SCC admission 改寫」,得查叢集中存放的工作負載 spec,而非已經過 admission 的 Pod spec:

deployment/gitea                   runAsUser=(空)
statefulset/gitea-postgresql       runAsUser=(空)
statefulset/gitea-valkey-primary   runAsUser=(空)

admission 只驗證或補值,不刪除已存在的欄位。欄位不存在,就是 Chart 渲染階段沒產生。

任意 UID 的一個外溢後果

以指派的 UID 執行時,/etc/passwd 中沒有對應項目,Gitea 產生 SSH 位址時因此拿數字 UID 當使用者名稱:

ssh_url = 1000660000@gitea-gitea.apps-crc.testing:gitea_admin/hello.git

正常應為 git@host。API 回應與 webhook payload 兩處數值一致,非單點異常。HTTP clone 與 push 不受影響(已實測);SSH 是否可用未測。需要時應設定 chart 的 RUN_USER

方式 查叢集 runAsUser
helm template 1000 / 1001 / 1001
helm install --dry-run=client(即預設的 --dry-run) 同上
helm install --dry-run=server
實際 helm install

Bitnami 子 chart 具備 OpenShift 偵測——.Capabilities.APIVersionssecurity.openshift.io/v1 時移除寫死的 UID。離線渲染不查叢集,該判斷不成立。

上表量測的是 helm install 的三種形式。helm template --dry-run=server 未測——上游回報該旗標在 helm template 上只影響 lookup 函式,對 .Capabilities 無效,行為可能隨 helm 版本而異。

界線:離線渲染對 SA 名稱、欄位路徑、Service 名稱與 port 有效,對任何會被叢集能力偵測改寫的欄位無效。此結論綁定單副本組合(postgresql + valkey);HA 組合未測。


三、configure-gitea 是設定與 admin 密碼的唯一來源

這個 initContainer 每次容器啟動都會執行 env2ini,依 gitea-inline-config Secret 重新產生整份 app.ini。直接修改容器內的檔案撐不過下一次重啟,而重啟不需要 helm upgrade——節點重新調度、OOM、crash 都會觸發。設定因此寫在 gitea.config.*,由 Helm 渲染進該 Secret。

它同時負責 admin 帳號。使用者已存在時,依 gitea.admin.passwordMode(預設 keepUpdated)呼叫 gitea admin user change-password。該模式的語意是每次 Pod 重建都把密碼重設為所定義的值——在網頁 UI 自行修改的 admin 密碼,下一次重啟也會被覆蓋回 Secret 裡的值:

Admin account 'gitea_admin' already exist. Running update to sync password...
gitea_admin's password has been successfully updated!

由此可知 Gitea 不在執行期讀取 Secret,密碼是啟動時寫進資料庫的。實測三格(單輪):

時點 新密碼 舊密碼
改 Secret 前 401 200
改 Secret 後、重啟前 401 200
重啟後 200 401

中間那格是重點:只改 Secret 不重啟,什麼都不會發生,也沒有任何錯誤訊息。Secret 讀回來是新值、round-trip 檢核照樣通過,而實際密碼仍是舊的——檢核工具讀 Secret,實際密碼在資料庫,兩者不同源。

passwordMode 另外兩個值(initialOnlyNoResetinitialOnlyRequireReset)只在建立帳號時設定密碼、之後不再更新,此時 Secret 與實際密碼會不一致。兩者未實測,依據為 Chart 的官方說明與渲染出的初始化腳本。

憑證注在 initContainer

第 7 步若只查 spec.template.spec.containerssecretKeyRef,輸出為。實際位置:

[configure-gitea] GITEA_ADMIN_USERNAME=<secretKeyRef: gitea-admin/username>
[configure-gitea] GITEA_ADMIN_PASSWORD=<secretKeyRef: gitea-admin/password>

照原樣把空值填成預期輸出,會把「什麼都沒驗到」記錄成通過。

設定位置已棄用

webhook.ALLOWED_HOST_LIST 目前靠 fallback 生效,Gitea 啟動時會留下:

Deprecation: config option `[webhook].ALLOWED_HOST_LIST` present,
please use `[security].ALLOWED_HOST_LIST` instead
because this fallback will be/has been removed in v28.0.0

改放 [security] 是否等效,未實測。


四、看不見的殘留

StorageClass 的 reclaimPolicyRetain。PVC 與 PV 物件刪除後,節點上 /var/lib/csi-hostpath-data/<pv-name>/ 的資料夾原封不動保留,且不出現在 oc get pvoc get pvccrc status 的任何輸出。另外 gitea-shared-storage 帶有 helm.sh/resource-policy: keep,helm uninstall 也不會刪它——雙層殘留。

項目
每輪產生 3 個目錄、約 70 MB
其中 PostgreSQL 資料目錄 約 69 MB
三輪累積實測 213 MB,清掉 6 個孤兒釋放 139 MB

一個沒有任何實際內容、只跑過一次 git push 的 Gitea,PostgreSQL 落地就是 70 MB。

清點方式為目錄名與現存 PV 名稱交叉比對(目錄名即 PV 名稱),確認後逐一指名刪除。量測有沉澱時間:push 結束立刻執行為 58 MB,約一分鐘後才是穩態的 74 MB。


五、腳本上的四個陷阱

四項皆為靜默失敗——不報錯,只是做錯或量錯。

  • credential.helper 經過兩層解析。 值含空白時 git 整串交給 shell,Windows 路徑的反斜線被當逸出字元吃掉,helper 收到不存在的路徑。對照:store --file=C:\...Unauthorized exit 128;改正斜線 → 列出 refs exit 0(三輪)。路徑含空白時需另加單引號,未實測。
  • credential.helper 是多值設定,-c 是追加不是取代。 系統層通常已設 manager,兩個 helper 都會被查詢。先以空值重設清單再加入自己的。
  • TLS 判定不能綁在字串上。 倉庫未建立時的認證失敗訊息四輪出現三種(Failed to authenticate user / Unauthorized / unable to get password from user),前兩者的差異來自憑證視窗前的人按了什麼。改為否定式:不含已知 TLS 錯誤字樣且 exit=128 即為通過。GIT_TERMINAL_PROMPT=0 只擋終端機提示,credential.interactive=false 才擋圖形視窗(單輪)。
  • 就緒判定不能只看 containerStatuses restartPolicy: Always 的 native sidecar 出現在 initContainerStatuses,只看前者會提早回報就緒。改用 Pod 的 Ready condition。同一迴圈也不比對 Pod 名稱——Chart 12.6.0 起由 StatefulSet 改為 Deployment,gitea-0 這類寫法會逾時。

未驗證項目

  • gitea.config.server.* 未設定。 DOMAINROOT_URLSSH_DOMAIN 仍是 Chart 預設的 git.example.com。push 與 API 不受影響,但 Gitea 自己產生的內容(網頁 clone 連結、webhook payload 的 clone_url、通知信)會指向錯誤網域。設定 webhook 或 CI 觸發前必須處理。
  • ALLOWED_HOST_LIST 改放 [security] 未實測。
  • SSH clone 未測。 ssh_url 帶的是數字 UID 而非 git
  • webhook 投遞結果無程式化來源。 Gitea 的 container log 不記錄投遞成敗,hooks/{id}/deliverieshooks/{id}/history 皆回 404;接收端的 log 是唯一可程式化取得的證據,網頁的 Recent Deliveries 未經 API 驗證。
  • passwordMode 只測 keepUpdated 另外兩個模式跳過密碼同步,依據為 Chart 官方說明與渲染出的初始化腳本。
  • HA 組合未部署。 不需 anyuid 的結論綁定 postgresql / valkey 兩個子 chart。
  • credential helper 路徑含空白未實測。
  • 第 15 步的 panic 未實地驗證成因。 六輪中發生一次,出現在最快就緒(43 秒)的那一輪。堆疊(repo_objectrepo_commit_nogogitrepo_commit)與上游已知的 nil pointer 問題相符,該問題被歸因於資料庫壓力下 GitRepo 為 nil;本次發生時 PostgreSQL 正在初始寫入,但未量測當下的資料庫狀態以佐證。快樂路徑的重試是繞過,不是修正。
  • 資源用量未量測。
  • 資料庫密碼為 Chart 預設。 gitea-inline-config 中的 PostgreSQL 與 Valkey 密碼是明文預設值,未更動。

參考文件

規格與說明:

  • gitcredentials —— credential.helper 的多值語意與 shell 執行規則
  • git-config —— http.sslBackendhttp.<url>.* 的 URL-scoped 設定
  • curl manpage —— --ssl-revoke-best-effort(Schannel 專屬)
  • Sidecar Containers —— restartPolicy: Always 的 initContainer 與就緒判定
  • helm-gitea README —— gitea.admin.passwordMode 三種模式的定義
  • helm install —— --dry-runclient / server 語意

第三方回報(本文據以佐證,非本文實測):

  • bitnami/containers #63711 —— 偵測到 OpenShift 時移除 runAsUserrunAsGroupfsGroup,以及 global.compatibility.openshift.adaptSecurityContext 開關
  • helm/helm #12740 —— helm templatehelm install.Capabilities 的處理不一致,--dry-run=serverhelm template 上只影響 lookup
  • go-gitea/gitea #36770 —— 與第 15 步同一條堆疊的 nil pointer panic,歸因於資料庫壓力下 GitRepo 為 nil
  • curl/curl #12239 —— Schannel 後端在自簽 CA 上回報 CERT_TRUST_REVOCATION_STATUS_UNKNOWN

上一篇
Day 7:打造內部私有資產庫:部署 Nexus 3 並配置 Outbound Proxy
下一篇
Day 9 中繼驗證:Gitea webhook 送達叢集內的私有 ClusterIP
系列文
防範軟體供應鏈攻擊:從零打造具備硬性阻擋能力的雲原生 CI/CD 流水線11
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言