@@ -1,5 +1,7 @@
import { Buffer } from "node:buffer" ;
import { existsSync } from "node:f s" ;
import { spawnSync } from "node:child_proces s" ;
import { existsSync , readFileSync } from "node:fs" ;
import { dirname , isAbsolute , relative , sep } from "node:path" ;
import type { UniDeskConfig } from "./config" ;
import { renderMachine } from "./cicd-render" ;
@@ -8,6 +10,7 @@ import { CliInputError } from "./output";
import {
capture ,
compactCapture ,
assertNoDuplicateYamlMappingKeys ,
parseJsonOutput ,
readYamlRecord ,
resolveRepoPath ,
@@ -15,7 +18,7 @@ import {
shQuote ,
} from "./platform-infra-ops-library" ;
type PublicEdgeAction = "plan" | "apply" | "status" ;
type PublicEdgeAction = "plan" | "apply" | "reconcile" | "status" ;
interface PublicEdgeOptions {
action : PublicEdgeAction ;
@@ -23,12 +26,23 @@ interface PublicEdgeOptions {
targetId : string | null ;
dryRun : boolean ;
confirm : boolean ;
authorityId : string | null ;
sourceCommit : string | null ;
ancestryFile : string | null ;
}
interface PublicEdgeDelivery {
enabled : boolean ;
authorityId : string ;
sourceBranch : string ;
pipelineId : string ;
}
export interface SiteReference {
id : string ;
configRef : string ;
path : string ;
sourceCommit? : string ;
}
export interface ResolvedSiteRoute {
@@ -68,6 +82,13 @@ export interface PublicEdgeTarget {
caddyfilePath : string ;
dataDir : string ;
configDir : string ;
statePath : string ;
lockPath : string ;
legacy : {
containerName : string ;
composePath : string ;
caddyfilePath : string ;
} ;
dnsServers : string [ ] ;
responseHeaderTimeoutSeconds : number ;
} ;
@@ -86,7 +107,6 @@ export function publicEdgeHelp(): Record<string, unknown> {
usage : [
"bun scripts/cli.ts platform-infra public-edge plan --target NC01" ,
"bun scripts/cli.ts platform-infra public-edge apply --target NC01 --dry-run" ,
"bun scripts/cli.ts platform-infra public-edge apply --target NC01 --confirm" ,
"bun scripts/cli.ts platform-infra public-edge status --target NC01" ,
] ,
source : "config/platform-infra/public-edge.yaml" ,
@@ -94,7 +114,10 @@ export function publicEdgeHelp(): Record<string, unknown> {
"平台 YAML 只拥有 listener、Caddy runtime 和 site 聚合顺序。" ,
"hostname 与 upstream 只从产品 owning YAML 的 configRef/path 解析。" ,
"apply 每次重新读取 YAML 与运行面,缺失引用时拒绝 mutation。" ,
"Caddyfile 与 Compose 同目录候选先校验,再以 rename 原子安装并重建唯一 Caddy 。" ,
"交互式 apply 只允许 dry-run; mutation 只接受 immutable master PaC archive reconcile 。" ,
"站点以 owner managed block 合并,禁止服务或 worktree 整文件强写。" ,
"reconcile 使用 host lock 与 source commit ancestor-only CAS,拒绝旧提交和分叉提交。" ,
"候选先校验,再原子安装并重建唯一 Caddy,同时记录 redacted provenance。" ,
"只有安装或唯一 Caddy 重建失败时回滚;站点探测失败只输出非阻塞 warning。" ,
] ,
} ;
@@ -106,18 +129,19 @@ export async function runPlatformInfraPublicEdgeCommand(config: UniDeskConfig, a
}
const options = parseOptions ( args ) ;
const root = readConfig ( options . configPath ) ;
const delivery = readDelivery ( root ) ;
const target = readTarget ( root , options . targetId ) ;
const resolution = resolveSites ( target ) ;
if ( options . action === "plan" ) {
const result = planResult ( options . configPath , target , resolution , "plan" ) ;
return renderMachine ( "platform-infra public-edge plan" , result , "json" , result . ok === true ) ;
}
if ( options . action === "apply" && options . dryRun ) {
if ( options . action === "apply" ) {
const result = planResult ( options . configPath , target , resolution , "dry-run" ) ;
return renderMachine ( "platform-infra public-edge apply" , result , "json" , result . ok === true ) ;
}
if ( options . action === "status" ) {
const result = await status ( config , options . configPath , target , resolution ) ;
const result = await status ( config , options . configPath , delivery , target , resolution ) ;
return renderMachine ( "platform-infra public-edge status" , result , "json" , result . ok === true ) ;
}
if ( resolution . unresolved . length > 0 ) {
@@ -125,30 +149,43 @@ export async function runPlatformInfraPublicEdgeCommand(config: UniDeskConfig, a
. . . planResult ( options . configPath , target , resolution , "blocked" ) ,
ok : false ,
mutation : false ,
error : "存在缺失或不兼容的产品 owning YAML 引用,apply 未执行。" ,
error : "存在缺失或不兼容的产品 owning YAML 引用,reconcile 未执行。" ,
} ;
return renderMachine ( "platform-infra public-edge apply " , result , "json" , false ) ;
return renderMachine ( "platform-infra public-edge reconcile " , result , "json" , false ) ;
}
const result = await apply ( config , options . configPath , target , resolution . sites ) ;
return renderMachine ( "platform-infra public-edge apply" , result , "json" , result . ok === true ) ;
if ( options . authorityId !== delivery . authorityId ) {
throw inputError ( "reconcile authority 与 owning YAML 不一致" , "reconcile-authority-mismatch" , "--authority" ) ;
}
if ( process . env . UNIDESK_PUBLIC_EDGE_RECONCILE !== "1" ) {
throw inputError ( "reconcile 仅允许 immutable PaC archive helper 调用" , "reconcile-automation-required" ) ;
}
const ancestry = readAncestry ( options . ancestryFile , options . sourceCommit ) ;
const result = await reconcile ( config , options . configPath , delivery , target , resolution . sites , options . sourceCommit ! , ancestry ) ;
return renderMachine ( "platform-infra public-edge reconcile" , result , "json" , result . ok === true ) ;
}
function parseOptions ( args : string [ ] ) : PublicEdgeOptions {
const action = args [ 0 ] ;
if ( action !== "plan" && action !== "apply" && action !== "status" ) {
throw inputError ( "public-edge action 必须是 plan、apply 或 status" , "invalid-action" , action ? ? "<missing>" ) ;
if ( action !== "plan" && action !== "apply" && action !== "reconcile" && action !== "status" ) {
throw inputError ( "public-edge action 必须是 plan、apply、reconcile 或 status" , "invalid-action" , action ? ? "<missing>" ) ;
}
let configPath = resolveRepoPath ( "config/platform-infra/public-edge.yaml" ) ;
let targetId : string | null = null ;
let dryRun = false ;
let confirm = false ;
let authorityId : string | null = null ;
let sourceCommit : string | null = null ;
let ancestryFile : string | null = null ;
for ( let index = 1 ; index < args . length ; index += 1 ) {
const arg = args [ index ] ! ;
if ( arg === "--config" || arg === "--target" ) {
if ( arg === "--config" || arg === "--target" || arg === "--authority" || arg === "--source-commit" || arg === "--ancestry-file" ) {
const value = args [ index + 1 ] ;
if ( value === undefined || value . startsWith ( "--" ) ) throw inputError ( ` ${ arg } 需要参数 ` , "missing-option-value" , arg ) ;
if ( arg === "--config" ) configPath = resolveRepoPath ( value ) ;
else targetId = value ;
else if ( arg === "--target" ) targetId = value ;
else if ( arg === "--authority" ) authorityId = value ;
else if ( arg === "--source-commit" ) sourceCommit = value ;
else ancestryFile = value ;
index += 1 ;
} else if ( arg === "--dry-run" ) {
dryRun = true ;
@@ -158,9 +195,19 @@ function parseOptions(args: string[]): PublicEdgeOptions {
throw inputError ( ` 不支持的 public-edge 参数: ${ arg } ` , "unsupported-option" , arg ) ;
}
}
if ( action ! == "apply" && ( dryRun || confirm ) ) throw inputError ( ` ${ action } 不接受 mutation 参数 ` , "mutation-option-not-allowed" ) ;
if ( action === "apply" && dryRun === confirm ) throw inputError ( "apply 必须且只能选择 --dry-run 或 --confirm" , "apply-mode-required ") ;
return { action , configPath , targetId , dryRun , confirm } ;
if ( action = == "apply" ) {
if ( confirm ) throw inputError ( "交互式 apply --confirm 已禁用;提交 owning YAML 后由唯一 PaC reconcile" , "interactive-mutation-disabled" , "--confirm ") ;
if ( ! dryRun ) throw inputError ( "apply 只接受 --dry-run" , "apply-dry-run-required" ) ;
} else if ( action === "reconcile" ) {
if ( ! confirm || dryRun ) throw inputError ( "reconcile 必须使用 --confirm" , "reconcile-confirm-required" ) ;
if ( authorityId === null || sourceCommit === null || ancestryFile === null ) {
throw inputError ( "reconcile 缺少 --authority、--source-commit 或 --ancestry-file" , "reconcile-provenance-required" ) ;
}
if ( ! / ^ [ 0 - 9 a - f ] { 4 0 } $ / u . t e s t ( s o u r c e C o m m i t ) ) t h r o w i n p u t E r r o r ( " - - s o u r c e - c o m m i t 必 须 是 4 0 位 c o m m i t " , " i n v a l i d - s o u r c e - c o m m i t " ) ;
} else if ( dryRun || confirm || authorityId !== null || sourceCommit !== null || ancestryFile !== null ) {
throw inputError ( ` ${ action } 不接受 mutation/provenance 参数 ` , "mutation-option-not-allowed" ) ;
}
return { action , configPath , targetId , dryRun , confirm , authorityId , sourceCommit , ancestryFile } ;
}
function readConfig ( configPath : string ) : Record < string , unknown > {
@@ -168,6 +215,18 @@ function readConfig(configPath: string): Record<string, unknown> {
return readYamlRecord ( configPath , "platform-infra-public-edge" ) ;
}
function readDelivery ( root : Record < string , unknown > ) : PublicEdgeDelivery {
const delivery = record ( root . delivery , "delivery" ) ;
const parsed = {
enabled : boolean ( delivery . enabled , "delivery.enabled" ) ,
authorityId : string ( delivery . authorityId , "delivery.authorityId" ) ,
sourceBranch : string ( delivery . sourceBranch , "delivery.sourceBranch" ) ,
pipelineId : string ( delivery . pipelineId , "delivery.pipelineId" ) ,
} ;
if ( ! parsed . enabled ) throw inputError ( "delivery.enabled 必须为 true" , "delivery-disabled" , "delivery.enabled" ) ;
return parsed ;
}
function readTarget ( root : Record < string , unknown > , requestedTargetId : string | null ) : PublicEdgeTarget {
const defaults = record ( root . defaults , "defaults" ) ;
const targets = record ( root . targets , "targets" ) ;
@@ -176,6 +235,7 @@ function readTarget(root: Record<string, unknown>, requestedTargetId: string | n
const listener = record ( target . listener , ` targets. ${ targetId } .listener ` ) ;
const isolation = record ( target . isolation , ` targets. ${ targetId } .isolation ` ) ;
const runtime = record ( target . runtime , ` targets. ${ targetId } .runtime ` ) ;
const legacy = record ( runtime . legacy , ` targets. ${ targetId } .runtime.legacy ` ) ;
const sites = array ( target . sites , ` targets. ${ targetId } .sites ` ) ;
const parsed : PublicEdgeTarget = {
id : targetId ,
@@ -198,6 +258,13 @@ function readTarget(root: Record<string, unknown>, requestedTargetId: string | n
caddyfilePath : absolutePath ( runtime . caddyfilePath , ` targets. ${ targetId } .runtime.caddyfilePath ` ) ,
dataDir : absolutePath ( runtime . dataDir , ` targets. ${ targetId } .runtime.dataDir ` ) ,
configDir : absolutePath ( runtime . configDir , ` targets. ${ targetId } .runtime.configDir ` ) ,
statePath : absolutePath ( runtime . statePath , ` targets. ${ targetId } .runtime.statePath ` ) ,
lockPath : absolutePath ( runtime . lockPath , ` targets. ${ targetId } .runtime.lockPath ` ) ,
legacy : {
containerName : string ( legacy . containerName , ` targets. ${ targetId } .runtime.legacy.containerName ` ) ,
composePath : absolutePath ( legacy . composePath , ` targets. ${ targetId } .runtime.legacy.composePath ` ) ,
caddyfilePath : absolutePath ( legacy . caddyfilePath , ` targets. ${ targetId } .runtime.legacy.caddyfilePath ` ) ,
} ,
dnsServers : array ( runtime . dnsServers , ` targets. ${ targetId } .runtime.dnsServers ` ) . map ( ( value , index ) = > ipv4 ( value , ` targets. ${ targetId } .runtime.dnsServers[ ${ index } ] ` ) ) ,
responseHeaderTimeoutSeconds : positiveInteger ( runtime . responseHeaderTimeoutSeconds , ` targets. ${ targetId } .runtime.responseHeaderTimeoutSeconds ` ) ,
} ,
@@ -207,6 +274,9 @@ function readTarget(root: Record<string, unknown>, requestedTargetId: string | n
id : string ( site . id , ` targets. ${ targetId } .sites[ ${ index } ].id ` ) ,
configRef : yamlConfigPath ( site . configRef , ` targets. ${ targetId } .sites[ ${ index } ].configRef ` ) ,
path : string ( site . path , ` targets. ${ targetId } .sites[ ${ index } ].path ` ) ,
. . . ( site . sourceCommit === undefined
? { }
: { sourceCommit : gitCommit ( site . sourceCommit , ` targets. ${ targetId } .sites[ ${ index } ].sourceCommit ` ) } ) ,
} ;
} ) ,
} ;
@@ -229,8 +299,7 @@ export function resolveSites(target: PublicEdgeTarget): ResolutionResult {
const ownerPath = reference . configRef . startsWith ( "/" )
? reference . configRef
: resolveRepoPath ( reference . configRef ) ;
if ( ! existsSync ( ownerPath ) ) throw new Error ( ` configRef 文件不存在: ${ reference . configRef } ` ) ;
const owner = resolveObjectPath ( readYamlRecord ( ownerPath ) , reference . path ) ;
const owner = resolveObjectPath ( readSiteOwner ( reference , ownerPath ) , reference . path ) ;
const exposure = exposureObject ( owner ) ;
const dns = optionalRecord ( exposure . dns ) ;
const label = ` ${ reference . configRef } # ${ reference . path } ` ;
@@ -267,6 +336,38 @@ export function resolveSites(target: PublicEdgeTarget): ResolutionResult {
return { sites , unresolved } ;
}
function readSiteOwner ( reference : SiteReference , ownerPath : string ) : Record < string , unknown > {
if ( ! isAbsolute ( reference . configRef ) ) {
if ( reference . sourceCommit !== undefined ) throw new Error ( ` 仓库内 configRef 不接受 sourceCommit: ${ reference . configRef } ` ) ;
if ( ! existsSync ( ownerPath ) ) throw new Error ( ` configRef 文件不存在: ${ reference . configRef } ` ) ;
return readYamlRecord ( ownerPath ) ;
}
if ( reference . sourceCommit === undefined ) {
throw new Error ( ` 绝对 configRef 必须声明 sourceCommit: ${ reference . configRef } ` ) ;
}
const repository = spawnSync ( "git" , [ "-C" , dirname ( ownerPath ) , "rev-parse" , "--show-toplevel" ] , {
encoding : "utf8" ,
timeout : 10_000 ,
} ) ;
const repositoryRoot = repository . status === 0 ? repository . stdout . trim ( ) : "" ;
if ( ! isAbsolute ( repositoryRoot ) ) throw new Error ( ` 外部 configRef 不在可读 Git 仓库: ${ reference . configRef } ` ) ;
const repositoryPath = relative ( repositoryRoot , ownerPath ) ;
if ( repositoryPath . length === 0 || repositoryPath === ".." || repositoryPath . startsWith ( ` .. ${ sep } ` ) || isAbsolute ( repositoryPath ) ) {
throw new Error ( ` 外部 configRef 不属于解析出的 Git 仓库: ${ reference . configRef } ` ) ;
}
const gitPath = repositoryPath . split ( sep ) . join ( "/" ) ;
const source = spawnSync ( "git" , [ "-C" , repositoryRoot , "show" , ` ${ reference . sourceCommit } : ${ gitPath } ` ] , {
encoding : "utf8" ,
timeout : 10_000 ,
maxBuffer : 4 * 1024 * 1024 ,
} ) ;
if ( source . status !== 0 ) {
throw new Error ( ` 外部 configRef 无法从 pinned commit 读取: ${ reference . configRef } @ ${ reference . sourceCommit } ` ) ;
}
assertNoDuplicateYamlMappingKeys ( source . stdout , ` ${ reference . configRef } @ ${ reference . sourceCommit } ` ) ;
return record ( Bun . YAML . parse ( source . stdout ) as unknown , ` ${ reference . configRef } @ ${ reference . sourceCommit } ` ) ;
}
function resolveObjectPath ( root : unknown , path : string ) : Record < string , unknown > {
let value : unknown = root ;
for ( const segment of path . split ( "." ) ) {
@@ -356,6 +457,7 @@ function planResult(configPath: string, target: PublicEdgeTarget, resolution: Re
warnings : resolution.unresolved.map ( ( item ) = > ( { . . . item , level : "warning" , blocking : false } ) ) ,
complete ,
rendered : rendered === null ? null : {
desiredSourceCommit : checkoutSourceCommit ( ) ,
caddyfileFingerprint : sha256Fingerprint ( rendered . caddyfile ) ,
composeFingerprint : sha256Fingerprint ( rendered . compose ) ,
siteCount : resolution.sites.length ,
@@ -364,14 +466,30 @@ function planResult(configPath: string, target: PublicEdgeTarget, resolution: Re
} ;
}
async function apply ( config : UniDeskConfig , configPath : string , target : PublicEdgeTarget , sites : ResolvedSite [ ] ) : Promise < Record < string , unknown > > {
async function reconcile (
config : UniDeskConfig ,
configPath : string ,
delivery : PublicEdgeDelivery ,
target : PublicEdgeTarget ,
sites : ResolvedSite [ ] ,
sourceCommit : string ,
ancestry : string [ ] ,
) : Promise < Record < string , unknown > > {
const artifacts = renderPublicEdgeArtifacts ( target , sites ) ;
const result = await capture ( config , target . route , [ "sh" ] , applyScript ( target , sites , artifacts ) , { runtimeTimeoutMs : 120_000 } ) ;
const result = await capture (
config ,
target . route ,
[ "sh" ] ,
applyScript ( target , sites , artifacts , { authorityId : delivery.authorityId , sourceCommit , ancestry } ) ,
{ runtimeTimeoutMs : 120_000 } ,
) ;
const runtime = parseJsonOutput ( result . stdout ) ? ? { ok : false , capture : compactCapture ( result , { full : true } ) } ;
return {
ok : result.exitCode === 0 && runtime . ok !== false ,
action : "platform-infra-public-edge-apply " ,
action : "platform-infra-public-edge-reconcile " ,
mutation : result.exitCode === 0 && runtime . ok !== false && runtime . mutation === true ,
authority : { id : delivery.authorityId , sourceBranch : delivery.sourceBranch , pipelineId : delivery.pipelineId } ,
sourceCommit ,
configRef : ` ${ configPath } #targets. ${ target . id } ` ,
target : targetSummary ( target ) ,
sites ,
@@ -380,15 +498,34 @@ async function apply(config: UniDeskConfig, configPath: string, target: PublicEd
} ;
}
async function status ( config : UniDeskConfig , configPath : string , target : PublicEdgeTarget , resolution : ResolutionResult ) : Promise < Record < string , unknown > > {
async function status (
config : UniDeskConfig ,
configPath : string ,
delivery : PublicEdgeDelivery ,
target : PublicEdgeTarget ,
resolution : ResolutionResult ,
) : Promise < Record < string , unknown > > {
const artifacts = resolution . unresolved . length === 0 ? renderPublicEdgeArtifacts ( target , resolution . sites ) : null ;
const result = await capture ( config , target . route , [ "sh" ] , statusScript ( target , resolution . sites , artifacts ? . caddyfile ? ? null ) ) ;
const result = await capture (
config ,
target . route ,
[ "sh" ] ,
statusScript (
target ,
resolution . sites ,
artifacts ? . caddyfile ? ? null ,
artifacts ? . compose ? ? null ,
checkoutSourceCommit ( ) ,
delivery . authorityId ,
) ,
) ;
const runtime = parseJsonOutput ( result . stdout ) ? ? { ok : false , capture : compactCapture ( result , { full : true } ) } ;
const ok = result . exitCode === 0 && runtime . ok !== false ;
return {
ok ,
action : "platform-infra-public-edge-status" ,
mutation : false ,
authority : { id : delivery.authorityId , sourceBranch : delivery.sourceBranch , pipelineId : delivery.pipelineId } ,
configRef : ` ${ configPath } #targets. ${ target . id } ` ,
target : targetSummary ( target ) ,
sites : resolution.sites ,
@@ -402,13 +539,19 @@ async function status(config: UniDeskConfig, configPath: string, target: PublicE
export function renderPublicEdgeArtifacts ( target : PublicEdgeTarget , sites : ResolvedSite [ ] ) : { caddyfile : string ; compose : string } {
const protocols = target . listener . protocols . join ( " " ) ;
const global = ` \ { \ n \ tservers \ { \ n \ t \ tprotocols ${ protocols } \ n \ t \ } \ n \ } \ n ` ;
const blocks = sites . map ( ( site ) = > renderSiteBlock ( site , target . runtime . responseHeaderTimeoutSeconds ) ) . join ( "\n" ) ;
const global = renderManagedBlock ( "public-edge-global" , ` \ { \ n \ tservers \ { \ n \ t \ tprotocols ${ protocols } \ n \ t \ } \ n \ } ` ) ;
const blocks = sites
. map ( ( site ) = > renderManagedBlock ( ` public-edge-site: ${ site . id } ` , renderSiteBlock ( site , target . runtime . responseHeaderTimeoutSeconds ) ) )
. join ( "\n" ) ;
const dnsServers = target . runtime . dnsServers . map ( ( server ) = > ` - ${ server } ` ) . join ( "\n" ) ;
const compose = ` services: \ n caddy: \ n image: ${ target . runtime . image } \ n container_name: ${ target . runtime . containerName } \ n network_mode: host \ n restart: unless-stopped \ n dns: \ n ${ dnsServers } \ n volumes: \ n - ${ target . runtime . caddyfilePath } :/etc/caddy/Caddyfile:ro \ n - ${ target . runtime . dataDir } :/data \ n - ${ target . runtime . configDir } :/config \ n ` ;
return { caddyfile : ` ${ global } \ n ${ blocks } ` , compose } ;
}
function renderManagedBlock ( owner : string , body : string ) : string {
return ` # BEGIN unidesk managed ${ owner } \ n ${ body . trim ( ) } \ n# END unidesk managed ${ owner } \ n ` ;
}
function renderSiteBlock ( site : ResolvedSite , defaultTimeoutSeconds : number ) : string {
if ( site . routes === undefined ) {
return ` ${ site . hostname } \ { \ n ${ renderReverseProxy ( site . upstream , defaultTimeoutSeconds , site . upstreamTlsServerName , 1 ) } \ n \ } \ n ` ;
@@ -439,161 +582,321 @@ ${tab}\t\}
$ { tab } \ } ` ;
}
export function applyScript ( target : PublicEdgeTarget , sites : ResolvedSite [ ] , artifacts : { caddyfile : string ; compose : string } ) : string {
export function applyScript (
target : PublicEdgeTarget ,
sites : ResolvedSite [ ] ,
artifacts : { caddyfile : string ; compose : string } ,
provenance : { authorityId : string ; sourceCommit : string ; ancestry : string [ ] } ,
) : string {
const caddyfile = Buffer . from ( artifacts . caddyfile , "utf8" ) . toString ( "base64" ) ;
const compose = Buffer . from ( artifacts . compose , "utf8" ) . toString ( "base64" ) ;
const ancestry = Buffer . from ( ` ${ provenance . ancestry . join ( "\n" ) } \ n ` , "utf8" ) . toString ( "base64" ) ;
const desiredCaddyFingerprint = sha256Fingerprint ( artifacts . caddyfile ) ;
const desiredComposeFingerprint = sha256Fingerprint ( artifacts . compose ) ;
const dnsItems = sites . map ( ( site ) = > shQuote ( ` ${ site . hostname } | ${ site . expectedA } ` ) ) . join ( " " ) ;
const probeItems = sites . map ( ( site ) = > shQuote ( ` ${ site . hostname } | ${ site . healthPath } ` ) ) . join ( " " ) ;
return ` set -u
mkdir - p "$(dirname ${shQuote(target.runtime.lockPath)})"
exec 9 > $ { shQuote ( target . runtime . lockPath ) }
if ! flock - x 9 ; then
printf '%s\\n' '{"ok":false,"mutation":false,"error":"public-edge-lock-failed","valuesPrinted":false}'
exit 1
fi
tmp = "$(mktemp -d)"
caddy_candidate = $ { shQuote ( ` ${ target . runtime . caddyfilePath } .next ` ) } . $ $
compose_candidate = $ { shQuote ( ` ${ target . runtime . composePath } .next ` ) } . $ $
caddy_rollback = $ { shQuote ( ` ${ target . runtime . caddyfilePath } .rollback ` ) } . $ $
compose_rollback = $ { shQuote ( ` ${ target . runtime . composePath } .rollback ` ) } . $ $
trap 'rm -rf "$tmp"; rm -f "$caddy_candidate" "$compose_candidate" "$caddy_rollback" "$compose_rollback"' EXIT
mkdir_rc = 0
mkdir - p $ { shQuote ( target . runtime . workDir ) } $ { shQuote ( target . runtime . dataDir ) } $ { shQuote ( target . runtime . configDir ) } || mkdir_rc = $ ?
candidate_write_rc = 1
if [ "$mkdir_rc" - eq 0 ] ; then
candidate_write_rc = 0
printf '%s' $ { shQuote ( caddyfile ) } | base64 - d > "$caddy _candidate" || candidate_write_rc = $ ?
printf '%s' $ { shQuote ( compose ) } | base64 - d > "$compose_candidate" || candidate_write_rc = $ ?
trap 'rm -rf "$tmp"; rm -f "$caddy_candidate" "$compose_candidate"' EXIT
ancestry_file = "$tmp/ancestors"
desired_caddy = "$tmp/Caddyfile.desired"
current_caddy = "$tmp/Caddyfile.current"
current_caddy_source = "empty"
mkdir - p $ { shQuote ( target . runtime . workDir ) } $ { shQuote ( target . runtime . dataDir ) } $ { shQuote ( target . runtime . configDir ) } "$(dirname ${shQuote(target.runtime.statePath)})"
printf '%s' $ { shQuote ( ancestry ) } | base64 - d > "$ancestry_file"
printf '%s' $ { shQuote ( caddyfile ) } | base64 - d > "$desired_caddy"
printf '%s' $ { shQuote ( compose ) } | base64 - d > "$compose _candidate"
installed_commit = ""
installed_authority = ""
installed_caddy_sha = ""
installed_compose_sha = ""
if [ - f $ { shQuote ( target . runtime . statePath ) } ] ; then
state_fields = "$tmp/installed-state-fields"
python3 - $ { shQuote ( target . runtime . statePath ) } "$state_fields" << 'PY' || {
import json , pathlib , re , sys
state = json . load ( open ( sys . argv [ 1 ] , encoding = "utf-8" ) )
valid = (
state . get ( "version" ) == 1
and isinstance ( state . get ( "authorityId" ) , str ) and bool ( state [ "authorityId" ] )
and isinstance ( state . get ( "sourceCommit" ) , str ) and re . fullmatch ( r "[0-9a-f]{40}" , state [ "sourceCommit" ] ) is not None
and isinstance ( state . get ( "managedCaddyFingerprint" ) , str ) and re . fullmatch ( r "sha256:[0-9a-f]{64}" , state [ "managedCaddyFingerprint" ] ) is not None
and isinstance ( state . get ( "composeFingerprint" ) , str ) and re . fullmatch ( r "sha256:[0-9a-f]{64}" , state [ "composeFingerprint" ] ) is not None
and isinstance ( state . get ( "siteCount" ) , int ) and state [ "siteCount" ] >= 0
)
if not valid :
raise SystemExit ( 2 )
pathlib . Path ( sys . argv [ 2 ] ) . write_text ( "\\n" . join ( [
state [ "sourceCommit" ] ,
state [ "authorityId" ] ,
state [ "managedCaddyFingerprint" ] ,
state [ "composeFingerprint" ] ,
] ) + "\\n" , encoding = "utf-8" )
PY
printf '%s\\n' '{"ok":false,"mutation":false,"error":"installed-provenance-invalid","valuesPrinted":false}'
exit 1
}
installed_commit = "$(sed -n '1p' " $state_fields ")"
installed_authority = "$(sed -n '2p' " $state_fields ")"
installed_caddy_sha = "$(sed -n '3p' " $state_fields ")"
installed_compose_sha = "$(sed -n '4p' " $state_fields ")"
fi
container_rc = 0 ; docker inspect - f '{{.State.Running}}' $ { shQuote ( target . runtime . containerName ) } 2 > / d e v / n u l l | g r e p - q x t r u e | | c o n t a i n e r _ r c = 1
listeners_rc = 0 ; ss - ltn | grep - Eq '[:.]${target.listener.httpPort}[[:space:]]' || listeners_rc = 1 ; ss - ltn | grep - Eq '[:.]${target.listener.httpsPort}[[:space:]]' || listeners_rc = 1
if [ - n "$installed_authority" ] && [ "$installed_authority" != $ { shQuote ( provenance . authorityId ) } ] ; then
printf '%s\\n' '{"ok":false,"mutation":false,"error":"installed-authority-mismatch","valuesPrinted":false}'
exit 1
fi
if [ - n "$installed_commit" ] && ! grep - Fxq "$installed_commit" "$ancestry_file" ; then
python3 - "$installed_commit" $ { shQuote ( provenance . sourceCommit ) } << 'PY'
import json , sys
print ( json . dumps ( { "ok" : False , "mutation" : False , "error" : "stale-or-diverged-source-commit" , "installedSourceCommit" : sys . argv [ 1 ] , "candidateSourceCommit" : sys . argv [ 2 ] , "valuesPrinted" : False } ) )
PY
exit 1
fi
if [ - f $ { shQuote ( target . runtime . caddyfilePath ) } ] ; then
cp $ { shQuote ( target . runtime . caddyfilePath ) } "$current_caddy"
current_caddy_source = "managed"
elif [ - f $ { shQuote ( target . runtime . legacy . caddyfilePath ) } ] ; then
cp $ { shQuote ( target . runtime . legacy . caddyfilePath ) } "$current_caddy"
current_caddy_source = "legacy"
else
: > "$current_caddy"
fi
python3 - "$current_caddy" "$desired_caddy" "$caddy_candidate" "$current_caddy_source" << 'PY'
import pathlib , re , sys
current = pathlib . Path ( sys . argv [ 1 ] ) . read_text ( encoding = "utf-8" )
desired = pathlib . Path ( sys . argv [ 2 ] ) . read_text ( encoding = "utf-8" )
pattern = re . compile ( r "(?ms)^# BEGIN unidesk managed (?P<name>[^\\n]+)\\n(?P<body>.*?)\\n# END unidesk managed (?P=name)\\n*" )
desired_blocks = [ m . group ( 0 ) . rstrip ( ) for m in pattern . finditer ( desired ) ]
is_public_edge = lambda name : re.fullmatch ( r "public-edge(?:-global|-site:.+)" , name ) is not None
if not desired_blocks or any ( not is_public_edge ( m . group ( "name" ) ) for m in pattern . finditer ( desired ) ) :
raise SystemExit ( "desired public-edge managed blocks invalid" )
if sys . argv [ 4 ] == "legacy" :
preserved = [ m . group ( 0 ) . rstrip ( ) for m in pattern . finditer ( current ) if not is_public_edge ( m . group ( "name" ) ) ]
base = "\\n\\n" . join ( preserved )
else :
base = pattern . sub ( lambda m : "" if is_public_edge ( m . group ( "name" ) ) else m . group ( 0 ) , current ) . rstrip ( )
merged = ( base + "\\n\\n" if base else "" ) + "\\n\\n" . join ( desired_blocks ) + "\\n"
pathlib . Path ( sys . argv [ 3 ] ) . write_text ( merged , encoding = "utf-8" )
PY
new_running = 0 ; docker inspect - f '{{.State.Running}}' $ { shQuote ( target . runtime . containerName ) } 2 > / d e v / n u l l | g r e p - q x t r u e & & n e w _ r u n n i n g = 1
legacy_running = 0 ; docker inspect - f '{{.State.Running}}' $ { shQuote ( target . runtime . legacy . containerName ) } 2 > / d e v / n u l l | g r e p - q x t r u e & & l e g a c y _ r u n n i n g = 1
if [ "$new_running" - ne 1 ] && [ "$legacy_running" - ne 1 ] ; then
printf '%s\\n' '{"ok":false,"mutation":false,"error":"managed-edge-not-running","valuesPrinted":false}'
exit 1
fi
listeners_rc = 0
ss - ltn | grep - Eq '[:.]${target.listener.httpPort}[[:space:]]' || listeners_rc = 1
ss - ltn | grep - Eq '[:.]${target.listener.httpsPort}[[:space:]]' || listeners_rc = 1
udp_rc = 0
$ { target . isolation . preservedUdpPorts . map ( ( value ) = > ` ss -lun | grep -Eq '[:.] ${ value } [[:space:]]' || udp_rc=1 ` ) . join ( "\n" ) }
dns_rc = 0
for item in $ { dnsItems } ; do host = "${" $ { item % % | * } "}" ; expected = "${" $ { item # * | } "}" ; getent ahostsv4 "$host" | awk '{print $1}' | grep - Fxq "$expected" || dns_rc = 1 ; done
compose_validate_rc = 1 ; adapt_rc = 1 ; validate_rc = 1
if [ "$candidate_write_rc" - eq 0 ] && [ "$container_rc" - eq 0 ] && [ "$listeners_rc" - eq 0 ] && [ "$udp_rc" - eq 0 ] && [ "$dns_rc" - eq 0 ] ; then
docker compose - f "$compose _candidate" config > / d e v / n u l l 2 > " $ t m p / c o m p o s e - v a l i d a t e . e r r " ; c o m p o s e _ v a l i d a t e _ r c = $ ?
if [ "$compose_ validate_rc" - eq 0 ] ; then
docker run -- rm - v "$caddy_candidate:/etc/caddy/Caddyfile:ro" $ { shQuote ( target . runtime . image ) } caddy adapt -- validate -- config / etc / caddy / Caddyfile > / d e v / n u l l 2 > " $ t m p / a d a p t . e r r " ; a d a p t _ r c = $ ?
fi
if [ "$adapt_rc" - eq 0 ] ; then
docker run -- rm - v "$caddy_candidate:/etc/caddy/Caddyfile:ro" $ { shQuote ( target . runtime . image ) } caddy validate -- config / etc / caddy / Caddyfile > / d e v / n u l l 2 > " $ t m p / v a l i d a t e . e r r " ; v a l i d a t e _ r c = $ ?
fi
fi
preserve_rc = - 1 ; install_attempted = 0 ; install_compose_rc = - 1 ; install_caddy_rc = - 1
recreate_attempted = 0 ; recreate_rc = - 1 ; runtime_ready_rc = - 1 ; probe_attempted = 0 ; probe_rc = - 1
probe_failures = "$tmp/probe-failures.tsv" ; : > "$probe_failures"
rollback_attempted = 0 ; rollback_compose_rc = - 1 ; rollback_caddy_rc = - 1 ; rollback_recreate_rc = - 1
mutation_started = 0
if [ "$compose_validate_rc" - eq 0 ] && [ "$validate_rc" - eq 0 ] ; then
preserve_rc = 0
cp $ { shQuote ( target . runtime . caddyfilePath ) } "$tmp/Caddyfile.previous" || preserve_rc = $ ?
cp $ { shQuote ( target . runtime . composePath ) } "$tmp/docker-compose.previous.yaml" || preserve_rc = $ ?
if [ "$preserve_rc" - eq 0 ] ; then
install_attempted = 1
chmod 0644 "$compose_candidate" && mv "$compose_candidate" $ { shQuote ( target . runtime . composePath ) }
install_compose_rc = $ ?
if [ "$install_compose_rc" - eq 0 ] ; then
mutation_started = 1
chmod 0644 "$caddy_candidate" && mv "$caddy_candidate" $ { shQuote ( target . runtime . caddyfilePath ) }
install_caddy_rc = $ ?
if [ "$install_caddy_rc" - eq 0 ] ; then
recreate_attempted = 1
docker compose - f $ { shQuote ( target . runtime . composePath ) } up - d -- no - build -- no - deps -- force - recreate caddy > / d e v / n u l l 2 > " $ t m p / r e c r e a t e . e r r " ; r e c r e a t e _ r c = $ ?
if [ "$recreate_rc" - eq 0 ] ; then
runtime_ready_rc = 1
for attempt in $ ( seq 1 20 ) ; do
if docker inspect - f '{{.State.Running}}' $ { shQuote ( target . runtime . containerName ) } 2 > / d e v / n u l l | g r e p - q x t r u e & & s s - l t n | g r e p - E q ' [ : . ] $ { t a r g e t . l i s t e n e r . h t t p P o r t } [ [ : s p a c e : ] ] ' & & s s - l t n | g r e p - E q ' [ : . ] $ { t a r g e t . l i s t e n e r . h t t p s P o r t } [ [ : s p a c e : ] ] ' ; t h e n
runtime_ready_rc = 0
break
fi
sleep 1
done
fi
if [ "$recreate_rc" - eq 0 ] && [ "$runtime_ready_rc" - eq 0 ] ; then
probe_attempted = 1 ; probe_rc = 0
for item in $ { probeItems } ; do
host = "${" $ { item % % | * } "}" ; path = "${" $ { item # * | } "}" ; path = "/${" $ { path # / } "}"
if ! curl -- noproxy '*' - fsS -- connect - timeout 5 -- max - time 20 -- resolve "$host:${target.listener.httpsPort}:127.0.0.1" "https://$host$path" > / d e v / n u l l 2 > > " $ t m p / p r o b e . e r r " ; t h e n
probe_rc = 1
printf '%s\t%s\n' "$host" "$path" >> "$probe_failures"
fi
done
fi
fi
fi
fi
fi
if [ "$mutation_started" - eq 1 ] && { [ "$install_caddy_rc" - ne 0 ] || [ "$recreate_rc" - ne 0 ] || [ "$runtime_ready_rc" - ne 0 ] ; } ; then
rollback_attempted = 1
cp "$tmp/docker-compose.previous.yaml" "$compose_rollback" && chmod 0644 "$compose_rollback" && mv "$compose_rollback" $ { shQuote ( target . runtime . composePath ) }
rollback_compose_rc = $ ?
cp "$tmp/Caddyfile.previous" "$caddy_rollback" && chmod 0644 "$caddy_rollback" && mv "$caddy_rollback" $ { shQuote ( target . runtime . caddyfilePath ) }
rollback_caddy_rc = $ ?
if [ "$rollback_compose_rc" - eq 0 ] && [ "$rollback_caddy_rc" - eq 0 ] ; then
docker compose - f $ { shQuote ( target . runtime . composePath ) } up - d -- no - build -- no - deps -- force - recreate caddy > / d e v / n u l l 2 > > " $ t m p / r e c r e a t e . e r r " ; r o l l b a c k _ r e c r e a t e _ r c = $ ?
fi
fi
python3 - "$container_rc" "$listeners_rc" "$udp_rc" "$dns_rc" "$candidate_write_rc" "$compose_validate_rc" "$adapt_rc" "$validate_rc" "$preserve_rc" "$install_attempted" "$install_compose_rc" "$install_caddy_rc" "$recreate_attempted" "$recreate_rc" "$runtime_ready_rc" "$probe_attempted" "$probe_rc" "$rollback_attempted" "$rollback_compose_rc" "$rollback_caddy_rc" "$rollback_recreate_rc" "$probe_failures" << 'PY'
compose_rc = 0 ; docker compose - f "$compose_candidate" config > / d e v / n u l l 2 > " $ t m p / c o m p o s e . e r r " | | c o m p o s e _ r c = $ ?
adapt_rc = 0 ; docker run -- rm - v "$caddy_candidate:/etc/caddy/Caddyfile:ro" $ { shQuote ( target . runtime . image ) } caddy adapt -- validate -- config / etc / caddy / Caddyfile > / d e v / n u l l 2 > " $ t m p / a d a p t . e r r " | | a d a p t _ r c = $ ?
validate_rc = 0 ; docker run -- rm - v "$caddy _candidate:/etc/caddy/Caddyfile:ro" $ { shQuote ( target . runtime . image ) } caddy validate -- config / etc / caddy / Caddyfile > / d e v / n u l l 2 > " $ t m p / v a l i d a t e . e r r " | | v a l i d a t e _ r c = $ ?
if [ "$listeners_rc" - ne 0 ] || [ "$udp_rc" - ne 0 ] || [ "$dns_rc" - ne 0 ] || [ "$compose_rc" - ne 0 ] || [ "$adapt_rc" - ne 0 ] || [ "$ validate_rc" - ne 0 ] ; then
python3 - "$listeners_rc" "$udp_rc" "$dns_rc" "$compose_rc" "$adapt_rc" "$validate_rc" << 'PY'
import json , sys
names = [ "tcpListeners" , "preservedUdpListeners" , "dns" , "compose" , "caddyAdapt" , "caddyValidate" ]
print ( json . dumps ( { "ok" : False , "mutation" : False , "error" : "candidate-validation-failed" , "checks" : { name : { "exitCode" : int ( value ) } for name , value in zip ( names , sys . argv [ 1 : ] ) } , "valuesPrinted" : False } ) )
PY
exit 1
fi
current_managed_sha = "$(python3 - " $current_caddy " << 'PY'
import hashlib , pathlib , re , sys
text = pathlib . Path ( sys . argv [ 1 ] ) . read_text ( encoding = "utf-8" )
pattern = re . compile ( r "(?ms)^# BEGIN unidesk managed (?P<name>[^\\n]+)\\n(?P<body>.*?)\\n# END unidesk managed (?P=name)\\n*" )
blocks = [ m . group ( 0 ) . rstrip ( ) for m in pattern . finditer ( text ) if re . fullmatch ( r "public-edge(?:-global|-site:.+)" , m . group ( "name" ) ) ]
print ( "sha256:" + hashlib . sha256 ( ( "\\n\\n" . join ( blocks ) + ( "\\n" if blocks else "" ) ) . encode ( ) ) . hexdigest ( ) if blocks else "" )
PY
) "
current_compose_sha = ""
if [ - f $ { shQuote ( target . runtime . composePath ) } ] ; then current_compose_sha = "sha256:$(sha256sum ${shQuote(target.runtime.composePath)} | awk '{print $1}')" ; fi
if [ "$new_running" - eq 1 ] \
&& [ "$installed _commit" = $ { shQuote ( provenance . sourceCommit ) } ] \
&& [ "$installed_caddy_sha" = $ { shQuote ( desiredCaddyFingerprint ) } ] \
&& [ "$installed_compose_sha" = $ { shQuote ( desiredComposeFingerprint ) } ] \
&& [ "$current_managed_sha" = $ { shQuote ( desiredCaddyFingerprint ) } ] \
&& [ "$current_compose_sha" = $ { shQuote ( desiredComposeFingerprint ) } ] ; then
python3 - $ { shQuote ( provenance . authorityId ) } $ { shQuote ( provenance . sourceCommit ) } << 'PY'
import json , sys
print ( json . dumps ( { "ok" : True , "mutation" : False , "mode" : "already-current" , "authorityId" : sys . argv [ 1 ] , "sourceCommit" : sys . argv [ 2 ] , "valuesPrinted" : False } ) )
PY
exit 0
fi
had_compose = 0 ; had_caddy = 0 ; had_state = 0
if [ - f $ { shQuote ( target . runtime . composePath ) } ] ; then cp $ { shQuote ( target . runtime . composePath ) } "$tmp/compose.previous" ; had_compose = 1 ; fi
if [ - f $ { shQuote ( target . runtime . caddyfilePath ) } ] ; then cp $ { shQuote ( target . runtime . caddyfilePath ) } "$tmp/Caddyfile.previous" ; had_caddy = 1 ; fi
if [ - f $ { shQuote ( target . runtime . statePath ) } ] ; then cp $ { shQuote ( target . runtime . statePath ) } "$tmp/state.previous" ; had_state = 1 ; fi
chmod 0644 "$compose_candidate" "$caddy_candidate"
mv "$compose_candidate" $ { shQuote ( target . runtime . composePath ) }
mv "$caddy_candidate" $ { shQuote ( target . runtime . caddyfilePath ) }
if [ "$legacy_running" - eq 1 ] ; then docker rm - f $ { shQuote ( target . runtime . legacy . containerName ) } > / d e v / n u l l 2 > " $ t m p / l e g a c y - s t o p . e r r " ; f i
recreate_rc = 0
docker compose - f $ { shQuote ( target . runtime . composePath ) } up - d -- no - build -- no - deps -- force - recreate caddy > / d e v / n u l l 2 > " $ t m p / r e c r e a t e . e r r " | | r e c r e a t e _ r c = $ ?
ready_rc = 1
if [ "$recreate_rc" - eq 0 ] ; then
for attempt in $ ( seq 1 20 ) ; do
if docker inspect - f '{{.State.Running}}' $ { shQuote ( target . runtime . containerName ) } 2 > / d e v / n u l l | g r e p - q x t r u e & & s s - l t n | g r e p - E q ' [ : . ] $ { t a r g e t . l i s t e n e r . h t t p P o r t } [ [ : s p a c e : ] ] ' & & s s - l t n | g r e p - E q ' [ : . ] $ { t a r g e t . l i s t e n e r . h t t p s P o r t } [ [ : s p a c e : ] ] ' ; t h e n r e a d y _ r c = 0 ; b r e a k ; f i
sleep 1
done
fi
state_rc = 1
if [ "$recreate_rc" - eq 0 ] && [ "$ready_rc" - eq 0 ] ; then
state_candidate = "$tmp/reconcile-state.json"
python3 - "$state_candidate" $ { shQuote ( provenance . authorityId ) } $ { shQuote ( provenance . sourceCommit ) } $ { shQuote ( desiredCaddyFingerprint ) } $ { shQuote ( desiredComposeFingerprint ) } $ { sites . length } << 'PY'
import datetime , json , pathlib , sys
path = pathlib . Path ( sys . argv [ 1 ] )
path . write_text ( json . dumps ( {
"version" : 1 ,
"authorityId" : sys . argv [ 2 ] ,
"sourceCommit" : sys . argv [ 3 ] ,
"managedCaddyFingerprint" : sys . argv [ 4 ] ,
"composeFingerprint" : sys . argv [ 5 ] ,
"siteCount" : int ( sys . argv [ 6 ] ) ,
"updatedAt" : datetime . datetime . now ( datetime . timezone . utc ) . isoformat ( ) ,
} , separators = ( "," , ":" ) ) + "\\n" , encoding = "utf-8" )
PY
chmod 0644 "$state_candidate"
mv "$state_candidate" $ { shQuote ( target . runtime . statePath ) } && state_rc = 0
fi
rollback_rc = - 1
if [ "$recreate_rc" - ne 0 ] || [ "$ready_rc" - ne 0 ] || [ "$state_rc" - ne 0 ] ; then
docker rm - f $ { shQuote ( target . runtime . containerName ) } > / d e v / n u l l 2 > & 1 | | t r u e
if [ "$had_compose" - eq 1 ] ; then cp "$tmp/compose.previous" $ { shQuote ( target . runtime . composePath ) } ; else rm - f $ { shQuote ( target . runtime . composePath ) } ; fi
if [ "$had_caddy" - eq 1 ] ; then cp "$tmp/Caddyfile.previous" $ { shQuote ( target . runtime . caddyfilePath ) } ; else rm - f $ { shQuote ( target . runtime . caddyfilePath ) } ; fi
if [ "$had_state" - eq 1 ] ; then cp "$tmp/state.previous" $ { shQuote ( target . runtime . statePath ) } ; else rm - f $ { shQuote ( target . runtime . statePath ) } ; fi
rollback_rc = 0
if [ "$legacy_running" - eq 1 ] ; then
docker compose - f $ { shQuote ( target . runtime . legacy . composePath ) } up - d -- no - build -- no - deps -- force - recreate caddy > / d e v / n u l l 2 > > " $ t m p / r o l l b a c k . e r r " | | r o l l b a c k _ r c = $ ?
elif [ "$new_running" - eq 1 ] && [ "$had_compose" - eq 1 ] ; then
docker compose - f $ { shQuote ( target . runtime . composePath ) } up - d -- no - build -- no - deps -- force - recreate caddy > / d e v / n u l l 2 > > " $ t m p / r o l l b a c k . e r r " | | r o l l b a c k _ r c = $ ?
fi
python3 - "$recreate_rc" "$ready_rc" "$state_rc" "$rollback_rc" << 'PY'
import json , sys
print ( json . dumps ( { "ok" : False , "mutation" : False , "error" : "reconcile-failed" , "recreate" : { "exitCode" : int ( sys . argv [ 1 ] ) , "readyExitCode" : int ( sys . argv [ 2 ] ) } , "provenance" : { "exitCode" : int ( sys . argv [ 3 ] ) } , "rollback" : { "exitCode" : int ( sys . argv [ 4 ] ) } , "valuesPrinted" : False } ) )
PY
exit 1
fi
probe_rc = 0
probe_failures = "$tmp/probe-failures.tsv" ; : > "$probe_failures"
probe_index = 0
for item in $ { probeItems } ; do
probe_index = $ ( ( probe_index + 1 ) )
(
host = "${" $ { item % % | * } "}" ; path = "${" $ { item # * | } "}" ; path = "/${" $ { path # / } "}"
if ! curl -- noproxy '*' - fsS -- connect - timeout 3 -- max - time 10 -- resolve "$host:${target.listener.httpsPort}:127.0.0.1" "https://$host$path" > / d e v / n u l l 2 > " $ t m p / p r o b e - $ p r o b e _ i n d e x . e r r " ; t h e n
printf '%s\t%s\\n' "$host" "$path" > "$tmp/probe-failure-$probe_index.tsv"
fi
) &
done
wait || true
for failure in "$tmp" / probe - failure - * . tsv ; do
[ - f "$failure" ] || continue
probe_rc = 1
cat "$failure" >> "$probe_failures"
done
python3 - "$probe_rc" "$probe_failures" $ { shQuote ( provenance . authorityId ) } $ { shQuote ( provenance . sourceCommit ) } "$legacy_running" << 'PY'
import json , sys
values = [ int ( value ) for value in sys . argv [ 1 : - 1 ] ]
( container , listeners , udp , dns , candidate_write , compose_validate , adapt , validate , preserve ,
install_attempted , install_compose , install_caddy , recreate_attempted , recreate , runtime_ready ,
probe_attempted , probe , rollback_attempted , rollback_compose , rollback_caddy , rollback_recreate ) = values
ok = all ( value == 0 for value in [ container , listeners , udp , dns , candidate_write , compose_validate , adapt , validate , preserve , install_compose , install_caddy , recreate , runtime_ready ] ) and rollback_attempted == 0
if rollback_attempted :
rollback_exit = 0 if all ( value == 0 for value in [ rollback_compose , rollback_caddy , rollback_recreate ] ) else 1
else :
rollback_exit = None
warnings = [ ]
with open ( sys . argv [ - 1 ] , encoding = "utf-8" ) as handle :
with open ( sys . argv [ 2 ] , encoding = "utf-8" ) as handle :
for line in handle :
hostname , path = line . rstrip ( "\\n" ) . split ( "\\t" , 1 )
warnings . append ( { "code" : "site-probe-failed" , "level" : "warning" , "blocking" : False , "hostname" : hostname , "path" : path } )
failure_phase = None
for name , failed in [
( "validation" , any ( value != 0 for value in [ container , listeners , udp , dns , candidate_write , compose_validate , adapt , validate ] ) ) ,
( "install" , preserve >= 0 and ( preserve != 0 or ( install_attempted == 1 and any ( value != 0 for value in [ install_compose , install_caddy ] ) ) ) ) ,
( "recreate" , recreate_attempted == 1 and any ( value != 0 for value in [ recreate , runtime_ready ] ) ) ,
( "rollback" , rollback_attempted == 1 and rollback_exit != 0 ) ,
] :
if failed :
failure_phase = name
print ( json . dumps ( {
"ok" : ok ,
"mutation" : ok ,
"failurePhase" : failure_phase ,
"preflight" : { "container" : { "exitCode" : container } , "tcpListeners" : { "exitCode" : listeners } , "preservedUdpListeners" : { "exitCode" : udp } , "dns" : { "exitCode" : dns } } ,
"validation" : { "candidateWrite" : { "exitCode" : candidate_write } , "compose" : { "exitCode" : compose_validate } , "caddyAdapt" : { "exitCode" : adapt } , "caddyValidate" : { "exitCode" : validate } } ,
"install " : { "attempted" : bool ( install_attempted ) , "preserveExitCode" : preserve , "composeExitCode" : install_compose , "caddyfileExitCode" : install_caddy , "exitCode" : 0 if install_attempted and preserve == install_compose == install_caddy == 0 else 1 } ,
"recreate" : { "attempted" : bool ( recreate_attempted ) , "exitCode" : recreate , "runtimeReadyExitCode" : runtime_ready } ,
"probe" : { "attempted" : bool ( probe_attempted ) , "exitCode" : probe , "blocking" : False } ,
"ok" : True ,
"mutation" : True ,
"authorityId" : sys . argv [ 3 ] ,
"sourceCommit" : sys . argv [ 4 ] ,
"legacyRetired" : sys . argv [ 5 ] == "1" ,
"probe " : { "attempted" : True , "exitCode" : int ( sys . argv [ 1 ] ) , "blocking" : False } ,
"warnings" : warnings ,
"rollback" : { "attempted" : bool ( rollback_attempted ) , "composeExitCode" : rollback_compose , "caddyfileExitCode" : rollback_caddy , "recreateExitCode" : rollback_recreate , "exitCode" : rollback_exit } ,
"valuesPrinted" : False ,
} ) )
raise SystemExit ( 0 if ok else 1 )
PY
` ;
}
function statusScript ( target : PublicEdgeTarget , sites : ResolvedSite [ ] , desiredCaddyfile : string | null ) : string {
function statusScript (
target : PublicEdgeTarget ,
sites : ResolvedSite [ ] ,
desiredCaddyfile : string | null ,
desiredCompose : string | null ,
desiredSourceCommit : string | null ,
authorityId : string ,
) : string {
const probeItems = sites . map ( ( site ) = > shQuote ( ` ${ site . hostname } | ${ site . healthPath } | ${ site . expectedA } ` ) ) . join ( " " ) ;
const desiredFingerprint = desiredCaddyfile === null ? "" : sha256Fingerprint ( desiredCaddyfile ) ;
const desiredComposeFingerprint = desiredCompose === null ? "" : sha256Fingerprint ( desiredCompose ) ;
return ` set -u
container_rc = 0 ; docker inspect - f '{{.State.Running}}' $ { shQuote ( target . runtime . containerName ) } 2 > / d e v / n u l l | g r e p - q x t r u e | | c o n t a i n e r _ r c = 1
legacy_rc = 0 ; docker inspect - f '{{.State.Running}}' $ { shQuote ( target . runtime . legacy . containerName ) } 2 > / d e v / n u l l | g r e p - q x t r u e | | l e g a c y _ r c = 1
listeners_rc = 0 ; ss - ltn | grep - Eq '[:.]${target.listener.httpPort}[[:space:]]' || listeners_rc = 1 ; ss - ltn | grep - Eq '[:.]${target.listener.httpsPort}[[:space:]]' || listeners_rc = 1
udp_rc = 0
$ { target . isolation . preservedUdpPorts . map ( ( value ) = > ` ss -lun | grep -Eq '[:.] ${ value } [[:space:]]' || udp_rc=1 ` ) . join ( "\n" ) }
validate_rc = 0 ; docker exec $ { shQuote ( target . runtime . containerName ) } caddy validate -- config / etc / caddy / Caddyfile > / d e v / n u l l 2 > / d e v / n u l l | | v a l i d a t e _ r c = 1
sites_rc = 0 ; dns_rc = 0
sites_rc = 0 ; dns_rc = 0 ; probe_index = 0
for item in $ { probeItems } ; do
host = "${" $ { item % % | * } "}" ; rest = "${" $ { item # * | } "}" ; path = "/${" $ { rest % % | * } "}" ; path = "/${" $ { path # / } "}" ; expected = "${" $ { rest # * | } "}"
getent ahostsv4 "$host" | awk '{print $1}' | grep - Fxq "$expected" || dns_rc = 1
curl -- noproxy '*' - fsS -- connect - timeout 5 -- max - time 20 -- resolve "$host:${target.listener.httpsPort}:127.0.0.1" "https://$host$path" > / d e v / n u l l | | s i t e s _ r c = 1
probe_index = $ ( ( probe_index + 1 ) )
(
host = "${" $ { item % % | * } "}" ; rest = "${" $ { item # * | } "}" ; path = "/${" $ { rest % % | * } "}" ; path = "/${" $ { path # / } "}" ; expected = "${" $ { rest # * | } "}"
getent ahostsv4 "$host" | awk '{print $1}' | grep - Fxq "$expected" || : > "/tmp/public-edge-dns-$$-$probe_index.failed"
curl -- noproxy '*' - fsS -- connect - timeout 3 -- max - time 10 -- resolve "$host:${target.listener.httpsPort}:127.0.0.1" "https://$host$path" > / d e v / n u l l | | : > " / t m p / p u b l i c - e d g e - s i t e - $ $ - $ p r o b e _ i n d e x . f a i l e d "
) &
done
current_sha = "" ; if [ - f $ { shQuote ( target . runtime . caddyfilePath ) } ] ; then current_sha = "sha256:$(sha256sum ${shQuote(target.runtime.caddyfilePath)} | awk '{print $1}')" ; fi
python3 - "$container_rc" "$listeners_rc" "$udp_rc" "$validate_rc" "$dns_rc" "$sites_rc" "$current_sha" $ { shQuote ( desiredFingerprint ) } << 'PY'
import json , sys
container , listeners , udp , validate , dns , sites = [ int ( value ) for value in sys . argv [ 1 :7 ] ]
current , desired = sys . argv [ 7 :9 ]
ok = all ( value == 0 for value in [ container , listeners , udp , validate , dns , sites ] )
print ( json . dumps ( { "ok" : ok , "container" : { "exitCode" : container } , "tcpListeners" : { "exitCode" : listeners } , "preservedUdpListeners" : { "exitCode" : udp } , "validate" : { "exitCode" : validate } , "dns" : { "exitCode" : dns } , "siteProbes" : { "exitCode" : sites } , "config" : { "currentFingerprint" : current or None , "desiredFingerprint" : desired or None , "matchesDesired" : bool ( desired ) and current == desired } , "valuesPrinted" : False } ) )
wait || true
for failure in / t m p / p u b l i c - e d g e - d n s - $ $ - * . f a i l e d ; d o [ - f " $ f a i l u r e " ] | | c o n t i n u e ; d n s _ r c = 1 ; r m - f " $ f a i l u r e " ; d o n e
for failure in / t m p / p u b l i c - e d g e - s i t e - $ $ - * . f a i l e d ; d o [ - f " $ f a i l u r e " ] | | c o n t i n u e ; s i t e s _ r c = 1 ; r m - f " $ f a i l u r e " ; d o n e
current_sha = ""
if [ - f $ { shQuote ( target . runtime . caddyfilePath ) } ] ; then
current_sha = " $ ( python3 - $ { shQuote ( target . runtime . caddyfilePath ) } << 'PY'
import hashlib , pathlib , re , sys
text = pathlib . Path ( sys . argv [ 1 ] ) . read_text ( encoding = "utf-8" )
pattern = re . compile ( r "(?ms)^# BEGIN unidesk managed (?P<name>[^\\n]+)\\n(?P<body>.*?)\\n# END unidesk managed (?P=name)\\n*" )
blocks = [ m . group ( 0 ) . rstrip ( ) for m in pattern . finditer ( text ) if re . fullmatch ( r "public-edge(?:-global|-site:.+)" , m . group ( "name" ) ) ]
print ( "sha256:" + hashlib . sha256 ( ( "\\n\\n" . join ( blocks ) + ( "\\n" if blocks else "" ) ) . encode ( ) ) . hexdigest ( ) if blocks else "" )
PY
) "
fi
current_compose_sha = ""
if [ - f $ { shQuote ( target . runtime . composePath ) } ] ; then current_compose_sha = "sha256:$(sha256sum ${shQuote(target.runtime.composePath)} | awk '{print $1}')" ; fi
python3 - "$container_rc" "$legacy_rc" "$listeners_rc" "$udp_rc" "$validate_rc" "$dns_rc" "$sites_rc" "$current_sha" $ { shQuote ( desiredFingerprint ) } "$current_compose_sha" $ { shQuote ( desiredComposeFingerprint ) } $ { shQuote ( desiredSourceCommit ? ? "" ) } $ { shQuote ( authorityId ) } $ { shQuote ( target . runtime . statePath ) } << 'PY'
import json , pathlib , sys
container , legacy , listeners , udp , validate , dns , sites = [ int ( value ) for value in sys . argv [ 1 :8 ] ]
current , desired , current_compose , desired_compose , desired_commit , desired_authority , state_path = sys . argv [ 8 :15 ]
state = None
try :
state = json . loads ( pathlib . Path ( state_path ) . read_text ( encoding = "utf-8" ) )
except FileNotFoundError :
pass
except Exception as error :
state = { "error" : "installed-provenance-invalid" , "detail" : str ( error ) }
state_matches_runtime = (
state is not None
and "error" not in state
and state . get ( "version" ) == 1
and state . get ( "authorityId" ) == desired_authority
and state . get ( "sourceCommit" ) == desired_commit
and state . get ( "managedCaddyFingerprint" ) == current
and state . get ( "composeFingerprint" ) == current_compose
)
config_matches_desired = bool ( desired ) and current == desired and bool ( desired_compose ) and current_compose == desired_compose
ok = all ( value == 0 for value in [ container , listeners , udp , validate , dns , sites ] ) and legacy != 0 and state_matches_runtime and config_matches_desired
print ( json . dumps ( { "ok" : ok , "container" : { "exitCode" : container } , "legacyContainer" : { "exitCode" : legacy , "retired" : legacy != 0 } , "tcpListeners" : { "exitCode" : listeners } , "preservedUdpListeners" : { "exitCode" : udp } , "validate" : { "exitCode" : validate } , "dns" : { "exitCode" : dns } , "siteProbes" : { "exitCode" : sites } , "provenance" : { "installed" : state , "desiredSourceCommit" : desired_commit or None , "matchesRuntime" : state_matches_runtime } , "config" : { "currentManagedFingerprint" : current or None , "desiredManagedFingerprint" : desired or None , "currentComposeFingerprint" : current_compose or None , "desiredComposeFingerprint" : desired_compose or None , "matchesDesired" : config_matches_desired } , "valuesPrinted" : False } ) )
raise SystemExit ( 0 if ok else 1 )
PY
` ;
@@ -612,10 +915,30 @@ function targetSummary(target: PublicEdgeTarget): Record<string, unknown> {
composePath : target.runtime.composePath ,
dataDir : target.runtime.dataDir ,
configDir : target.runtime.configDir ,
statePath : target.runtime.statePath ,
lockPath : target.runtime.lockPath ,
legacyContainerName : target.runtime.legacy.containerName ,
dnsServers : target.runtime.dnsServers ,
} ;
}
function readAncestry ( path : string | null , sourceCommit : string | null ) : string [ ] {
if ( path === null || sourceCommit === null || ! existsSync ( path ) ) {
throw inputError ( "reconcile ancestry file 不存在" , "reconcile-ancestry-missing" , "--ancestry-file" ) ;
}
const commits = readFileSync ( path , "utf8" ) . split ( / \ r ? \ n / u ) . f i l t e r ( ( v a l u e ) = > v a l u e . l e n g t h > 0 ) ;
if ( commits . length === 0 || commits . some ( ( value ) = > ! / ^ [ 0 - 9 a - f ] { 4 0 } $ / u . t e s t ( v a l u e ) ) | | ! c o m m i t s . i n c l u d e s ( s o u r c e C o m m i t ) ) {
throw inputError ( "reconcile ancestry file 无效或不包含 candidate commit" , "reconcile-ancestry-invalid" , "--ancestry-file" ) ;
}
return commits ;
}
function checkoutSourceCommit ( ) : string | null {
const result = spawnSync ( "git" , [ "rev-parse" , "HEAD" ] , { cwd : resolveRepoPath ( "." ) , encoding : "utf8" } ) ;
const value = result . status === 0 ? result . stdout . trim ( ) : "" ;
return / ^ [ 0 - 9 a - f ] { 4 0 } $ / u . t e s t ( v a l u e ) ? v a l u e : n u l l ;
}
function record ( value : unknown , path : string ) : Record < string , unknown > {
if ( typeof value !== "object" || value === null || Array . isArray ( value ) ) throw new Error ( ` ${ path } 必须是对象 ` ) ;
return value as Record < string , unknown > ;
@@ -625,6 +948,11 @@ function optionalRecord(value: unknown): Record<string, unknown> | null {
return typeof value === "object" && value !== null && ! Array . isArray ( value ) ? value as Record < string , unknown > : null ;
}
function boolean ( value : unknown , path : string ) : boolean {
if ( typeof value !== "boolean" ) throw new Error ( ` ${ path } 必须是布尔值 ` ) ;
return value ;
}
function array ( value : unknown , path : string ) : unknown [ ] {
if ( ! Array . isArray ( value ) ) throw new Error ( ` ${ path } 必须是数组 ` ) ;
return value ;
@@ -697,6 +1025,12 @@ function yamlConfigPath(value: unknown, path: string): string {
return result ;
}
function gitCommit ( value : unknown , path : string ) : string {
const result = string ( value , path ) ;
if ( ! / ^ [ 0 - 9 a - f ] { 4 0 } $ / u . t e s t ( r e s u l t ) ) t h r o w n e w E r r o r ( ` $ { p a t h } 必 须 是 4 0 位 G i t c o m m i t ` ) ;
return result ;
}
function duration ( value : unknown , path : string ) : string {
const result = string ( value , path ) ;
if ( ! / ^ [ 1 - 9 ] [ 0 - 9 ] * ( ? : m s | s | m ) $ / u . t e s t ( r e s u l t ) ) t h r o w n e w E r r o r ( ` $ { p a t h } 必 须 是 正 整 数 加 m s 、 s 或 m ` ) ;