feat: deploy NC01 Sub2API target
Pipelines as Code CI / hwlab-web-probe-sentinel-nc01- Success

This commit is contained in:
Codex
2026-07-09 13:16:58 +02:00
parent 21935e7319
commit 8347d5ed1b
17 changed files with 483 additions and 37 deletions
+106 -8
View File
@@ -15,10 +15,11 @@ import { yamlBooleanField, yamlFieldLabel, yamlIntegerField } from "../platform-
import { fingerprintSecretValues, parseEnvFile, readEnvSourceFile, readTextFile, redactRepoPath, requiredEnvValue } from "../secrets";
import type { AccountLocalProxySecretMaterial, EgressProxySecretMaterial, ExternalActiveSecretMaterial, PublicExposureSecretMaterial, Sub2ApiConfig, Sub2ApiPublicExposureConfig, Sub2ApiTargetConfig } from "./entry";
import { fieldManager, requiredSecretKeys, sub2apiCaddyManagedMarker } from "./entry";
import { fieldManager, requiredSecretKeys, serviceName, sub2apiCaddyManagedMarker } from "./entry";
import { managedResourceCleanupPlan } from "./manifest";
export function renderPk01Caddyfile(target: Sub2ApiTargetConfig, exposure: Sub2ApiPublicExposureConfig): string {
if (exposure.pk01 === null) throw new Error(`publicExposure.pk01 is required for target ${target.id} when rendering PK01 Caddy`);
const apexHost = baseDomain(exposure.dns.hostname);
const apiBlock = renderCaddyManagedBlock(
sub2apiCaddyManagedMarker(target),
@@ -48,11 +49,16 @@ export function publicExposureUpstream(target: Sub2ApiTargetConfig, exposure: Su
if (exposure.local === null) throw new Error(`publicExposure.local is required for target ${target.id} when mode=pk01-local`);
return `${exposure.local.upstreamHost}:${exposure.local.upstreamPort}`;
}
if (exposure.mode === "node-local-https") {
if (exposure.localHttps === null) throw new Error(`publicExposure.localHttps is required for target ${target.id} when mode=node-local-https`);
return `${exposure.dns.hostname}:${exposure.localHttps.httpsPort}`;
}
if (exposure.frpc === null) throw new Error(`publicExposure.frpc is required for target ${target.id} when mode=frp`);
return `127.0.0.1:${exposure.frpc.remotePort}`;
}
export function renderPk01CaddyService(exposure: Sub2ApiPublicExposureConfig): string {
if (exposure.pk01 === null) throw new Error("publicExposure.pk01 is required when rendering PK01 Caddy service");
return `[Unit]
Description=UniDesk PK01 Caddy edge for PikaPython and Sub2API
After=network-online.target docker.service
@@ -227,6 +233,8 @@ export function applyScript(
): string {
const encoded = Buffer.from(yaml, "utf8").toString("base64");
const cleanupPlan = managedResourceCleanupPlan(sub2api, target);
const localHttpsExposure = target.publicExposure?.enabled === true && target.publicExposure.mode === "node-local-https" ? target.publicExposure : null;
const localHttps = localHttpsExposure?.localHttps ?? null;
const appSecretName = sub2api.runtime.database.secretName;
const publicExposureSecretName = publicExposureSecretMaterial?.secretName ?? sub2api.defaults.cleanup.publicExposure.secretName;
const publicExposureSecretKey = publicExposureSecretMaterial?.secretKey ?? "frpc.toml";
@@ -299,6 +307,24 @@ export function applyScript(
proxyUrl: accountLocalProxySecretMaterial.proxyUrl,
valuesPrinted: false,
}))})`;
const localHttpsSummary = localHttps === null
? "None"
: `json.loads(${JSON.stringify(JSON.stringify({
publicBaseUrl: localHttpsExposure?.publicBaseUrl,
hostname: localHttpsExposure?.dns.hostname,
expectedA: localHttpsExposure?.dns.expectedA,
mode: "node-local-docker-caddy",
route: localHttps.route,
upstreamMode: localHttps.upstreamMode,
composePath: localHttps.composePath,
projectName: localHttps.projectName,
serviceName: localHttps.serviceName,
containerName: localHttps.containerName,
image: localHttps.image,
bind: `${localHttps.hostBind}:${localHttps.httpsPort}`,
tlsMode: localHttps.tlsMode,
valuesPrinted: false,
}))})`;
return `
set -u
tmp="$(mktemp -d)"
@@ -319,6 +345,8 @@ apply_out="$tmp/apply.out"
apply_err="$tmp/apply.err"
cleanup_out="$tmp/cleanup.out"
cleanup_err="$tmp/cleanup.err"
local_https_out="$tmp/local-https.out"
local_https_err="$tmp/local-https.err"
kubectl create namespace ${target.namespace} --dry-run=client -o yaml | kubectl apply --server-side --force-conflicts --field-manager=${fieldManager} -f - >"$ns_out" 2>"$ns_err"
ns_rc=$?
secret_action="unknown"
@@ -329,6 +357,8 @@ egress_secret_action="not-enabled"
egress_secret_rc=0
account_local_proxy_secret_action="not-enabled"
account_local_proxy_secret_rc=0
local_https_action="not-enabled"
local_https_rc=0
if [ "$ns_rc" -eq 0 ]; then
if [ "${target.databaseMode}" = "external-pending" ]; then
secret_action="external-pending-not-managed"
@@ -506,7 +536,71 @@ else
printf '%s\\n' 'skipped because apply failed' >"$cleanup_err"
cleanup_rc=1
fi
python3 - "$ns_rc" "$secret_rc" "$exposure_secret_rc" "$egress_secret_rc" "$account_local_proxy_secret_rc" "$apply_rc" "$cleanup_rc" "$secret_action" "$exposure_secret_action" "$egress_secret_action" "$account_local_proxy_secret_action" "$ns_out" "$ns_err" "$secret_out" "$secret_err" "$exposure_secret_out" "$exposure_secret_err" "$egress_secret_out" "$egress_secret_err" "$account_local_proxy_secret_out" "$account_local_proxy_secret_err" "$apply_out" "$apply_err" "$cleanup_out" "$cleanup_err" <<'PY'
if [ "${localHttps === null ? "false" : "true"}" = "true" ]; then
if [ "$apply_rc" -eq 0 ] && [ "$cleanup_rc" -eq 0 ]; then
edge_dir=${localHttps === null ? "\"\"" : shQuote(localHttps.workDir)}
compose_path=${localHttps === null ? "\"\"" : shQuote(localHttps.composePath)}
caddyfile_path=${localHttps === null ? "\"\"" : shQuote(localHttps.caddyfilePath)}
data_dir=${localHttps === null ? "\"\"" : shQuote(localHttps.dataDir)}
config_dir=${localHttps === null ? "\"\"" : shQuote(localHttps.configDir)}
mkdir -p "$edge_dir" "$(dirname "$compose_path")" "$(dirname "$caddyfile_path")" "$data_dir" "$config_dir" >"$local_https_out" 2>"$local_https_err"
local_https_rc=$?
if [ "$local_https_rc" -eq 0 ]; then
service_cluster_ip="$(kubectl -n ${target.namespace} get service ${serviceName} -o jsonpath='{.spec.clusterIP}' 2>>"$local_https_err" || true)"
if [ -z "$service_cluster_ip" ] || [ "$service_cluster_ip" = "None" ]; then
printf '%s\\n' 'sub2api service clusterIP is empty; cannot render node-local HTTPS edge' >>"$local_https_err"
local_https_rc=1
fi
fi
if [ "$local_https_rc" -eq 0 ]; then
cat >"$caddyfile_path" <<'CADDY'
https://${localHttpsExposure?.dns.hostname}:${localHttps?.httpsPort} {
tls internal
reverse_proxy __SUB2API_SERVICE_CLUSTER_IP__:8080 {
transport http {
response_header_timeout ${localHttps?.responseHeaderTimeoutSeconds}s
}
}
}
CADDY
sed -i "s/__SUB2API_SERVICE_CLUSTER_IP__/$service_cluster_ip/g" "$caddyfile_path"
cat >"$compose_path" <<'COMPOSE'
name: ${localHttps?.projectName}
services:
${localHttps?.serviceName}:
image: ${localHttps?.image}
container_name: ${localHttps?.containerName}
restart: unless-stopped
ports:
- "${localHttps?.hostBind}:${localHttps?.httpsPort}:${localHttps?.httpsPort}"
volumes:
- ${localHttps?.caddyfilePath}:/etc/caddy/Caddyfile:ro
- ${localHttps?.dataDir}:/data
- ${localHttps?.configDir}:/config
COMPOSE
chmod 0644 "$caddyfile_path" "$compose_path" >>"$local_https_out" 2>>"$local_https_err" || local_https_rc=$?
fi
if [ "$local_https_rc" -eq 0 ]; then
docker compose -f "$compose_path" -p ${localHttps === null ? "\"\"" : shQuote(localHttps.projectName)} up -d --force-recreate --remove-orphans >>"$local_https_out" 2>>"$local_https_err"
local_https_rc=$?
fi
if [ "$local_https_rc" -eq 0 ]; then
sleep 2
curl --noproxy '*' -kfsS --connect-timeout 5 --max-time 20 --resolve ${localHttpsExposure === null || localHttps === null ? "\"\"" : shQuote(`${localHttpsExposure.dns.hostname}:${localHttps.httpsPort}:127.0.0.1`)} ${localHttpsExposure === null ? "\"\"" : shQuote(`${localHttpsExposure.publicBaseUrl}/health`)} >>"$local_https_out" 2>>"$local_https_err"
local_https_rc=$?
fi
local_https_action="synced"
else
printf '%s\\n' 'skipped because Kubernetes apply or cleanup failed' >"$local_https_err"
: >"$local_https_out"
local_https_action="skipped-after-k8s-failure"
fi
else
: >"$local_https_out"
: >"$local_https_err"
fi
python3 - "$ns_rc" "$secret_rc" "$exposure_secret_rc" "$egress_secret_rc" "$account_local_proxy_secret_rc" "$apply_rc" "$cleanup_rc" "$local_https_rc" "$secret_action" "$exposure_secret_action" "$egress_secret_action" "$account_local_proxy_secret_action" "$local_https_action" "$ns_out" "$ns_err" "$secret_out" "$secret_err" "$exposure_secret_out" "$exposure_secret_err" "$egress_secret_out" "$egress_secret_err" "$account_local_proxy_secret_out" "$account_local_proxy_secret_err" "$apply_out" "$apply_err" "$cleanup_out" "$cleanup_err" "$local_https_out" "$local_https_err" <<'PY'
import json
import sys
ns_rc = int(sys.argv[1])
@@ -516,11 +610,13 @@ egress_secret_rc = int(sys.argv[4])
account_local_proxy_secret_rc = int(sys.argv[5])
apply_rc = int(sys.argv[6])
cleanup_rc = int(sys.argv[7])
secret_action = sys.argv[8]
exposure_secret_action = sys.argv[9]
egress_secret_action = sys.argv[10]
account_local_proxy_secret_action = sys.argv[11]
paths = sys.argv[12:]
local_https_rc = int(sys.argv[8])
secret_action = sys.argv[9]
exposure_secret_action = sys.argv[10]
egress_secret_action = sys.argv[11]
account_local_proxy_secret_action = sys.argv[12]
local_https_action = sys.argv[13]
paths = sys.argv[14:]
def text(path):
try:
return open(path, encoding="utf-8").read()
@@ -532,7 +628,7 @@ def parsed(path):
except Exception:
return None
payload = {
"ok": ns_rc == 0 and secret_rc == 0 and exposure_secret_rc == 0 and egress_secret_rc == 0 and account_local_proxy_secret_rc == 0 and apply_rc == 0 and cleanup_rc == 0,
"ok": ns_rc == 0 and secret_rc == 0 and exposure_secret_rc == 0 and egress_secret_rc == 0 and account_local_proxy_secret_rc == 0 and apply_rc == 0 and cleanup_rc == 0 and local_https_rc == 0,
"target": "${target.id}",
"namespace": "${target.namespace}",
"databaseMode": "${target.databaseMode}",
@@ -547,6 +643,7 @@ payload = {
"valuesPrinted": False,
},
"publicExposure": ${exposureSecretSummary},
"localHttpsExposure": ${localHttpsSummary},
"egressProxy": ${egressProxySecretSummary},
"accountLocalProxy": ${accountLocalProxySecretSummary},
"steps": {
@@ -557,6 +654,7 @@ payload = {
"accountLocalProxySecret": {"exitCode": account_local_proxy_secret_rc, "action": account_local_proxy_secret_action, "stdout": text(paths[8])[-4000:], "stderr": text(paths[9])[-4000:]},
"apply": {"exitCode": apply_rc, "stdout": text(paths[10])[-8000:], "stderr": text(paths[11])[-4000:]},
"cleanup": {"exitCode": cleanup_rc, "summary": parsed(paths[12]), "stdout": text(paths[12])[-8000:], "stderr": text(paths[13])[-4000:]},
"localHttpsExposure": {"exitCode": local_https_rc, "action": local_https_action, "stdout": text(paths[14])[-4000:], "stderr": text(paths[15])[-4000:]},
},
}
print(json.dumps(payload, ensure_ascii=False, indent=2))