1934 lines
93 KiB
TypeScript
1934 lines
93 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { rootPath } from "./config";
|
|
import { runCommand, type CommandResult } from "./command";
|
|
import {
|
|
HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH,
|
|
type ArgoOptions,
|
|
type CiBuildBenchmarkCachePolicy,
|
|
type CiBuildBenchmarkProfileSpec,
|
|
type ControlPlaneEgressProxySpec,
|
|
type ControlPlaneGitMirrorGithubTransportSpec,
|
|
type ControlPlaneHostRouteEgressProxySpec,
|
|
type ControlPlaneK3sInstallSpec,
|
|
type ControlPlaneK3sNodeSpec,
|
|
type ControlPlaneNodeSpec,
|
|
type ControlPlaneRegistrySpec,
|
|
type ControlPlaneRuntimeProxySpec,
|
|
type ControlPlaneTargetSpec,
|
|
type TektonInstallOptions,
|
|
type ToolsImageOptions,
|
|
} from "./hwlab-node-control-plane-model";
|
|
import { controlPlaneRegistrySummary } from "./hwlab-node-control-plane-registry";
|
|
import { argoInternalHttpIdentity } from "./hwlab-node-control-plane-argo-model";
|
|
import { argoReconciliationStatusPython } from "./hwlab-node-control-plane-argo-runtime";
|
|
import { detachedJobLaunchScript, manifestObjectSummary, remoteJobStateDir, remoteJobStatusScript, runTargetK3s } from "./hwlab-node-control-plane-executor";
|
|
|
|
export function planSummary(node: ControlPlaneNodeSpec, target: ControlPlaneTargetSpec): Record<string, unknown> {
|
|
return {
|
|
id: target.id,
|
|
node: node.id,
|
|
kubeRoute: node.kubeRoute,
|
|
kubeExecution: node.kubeExecution,
|
|
lane: target.lane,
|
|
enabled: target.enabled,
|
|
ciNamespace: target.ciNamespace,
|
|
runtimeNamespace: target.runtimeNamespace,
|
|
k3sNodeConfig: k3sNodeConfigPlan(node),
|
|
registry: controlPlaneRegistrySummary(node.registry),
|
|
egressProxy: controlPlaneEgressProxySummary(node.egressProxy),
|
|
sourceBranch: target.source.branch,
|
|
gitopsBranch: target.gitops.branch,
|
|
gitopsPath: target.gitops.path,
|
|
gitMirrorNamespace: target.gitMirror.namespace,
|
|
readUrl: target.gitMirror.readUrl,
|
|
writeUrl: target.gitMirror.writeUrl,
|
|
pipeline: target.tekton.pipelineName,
|
|
pipelineRunPrefix: target.tekton.pipelineRunPrefix,
|
|
serviceAccount: target.tekton.serviceAccountName,
|
|
gitWorkspaceSecret: tektonGitWorkspaceSecretSummary(target),
|
|
runtimeObserverRbac: target.tekton.runtimeObserverRbac,
|
|
argoObserverRbac: target.tekton.argoObserverRbac,
|
|
tektonInstall: {
|
|
enabled: target.tekton.install.enabled,
|
|
version: target.tekton.install.version,
|
|
manifests: target.tekton.install.manifests,
|
|
requiredCrds: target.tekton.install.requiredCrds,
|
|
expectedDeploymentNamespaces: target.tekton.install.expectedDeploymentNamespaces,
|
|
},
|
|
toolsImage: target.tekton.toolsImage,
|
|
argoApplication: target.argo.applicationName,
|
|
argoInstall: {
|
|
enabled: target.argo.install.enabled,
|
|
version: target.argo.install.version,
|
|
manifestUrl: target.argo.install.manifestUrl,
|
|
imageRewrites: target.argo.install.imageRewrites,
|
|
internalHttp: target.argo.install.internalHttp,
|
|
},
|
|
};
|
|
}
|
|
|
|
export function expectedSummary(node: ControlPlaneNodeSpec, target: ControlPlaneTargetSpec): Record<string, unknown> {
|
|
return {
|
|
sourceRepo: target.source.repository,
|
|
kubeExecution: node.kubeExecution,
|
|
branch: target.source.branch,
|
|
gitopsBranch: target.gitops.branch,
|
|
runtimePath: target.gitops.path,
|
|
runtimeNamespace: target.runtimeNamespace,
|
|
namespace: target.ciNamespace,
|
|
k3sNodeConfig: k3sNodeConfigPlan(node),
|
|
gitMirror: {
|
|
namespace: target.gitMirror.namespace,
|
|
readUrl: target.gitMirror.readUrl,
|
|
writeUrl: target.gitMirror.writeUrl,
|
|
cachePvc: target.gitMirror.cachePvcName,
|
|
cachePvcStorage: target.gitMirror.cachePvcStorage,
|
|
cacheHostPath: target.gitMirror.cacheHostPath,
|
|
servicePort: target.gitMirror.servicePort,
|
|
readContainerPort: target.gitMirror.readContainerPort,
|
|
writeContainerPort: target.gitMirror.writeContainerPort,
|
|
deploymentReplicas: target.gitMirror.deploymentReplicas,
|
|
syncConfigMap: target.gitMirror.syncConfigMapName,
|
|
egressProxy: target.gitMirror.egressProxy,
|
|
effectiveEgressProxy: gitMirrorEffectiveEgressProxySummary(node, target),
|
|
githubTransport: gitMirrorGithubTransportSummary(target.gitMirror.githubTransport),
|
|
statusSummaryKeys: ["localSource", "githubSource", "localGitops", "githubGitops", "pendingFlush", "flushNeeded", "githubInSync"],
|
|
},
|
|
pipeline: target.tekton.pipelineName,
|
|
pipelineRunPrefix: target.tekton.pipelineRunPrefix,
|
|
serviceAccount: target.tekton.serviceAccountName,
|
|
gitWorkspaceSecret: tektonGitWorkspaceSecretSummary(target),
|
|
runtimeObserverRbac: target.tekton.runtimeObserverRbac,
|
|
argoObserverRbac: target.tekton.argoObserverRbac,
|
|
tektonInstall: {
|
|
enabled: target.tekton.install.enabled,
|
|
sourceKind: target.tekton.install.sourceKind,
|
|
version: target.tekton.install.version,
|
|
manifests: target.tekton.install.manifests,
|
|
requiredCrds: target.tekton.install.requiredCrds,
|
|
expectedDeploymentNamespaces: target.tekton.install.expectedDeploymentNamespaces,
|
|
readinessTimeoutSeconds: target.tekton.install.readinessTimeoutSeconds,
|
|
},
|
|
toolsImage: target.tekton.toolsImage,
|
|
argoNamespace: target.argo.namespace,
|
|
argoApplication: target.argo.applicationName,
|
|
argoInstall: {
|
|
enabled: target.argo.install.enabled,
|
|
sourceKind: target.argo.install.sourceKind,
|
|
version: target.argo.install.version,
|
|
manifestUrl: target.argo.install.manifestUrl,
|
|
preloadImages: target.argo.install.preloadImages,
|
|
imageRewrites: target.argo.install.imageRewrites,
|
|
requiredCrds: target.argo.install.requiredCrds,
|
|
expectedDeployments: target.argo.install.expectedDeployments,
|
|
expectedStatefulSets: target.argo.install.expectedStatefulSets,
|
|
internalHttp: target.argo.install.internalHttp === null ? null : {
|
|
...target.argo.install.internalHttp,
|
|
rolloutIdentity: argoInternalHttpIdentity(target),
|
|
},
|
|
reconciliation: target.argo.install.reconciliation,
|
|
},
|
|
registry: controlPlaneRegistrySummary(node.registry),
|
|
imagePolicy: {
|
|
noPrivateInputImages: true,
|
|
buildInput: { sourceKind: target.tekton.toolsImage.sourceKind, context: target.tekton.toolsImage.context, dockerfile: target.tekton.toolsImage.dockerfile ?? null, dockerfileInline: target.tekton.toolsImage.dockerfileInline ?? null, composeFile: target.tekton.toolsImage.composeFile ?? null, publicBaseImages: target.tekton.toolsImage.publicBaseImages },
|
|
outputImage: target.tekton.toolsImage.output,
|
|
},
|
|
};
|
|
}
|
|
|
|
export function k3sNodeConfigPlan(node: ControlPlaneNodeSpec): Record<string, unknown> {
|
|
if (node.k3s === null) return { managed: false };
|
|
const dropIn = k3sDropInContent(node.k3s);
|
|
return {
|
|
managed: true,
|
|
serviceName: node.k3s.serviceName,
|
|
dropInPath: node.k3s.dropInPath,
|
|
nodeStatusName: node.k3s.nodeStatusName,
|
|
desiredMaxPods: node.k3s.kubelet.maxPods,
|
|
dropInSha256: sha256Short(dropIn),
|
|
execStartPreCount: node.k3s.execStartPre.length,
|
|
serverArgCount: node.k3s.serverArgs.length,
|
|
};
|
|
}
|
|
|
|
export function k3sInstallPlan(node: ControlPlaneNodeSpec, target: ControlPlaneTargetSpec, spec: ControlPlaneK3sInstallSpec): Record<string, unknown> {
|
|
return {
|
|
node: node.id,
|
|
route: node.route,
|
|
kubeRoute: node.kubeRoute,
|
|
lane: target.lane,
|
|
version: spec.version,
|
|
channel: spec.channel,
|
|
installScriptUrl: spec.installScriptUrl,
|
|
binaryUrl: spec.binaryUrl,
|
|
sha256Url: spec.sha256Url,
|
|
expectedSha256: `sha256:${spec.expectedSha256}`,
|
|
hostProxyConfigRef: spec.hostProxyConfigRef,
|
|
proxyEnvPath: spec.proxyEnvPath,
|
|
registriesYamlPath: spec.registriesYamlPath,
|
|
state: spec.state,
|
|
localRegistry: spec.localRegistry,
|
|
serviceName: node.k3s?.serviceName ?? null,
|
|
nodeStatusName: node.k3s?.nodeStatusName ?? null,
|
|
serverArgCount: node.k3s?.serverArgs.length ?? 0,
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
export function k3sDropInContent(spec: ControlPlaneK3sNodeSpec): string {
|
|
return [
|
|
"# Managed by UniDesk. Source: config/hwlab-node-control-plane.yaml nodes.<node>.k3s",
|
|
"[Service]",
|
|
...spec.execStartPre.map((command) => `ExecStartPre=${command.map(systemdExecArg).join(" ")}`),
|
|
"ExecStart=",
|
|
`ExecStart=${["/usr/local/bin/k3s", ...spec.serverArgs].map(systemdExecArg).join(" ")}`,
|
|
"",
|
|
].join("\n");
|
|
}
|
|
|
|
export function k3sNodeConfigHostScript(spec: ControlPlaneK3sNodeSpec, apply: boolean): string {
|
|
const content = k3sDropInContent(spec);
|
|
const encoded = Buffer.from(content, "utf8").toString("base64");
|
|
return `
|
|
set +e
|
|
service=${shQuote(spec.serviceName)}
|
|
dropin=${shQuote(spec.dropInPath)}
|
|
node=${shQuote(spec.nodeStatusName)}
|
|
desired_max_pods=${shQuote(String(spec.kubelet.maxPods))}
|
|
expected_sha=${shQuote(sha256Short(content))}
|
|
apply=${apply ? "true" : "false"}
|
|
expected=$(mktemp /tmp/unidesk-k3s-dropin.XXXXXX)
|
|
printf %s ${shQuote(encoded)} | base64 -d >"$expected"
|
|
before_sha=
|
|
if [ -f "$dropin" ]; then before_sha=$(sha256sum "$dropin" | awk '{print "sha256:"$1}'); fi
|
|
service_before=$(systemctl is-active "$service" 2>/dev/null || true)
|
|
before_capacity=
|
|
before_allocatable=
|
|
if [ "$service_before" = active ]; then
|
|
for _ in $(seq 1 10); do
|
|
before_capacity=$(kubectl get node "$node" -o 'jsonpath={.status.capacity.pods}' 2>/dev/null || true)
|
|
before_allocatable=$(kubectl get node "$node" -o 'jsonpath={.status.allocatable.pods}' 2>/dev/null || true)
|
|
[ -n "$before_capacity" ] && [ -n "$before_allocatable" ] && break
|
|
sleep 2
|
|
done
|
|
fi
|
|
changed=false
|
|
restart_required=false
|
|
if ! cmp -s "$expected" "$dropin" 2>/dev/null; then changed=true; fi
|
|
if [ "$changed" = true ] || [ "$service_before" != active ]; then
|
|
restart_required=true
|
|
elif [ -n "$before_capacity" ] && [ -n "$before_allocatable" ] && \
|
|
{ [ "$before_capacity" != "$desired_max_pods" ] || [ "$before_allocatable" != "$desired_max_pods" ]; }; then
|
|
restart_required=true
|
|
fi
|
|
mutation=false
|
|
daemon_reload_rc=0
|
|
restart_rc=0
|
|
credential_backup_path=
|
|
credential_backup_rc=0
|
|
if [ "$apply" = true ] && [ "$restart_required" = true ]; then
|
|
mkdir -p "$(dirname "$dropin")"
|
|
if [ "$changed" = true ]; then
|
|
cp "$expected" "$dropin"
|
|
chown 0:0 "$dropin" 2>/dev/null || true
|
|
chmod 0644 "$dropin"
|
|
fi
|
|
systemctl daemon-reload || daemon_reload_rc=$?
|
|
if [ "$daemon_reload_rc" = 0 ]; then
|
|
derived_passwd=/var/lib/rancher/k3s/server/cred/passwd
|
|
systemctl stop "$service" || restart_rc=$?
|
|
if [ "$restart_rc" = 0 ] && [ -f "$derived_passwd" ]; then
|
|
credential_backup_path="$derived_passwd.unidesk-pre-restart-$(date -u +%Y%m%dT%H%M%SZ)-$$"
|
|
mv "$derived_passwd" "$credential_backup_path" || credential_backup_rc=$?
|
|
fi
|
|
if [ "$restart_rc" = 0 ]; then
|
|
systemctl start "$service" || restart_rc=$?
|
|
fi
|
|
fi
|
|
mutation=true
|
|
fi
|
|
rm -f "$expected"
|
|
ready=false
|
|
if [ "$apply" = true ] && [ "$restart_required" = true ] && [ "$daemon_reload_rc" = 0 ] && [ "$restart_rc" = 0 ]; then
|
|
for _ in $(seq 1 60); do
|
|
if [ "$(systemctl is-active "$service" 2>/dev/null || true)" = active ] && \
|
|
[ "$(kubectl get node "$node" -o 'jsonpath={.status.conditions[?(@.type=="Ready")].status}' 2>/dev/null || true)" = True ]; then
|
|
ready=true
|
|
break
|
|
fi
|
|
sleep 2
|
|
done
|
|
else
|
|
for _ in $(seq 1 5); do
|
|
if [ "$(systemctl is-active "$service" 2>/dev/null || true)" = active ] && \
|
|
[ "$(kubectl get node "$node" -o 'jsonpath={.status.conditions[?(@.type=="Ready")].status}' 2>/dev/null || true)" = True ]; then
|
|
ready=true
|
|
break
|
|
fi
|
|
sleep 2
|
|
done
|
|
fi
|
|
after_sha=
|
|
if [ -f "$dropin" ]; then after_sha=$(sha256sum "$dropin" | awk '{print "sha256:"$1}'); fi
|
|
after_capacity=$(kubectl get node "$node" -o 'jsonpath={.status.capacity.pods}' 2>/dev/null || true)
|
|
after_allocatable=$(kubectl get node "$node" -o 'jsonpath={.status.allocatable.pods}' 2>/dev/null || true)
|
|
service_active=$(systemctl is-active "$service" 2>/dev/null || true)
|
|
python3 - "$apply" "$mutation" "$changed" "$restart_required" "$daemon_reload_rc" "$restart_rc" "$credential_backup_path" "$credential_backup_rc" "$before_sha" "$after_sha" "$expected_sha" "$service_active" "$ready" "$before_capacity" "$before_allocatable" "$after_capacity" "$after_allocatable" "$desired_max_pods" <<'PY'
|
|
import json, sys
|
|
apply, mutation, changed, restart_required = [value == "true" for value in sys.argv[1:5]]
|
|
daemon_reload_rc, restart_rc = int(sys.argv[5]), int(sys.argv[6])
|
|
credential_backup_path, credential_backup_rc = sys.argv[7], int(sys.argv[8])
|
|
before_sha, after_sha, expected_sha = sys.argv[9:12]
|
|
service_active, ready = sys.argv[12], sys.argv[13] == "true"
|
|
before_capacity, before_allocatable, after_capacity, after_allocatable, desired = sys.argv[14:19]
|
|
def number(value):
|
|
return int(value) if value.isdigit() else None
|
|
warnings = []
|
|
if credential_backup_path:
|
|
warnings.append({
|
|
"code": "k3s-derived-credential-backed-up",
|
|
"blocking": False,
|
|
"message": "已在受控重启前备份 k3s 派生 passwd,避免 datastore freshness 门禁阻断变更",
|
|
"backupPath": credential_backup_path,
|
|
"backupExitCode": credential_backup_rc,
|
|
})
|
|
ok = (
|
|
after_sha == expected_sha
|
|
and service_active == "active"
|
|
and ready
|
|
and after_capacity == desired
|
|
and after_allocatable == desired
|
|
and daemon_reload_rc == 0
|
|
and restart_rc == 0
|
|
)
|
|
print(json.dumps({
|
|
"ok": ok,
|
|
"mode": "apply" if apply else "status",
|
|
"mutation": mutation,
|
|
"changed": changed,
|
|
"restartRequired": restart_required,
|
|
"daemonReloadExitCode": daemon_reload_rc,
|
|
"restartExitCode": restart_rc,
|
|
"warnings": warnings,
|
|
"beforeDropInSha256": before_sha or None,
|
|
"dropInSha256": after_sha or None,
|
|
"expectedDropInSha256": expected_sha,
|
|
"dropInMatches": after_sha == expected_sha,
|
|
"serviceActive": service_active == "active",
|
|
"nodeReady": ready,
|
|
"desiredMaxPods": int(desired),
|
|
"beforeCapacityPods": number(before_capacity),
|
|
"beforeAllocatablePods": number(before_allocatable),
|
|
"liveCapacityPods": number(after_capacity),
|
|
"liveAllocatablePods": number(after_allocatable),
|
|
"valuesPrinted": False,
|
|
}, ensure_ascii=False))
|
|
PY
|
|
rc=$?
|
|
if [ "$rc" != 0 ]; then exit "$rc"; fi
|
|
if [ "$apply" = true ]; then
|
|
[ "$after_sha" = "$expected_sha" ] && [ "$service_active" = active ] && [ "$ready" = true ] && \
|
|
[ "$after_capacity" = "$desired_max_pods" ] && [ "$after_allocatable" = "$desired_max_pods" ] && \
|
|
[ "$daemon_reload_rc" = 0 ] && [ "$restart_rc" = 0 ]
|
|
exit $?
|
|
fi
|
|
exit 0
|
|
`;
|
|
}
|
|
|
|
export function k3sServiceUnitContent(spec: ControlPlaneK3sNodeSpec, install: ControlPlaneK3sInstallSpec): string {
|
|
return [
|
|
"[Unit]",
|
|
"Description=Lightweight Kubernetes",
|
|
"Documentation=https://k3s.io",
|
|
"Wants=network-online.target",
|
|
"After=network-online.target",
|
|
"",
|
|
"[Install]",
|
|
"WantedBy=multi-user.target",
|
|
"",
|
|
"[Service]",
|
|
"Type=notify",
|
|
`EnvironmentFile=-${install.proxyEnvPath}`,
|
|
"KillMode=process",
|
|
"Delegate=yes",
|
|
"LimitNOFILE=1048576",
|
|
"LimitNPROC=infinity",
|
|
"LimitCORE=infinity",
|
|
"TasksMax=infinity",
|
|
"TimeoutStartSec=0",
|
|
"Restart=always",
|
|
"RestartSec=5s",
|
|
"ExecStartPre=-/sbin/modprobe br_netfilter",
|
|
"ExecStartPre=-/sbin/modprobe overlay",
|
|
`ExecStart=${["/usr/local/bin/k3s", ...spec.serverArgs].map(systemdExecArg).join(" ")}`,
|
|
"",
|
|
].join("\n");
|
|
}
|
|
|
|
export function k3sRegistriesYaml(install: ControlPlaneK3sInstallSpec): string {
|
|
const endpoint = install.localRegistry.bind.split(":").slice(0, 2).join(":");
|
|
return [
|
|
"mirrors:",
|
|
` "${endpoint}":`,
|
|
" endpoint:",
|
|
` - "http://${endpoint}"`,
|
|
"configs:",
|
|
` "${endpoint}":`,
|
|
" tls:",
|
|
" insecure_skip_verify: true",
|
|
"",
|
|
].join("\n");
|
|
}
|
|
|
|
export function controlPlaneEgressProxySummary(proxy: ControlPlaneEgressProxySpec | null): Record<string, unknown> | null {
|
|
if (proxy === null) return null;
|
|
if (proxy.mode === "host-route") {
|
|
return {
|
|
mode: proxy.mode,
|
|
clientName: proxy.clientName,
|
|
hostProxyConfigRef: proxy.hostProxyConfigRef,
|
|
proxyEnvPath: proxy.proxyEnvPath,
|
|
proxyUrl: proxy.proxyUrl,
|
|
noProxyCount: proxy.noProxy.length,
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
return {
|
|
mode: proxy.mode,
|
|
clientName: proxy.clientName,
|
|
namespace: proxy.namespace,
|
|
serviceName: proxy.serviceName,
|
|
port: proxy.port,
|
|
sourceConfigRef: proxy.sourceConfigRef,
|
|
sourceType: proxy.sourceType,
|
|
sourceRef: proxy.sourceRef,
|
|
sourceKey: proxy.sourceKey,
|
|
sourceFingerprint: proxy.sourceFingerprint,
|
|
preferredOutbound: proxy.preferredOutbound,
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
export function hostRouteNoProxy(proxy: ControlPlaneHostRouteEgressProxySpec): readonly string[] {
|
|
return [...new Set(["localhost", "127.0.0.1", "::1", "127.0.0.1:5000", "localhost:5000", ...proxy.noProxy])];
|
|
}
|
|
|
|
export function runtimeHostProxyConfig(node: ControlPlaneNodeSpec, spec: ControlPlaneRuntimeProxySpec): Record<string, unknown> {
|
|
if (!spec.enabled) {
|
|
return {
|
|
enabled: false,
|
|
mode: "host-route",
|
|
configRef: spec.configRef,
|
|
hostNetwork: false,
|
|
injectEnv: false,
|
|
deployments: [],
|
|
statefulSets: [],
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
if (node.egressProxy?.mode !== "host-route") {
|
|
throw new Error(`runtimeProxy enabled for ${node.id} requires nodes.${node.id}.egressProxy.mode=host-route`);
|
|
}
|
|
return {
|
|
enabled: true,
|
|
mode: spec.mode,
|
|
configRef: spec.configRef,
|
|
hostNetwork: spec.hostNetwork,
|
|
injectEnv: spec.injectEnv,
|
|
deployments: spec.deployments,
|
|
statefulSets: spec.statefulSets,
|
|
proxyUrl: node.egressProxy.proxyUrl,
|
|
noProxy: hostRouteNoProxy(node.egressProxy),
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
export function runtimeHostProxyEnv(node: ControlPlaneNodeSpec, spec: ControlPlaneRuntimeProxySpec): readonly Record<string, string>[] {
|
|
if (!spec.enabled || !spec.injectEnv) return [];
|
|
if (node.egressProxy?.mode !== "host-route") {
|
|
throw new Error(`runtimeProxy env for ${node.id} requires nodes.${node.id}.egressProxy.mode=host-route`);
|
|
}
|
|
const proxyUrl = node.egressProxy.proxyUrl;
|
|
const noProxy = hostRouteNoProxy(node.egressProxy).join(",");
|
|
return [
|
|
{ name: "HTTP_PROXY", value: proxyUrl },
|
|
{ name: "HTTPS_PROXY", value: proxyUrl },
|
|
{ name: "ALL_PROXY", value: proxyUrl },
|
|
{ name: "NO_PROXY", value: noProxy },
|
|
{ name: "http_proxy", value: proxyUrl },
|
|
{ name: "https_proxy", value: proxyUrl },
|
|
{ name: "all_proxy", value: proxyUrl },
|
|
{ name: "no_proxy", value: noProxy },
|
|
];
|
|
}
|
|
|
|
export function runtimeProxyReady(status: Record<string, unknown>): boolean {
|
|
const proxy = record(status.runtimeProxy);
|
|
return proxy.enabled !== true || boolField(proxy, "ready");
|
|
}
|
|
|
|
export function gitMirrorRuntimeProxySpec(node: ControlPlaneNodeSpec, target: ControlPlaneTargetSpec): ControlPlaneRuntimeProxySpec {
|
|
const config = target.gitMirror.egressProxy;
|
|
if (config === null || config.mode !== "host-route") {
|
|
return { enabled: false, mode: "host-route", configRef: null, hostNetwork: false, injectEnv: false, deployments: [], statefulSets: [] };
|
|
}
|
|
return {
|
|
enabled: config.podHostNetwork || config.injectPodEnv,
|
|
mode: "host-route",
|
|
configRef: `nodes.${node.id}.egressProxy`,
|
|
hostNetwork: config.podHostNetwork,
|
|
injectEnv: config.injectPodEnv,
|
|
deployments: [],
|
|
statefulSets: [],
|
|
};
|
|
}
|
|
|
|
export function gitMirrorEffectiveEgressProxySummary(node: ControlPlaneNodeSpec, target: ControlPlaneTargetSpec): Record<string, unknown> {
|
|
const config = target.gitMirror.egressProxy;
|
|
if (config === null || config.mode === "direct") {
|
|
return {
|
|
mode: "direct",
|
|
required: false,
|
|
transport: target.gitMirror.githubTransport.mode,
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
const proxy = node.egressProxy;
|
|
if (config.mode === "host-route") {
|
|
return {
|
|
mode: config.mode,
|
|
required: config.required,
|
|
transport: target.gitMirror.githubTransport.mode,
|
|
podHostNetwork: config.podHostNetwork,
|
|
injectPodEnv: config.injectPodEnv,
|
|
ready: proxy?.mode === "host-route",
|
|
nodeProxy: controlPlaneEgressProxySummary(proxy),
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
return {
|
|
mode: config.mode,
|
|
required: config.required,
|
|
transport: target.gitMirror.githubTransport.mode,
|
|
podHostNetwork: config.podHostNetwork,
|
|
injectPodEnv: config.injectPodEnv,
|
|
ready: proxy !== null,
|
|
nodeProxy: controlPlaneEgressProxySummary(proxy),
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
export function gitMirrorGithubTransportSummary(transport: ControlPlaneGitMirrorGithubTransportSpec): Record<string, unknown> {
|
|
if (transport.mode === "ssh") {
|
|
return {
|
|
mode: "ssh",
|
|
privateKeySecretKey: transport.privateKeySecretKey,
|
|
privateKeySourceRef: transport.privateKeySourceRef,
|
|
privateKeySourceKey: transport.privateKeySourceKey,
|
|
privateKeySourceEncoding: transport.privateKeySourceEncoding,
|
|
knownHostsSecretKey: transport.knownHostsSecretKey,
|
|
knownHostsSourceRef: transport.knownHostsSourceRef,
|
|
knownHostsSourceKey: transport.knownHostsSourceKey,
|
|
knownHostsSourceEncoding: transport.knownHostsSourceEncoding,
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
return {
|
|
mode: "https",
|
|
username: transport.username,
|
|
tokenSecretName: transport.tokenSecretName,
|
|
tokenSecretKey: transport.tokenSecretKey,
|
|
tokenSourceRef: transport.tokenSourceRef,
|
|
tokenSourceKey: transport.tokenSourceKey,
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
export function tektonGitWorkspaceSecretSummary(target: ControlPlaneTargetSpec): Record<string, unknown> {
|
|
const transport = target.gitMirror.githubTransport;
|
|
const secret = target.tekton.gitWorkspaceSecret;
|
|
return {
|
|
name: secret.name,
|
|
namespace: secret.namespace,
|
|
sourceRefFrom: secret.sourceRefFrom,
|
|
privateKeySecretKey: secret.privateKeySecretKey,
|
|
privateKeySourceRef: transport.mode === "ssh" ? transport.privateKeySourceRef : null,
|
|
privateKeySourceKey: transport.mode === "ssh" ? transport.privateKeySourceKey : null,
|
|
knownHostsSecretKey: secret.knownHostsSecretKey,
|
|
knownHostsSourceRef: transport.mode === "ssh" ? transport.knownHostsSourceRef : null,
|
|
knownHostsSourceKey: transport.mode === "ssh" ? transport.knownHostsSourceKey : null,
|
|
valuesPrinted: false,
|
|
};
|
|
}
|
|
|
|
export function systemdExecArg(value: string): string {
|
|
if (/^[A-Za-z0-9_@%+=:,./-]+$/u.test(value)) return value;
|
|
return `"${value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"").replaceAll("$", "\\$").replaceAll("`", "\\`")}"`;
|
|
}
|
|
|
|
export function k3sInstallSubmitScript(node: ControlPlaneNodeSpec, target: ControlPlaneTargetSpec, install: ControlPlaneK3sInstallSpec): string {
|
|
if (node.k3s === null) throw new Error("k3sInstallSubmitScript requires node.k3s");
|
|
const runner = k3sInstallRunnerScript(node, target, install);
|
|
const encoded = Buffer.from(runner, "utf8").toString("base64");
|
|
return `
|
|
set -eu
|
|
state_dir=${shQuote(install.state.dir)}
|
|
runner="$state_dir/install-runner.sh"
|
|
pid_file="$state_dir/pid"
|
|
status_file=${shQuote(install.state.statusPath)}
|
|
mkdir -p "$state_dir"
|
|
if [ -s "$pid_file" ]; then
|
|
pid="$(cat "$pid_file" 2>/dev/null || true)"
|
|
if [ -n "$pid" ] && kill -0 "$pid" >/dev/null 2>&1; then
|
|
python3 - "$pid" "$status_file" <<'PY'
|
|
import json, pathlib, sys
|
|
status = None
|
|
path = pathlib.Path(sys.argv[2])
|
|
if path.exists():
|
|
try:
|
|
status = json.loads(path.read_text())
|
|
except Exception as exc:
|
|
status = {"parseError": str(exc)}
|
|
print(json.dumps({"ok": True, "alreadyRunning": True, "pid": sys.argv[1], "status": status, "valuesPrinted": False}, ensure_ascii=False))
|
|
PY
|
|
exit 0
|
|
fi
|
|
fi
|
|
printf %s ${shQuote(encoded)} | base64 -d >"$runner"
|
|
chmod 0700 "$runner"
|
|
cat >"$status_file" <<JSON
|
|
{"ok":false,"phase":"submitted","node":"${node.id}","lane":"${target.lane}","updatedAt":"$(date -u +%Y-%m-%dT%H:%M:%SZ)","valuesPrinted":false}
|
|
JSON
|
|
nohup sh "$runner" >${shQuote(install.state.logPath)} 2>&1 &
|
|
pid=$!
|
|
printf '%s\\n' "$pid" >"$pid_file"
|
|
python3 - "$pid" "$state_dir" "$status_file" <<'PY'
|
|
import json, sys
|
|
print(json.dumps({"ok": True, "submitted": True, "pid": sys.argv[1], "stateDir": sys.argv[2], "statusPath": sys.argv[3], "valuesPrinted": False}, ensure_ascii=False))
|
|
PY
|
|
`;
|
|
}
|
|
|
|
export function k3sInstallRunnerScript(node: ControlPlaneNodeSpec, target: ControlPlaneTargetSpec, install: ControlPlaneK3sInstallSpec): string {
|
|
if (node.k3s === null) throw new Error("k3sInstallRunnerScript requires node.k3s");
|
|
const unit = Buffer.from(k3sServiceUnitContent(node.k3s, install), "utf8").toString("base64");
|
|
const dropIn = Buffer.from(k3sDropInContent(node.k3s), "utf8").toString("base64");
|
|
const registries = Buffer.from(k3sRegistriesYaml(install), "utf8").toString("base64");
|
|
return `#!/bin/sh
|
|
set -eu
|
|
state_dir=${shQuote(install.state.dir)}
|
|
status_file=${shQuote(install.state.statusPath)}
|
|
proxy_env=${shQuote(install.proxyEnvPath)}
|
|
registries_yaml=${shQuote(install.registriesYamlPath)}
|
|
binary_url=${shQuote(install.binaryUrl)}
|
|
sha256_url=${shQuote(install.sha256Url)}
|
|
expected_sha=${shQuote(install.expectedSha256)}
|
|
version=${shQuote(install.version)}
|
|
node_name=${shQuote(node.k3s.nodeStatusName)}
|
|
service_name=${shQuote(node.k3s.serviceName)}
|
|
registry_name=${shQuote(install.localRegistry.containerName)}
|
|
registry_image=${shQuote(install.localRegistry.image)}
|
|
registry_bind=${shQuote(install.localRegistry.bind)}
|
|
log_status() {
|
|
phase="$1"; ok="$2"; message="$3"
|
|
python3 - "$status_file" "$phase" "$ok" "$message" <<'PY'
|
|
import json, pathlib, sys, datetime
|
|
path = pathlib.Path(sys.argv[1])
|
|
payload = {
|
|
"ok": sys.argv[3] == "true",
|
|
"phase": sys.argv[2],
|
|
"message": sys.argv[4],
|
|
"updatedAt": datetime.datetime.utcnow().replace(microsecond=0).isoformat() + "Z",
|
|
"node": "${node.id}",
|
|
"lane": "${target.lane}",
|
|
"valuesPrinted": False,
|
|
}
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(json.dumps(payload, ensure_ascii=False) + "\\n")
|
|
PY
|
|
}
|
|
log_status starting false "k3s install started"
|
|
mkdir -p "$state_dir" /usr/local/bin /etc/rancher/k3s /etc/systemd/system/k3s.service.d
|
|
if [ ! -s "$proxy_env" ]; then
|
|
log_status failed false "proxy env missing"
|
|
exit 41
|
|
fi
|
|
. "$proxy_env"
|
|
export HTTP_PROXY HTTPS_PROXY ALL_PROXY NO_PROXY http_proxy https_proxy all_proxy no_proxy
|
|
curl -fsS --max-time 20 --proxy "$HTTPS_PROXY" -o /dev/null https://www.gstatic.com/generate_204
|
|
log_status proxy-ready false "host proxy probe passed"
|
|
if command -v docker >/dev/null 2>&1; then
|
|
if docker ps --format '{{.Names}}' | grep -Fx "$registry_name" >/dev/null 2>&1; then
|
|
:
|
|
elif docker ps -a --format '{{.Names}}' | grep -Fx "$registry_name" >/dev/null 2>&1; then
|
|
docker start "$registry_name" >/dev/null 2>&1 || true
|
|
else
|
|
docker run -d --restart unless-stopped --name "$registry_name" -p "$registry_bind" "$registry_image" >/dev/null 2>&1 || true
|
|
fi
|
|
fi
|
|
log_status downloading false "downloading k3s binary through host proxy"
|
|
current_sha="$(sha256sum /usr/local/bin/k3s 2>/dev/null | cut -d' ' -f1 || true)"
|
|
if [ "$current_sha" != "$expected_sha" ]; then
|
|
sha_file="$state_dir/sha256sum-amd64.txt"
|
|
curl -fL --connect-timeout ${install.downloads.connectTimeoutSeconds} --max-time ${install.downloads.maxTimeSeconds} --retry ${install.downloads.retry} --retry-delay ${install.downloads.retryDelaySeconds} -o "$sha_file" "$sha256_url"
|
|
if ! grep -F "$expected_sha" "$sha_file" >/dev/null 2>&1; then
|
|
log_status failed false "expected k3s sha256 missing from upstream sha256 file"
|
|
exit 44
|
|
fi
|
|
tmp_bin=/usr/local/bin/k3s.tmp.$$
|
|
curl -fL --connect-timeout ${install.downloads.connectTimeoutSeconds} --max-time ${install.downloads.maxTimeSeconds} --retry ${install.downloads.retry} --retry-delay ${install.downloads.retryDelaySeconds} -o "$tmp_bin" "$binary_url"
|
|
actual_sha=$(sha256sum "$tmp_bin" | cut -d' ' -f1)
|
|
if [ "$actual_sha" != "$expected_sha" ]; then
|
|
rm -f "$tmp_bin"
|
|
log_status failed false "k3s binary sha256 mismatch"
|
|
exit 42
|
|
fi
|
|
chmod 0755 "$tmp_bin"
|
|
mv -f "$tmp_bin" /usr/local/bin/k3s
|
|
fi
|
|
ln -sf /usr/local/bin/k3s /usr/local/bin/kubectl
|
|
ln -sf /usr/local/bin/k3s /usr/local/bin/crictl
|
|
ln -sf /usr/local/bin/k3s /usr/local/bin/ctr
|
|
printf %s ${shQuote(registries)} | base64 -d >"$registries_yaml"
|
|
printf %s ${shQuote(unit)} | base64 -d >/etc/systemd/system/k3s.service
|
|
printf %s ${shQuote(dropIn)} | base64 -d >${shQuote(node.k3s.dropInPath)}
|
|
log_status systemd false "starting k3s service"
|
|
systemctl daemon-reload
|
|
systemctl enable --now "$service_name"
|
|
for _ in $(seq 1 120); do
|
|
if /usr/local/bin/kubectl get node "$node_name" >/tmp/unidesk-k3s-node.out 2>/tmp/unidesk-k3s-node.err; then
|
|
if /usr/local/bin/kubectl get node "$node_name" -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' 2>/dev/null | grep -Fx True >/dev/null 2>&1; then
|
|
log_status succeeded true "k3s node ready"
|
|
exit 0
|
|
fi
|
|
fi
|
|
sleep 5
|
|
done
|
|
log_status failed false "timed out waiting for k3s node ready"
|
|
exit 43
|
|
`;
|
|
}
|
|
|
|
export function k3sInstallStatusScript(node: ControlPlaneNodeSpec, target: ControlPlaneTargetSpec, install: ControlPlaneK3sInstallSpec, tailLines: number): string {
|
|
if (node.k3s === null) throw new Error("k3sInstallStatusScript requires node.k3s");
|
|
return `
|
|
set +e
|
|
status_path=${shQuote(install.state.statusPath)}
|
|
log_path=${shQuote(install.state.logPath)}
|
|
pid_file=${shQuote(`${install.state.dir}/pid`)}
|
|
service_name=${shQuote(node.k3s.serviceName)}
|
|
node_name=${shQuote(node.k3s.nodeStatusName)}
|
|
expected_sha=${shQuote(install.expectedSha256)}
|
|
proxy_env=${shQuote(install.proxyEnvPath)}
|
|
registry_name=${shQuote(install.localRegistry.containerName)}
|
|
running=false
|
|
pid=
|
|
if [ -s "$pid_file" ]; then
|
|
pid="$(cat "$pid_file" 2>/dev/null || true)"
|
|
if [ -n "$pid" ] && kill -0 "$pid" >/dev/null 2>&1; then running=true; fi
|
|
fi
|
|
service_active="$(systemctl is-active "$service_name" 2>/dev/null || true)"
|
|
binary_sha="$(sha256sum /usr/local/bin/k3s 2>/dev/null | cut -d' ' -f1 || true)"
|
|
proxy_probe=false
|
|
if [ -s "$proxy_env" ]; then
|
|
. "$proxy_env"
|
|
curl -fsS --max-time 20 --proxy "$HTTPS_PROXY" -o /dev/null https://www.gstatic.com/generate_204 >/dev/null 2>&1 && proxy_probe=true
|
|
fi
|
|
registry_running=false
|
|
if command -v docker >/dev/null 2>&1; then
|
|
docker ps --format '{{.Names}}' 2>/dev/null | grep -Fx "$registry_name" >/dev/null 2>&1 && registry_running=true
|
|
fi
|
|
node_ready=false
|
|
node_capacity=
|
|
node_allocatable=
|
|
if command -v kubectl >/dev/null 2>&1; then
|
|
ready="$(kubectl get node "$node_name" -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' 2>/dev/null || true)"
|
|
[ "$ready" = "True" ] && node_ready=true
|
|
node_capacity="$(kubectl get node "$node_name" -o jsonpath='{.status.capacity.pods}' 2>/dev/null || true)"
|
|
node_allocatable="$(kubectl get node "$node_name" -o jsonpath='{.status.allocatable.pods}' 2>/dev/null || true)"
|
|
fi
|
|
python3 - "$status_path" "$log_path" "$running" "$pid" "$service_active" "$binary_sha" "$expected_sha" "$proxy_probe" "$registry_running" "$node_ready" "$node_capacity" "$node_allocatable" ${shQuote(String(tailLines))} <<'PY'
|
|
import json, pathlib, sys
|
|
status_path = pathlib.Path(sys.argv[1])
|
|
log_path = pathlib.Path(sys.argv[2])
|
|
status = None
|
|
if status_path.exists():
|
|
try:
|
|
status = json.loads(status_path.read_text())
|
|
except Exception as exc:
|
|
status = {"parseError": str(exc), "rawTail": status_path.read_text(errors="replace")[-1000:]}
|
|
tail_lines = int(sys.argv[13])
|
|
log_tail = ""
|
|
if log_path.exists():
|
|
log_tail = "\\n".join(log_path.read_text(errors="replace").splitlines()[-tail_lines:])
|
|
binary_sha = sys.argv[6]
|
|
payload = {
|
|
"node": "${node.id}",
|
|
"lane": "${target.lane}",
|
|
"running": sys.argv[3] == "true",
|
|
"pid": sys.argv[4] or None,
|
|
"status": status,
|
|
"checks": {
|
|
"serviceActive": sys.argv[5] == "active",
|
|
"binarySha256Ok": binary_sha == sys.argv[7],
|
|
"binarySha256Prefix": binary_sha[:12] if binary_sha else "",
|
|
"proxyProbe": sys.argv[8] == "true",
|
|
"registryRunning": sys.argv[9] == "true",
|
|
"nodeReady": sys.argv[10] == "true",
|
|
"capacityPods": int(sys.argv[11]) if sys.argv[11].isdigit() else None,
|
|
"allocatablePods": int(sys.argv[12]) if sys.argv[12].isdigit() else None,
|
|
},
|
|
"logBytes": log_path.stat().st_size if log_path.exists() else 0,
|
|
"logTail": log_tail,
|
|
"valuesPrinted": False,
|
|
}
|
|
print(json.dumps(payload, ensure_ascii=False))
|
|
PY
|
|
`;
|
|
}
|
|
|
|
export function statusScript(nodeSpec: ControlPlaneNodeSpec, target: ControlPlaneTargetSpec): string {
|
|
const tektonRequiredCrds = shellJsonArray(target.tekton.install.requiredCrds);
|
|
const tektonDeploymentNamespaces = shellJsonArray(target.tekton.install.expectedDeploymentNamespaces);
|
|
const requiredCrds = shellJsonArray(target.argo.install.requiredCrds);
|
|
const argoDeployments = shellJsonArray(target.argo.install.expectedDeployments);
|
|
const argoStatefulSets = shellJsonArray(target.argo.install.expectedStatefulSets);
|
|
const tektonRuntimeProxy = JSON.stringify(runtimeHostProxyConfig(nodeSpec, target.tekton.install.runtimeProxy));
|
|
const argoRuntimeProxy = JSON.stringify(runtimeHostProxyConfig(nodeSpec, target.argo.install.runtimeProxy));
|
|
const argoInternalHttp = JSON.stringify(target.argo.install.internalHttp === null ? null : {
|
|
...target.argo.install.internalHttp,
|
|
rolloutIdentity: argoInternalHttpIdentity(target),
|
|
});
|
|
const argoReconciliation = JSON.stringify(target.argo.install.reconciliation);
|
|
const k3s = nodeSpec.k3s;
|
|
const k3sDropIn = k3s === null ? "" : k3sDropInContent(k3s);
|
|
const gitMirrorEgressProxyJson = JSON.stringify(gitMirrorEffectiveEgressProxySummary(nodeSpec, target));
|
|
return `
|
|
set +e
|
|
node=${shQuote(target.node)}
|
|
lane=${shQuote(target.lane)}
|
|
ci_ns=${shQuote(target.ciNamespace)}
|
|
runtime_ns=${shQuote(target.runtimeNamespace)}
|
|
gitmirror_ns=${shQuote(target.gitMirror.namespace)}
|
|
read_deploy=${shQuote(target.gitMirror.serviceReadName)}
|
|
write_deploy=${shQuote(target.gitMirror.serviceWriteName)}
|
|
read_svc=${shQuote(target.gitMirror.serviceReadName)}
|
|
write_svc=${shQuote(target.gitMirror.serviceWriteName)}
|
|
cache_pvc=${shQuote(target.gitMirror.cachePvcName)}
|
|
cache_host_path=${shQuote(target.gitMirror.cacheHostPath ?? "")}
|
|
github_transport_mode=${shQuote(target.gitMirror.githubTransport.mode)}
|
|
github_ssh_secret=${shQuote(target.gitMirror.githubTransport.mode === "ssh" ? target.gitMirror.secretName : "")}
|
|
github_ssh_private_key=${shQuote(target.gitMirror.githubTransport.mode === "ssh" ? target.gitMirror.githubTransport.privateKeySecretKey : "")}
|
|
github_ssh_private_source_ref=${shQuote(target.gitMirror.githubTransport.mode === "ssh" ? target.gitMirror.githubTransport.privateKeySourceRef : "")}
|
|
github_ssh_private_source_key=${shQuote(target.gitMirror.githubTransport.mode === "ssh" ? target.gitMirror.githubTransport.privateKeySourceKey : "")}
|
|
github_ssh_known_hosts_key=${shQuote(target.gitMirror.githubTransport.mode === "ssh" ? target.gitMirror.githubTransport.knownHostsSecretKey ?? "" : "")}
|
|
github_ssh_known_hosts_source_ref=${shQuote(target.gitMirror.githubTransport.mode === "ssh" ? target.gitMirror.githubTransport.knownHostsSourceRef ?? "" : "")}
|
|
github_ssh_known_hosts_source_key=${shQuote(target.gitMirror.githubTransport.mode === "ssh" ? target.gitMirror.githubTransport.knownHostsSourceKey ?? "" : "")}
|
|
github_token_secret=${shQuote(target.gitMirror.githubTransport.mode === "https" ? target.gitMirror.githubTransport.tokenSecretName : "")}
|
|
github_token_key=${shQuote(target.gitMirror.githubTransport.mode === "https" ? target.gitMirror.githubTransport.tokenSecretKey : "")}
|
|
github_token_source_ref=${shQuote(target.gitMirror.githubTransport.mode === "https" ? target.gitMirror.githubTransport.tokenSourceRef : "")}
|
|
github_token_source_key=${shQuote(target.gitMirror.githubTransport.mode === "https" ? target.gitMirror.githubTransport.tokenSourceKey : "")}
|
|
gitmirror_egress_proxy_json=${shQuote(gitMirrorEgressProxyJson)}
|
|
pipeline=${shQuote(target.tekton.pipelineName)}
|
|
service_account=${shQuote(target.tekton.serviceAccountName)}
|
|
runtime_observer_role=${shQuote(target.tekton.runtimeObserverRbac.roleName)}
|
|
runtime_observer_rolebinding=${shQuote(target.tekton.runtimeObserverRbac.roleBindingName)}
|
|
ci_git_secret=${shQuote(target.tekton.gitWorkspaceSecret.name)}
|
|
ci_git_private_key=${shQuote(target.tekton.gitWorkspaceSecret.privateKeySecretKey)}
|
|
ci_git_known_hosts_key=${shQuote(target.tekton.gitWorkspaceSecret.knownHostsSecretKey)}
|
|
ci_git_source_ref_from=${shQuote(target.tekton.gitWorkspaceSecret.sourceRefFrom)}
|
|
ci_git_private_source_ref=${shQuote(target.gitMirror.githubTransport.mode === "ssh" ? target.gitMirror.githubTransport.privateKeySourceRef : "")}
|
|
ci_git_private_source_key=${shQuote(target.gitMirror.githubTransport.mode === "ssh" ? target.gitMirror.githubTransport.privateKeySourceKey : "")}
|
|
ci_git_known_hosts_source_ref=${shQuote(target.gitMirror.githubTransport.mode === "ssh" ? target.gitMirror.githubTransport.knownHostsSourceRef ?? "" : "")}
|
|
ci_git_known_hosts_source_key=${shQuote(target.gitMirror.githubTransport.mode === "ssh" ? target.gitMirror.githubTransport.knownHostsSourceKey ?? "" : "")}
|
|
argo_ns=${shQuote(target.argo.namespace)}
|
|
argo_project=${shQuote(target.argo.projectName)}
|
|
argo_app=${shQuote(target.argo.applicationName)}
|
|
argo_observer_role=${shQuote(target.tekton.argoObserverRbac.roleName)}
|
|
argo_observer_rolebinding=${shQuote(target.tekton.argoObserverRbac.roleBindingName)}
|
|
registry=${shQuote(nodeSpec.registry.endpoint)}
|
|
registry_spec_json=${shQuote(JSON.stringify(controlPlaneRegistrySummary(nodeSpec.registry)))}
|
|
tools_image=${shQuote(target.tekton.toolsImage.output)}
|
|
tekton_required_crds_json=${shQuote(tektonRequiredCrds)}
|
|
tekton_deployment_namespaces_json=${shQuote(tektonDeploymentNamespaces)}
|
|
required_crds_json=${shQuote(requiredCrds)}
|
|
argo_deployments_json=${shQuote(argoDeployments)}
|
|
argo_statefulsets_json=${shQuote(argoStatefulSets)}
|
|
tekton_runtime_proxy_json=${shQuote(tektonRuntimeProxy)}
|
|
argo_runtime_proxy_json=${shQuote(argoRuntimeProxy)}
|
|
argo_internal_http_json=${shQuote(argoInternalHttp)}
|
|
argo_reconciliation_json=${shQuote(argoReconciliation)}
|
|
k3s_managed=${k3s === null ? "false" : "true"}
|
|
k3s_service=${shQuote(k3s?.serviceName ?? "")}
|
|
k3s_dropin=${shQuote(k3s?.dropInPath ?? "")}
|
|
k3s_node=${shQuote(k3s?.nodeStatusName ?? "")}
|
|
k3s_desired_max_pods=${shQuote(String(k3s?.kubelet.maxPods ?? ""))}
|
|
k3s_expected_sha=${shQuote(k3s === null ? "" : sha256Short(k3sDropIn))}
|
|
exists_ns() { kubectl get ns "$1" >/dev/null 2>&1 && printf true || printf false; }
|
|
exists_res() { kubectl -n "$1" get "$2" "$3" >/dev/null 2>&1 && printf true || printf false; }
|
|
deploy_ready() { desired=$(kubectl -n "$1" get deploy "$2" -o 'jsonpath={.spec.replicas}' 2>/dev/null || true); ready=$(kubectl -n "$1" get deploy "$2" -o 'jsonpath={.status.readyReplicas}' 2>/dev/null || true); [ -n "$desired" ] && [ "$desired" -gt 0 ] 2>/dev/null && [ "\${ready:-0}" = "$desired" ] && printf true || printf false; }
|
|
sts_ready() { desired=$(kubectl -n "$1" get statefulset "$2" -o 'jsonpath={.spec.replicas}' 2>/dev/null || true); ready=$(kubectl -n "$1" get statefulset "$2" -o 'jsonpath={.status.readyReplicas}' 2>/dev/null || true); [ -n "$desired" ] && [ "$desired" -gt 0 ] 2>/dev/null && [ "\${ready:-0}" = "$desired" ] && printf true || printf false; }
|
|
endpoint_ready() { endpoints=$(kubectl -n "$1" get endpoints "$2" -o 'jsonpath={.subsets[*].addresses[*].ip}' 2>/dev/null || true); [ -n "$endpoints" ] && printf true || printf false; }
|
|
can_i_runtime() { kubectl auth can-i "$1" "$2" --as="system:serviceaccount:$ci_ns:$service_account" -n "$runtime_ns" >/dev/null 2>&1 && printf true || printf false; }
|
|
can_i_argo() { kubectl auth can-i "$1" "$2" --as="system:serviceaccount:$ci_ns:$service_account" -n "$argo_ns" >/dev/null 2>&1 && printf true || printf false; }
|
|
runtime_observer_role_exists=$(exists_res "$runtime_ns" role "$runtime_observer_role")
|
|
runtime_observer_rolebinding_exists=$(exists_res "$runtime_ns" rolebinding "$runtime_observer_rolebinding")
|
|
runtime_observer_can_list_deployments=$(can_i_runtime list deployments.apps)
|
|
runtime_observer_can_list_statefulsets=$(can_i_runtime list statefulsets.apps)
|
|
runtime_observer_ready=false
|
|
if [ "$runtime_observer_role_exists" = true ] && [ "$runtime_observer_rolebinding_exists" = true ] && [ "$runtime_observer_can_list_deployments" = true ] && [ "$runtime_observer_can_list_statefulsets" = true ]; then runtime_observer_ready=true; fi
|
|
argo_observer_role_exists=$(exists_res "$argo_ns" role "$argo_observer_role")
|
|
argo_observer_rolebinding_exists=$(exists_res "$argo_ns" rolebinding "$argo_observer_rolebinding")
|
|
argo_observer_can_get_application=$(can_i_argo get applications.argoproj.io)
|
|
argo_observer_ready=false
|
|
if [ "$argo_observer_role_exists" = true ] && [ "$argo_observer_rolebinding_exists" = true ] && [ "$argo_observer_can_get_application" = true ]; then argo_observer_ready=true; fi
|
|
github_transport_json=$(python3 - "$github_transport_mode" "$gitmirror_ns" "$github_ssh_secret" "$github_ssh_private_key" "$github_ssh_private_source_ref" "$github_ssh_private_source_key" "$github_ssh_known_hosts_key" "$github_ssh_known_hosts_source_ref" "$github_ssh_known_hosts_source_key" "$github_token_secret" "$github_token_key" "$github_token_source_ref" "$github_token_source_key" <<'PY'
|
|
import hashlib, json, subprocess, sys
|
|
mode, namespace, ssh_secret, ssh_private_key, ssh_private_source_ref, ssh_private_source_key, ssh_known_hosts_key, ssh_known_hosts_source_ref, ssh_known_hosts_source_key, token_secret, token_key, token_source_ref, token_source_key = sys.argv[1:14]
|
|
def read_secret(name):
|
|
proc = subprocess.run(["kubectl", "-n", namespace, "get", "secret", name, "-o", "json"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
if proc.returncode != 0:
|
|
return False, {}, {}
|
|
try:
|
|
obj = json.loads(proc.stdout)
|
|
except Exception:
|
|
obj = {}
|
|
return True, obj.get("data") or {}, obj.get("metadata", {}).get("annotations") or {}
|
|
def fingerprint(value):
|
|
return "sha256:" + hashlib.sha256(value.encode()).hexdigest()[:16] if value else None
|
|
if mode == "ssh":
|
|
exists, data, annotations = read_secret(ssh_secret)
|
|
private_encoded = data.get(ssh_private_key) if isinstance(data, dict) else None
|
|
known_hosts_encoded = data.get(ssh_known_hosts_key) if ssh_known_hosts_key and isinstance(data, dict) else None
|
|
private_present = isinstance(private_encoded, str) and len(private_encoded) > 0
|
|
known_hosts_expected = bool(ssh_known_hosts_key)
|
|
known_hosts_present = isinstance(known_hosts_encoded, str) and len(known_hosts_encoded) > 0
|
|
print(json.dumps({
|
|
"mode": mode,
|
|
"required": True,
|
|
"ready": exists and private_present and (not known_hosts_expected or known_hosts_present),
|
|
"secretName": ssh_secret,
|
|
"privateKeySecretKey": ssh_private_key,
|
|
"privateKeySourceRef": ssh_private_source_ref,
|
|
"privateKeySourceKey": ssh_private_source_key,
|
|
"privateKeySecretExists": exists,
|
|
"privateKeyPresent": private_present,
|
|
"privateKeyBytes": len(private_encoded) if private_present else 0,
|
|
"privateKeyFingerprint": annotations.get("unidesk.ai/private-key-fingerprint") or fingerprint(private_encoded),
|
|
"knownHostsSecretKey": ssh_known_hosts_key or None,
|
|
"knownHostsSourceRef": ssh_known_hosts_source_ref or None,
|
|
"knownHostsSourceKey": ssh_known_hosts_source_key or None,
|
|
"knownHostsPresent": (known_hosts_present if known_hosts_expected else None),
|
|
"knownHostsBytes": (len(known_hosts_encoded) if known_hosts_present else 0) if known_hosts_expected else None,
|
|
"knownHostsFingerprint": annotations.get("unidesk.ai/known-hosts-fingerprint") or fingerprint(known_hosts_encoded),
|
|
"valuesPrinted": False,
|
|
}))
|
|
raise SystemExit(0)
|
|
if mode != "https":
|
|
print(json.dumps({"mode": mode, "required": True, "ready": False, "valuesPrinted": False}))
|
|
raise SystemExit(0)
|
|
exists, data, _ = read_secret(token_secret)
|
|
encoded = data.get(token_key) if isinstance(data, dict) else None
|
|
present = isinstance(encoded, str) and len(encoded) > 0
|
|
print(json.dumps({"mode": mode, "required": True, "ready": exists and present, "tokenSecretName": token_secret, "tokenSecretKey": token_key, "tokenSourceRef": token_source_ref, "tokenSourceKey": token_source_key, "tokenSecretExists": exists, "tokenKeyPresent": present, "tokenKeyBytes": len(encoded) if present else 0, "tokenFingerprint": fingerprint(encoded), "valuesPrinted": False}))
|
|
PY
|
|
)
|
|
ci_git_workspace_json=$(python3 - "$ci_ns" "$ci_git_secret" "$ci_git_private_key" "$ci_git_known_hosts_key" "$ci_git_source_ref_from" "$ci_git_private_source_ref" "$ci_git_private_source_key" "$ci_git_known_hosts_source_ref" "$ci_git_known_hosts_source_key" <<'PY'
|
|
import hashlib, json, subprocess, sys
|
|
namespace, secret, private_key, known_hosts_key, source_ref_from, private_source_ref, private_source_key, known_hosts_source_ref, known_hosts_source_key = sys.argv[1:10]
|
|
proc = subprocess.run(["kubectl", "-n", namespace, "get", "secret", secret, "-o", "json"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
exists = proc.returncode == 0
|
|
obj = {}
|
|
if exists:
|
|
try:
|
|
obj = json.loads(proc.stdout)
|
|
except Exception:
|
|
obj = {}
|
|
data = obj.get("data") or {}
|
|
annotations = obj.get("metadata", {}).get("annotations") or {}
|
|
def fingerprint(value):
|
|
return "sha256:" + hashlib.sha256(value.encode()).hexdigest()[:16] if value else None
|
|
private_encoded = data.get(private_key) if isinstance(data, dict) else None
|
|
known_hosts_encoded = data.get(known_hosts_key) if isinstance(data, dict) else None
|
|
private_present = isinstance(private_encoded, str) and len(private_encoded) > 0
|
|
known_hosts_present = isinstance(known_hosts_encoded, str) and len(known_hosts_encoded) > 0
|
|
print(json.dumps({
|
|
"required": True,
|
|
"ready": exists and private_present and known_hosts_present,
|
|
"namespace": namespace,
|
|
"secretName": secret,
|
|
"sourceRefFrom": source_ref_from,
|
|
"privateKeySecretKey": private_key,
|
|
"privateKeySourceRef": private_source_ref,
|
|
"privateKeySourceKey": private_source_key,
|
|
"privateKeySecretExists": exists,
|
|
"privateKeyPresent": private_present,
|
|
"privateKeyBytes": len(private_encoded) if private_present else 0,
|
|
"privateKeyFingerprint": annotations.get("unidesk.ai/private-key-fingerprint") or fingerprint(private_encoded),
|
|
"knownHostsSecretKey": known_hosts_key,
|
|
"knownHostsSourceRef": known_hosts_source_ref,
|
|
"knownHostsSourceKey": known_hosts_source_key,
|
|
"knownHostsPresent": known_hosts_present,
|
|
"knownHostsBytes": len(known_hosts_encoded) if known_hosts_present else 0,
|
|
"knownHostsFingerprint": annotations.get("unidesk.ai/known-hosts-fingerprint") or fingerprint(known_hosts_encoded),
|
|
"valuesPrinted": False,
|
|
}))
|
|
PY
|
|
)
|
|
registry_endpoint_ready=false
|
|
if command -v curl >/dev/null 2>&1; then curl -fsS --max-time 3 "http://$registry/v2/" >/tmp/hwlab-registry.out 2>/tmp/hwlab-registry.err && registry_endpoint_ready=true; fi
|
|
registry_workload_json=$(python3 - "$registry_spec_json" "$registry_endpoint_ready" <<'PY'
|
|
import json, subprocess, sys
|
|
spec=json.loads(sys.argv[1])
|
|
endpoint_ready=sys.argv[2] == "true"
|
|
def run(args):
|
|
return subprocess.run(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
def exists(kind, namespace, name):
|
|
return run(["kubectl", "-n", namespace, "get", kind, name]).returncode == 0
|
|
def deploy_ready(namespace, name):
|
|
proc=run(["kubectl", "-n", namespace, "get", "deploy", name, "-o", "json"])
|
|
if proc.returncode != 0:
|
|
return {"exists": False, "ready": False, "desired": None, "readyReplicas": None}
|
|
try:
|
|
obj=json.loads(proc.stdout or "{}")
|
|
except Exception:
|
|
obj={}
|
|
desired=int(obj.get("spec", {}).get("replicas") or 0)
|
|
ready=int(obj.get("status", {}).get("readyReplicas") or 0)
|
|
return {"exists": True, "ready": desired > 0 and ready == desired, "desired": desired, "readyReplicas": ready}
|
|
if spec.get("mode") != "k8s-workload":
|
|
print(json.dumps({"mode": spec.get("mode") or "host-docker", "managed": False, "ready": endpoint_ready, "endpointReady": endpoint_ready, "valuesPrinted": False}))
|
|
raise SystemExit(0)
|
|
ns=str(spec.get("namespace") or "")
|
|
deploy=str(spec.get("deploymentName") or "")
|
|
svc=str(spec.get("serviceName") or "")
|
|
pvc=str(spec.get("pvcName") or "")
|
|
deployment=deploy_ready(ns, deploy)
|
|
namespace_exists=run(["kubectl", "get", "ns", ns]).returncode == 0
|
|
pvc_exists=exists("pvc", ns, pvc)
|
|
service_exists=exists("service", ns, svc)
|
|
ready=endpoint_ready and namespace_exists and pvc_exists and service_exists and deployment.get("ready") is True
|
|
print(json.dumps({
|
|
"mode": "k8s-workload",
|
|
"managed": True,
|
|
"ready": ready,
|
|
"endpointReady": endpoint_ready,
|
|
"namespace": ns,
|
|
"namespaceExists": namespace_exists,
|
|
"deploymentName": deploy,
|
|
"deployment": deployment,
|
|
"serviceName": svc,
|
|
"serviceExists": service_exists,
|
|
"pvcName": pvc,
|
|
"pvcExists": pvc_exists,
|
|
"seededFromOldRegistry": False,
|
|
"zeroSeeded": True,
|
|
"valuesPrinted": False,
|
|
}))
|
|
PY
|
|
)
|
|
registry_ready=$(python3 - "$registry_workload_json" <<'PY'
|
|
import json, sys
|
|
try:
|
|
data=json.loads(sys.argv[1] or "{}")
|
|
except Exception:
|
|
data={}
|
|
print("true" if data.get("ready") is True else "false")
|
|
PY
|
|
)
|
|
tools_repo_tag=\${tools_image#\${registry}/}
|
|
tools_repo=\${tools_repo_tag%:*}
|
|
tools_tag=\${tools_repo_tag##*:}
|
|
tools_image_ready=false
|
|
manifest_accept='application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json'
|
|
if [ "$tools_repo" != "$tools_repo_tag" ] && command -v curl >/dev/null 2>&1; then curl -fsS --max-time 5 -H "Accept: $manifest_accept" "http://$registry/v2/$tools_repo/manifests/$tools_tag" >/tmp/hwlab-tools-image.out 2>/tmp/hwlab-tools-image.err && tools_image_ready=true; fi
|
|
cache_host_path_ready=false
|
|
if [ -n "$cache_host_path" ] && kubectl -n "$gitmirror_ns" exec deploy/"$read_deploy" -- sh -lc 'test -d /cache' >/dev/null 2>&1; then cache_host_path_ready=true; fi
|
|
k3s_fragment=$(python3 - "$k3s_managed" "$k3s_service" "$k3s_dropin" "$k3s_node" "$k3s_desired_max_pods" "$k3s_expected_sha" <<'PY'
|
|
import hashlib, json, re, subprocess, sys
|
|
managed = sys.argv[1] == "true"
|
|
service, dropin, node_name, desired_raw, expected_sha = sys.argv[2:7]
|
|
def run(args):
|
|
return subprocess.run(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
def to_int(value):
|
|
try:
|
|
return int(value)
|
|
except Exception:
|
|
return None
|
|
if not managed:
|
|
print(json.dumps({"managed": False, "ready": True}))
|
|
raise SystemExit(0)
|
|
desired = to_int(desired_raw)
|
|
node_json = run(["kubectl", "get", "node", node_name, "-o", "json"])
|
|
capacity = None
|
|
allocatable = None
|
|
node_ready = False
|
|
if node_json.returncode == 0:
|
|
data = json.loads(node_json.stdout)
|
|
capacity = to_int(data.get("status", {}).get("capacity", {}).get("pods"))
|
|
allocatable = to_int(data.get("status", {}).get("allocatable", {}).get("pods"))
|
|
for condition in data.get("status", {}).get("conditions", []):
|
|
if condition.get("type") == "Ready":
|
|
node_ready = condition.get("status") == "True"
|
|
unit = run(["systemctl", "cat", service])
|
|
unit_text = unit.stdout if unit.returncode == 0 else ""
|
|
dropin_read = run(["cat", dropin])
|
|
dropin_exists = dropin_read.returncode == 0
|
|
dropin_text = dropin_read.stdout if dropin_exists else ""
|
|
dropin_sha = "sha256:" + hashlib.sha256(dropin_text.encode()).hexdigest() if dropin_exists else None
|
|
matches = re.findall(r"max-pods=([0-9]+)", unit_text + "\\n" + dropin_text)
|
|
configured = to_int(matches[-1]) if matches else None
|
|
dropin_matches = dropin_sha == expected_sha
|
|
ready = dropin_matches and capacity == desired and allocatable == desired
|
|
source = "managed-dropin" if dropin_matches else ("systemd-or-config" if configured is not None else "kubelet-default")
|
|
print(json.dumps({
|
|
"managed": True,
|
|
"ready": ready,
|
|
"serviceName": service,
|
|
"dropInPath": dropin,
|
|
"dropInExists": dropin_exists,
|
|
"dropInSha256": dropin_sha,
|
|
"expectedDropInSha256": expected_sha,
|
|
"dropInMatches": dropin_matches,
|
|
"configuredMaxPods": configured,
|
|
"desiredMaxPods": desired,
|
|
"liveNodeName": node_name,
|
|
"liveCapacityPods": capacity,
|
|
"liveAllocatablePods": allocatable,
|
|
"nodeReady": node_ready,
|
|
"restartRequired": not ready,
|
|
"source": source,
|
|
"unitReadable": unit.returncode == 0,
|
|
}))
|
|
PY
|
|
)
|
|
python3 - "$tekton_required_crds_json" "$tekton_deployment_namespaces_json" "$tekton_runtime_proxy_json" <<'PY' >/tmp/hwlab-node-tekton-status-fragments.json
|
|
import json, subprocess, sys
|
|
required_crds=json.loads(sys.argv[1])
|
|
namespaces=json.loads(sys.argv[2])
|
|
runtime_proxy=json.loads(sys.argv[3])
|
|
def run(args):
|
|
return subprocess.run(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
def exists(args):
|
|
return run(args).returncode == 0
|
|
def env_map(container):
|
|
result={}
|
|
for item in container.get("env") or []:
|
|
name=item.get("name")
|
|
if isinstance(name, str) and "value" in item:
|
|
result[name]=str(item.get("value") or "")
|
|
return result
|
|
def runtime_proxy_workload(kind, namespace, name, cfg):
|
|
proc=run(["kubectl", "-n", namespace, "get", kind, name, "-o", "json"])
|
|
if proc.returncode != 0:
|
|
return {"kind": kind, "namespace": namespace, "name": name, "exists": False, "ready": False}
|
|
obj=json.loads(proc.stdout or "{}")
|
|
template=obj.get("spec", {}).get("template", {})
|
|
spec=template.get("spec", {})
|
|
annotations=template.get("metadata", {}).get("annotations") or {}
|
|
containers=spec.get("containers") or []
|
|
expected_env={
|
|
"HTTP_PROXY": str(cfg.get("proxyUrl") or ""),
|
|
"HTTPS_PROXY": str(cfg.get("proxyUrl") or ""),
|
|
"ALL_PROXY": str(cfg.get("proxyUrl") or ""),
|
|
"NO_PROXY": ",".join([str(item) for item in cfg.get("noProxy") or []]),
|
|
"http_proxy": str(cfg.get("proxyUrl") or ""),
|
|
"https_proxy": str(cfg.get("proxyUrl") or ""),
|
|
"all_proxy": str(cfg.get("proxyUrl") or ""),
|
|
"no_proxy": ",".join([str(item) for item in cfg.get("noProxy") or []]),
|
|
}
|
|
env_matches=True
|
|
if cfg.get("injectEnv"):
|
|
env_matches=bool(containers) and all(all(env_map(container).get(key) == value for key, value in expected_env.items()) for container in containers)
|
|
host_network_matches=(not cfg.get("hostNetwork")) or spec.get("hostNetwork") is True
|
|
dns_policy_matches=(not cfg.get("hostNetwork")) or spec.get("dnsPolicy") == "ClusterFirstWithHostNet"
|
|
annotation_matches=annotations.get("unidesk.ai/runtime-proxy") == "host-route" and annotations.get("unidesk.ai/runtime-proxy-config-ref") == str(cfg.get("configRef") or "")
|
|
return {
|
|
"kind": kind,
|
|
"namespace": namespace,
|
|
"name": name,
|
|
"exists": True,
|
|
"ready": host_network_matches and dns_policy_matches and env_matches and annotation_matches,
|
|
"hostNetwork": spec.get("hostNetwork") is True,
|
|
"hostNetworkMatches": host_network_matches,
|
|
"dnsPolicy": spec.get("dnsPolicy") or None,
|
|
"dnsPolicyMatches": dns_policy_matches,
|
|
"injectEnv": bool(cfg.get("injectEnv")),
|
|
"envMatches": env_matches,
|
|
"annotationMatches": annotation_matches,
|
|
}
|
|
def runtime_proxy_status(cfg, namespace_values):
|
|
if not cfg.get("enabled"):
|
|
return {"enabled": False, "ready": True, "mode": cfg.get("mode") or "host-route", "workloads": [], "valuesPrinted": False}
|
|
workloads=[]
|
|
missing=[]
|
|
for kind, names in (("deployment", cfg.get("deployments") or []), ("statefulset", cfg.get("statefulSets") or [])):
|
|
for name in [str(item) for item in names]:
|
|
found=[]
|
|
for namespace in namespace_values:
|
|
proc=run(["kubectl", "-n", namespace, "get", kind, name, "-o", "name"])
|
|
if proc.returncode == 0:
|
|
found.append(namespace)
|
|
if not found:
|
|
missing.append({"kind": kind, "name": name})
|
|
workloads.append({"kind": kind, "name": name, "exists": False, "ready": False})
|
|
else:
|
|
workloads.extend(runtime_proxy_workload(kind, namespace, name, cfg) for namespace in found)
|
|
return {
|
|
"enabled": True,
|
|
"mode": cfg.get("mode") or "host-route",
|
|
"configRef": cfg.get("configRef"),
|
|
"hostNetwork": bool(cfg.get("hostNetwork")),
|
|
"injectEnv": bool(cfg.get("injectEnv")),
|
|
"selectedDeployments": cfg.get("deployments") or [],
|
|
"selectedStatefulSets": cfg.get("statefulSets") or [],
|
|
"missing": missing,
|
|
"workloads": workloads,
|
|
"ready": len(missing) == 0 and all(item.get("ready") for item in workloads),
|
|
"valuesPrinted": False,
|
|
}
|
|
def namespace_deployments(namespace):
|
|
proc = run(["kubectl", "-n", namespace, "get", "deploy", "-o", "json"])
|
|
if proc.returncode != 0:
|
|
return {"namespace": namespace, "namespaceExists": False, "deployments": [], "ready": False}
|
|
try:
|
|
data=json.loads(proc.stdout)
|
|
except Exception:
|
|
return {"namespace": namespace, "namespaceExists": True, "deployments": [], "ready": False}
|
|
deployments=[]
|
|
for item in data.get("items", []):
|
|
desired=int(item.get("spec", {}).get("replicas") or 0)
|
|
ready=int(item.get("status", {}).get("readyReplicas") or 0)
|
|
deployments.append({"name": item.get("metadata", {}).get("name"), "desired": desired, "readyReplicas": ready, "ready": desired > 0 and ready == desired})
|
|
return {"namespace": namespace, "namespaceExists": True, "deployments": deployments, "ready": len(deployments) > 0 and all(item["ready"] for item in deployments)}
|
|
crds=[{"name": name, "exists": exists(["kubectl", "get", "crd", name])} for name in required_crds]
|
|
deployment_namespaces=[namespace_deployments(ns) for ns in namespaces]
|
|
print(json.dumps({"crds": crds, "deploymentNamespaces": deployment_namespaces, "crdsReady": all(item["exists"] for item in crds), "deploymentsReady": all(item["ready"] for item in deployment_namespaces) if deployment_namespaces else True, "runtimeProxy": runtime_proxy_status(runtime_proxy, namespaces)}))
|
|
PY
|
|
tekton_fragment=$(cat /tmp/hwlab-node-tekton-status-fragments.json 2>/dev/null || printf '{}')
|
|
tekton_installed=$(python3 - "$tekton_fragment" <<'PY'
|
|
import json, sys
|
|
data=json.loads(sys.argv[1] or '{}')
|
|
print('true' if data.get('crdsReady') and data.get('deploymentsReady') else 'false')
|
|
PY
|
|
)
|
|
python3 - "$required_crds_json" "$argo_deployments_json" "$argo_statefulsets_json" "$argo_runtime_proxy_json" "$argo_internal_http_json" "$argo_reconciliation_json" <<'PY' >/tmp/hwlab-node-status-fragments.json
|
|
import json, subprocess, sys
|
|
required_crds=json.loads(sys.argv[1])
|
|
deployments=json.loads(sys.argv[2])
|
|
statefulsets=json.loads(sys.argv[3])
|
|
runtime_proxy=json.loads(sys.argv[4])
|
|
internal_http=json.loads(sys.argv[5])
|
|
reconciliation=json.loads(sys.argv[6])
|
|
ns="${target.argo.namespace}"
|
|
def run(args):
|
|
return subprocess.run(args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
def exists(args):
|
|
return run(args).returncode == 0
|
|
def env_map(container):
|
|
result={}
|
|
for item in container.get("env") or []:
|
|
name=item.get("name")
|
|
if isinstance(name, str) and "value" in item:
|
|
result[name]=str(item.get("value") or "")
|
|
return result
|
|
def runtime_proxy_workload(kind, namespace, name, cfg):
|
|
proc=run(["kubectl", "-n", namespace, "get", kind, name, "-o", "json"])
|
|
if proc.returncode != 0:
|
|
return {"kind": kind, "namespace": namespace, "name": name, "exists": False, "ready": False}
|
|
obj=json.loads(proc.stdout or "{}")
|
|
template=obj.get("spec", {}).get("template", {})
|
|
spec=template.get("spec", {})
|
|
annotations=template.get("metadata", {}).get("annotations") or {}
|
|
containers=spec.get("containers") or []
|
|
expected_env={
|
|
"HTTP_PROXY": str(cfg.get("proxyUrl") or ""),
|
|
"HTTPS_PROXY": str(cfg.get("proxyUrl") or ""),
|
|
"ALL_PROXY": str(cfg.get("proxyUrl") or ""),
|
|
"NO_PROXY": ",".join([str(item) for item in cfg.get("noProxy") or []]),
|
|
"http_proxy": str(cfg.get("proxyUrl") or ""),
|
|
"https_proxy": str(cfg.get("proxyUrl") or ""),
|
|
"all_proxy": str(cfg.get("proxyUrl") or ""),
|
|
"no_proxy": ",".join([str(item) for item in cfg.get("noProxy") or []]),
|
|
}
|
|
env_matches=True
|
|
if cfg.get("injectEnv"):
|
|
env_matches=bool(containers) and all(all(env_map(container).get(key) == value for key, value in expected_env.items()) for container in containers)
|
|
host_network_matches=(not cfg.get("hostNetwork")) or spec.get("hostNetwork") is True
|
|
dns_policy_matches=(not cfg.get("hostNetwork")) or spec.get("dnsPolicy") == "ClusterFirstWithHostNet"
|
|
annotation_matches=annotations.get("unidesk.ai/runtime-proxy") == "host-route" and annotations.get("unidesk.ai/runtime-proxy-config-ref") == str(cfg.get("configRef") or "")
|
|
return {
|
|
"kind": kind,
|
|
"namespace": namespace,
|
|
"name": name,
|
|
"exists": True,
|
|
"ready": host_network_matches and dns_policy_matches and env_matches and annotation_matches,
|
|
"hostNetwork": spec.get("hostNetwork") is True,
|
|
"hostNetworkMatches": host_network_matches,
|
|
"dnsPolicy": spec.get("dnsPolicy") or None,
|
|
"dnsPolicyMatches": dns_policy_matches,
|
|
"injectEnv": bool(cfg.get("injectEnv")),
|
|
"envMatches": env_matches,
|
|
"annotationMatches": annotation_matches,
|
|
}
|
|
def runtime_proxy_status(cfg):
|
|
if not cfg.get("enabled"):
|
|
return {"enabled": False, "ready": True, "mode": cfg.get("mode") or "host-route", "workloads": [], "valuesPrinted": False}
|
|
workloads=[]
|
|
missing=[]
|
|
for kind, names in (("deployment", cfg.get("deployments") or []), ("statefulset", cfg.get("statefulSets") or [])):
|
|
for name in [str(item) for item in names]:
|
|
proc=run(["kubectl", "-n", ns, "get", kind, name, "-o", "name"])
|
|
if proc.returncode != 0:
|
|
missing.append({"kind": kind, "name": name})
|
|
workloads.append({"kind": kind, "name": name, "exists": False, "ready": False})
|
|
else:
|
|
workloads.append(runtime_proxy_workload(kind, ns, name, cfg))
|
|
return {
|
|
"enabled": True,
|
|
"mode": cfg.get("mode") or "host-route",
|
|
"configRef": cfg.get("configRef"),
|
|
"hostNetwork": bool(cfg.get("hostNetwork")),
|
|
"injectEnv": bool(cfg.get("injectEnv")),
|
|
"selectedDeployments": cfg.get("deployments") or [],
|
|
"selectedStatefulSets": cfg.get("statefulSets") or [],
|
|
"missing": missing,
|
|
"workloads": workloads,
|
|
"ready": len(missing) == 0 and all(item.get("ready") for item in workloads),
|
|
"valuesPrinted": False,
|
|
}
|
|
def ready(kind, name):
|
|
data = run(["kubectl", "-n", ns, "get", kind, name, "-o", "json"])
|
|
if data.returncode != 0:
|
|
return {"name": name, "exists": False, "ready": False, "desired": None, "readyReplicas": None}
|
|
obj=json.loads(data.stdout)
|
|
desired=int(obj.get("spec", {}).get("replicas") or 0)
|
|
ready_replicas=int(obj.get("status", {}).get("readyReplicas") or 0)
|
|
return {"name": name, "exists": True, "ready": desired > 0 and ready_replicas == desired, "desired": desired, "readyReplicas": ready_replicas}
|
|
def internal_http_status(cfg):
|
|
if not isinstance(cfg, dict):
|
|
return {"enabled": False, "ready": True, "valuesPrinted": False}
|
|
config_map=run(["kubectl", "-n", ns, "get", "configmap", str(cfg.get("configMapName") or ""), "-o", "json"])
|
|
deployment=run(["kubectl", "-n", ns, "get", "deployment", str(cfg.get("deploymentName") or ""), "-o", "json"])
|
|
config={}
|
|
workload={}
|
|
if config_map.returncode == 0:
|
|
try:
|
|
config=json.loads(config_map.stdout or "{}")
|
|
except Exception:
|
|
config={}
|
|
if deployment.returncode == 0:
|
|
try:
|
|
workload=json.loads(deployment.stdout or "{}")
|
|
except Exception:
|
|
workload={}
|
|
config_value=str((config.get("data") or {}).get(str(cfg.get("configKey") or "")) or "")
|
|
annotation_value=str((((workload.get("spec") or {}).get("template") or {}).get("metadata") or {}).get("annotations", {}).get(str(cfg.get("rolloutAnnotationKey") or "")) or "")
|
|
config_matches=config_value == "true"
|
|
rollout_matches=annotation_value == str(cfg.get("rolloutIdentity") or "")
|
|
return {
|
|
"enabled": True,
|
|
"ready": config_map.returncode == 0 and deployment.returncode == 0 and config_matches and rollout_matches,
|
|
"configMapName": cfg.get("configMapName"),
|
|
"configKey": cfg.get("configKey"),
|
|
"configMatches": config_matches,
|
|
"deploymentName": cfg.get("deploymentName"),
|
|
"rolloutAnnotationKey": cfg.get("rolloutAnnotationKey"),
|
|
"rolloutMatches": rollout_matches,
|
|
"valuesPrinted": False,
|
|
}
|
|
${argoReconciliationStatusPython()}
|
|
crds=[{"name": name, "exists": exists(["kubectl", "get", "crd", name])} for name in required_crds]
|
|
deploy=[ready("deployment", name) for name in deployments]
|
|
sts=[ready("statefulset", name) for name in statefulsets]
|
|
print(json.dumps({"crds": crds, "deployments": deploy, "statefulSets": sts, "crdsReady": all(item["exists"] for item in crds), "deploymentsReady": all(item["ready"] for item in deploy) if deploy else True, "statefulSetsReady": all(item["ready"] for item in sts) if sts else True, "runtimeProxy": runtime_proxy_status(runtime_proxy), "internalHttp": internal_http_status(internal_http), "reconciliation": reconciliation_status(reconciliation)}))
|
|
PY
|
|
argo_fragment=$(cat /tmp/hwlab-node-status-fragments.json 2>/dev/null || printf '{}')
|
|
cat <<JSON
|
|
{"observedAt":"$(date -u +%Y-%m-%dT%H:%M:%SZ)","node":"$node","lane":"$lane","components":{"k3sNodeConfig":$k3s_fragment,"tekton":{"installed":$tekton_installed,"controllerReady":$(deploy_ready tekton-pipelines tekton-pipelines-controller),"webhookReady":$(deploy_ready tekton-pipelines tekton-pipelines-webhook),"install":$tekton_fragment},"ciNamespace":{"name":"$ci_ns","exists":$(exists_ns "$ci_ns"),"serviceAccountExists":$(exists_res "$ci_ns" serviceaccount "$service_account"),"pipelineExists":$(exists_res "$ci_ns" pipeline "$pipeline"),"gitWorkspaceSecret":$ci_git_workspace_json},"gitMirror":{"namespace":"$gitmirror_ns","namespaceExists":$(exists_ns "$gitmirror_ns"),"readDeploymentReady":$(deploy_ready "$gitmirror_ns" "$read_deploy"),"writeDeploymentReady":$(deploy_ready "$gitmirror_ns" "$write_deploy"),"readServiceExists":$(exists_res "$gitmirror_ns" service "$read_svc"),"writeServiceExists":$(exists_res "$gitmirror_ns" service "$write_svc"),"readEndpointsReady":$(endpoint_ready "$gitmirror_ns" "$read_svc"),"writeEndpointsReady":$(endpoint_ready "$gitmirror_ns" "$write_svc"),"cachePvcExists":$(exists_res "$gitmirror_ns" pvc "$cache_pvc"),"cacheHostPath":"$cache_host_path","cacheHostPathReady":$cache_host_path_ready,"egressProxy":$gitmirror_egress_proxy_json,"githubTransport":$github_transport_json,"summary":{"localSource":null,"githubSource":null,"localGitops":null,"githubGitops":null,"pendingFlush":null,"flushNeeded":null,"githubInSync":null}},"argo":{"namespace":"$argo_ns","namespaceExists":$(exists_ns "$argo_ns"),"installed":$(kubectl get crd applications.argoproj.io appprojects.argoproj.io >/dev/null 2>&1 && printf true || printf false),"projectExists":$(kubectl -n "$argo_ns" get appproject "$argo_project" >/dev/null 2>&1 && printf true || printf false),"applicationExists":$(kubectl -n "$argo_ns" get application "$argo_app" >/dev/null 2>&1 && printf true || printf false),"argoObserverRbac":{"roleName":"$argo_observer_role","roleExists":$argo_observer_role_exists,"roleBindingName":"$argo_observer_rolebinding","roleBindingExists":$argo_observer_rolebinding_exists,"serviceAccountNamespace":"$ci_ns","serviceAccountName":"$service_account","canGetApplication":$argo_observer_can_get_application,"ready":$argo_observer_ready},"install":$argo_fragment},"registry":{"endpoint":"$registry","ready":$registry_ready,"endpointReady":$registry_endpoint_ready,"workload":$registry_workload_json,"toolsImage":"$tools_image","toolsImageReady":$tools_image_ready},"runtimeNamespace":{"name":"$runtime_ns","exists":$(exists_ns "$runtime_ns"),"runtimeObserverRbac":{"roleName":"$runtime_observer_role","roleExists":$runtime_observer_role_exists,"roleBindingName":"$runtime_observer_rolebinding","roleBindingExists":$runtime_observer_rolebinding_exists,"serviceAccountNamespace":"$ci_ns","serviceAccountName":"$service_account","canListDeployments":$runtime_observer_can_list_deployments,"canListStatefulSets":$runtime_observer_can_list_statefulsets,"ready":$runtime_observer_ready}}}}
|
|
JSON
|
|
`;
|
|
}
|
|
|
|
export function applyScript(yaml: string, node: ControlPlaneNodeSpec, target: ControlPlaneTargetSpec): string {
|
|
const encoded = Buffer.from(yaml, "utf8").toString("base64");
|
|
return `
|
|
set +e
|
|
manifest=$(mktemp /tmp/hwlab-node-infra.XXXXXX.yaml)
|
|
printf %s ${shQuote(encoded)} | base64 -d >"$manifest"
|
|
field_manager=${shQuote(controlPlaneFieldManager(target))}
|
|
${registryPreApplyScript(node)}
|
|
kubectl apply --server-side --force-conflicts --field-manager="$field_manager" -f "$manifest" >/tmp/hwlab-node-infra-apply.out 2>/tmp/hwlab-node-infra-apply.err
|
|
kubectl_rc=$?
|
|
${k3sApplyScriptFragment(node.k3s, target)}
|
|
${registryPostApplyScript(node)}
|
|
python3 - "$kubectl_rc" "$k3s_report_file" "$registry_pre_report_file" "$registry_report_file" <<'PY'
|
|
import json, pathlib, sys
|
|
k3s_report = {}
|
|
try:
|
|
k3s_report = json.loads(pathlib.Path(sys.argv[2]).read_text(errors='replace'))
|
|
except Exception as exc:
|
|
k3s_report = {"managed": None, "ok": False, "parseError": str(exc)}
|
|
registry_pre = {}
|
|
try:
|
|
registry_pre = json.loads(pathlib.Path(sys.argv[3]).read_text(errors='replace'))
|
|
except Exception as exc:
|
|
registry_pre = {"managed": None, "ok": False, "parseError": str(exc)}
|
|
registry_report = {}
|
|
try:
|
|
registry_report = json.loads(pathlib.Path(sys.argv[4]).read_text(errors='replace'))
|
|
except Exception as exc:
|
|
registry_report = {"managed": None, "ok": False, "parseError": str(exc)}
|
|
out=pathlib.Path('/tmp/hwlab-node-infra-apply.out').read_text(errors='replace') if pathlib.Path('/tmp/hwlab-node-infra-apply.out').exists() else ''
|
|
err=pathlib.Path('/tmp/hwlab-node-infra-apply.err').read_text(errors='replace') if pathlib.Path('/tmp/hwlab-node-infra-apply.err').exists() else ''
|
|
print(json.dumps({'k3sNodeConfig': k3s_report, 'registryMigration': {'preApply': registry_pre, 'postApply': registry_report}, 'kubernetesApply': {'applyExitCode': int(sys.argv[1]), 'stdoutPreview': out[-2000:], 'stderrPreview': err[-2000:], 'runtimeRolloutTriggered': False, 'pk01Touched': False}}, ensure_ascii=False))
|
|
PY
|
|
rm -f "$manifest"
|
|
if [ "$registry_pre_rc" != 0 ]; then exit "$registry_pre_rc"; fi
|
|
if [ "$kubectl_rc" != 0 ]; then exit "$kubectl_rc"; fi
|
|
if [ "$registry_rc" != 0 ]; then exit "$registry_rc"; fi
|
|
exit "$k3s_rc"
|
|
`;
|
|
}
|
|
|
|
export function registryPreApplyScript(node: ControlPlaneNodeSpec): string {
|
|
if (node.registry.mode !== "k8s-workload") {
|
|
return `
|
|
registry_pre_report_file=$(mktemp /tmp/hwlab-node-registry-pre.XXXXXX.json)
|
|
printf '{"managed":false,"ok":true,"mutation":false}\\n' >"$registry_pre_report_file"
|
|
registry_pre_rc=0
|
|
`;
|
|
}
|
|
const installRegistry = node.k3s?.install?.localRegistry ?? null;
|
|
return `
|
|
registry_pre_report_file=$(mktemp /tmp/hwlab-node-registry-pre.XXXXXX.json)
|
|
host_registry_name=${shQuote(installRegistry?.containerName ?? "")}
|
|
registry_pre_rc=0
|
|
host_registry_exists=false
|
|
host_registry_running=false
|
|
docker_stop_rc=0
|
|
if [ -n "$host_registry_name" ] && command -v docker >/dev/null 2>&1; then
|
|
if docker ps -a --format '{{.Names}}' 2>/dev/null | grep -Fx "$host_registry_name" >/dev/null 2>&1; then
|
|
host_registry_exists=true
|
|
if docker ps --format '{{.Names}}' 2>/dev/null | grep -Fx "$host_registry_name" >/dev/null 2>&1; then host_registry_running=true; fi
|
|
docker stop "$host_registry_name" >/tmp/hwlab-registry-docker-stop.out 2>/tmp/hwlab-registry-docker-stop.err || docker_stop_rc=$?
|
|
if [ "$docker_stop_rc" != 0 ]; then registry_pre_rc=$docker_stop_rc; fi
|
|
fi
|
|
fi
|
|
python3 - "$host_registry_exists" "$host_registry_running" "$docker_stop_rc" "$host_registry_name" <<'PY' >"$registry_pre_report_file"
|
|
import json, pathlib, sys
|
|
def tail(path):
|
|
p = pathlib.Path(path)
|
|
return p.read_text(errors='replace')[-1000:] if p.exists() else ''
|
|
print(json.dumps({
|
|
"managed": True,
|
|
"ok": int(sys.argv[3] or "0") == 0,
|
|
"mutation": sys.argv[1] == "true",
|
|
"mode": "k8s-workload",
|
|
"hostDockerRegistryName": sys.argv[4] or None,
|
|
"hostDockerRegistryExists": sys.argv[1] == "true",
|
|
"hostDockerRegistryWasRunning": sys.argv[2] == "true",
|
|
"stoppedBeforeApply": sys.argv[1] == "true" and int(sys.argv[3] or "0") == 0,
|
|
"seededFromOldRegistry": False,
|
|
"zeroSeeded": True,
|
|
"dockerStopExitCode": int(sys.argv[3] or "0"),
|
|
"dockerStopStderrTail": tail('/tmp/hwlab-registry-docker-stop.err'),
|
|
"valuesPrinted": False,
|
|
}, ensure_ascii=False))
|
|
PY
|
|
`;
|
|
}
|
|
|
|
export function registryPostApplyScript(node: ControlPlaneNodeSpec): string {
|
|
if (node.registry.mode !== "k8s-workload") {
|
|
return `
|
|
registry_report_file=$(mktemp /tmp/hwlab-node-registry.XXXXXX.json)
|
|
printf '{"managed":false,"ok":true,"mutation":false}\\n' >"$registry_report_file"
|
|
registry_rc=0
|
|
`;
|
|
}
|
|
return `
|
|
registry_report_file=$(mktemp /tmp/hwlab-node-registry.XXXXXX.json)
|
|
registry_ns=${shQuote(node.registry.namespace)}
|
|
registry_deploy=${shQuote(node.registry.deploymentName)}
|
|
registry_pvc=${shQuote(node.registry.pvcName)}
|
|
registry_endpoint=${shQuote(node.registry.endpoint)}
|
|
registry_rc=0
|
|
registry_pv=
|
|
registry_pv_path=
|
|
registry_pvc_bound=false
|
|
for _ in $(seq 1 60); do
|
|
registry_phase=$(kubectl -n "$registry_ns" get pvc "$registry_pvc" -o 'jsonpath={.status.phase}' 2>/dev/null || true)
|
|
if [ "$registry_phase" = Bound ]; then registry_pvc_bound=true; break; fi
|
|
sleep 2
|
|
done
|
|
if [ "$registry_pvc_bound" = true ]; then
|
|
registry_pv=$(kubectl -n "$registry_ns" get pvc "$registry_pvc" -o 'jsonpath={.spec.volumeName}' 2>/dev/null || true)
|
|
if [ -n "$registry_pv" ]; then
|
|
registry_pv_path=$(kubectl get pv "$registry_pv" -o 'jsonpath={.spec.hostPath.path}' 2>/dev/null || true)
|
|
if [ -z "$registry_pv_path" ]; then registry_pv_path=$(kubectl get pv "$registry_pv" -o 'jsonpath={.spec.local.path}' 2>/dev/null || true); fi
|
|
fi
|
|
fi
|
|
kubectl -n "$registry_ns" delete pod -l "app.kubernetes.io/name=$registry_deploy" --ignore-not-found >/tmp/hwlab-registry-pod-delete.out 2>/tmp/hwlab-registry-pod-delete.err || true
|
|
kubectl -n "$registry_ns" rollout status deploy/"$registry_deploy" --timeout=180s >/tmp/hwlab-registry-rollout.out 2>/tmp/hwlab-registry-rollout.err
|
|
registry_rollout_rc=$?
|
|
registry_endpoint_ready=false
|
|
if command -v curl >/dev/null 2>&1; then curl -fsS --max-time 5 "http://$registry_endpoint/v2/" >/tmp/hwlab-registry-endpoint.out 2>/tmp/hwlab-registry-endpoint.err && registry_endpoint_ready=true; fi
|
|
if [ "$registry_rollout_rc" != 0 ] || [ "$registry_endpoint_ready" != true ]; then registry_rc=1; fi
|
|
python3 - "$registry_rc" "$registry_pvc_bound" "$registry_pv" "$registry_pv_path" "$registry_rollout_rc" "$registry_endpoint_ready" "$registry_ns" "$registry_deploy" "$registry_pvc" "$registry_endpoint" <<'PY' >"$registry_report_file"
|
|
import json, pathlib, sys
|
|
def tail(path):
|
|
p = pathlib.Path(path)
|
|
return p.read_text(errors='replace')[-1000:] if p.exists() else ''
|
|
payload = {
|
|
"managed": True,
|
|
"ok": int(sys.argv[1]) == 0,
|
|
"mutation": True,
|
|
"mode": "k8s-workload",
|
|
"namespace": sys.argv[7],
|
|
"deploymentName": sys.argv[8],
|
|
"pvcName": sys.argv[9],
|
|
"endpoint": sys.argv[10],
|
|
"pvcBound": sys.argv[2] == "true",
|
|
"pvName": sys.argv[3] or None,
|
|
"pvPathPresent": bool(sys.argv[4]),
|
|
"seededFromOldRegistry": False,
|
|
"zeroSeeded": True,
|
|
"rolloutExitCode": int(sys.argv[5] or "1"),
|
|
"endpointReady": sys.argv[6] == "true",
|
|
"rolloutStdoutTail": tail('/tmp/hwlab-registry-rollout.out'),
|
|
"rolloutStderrTail": tail('/tmp/hwlab-registry-rollout.err'),
|
|
"valuesPrinted": False,
|
|
}
|
|
print(json.dumps(payload, ensure_ascii=False))
|
|
PY
|
|
`;
|
|
}
|
|
|
|
export function controlPlaneFieldManager(target: ControlPlaneTargetSpec): string {
|
|
return `unidesk-hwlab-${target.node.toLowerCase()}-${target.lane}-control-plane`;
|
|
}
|
|
|
|
export function k3sApplyScriptFragment(spec: ControlPlaneK3sNodeSpec | null, target: ControlPlaneTargetSpec): string {
|
|
if (spec === null) {
|
|
return `
|
|
k3s_report_file=$(mktemp /tmp/hwlab-node-k3s.XXXXXX.json)
|
|
printf '{"managed":false,"ok":true,"mutation":false}\\n' >"$k3s_report_file"
|
|
k3s_rc=0
|
|
`;
|
|
}
|
|
const content = k3sDropInContent(spec);
|
|
const encoded = Buffer.from(content, "utf8").toString("base64");
|
|
return `
|
|
k3s_report_file=$(mktemp /tmp/hwlab-node-k3s.XXXXXX.json)
|
|
k3s_service=${shQuote(spec.serviceName)}
|
|
k3s_dropin=${shQuote(spec.dropInPath)}
|
|
k3s_node=${shQuote(spec.nodeStatusName)}
|
|
k3s_namespace=${shQuote(target.ciNamespace)}
|
|
k3s_image=${shQuote(target.tekton.toolsImage.output)}
|
|
k3s_desired_max_pods=${shQuote(String(spec.kubelet.maxPods))}
|
|
k3s_expected_sha=${shQuote(sha256Short(content))}
|
|
k3s_before_capacity=$(kubectl get node "$k3s_node" -o 'jsonpath={.status.capacity.pods}' 2>/dev/null || true)
|
|
k3s_before_allocatable=$(kubectl get node "$k3s_node" -o 'jsonpath={.status.allocatable.pods}' 2>/dev/null || true)
|
|
capacity_restart=false
|
|
if [ "$k3s_before_capacity" != "$k3s_desired_max_pods" ] || [ "$k3s_before_allocatable" != "$k3s_desired_max_pods" ]; then capacity_restart=true; fi
|
|
k3s_current_dropin_sha=
|
|
if [ -f "$k3s_dropin" ]; then k3s_current_dropin_sha=$(sha256sum "$k3s_dropin" | awk '{print "sha256:"$1}'); fi
|
|
if [ "$k3s_current_dropin_sha" = "$k3s_expected_sha" ] && [ "$capacity_restart" = false ]; then
|
|
python3 - "$k3s_current_dropin_sha" "$k3s_expected_sha" "$k3s_service" "$k3s_dropin" "$k3s_node" "$k3s_desired_max_pods" "$k3s_before_capacity" "$k3s_before_allocatable" <<'PY' >"$k3s_report_file"
|
|
import json, sys
|
|
dropin_sha, expected_sha, service, dropin, node_name, desired, before_capacity, before_allocatable = sys.argv[1:9]
|
|
print(json.dumps({
|
|
"managed": True,
|
|
"ok": True,
|
|
"mutation": False,
|
|
"applyMode": "noop",
|
|
"completionPending": False,
|
|
"serviceName": service,
|
|
"dropInPath": dropin,
|
|
"dropInSha256": dropin_sha,
|
|
"expectedDropInSha256": expected_sha,
|
|
"dropInMatches": dropin_sha == expected_sha,
|
|
"nodeName": node_name,
|
|
"desiredMaxPods": int(desired),
|
|
"beforeCapacityPods": int(before_capacity) if before_capacity.isdigit() else None,
|
|
"beforeAllocatablePods": int(before_allocatable) if before_allocatable.isdigit() else None,
|
|
}, ensure_ascii=False))
|
|
PY
|
|
k3s_rc=0
|
|
else
|
|
k3s_job="hwlab-node-k3s-config-$(date +%s)"
|
|
k3s_job_manifest=$(mktemp /tmp/hwlab-node-k3s-job.XXXXXX.json)
|
|
k3s_host_script=$(mktemp /tmp/hwlab-node-k3s-host.XXXXXX.sh)
|
|
k3s_job_apply_stdout=/tmp/hwlab-node-k3s-job-apply.out
|
|
k3s_job_apply_stderr=/tmp/hwlab-node-k3s-job-apply.err
|
|
k3s_docker_stdout=/tmp/hwlab-node-k3s-docker.out
|
|
k3s_docker_stderr=/tmp/hwlab-node-k3s-docker.err
|
|
k3s_host_report="/tmp/$k3s_job-report.json"
|
|
rm -f "$k3s_host_report"
|
|
python3 - "$k3s_job_manifest" "$k3s_host_script" "$k3s_job" "$k3s_namespace" "$k3s_image" "$k3s_dropin" ${shQuote(encoded)} "$k3s_service" "$k3s_desired_max_pods" "$k3s_expected_sha" "$capacity_restart" "$k3s_host_report" <<'PY'
|
|
import json, os, shlex, sys
|
|
manifest_path, host_script_path, job, namespace, image, dropin, encoded, service, desired, expected_sha, capacity_restart, report_path = sys.argv[1:13]
|
|
script = f"""#!/bin/sh
|
|
set -eu
|
|
expected=/tmp/unidesk-k3s-dropin.conf
|
|
printf %s {shlex.quote(encoded)} | base64 -d > "$expected"
|
|
host_dropin=/host{shlex.quote(dropin)}
|
|
host_report=/host{shlex.quote(report_path)}
|
|
mkdir -p "$(dirname "$host_dropin")"
|
|
before_sha=
|
|
if [ -f "$host_dropin" ]; then before_sha=$(sha256sum "$host_dropin" | awk '{{print "sha256:"$1}}'); fi
|
|
changed=false
|
|
if ! cmp -s "$expected" "$host_dropin" 2>/dev/null; then
|
|
cp "$expected" "$host_dropin"
|
|
chown 0:0 "$host_dropin" 2>/dev/null || true
|
|
chmod 0644 "$host_dropin"
|
|
changed=true
|
|
fi
|
|
nsenter_path=$(command -v nsenter || true)
|
|
host_systemctl() {{
|
|
if command -v chroot >/dev/null 2>&1 && [ -x /host/usr/bin/systemctl ]; then
|
|
chroot /host /usr/bin/systemctl "$@"
|
|
return $?
|
|
fi
|
|
if [ -n "$nsenter_path" ]; then
|
|
"$nsenter_path" -t 1 -m -u -i -n -p -- /usr/bin/systemctl "$@"
|
|
return $?
|
|
fi
|
|
return 127
|
|
}}
|
|
daemon_reload_rc=0
|
|
restart_rc=0
|
|
restarted=false
|
|
if command -v chroot >/dev/null 2>&1 || [ -n "$nsenter_path" ]; then
|
|
host_systemctl daemon-reload || daemon_reload_rc=$?
|
|
if [ "$changed" = true ] || [ {shlex.quote(capacity_restart)} = true ]; then
|
|
restarted=true
|
|
host_systemctl restart {shlex.quote(service)} || restart_rc=$?
|
|
fi
|
|
else
|
|
daemon_reload_rc=127
|
|
restart_rc=127
|
|
fi
|
|
after_sha=
|
|
if [ -f "$host_dropin" ]; then after_sha=$(sha256sum "$host_dropin" | awk '{{print "sha256:"$1}}'); fi
|
|
service_active=unknown
|
|
if command -v chroot >/dev/null 2>&1 || [ -n "$nsenter_path" ]; then service_active=$(host_systemctl is-active {shlex.quote(service)} 2>/dev/null || true); fi
|
|
python3 - "$changed" "$restarted" "$daemon_reload_rc" "$restart_rc" "$before_sha" "$after_sha" "$service_active" "$nsenter_path" <<'REPORT' >"$host_report"
|
|
import json, sys
|
|
changed, restarted = sys.argv[1] == "true", sys.argv[2] == "true"
|
|
daemon_reload_rc, restart_rc = int(sys.argv[3] or "0"), int(sys.argv[4] or "0")
|
|
print(json.dumps({{
|
|
"jobChanged": changed,
|
|
"jobRestarted": restarted,
|
|
"daemonReloadExitCode": daemon_reload_rc,
|
|
"restartExitCode": restart_rc,
|
|
"beforeDropInSha256": sys.argv[5] or None,
|
|
"dropInSha256": sys.argv[6] or None,
|
|
"expectedDropInSha256": {json.dumps(expected_sha)},
|
|
"dropInMatches": sys.argv[6] == {json.dumps(expected_sha)},
|
|
"serviceActiveText": sys.argv[7] or None,
|
|
"nsenterPresent": bool(sys.argv[8]),
|
|
}}))
|
|
REPORT
|
|
chmod 0644 "$host_report" 2>/dev/null || true
|
|
cat "$host_report"
|
|
"""
|
|
with open(host_script_path, "w", encoding="utf-8") as handle:
|
|
handle.write(script)
|
|
os.chmod(host_script_path, 0o755)
|
|
manifest = {
|
|
"apiVersion": "batch/v1",
|
|
"kind": "Job",
|
|
"metadata": {"name": job, "namespace": namespace, "labels": {"app.kubernetes.io/part-of": "hwlab-node-control-plane", "unidesk.ai/operation": "k3s-node-config"}},
|
|
"spec": {
|
|
"backoffLimit": 0,
|
|
"ttlSecondsAfterFinished": 300,
|
|
"template": {
|
|
"metadata": {"labels": {"app.kubernetes.io/part-of": "hwlab-node-control-plane", "unidesk.ai/operation": "k3s-node-config"}},
|
|
"spec": {
|
|
"restartPolicy": "Never",
|
|
"hostPID": True,
|
|
"hostNetwork": True,
|
|
"containers": [{
|
|
"name": "apply-k3s-node-config",
|
|
"image": image,
|
|
"imagePullPolicy": "IfNotPresent",
|
|
"securityContext": {"privileged": True},
|
|
"command": ["/bin/sh", "-lc", script],
|
|
"volumeMounts": [{"name": "host-root", "mountPath": "/host"}],
|
|
}],
|
|
"volumes": [{"name": "host-root", "hostPath": {"path": "/", "type": "Directory"}}],
|
|
},
|
|
},
|
|
},
|
|
}
|
|
with open(manifest_path, "w", encoding="utf-8") as handle:
|
|
json.dump(manifest, handle)
|
|
PY
|
|
k3s_render_rc=$?
|
|
if [ "$k3s_render_rc" != 0 ]; then
|
|
python3 - "$k3s_render_rc" "$k3s_expected_sha" "$k3s_service" "$k3s_dropin" "$k3s_node" "$k3s_desired_max_pods" <<'PY' >"$k3s_report_file"
|
|
import json, sys
|
|
render_rc = int(sys.argv[1] or "1")
|
|
expected_sha, service, dropin, node_name, desired = sys.argv[2:7]
|
|
print(json.dumps({
|
|
"managed": True,
|
|
"ok": False,
|
|
"mutation": False,
|
|
"renderExitCode": render_rc,
|
|
"serviceName": service,
|
|
"dropInPath": dropin,
|
|
"expectedDropInSha256": expected_sha,
|
|
"nodeName": node_name,
|
|
"desiredMaxPods": int(desired),
|
|
}, ensure_ascii=False))
|
|
PY
|
|
k3s_rc=$k3s_render_rc
|
|
else
|
|
kubectl apply -f "$k3s_job_manifest" >"$k3s_job_apply_stdout" 2>"$k3s_job_apply_stderr"
|
|
k3s_job_apply_rc=$?
|
|
k3s_apply_mode=kubernetes-job
|
|
k3s_docker_rc=127
|
|
if [ "$k3s_job_apply_rc" != 0 ] && command -v docker >/dev/null 2>&1; then
|
|
k3s_apply_mode=docker-host-fallback
|
|
docker run --rm --privileged --pid=host --network=host -v /:/host --entrypoint /bin/sh "$k3s_image" "/host$k3s_host_script" >"$k3s_docker_stdout" 2>"$k3s_docker_stderr"
|
|
k3s_docker_rc=$?
|
|
fi
|
|
k3s_submit_rc=$k3s_job_apply_rc
|
|
if [ "$k3s_job_apply_rc" != 0 ] && [ "$k3s_docker_rc" = 0 ]; then k3s_submit_rc=0; fi
|
|
python3 - "$k3s_submit_rc" "$k3s_job_apply_rc" "$k3s_docker_rc" "$k3s_apply_mode" "$k3s_before_capacity" "$k3s_before_allocatable" "$k3s_expected_sha" "$k3s_service" "$k3s_dropin" "$k3s_node" "$k3s_desired_max_pods" "$k3s_job" "$k3s_namespace" "$k3s_host_report" "$k3s_job_apply_stdout" "$k3s_job_apply_stderr" "$k3s_docker_stdout" "$k3s_docker_stderr" <<'PY' >"$k3s_report_file"
|
|
import json, pathlib, sys
|
|
submit_rc, job_apply_rc, docker_rc = [int(value or "0") for value in sys.argv[1:4]]
|
|
apply_mode = sys.argv[4]
|
|
before_capacity, before_allocatable = sys.argv[5:7]
|
|
expected_sha, service, dropin, node_name, desired, job_name, namespace, host_report = sys.argv[7:15]
|
|
def read(path):
|
|
return pathlib.Path(path).read_text(errors='replace') if pathlib.Path(path).exists() else ''
|
|
try:
|
|
host_report_data = json.loads(read(host_report) or "{}")
|
|
except Exception:
|
|
host_report_data = {}
|
|
apply_ok = submit_rc == 0
|
|
print(json.dumps({
|
|
"managed": True,
|
|
"ok": apply_ok,
|
|
"mutation": apply_ok,
|
|
"completionPending": apply_ok and apply_mode == "kubernetes-job",
|
|
"applyMode": apply_mode,
|
|
"jobName": job_name,
|
|
"namespace": namespace,
|
|
"jobApplyExitCode": job_apply_rc,
|
|
"dockerFallbackExitCode": docker_rc,
|
|
"serviceName": service,
|
|
"dropInPath": dropin,
|
|
"dropInSha256": host_report_data.get("dropInSha256"),
|
|
"expectedDropInSha256": expected_sha,
|
|
"dropInMatches": host_report_data.get("dropInSha256") == expected_sha if host_report_data else None,
|
|
"daemonReloadExitCode": host_report_data.get("daemonReloadExitCode"),
|
|
"restartExitCode": host_report_data.get("restartExitCode"),
|
|
"serviceActive": host_report_data.get("serviceActiveText") == "active" if host_report_data else None,
|
|
"nodeName": node_name,
|
|
"desiredMaxPods": int(desired),
|
|
"beforeCapacityPods": int(before_capacity) if before_capacity.isdigit() else None,
|
|
"beforeAllocatablePods": int(before_allocatable) if before_allocatable.isdigit() else None,
|
|
"hostReportPath": host_report,
|
|
"statusCommand": f"bun scripts/cli.ts hwlab nodes control-plane infra status --node {node_name.upper()} --lane ${target.lane}",
|
|
"jobCompletionCommand": f"kubectl -n {namespace} wait --for=condition=complete job/{job_name} --timeout=120s",
|
|
"jobLogsCommand": f"kubectl -n {namespace} logs job/{job_name} --tail=120",
|
|
"jobApplyStdoutPreview": read(sys.argv[15])[-1000:],
|
|
"jobApplyStderrPreview": read(sys.argv[16])[-1000:],
|
|
"dockerStdoutPreview": read(sys.argv[17])[-1000:],
|
|
"dockerStderrPreview": read(sys.argv[18])[-1000:],
|
|
}, ensure_ascii=False))
|
|
PY
|
|
k3s_rc=$k3s_submit_rc
|
|
fi
|
|
rm -f "$k3s_job_manifest" "$k3s_host_script"
|
|
fi
|
|
`;
|
|
}
|
|
|
|
export function asRecord(value: unknown, path: string): Record<string, unknown> {
|
|
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be an object`);
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
export function record(value: unknown): Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
|
}
|
|
|
|
export function renderTable(headers: string[], rows: string[][]): string[] {
|
|
const widths = headers.map((header, index) => Math.max(header.length, ...rows.map((row) => row[index]?.length ?? 0)));
|
|
const render = (row: string[]) => row.map((cell, index) => cell.padEnd(widths[index] ?? cell.length)).join(" ").trimEnd();
|
|
return [render(headers), ...rows.map(render)];
|
|
}
|
|
|
|
export function renderCell(value: unknown, fallback = "-"): string {
|
|
if (value === undefined || value === null || value === "") return fallback;
|
|
return String(value);
|
|
}
|
|
|
|
export function optionsModeFromCommand(command: unknown): string {
|
|
const value = String(command ?? "");
|
|
if (value.endsWith(" status") || value.endsWith(" logs")) return "status";
|
|
return "benchmark";
|
|
}
|
|
|
|
export function stringField(obj: Record<string, unknown>, key: string, path: string): string {
|
|
const value = obj[key];
|
|
if (typeof value !== "string" || value.length === 0) throw new Error(`${path}.${key} must be a non-empty string`);
|
|
return value;
|
|
}
|
|
|
|
export function optionalStringField(obj: Record<string, unknown>, key: string, path: string): string | undefined {
|
|
const value = obj[key];
|
|
if (value === undefined) return undefined;
|
|
if (typeof value !== "string" || value.length === 0) throw new Error(`${path}.${key} must be a non-empty string`);
|
|
return value;
|
|
}
|
|
|
|
export function absoluteConfigPathField(obj: Record<string, unknown>, key: string, path: string): string {
|
|
const value = stringField(obj, key, path);
|
|
if (!value.startsWith("/") || value.includes("\0") || value.includes("..")) throw new Error(`${path}.${key} must be an absolute path without '..'`);
|
|
return value;
|
|
}
|
|
|
|
export function validateKubernetesName(value: string, path: string): void {
|
|
if (!/^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/u.test(value) || value.length > 253) throw new Error(`${path} must be a Kubernetes resource name`);
|
|
}
|
|
|
|
export function validateSecretKey(value: string, path: string): void {
|
|
if (!/^[A-Za-z0-9._-]+$/u.test(value)) throw new Error(`${path} must be a Kubernetes Secret key`);
|
|
}
|
|
|
|
export function validateEnvKey(value: string, path: string): void {
|
|
if (!/^[A-Z0-9_]+$/u.test(value)) throw new Error(`${path} must be an env key`);
|
|
}
|
|
|
|
export function validateSourceRef(value: string, path: string): void {
|
|
if (!/^[A-Za-z0-9_./-]+$/u.test(value) || value.includes("..")) throw new Error(`${path} has an unsupported sourceRef format`);
|
|
}
|
|
|
|
export function numberField(obj: Record<string, unknown>, key: string, path: string): number {
|
|
const value = obj[key];
|
|
if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) throw new Error(`${path}.${key} must be a positive integer`);
|
|
return value;
|
|
}
|
|
|
|
export function positiveConfigIntegerField(obj: Record<string, unknown>, key: string, path: string): number {
|
|
const value = obj[key];
|
|
if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) throw new Error(`${path}.${key} must be a positive integer`);
|
|
return value;
|
|
}
|
|
|
|
export function nonNegativeIntegerField(obj: Record<string, unknown>, key: string, path: string): number {
|
|
const value = obj[key];
|
|
if (typeof value !== "number" || !Number.isInteger(value) || value < 0) throw new Error(`${path}.${key} must be a non-negative integer`);
|
|
return value;
|
|
}
|
|
|
|
export function numberArrayField(obj: Record<string, unknown>, key: string, path: string): number[] {
|
|
const value = obj[key];
|
|
if (!Array.isArray(value) || value.some((item) => typeof item !== "number" || !Number.isInteger(item))) throw new Error(`${path}.${key} must be an array of integers`);
|
|
return [...value] as number[];
|
|
}
|
|
|
|
export function stringArrayField(obj: Record<string, unknown>, key: string, path: string): string[] {
|
|
const value = obj[key];
|
|
if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item.length === 0)) throw new Error(`${path}.${key} must be an array of non-empty strings`);
|
|
return [...value] as string[];
|
|
}
|
|
|
|
export function stringRecordField(obj: Record<string, unknown>, path: string): Record<string, string> {
|
|
const result: Record<string, string> = {};
|
|
for (const [key, value] of Object.entries(obj)) {
|
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(key)) throw new Error(`${path}.${key} has an unsupported key format`);
|
|
if (typeof value !== "string" || value.length === 0) throw new Error(`${path}.${key} must be a non-empty string`);
|
|
result[key] = value;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
export function booleanField(obj: Record<string, unknown>, key: string, path: string): boolean {
|
|
const value = obj[key];
|
|
if (typeof value !== "boolean") throw new Error(`${path}.${key} must be a boolean`);
|
|
return value;
|
|
}
|
|
|
|
export function boolField(obj: Record<string, unknown>, key: string): boolean {
|
|
return obj[key] === true;
|
|
}
|
|
|
|
export function numberValue(value: unknown): number | null {
|
|
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
}
|
|
|
|
export function requiredOption(args: string[], name: string): string {
|
|
const index = args.indexOf(name);
|
|
if (index === -1) throw new Error(`${name} is required`);
|
|
const value = args[index + 1];
|
|
if (value === undefined || value.startsWith("--") || value.length === 0) throw new Error(`${name} requires a value`);
|
|
return value;
|
|
}
|
|
|
|
export function optionValue(args: string[], name: string): string | null {
|
|
const index = args.indexOf(name);
|
|
if (index === -1) return null;
|
|
const value = args[index + 1];
|
|
if (value === undefined || value.startsWith("--") || value.length === 0) throw new Error(`${name} requires a value`);
|
|
return value;
|
|
}
|
|
|
|
export function positiveIntegerOption(args: string[], name: string, defaultValue: number, maxValue: number): number {
|
|
const raw = optionValue(args, name);
|
|
if (raw === null) return defaultValue;
|
|
const value = Number(raw);
|
|
if (!Number.isInteger(value) || value <= 0) throw new Error(`${name} must be a positive integer`);
|
|
return Math.min(value, maxValue);
|
|
}
|
|
|
|
export function compactCommandResult(result: CommandResult): Record<string, unknown> {
|
|
return {
|
|
exitCode: result.exitCode,
|
|
timedOut: result.timedOut,
|
|
stdoutBytes: Buffer.byteLength(result.stdout),
|
|
stderrBytes: Buffer.byteLength(result.stderr),
|
|
stdoutTail: result.stdout.slice(-2000),
|
|
stderrTail: result.stderr.slice(-2000),
|
|
};
|
|
}
|
|
|
|
export function shellJsonArray(items: readonly string[]): string {
|
|
return JSON.stringify([...items]);
|
|
}
|
|
|
|
export function shQuote(value: string): string {
|
|
return `'${value.replace(/'/gu, `'"'"'`)}'`;
|
|
}
|
|
|
|
export function validateHttpsUrl(value: string, path: string): void {
|
|
let parsed: URL;
|
|
try {
|
|
parsed = new URL(value);
|
|
} catch {
|
|
throw new Error(`${path} must be a valid URL`);
|
|
}
|
|
if (parsed.protocol !== "https:") throw new Error(`${path} must use https://`);
|
|
}
|
|
|
|
export function hostRouteProxyHostAllowed(value: string): boolean {
|
|
if (value === "127.0.0.1" || value === "localhost") return true;
|
|
const parts = value.split(".").map((part) => Number(part));
|
|
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return false;
|
|
return parts[0] === 10
|
|
|| (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31)
|
|
|| (parts[0] === 192 && parts[1] === 168);
|
|
}
|
|
|
|
export function sha256Short(text: string): string {
|
|
return `sha256:${createHash("sha256").update(text).digest("hex")}`;
|
|
}
|