718 lines
32 KiB
TypeScript
718 lines
32 KiB
TypeScript
import type { UniDeskConfig } from "./config";
|
|
import { rootPath } from "./config";
|
|
import { startJob } from "./jobs";
|
|
import {
|
|
arrayField,
|
|
booleanField,
|
|
capture,
|
|
compactCapture,
|
|
integerField,
|
|
recordField,
|
|
parseJsonOutput,
|
|
parseOpsApplyOptions,
|
|
parseOpsCommonOptions,
|
|
readYamlRecord,
|
|
shQuote,
|
|
stringField,
|
|
} from "./platform-infra-ops-library";
|
|
import { fingerprintSecretValues, readEnvSourceFile, requiredEnvValue } from "./secrets";
|
|
|
|
const configPath = rootPath("config", "platform-infra", "temporal.yaml");
|
|
const configLabel = "config/platform-infra/temporal.yaml";
|
|
const secretRoot = "/root/.unidesk/.state/secrets";
|
|
const fieldManager = "unidesk-platform-temporal";
|
|
|
|
export interface TemporalTarget {
|
|
id: string;
|
|
route: string;
|
|
namespace: string;
|
|
createNamespace: boolean;
|
|
enabled: boolean;
|
|
}
|
|
|
|
export interface TemporalWebProbeSmokeProfile {
|
|
path: string;
|
|
readySelector: string;
|
|
minimumBodyTextBytes: number;
|
|
screenshotName: string;
|
|
viewport: { width: number; height: number };
|
|
navigationTimeoutMs: number;
|
|
settleMs: number;
|
|
commandTimeoutSeconds: number;
|
|
outputLimits: { failures: number; network: number; console: number };
|
|
}
|
|
|
|
export interface TemporalWebProbeSpec {
|
|
enabled: boolean;
|
|
productId: "temporal";
|
|
runner: { node: string; lane: string };
|
|
origin: { browserProxyMode: "auto" | "direct" };
|
|
authentication: {
|
|
mode: "basic-auth";
|
|
path: string;
|
|
authenticatedSelector: string;
|
|
credentials: { configRef: string; targetId: string; secretName: string; passwordTargetKey: string };
|
|
};
|
|
defaultSmokeProfile: string;
|
|
smokeProfiles: Record<string, TemporalWebProbeSmokeProfile>;
|
|
}
|
|
|
|
export interface TemporalConfig {
|
|
defaults: { targetId: string };
|
|
images: { server: string; ui: string; authProxy: string; pullPolicy: string };
|
|
targets: TemporalTarget[];
|
|
database: {
|
|
configRef: string;
|
|
sourceRef: string;
|
|
sourceKeys: { user: string; password: string; database: string; visibilityDatabase: string };
|
|
host: string;
|
|
port: number;
|
|
sslMode: string;
|
|
disableHostVerification: boolean;
|
|
skipCreate: boolean;
|
|
secretName: string;
|
|
};
|
|
runtime: {
|
|
server: {
|
|
deploymentName: string;
|
|
serviceName: string;
|
|
replicas: number;
|
|
frontendPort: number;
|
|
dynamicConfigFilePath: string;
|
|
enableServiceLinks: boolean;
|
|
};
|
|
ui: {
|
|
deploymentName: string;
|
|
serviceName: string;
|
|
replicas: number;
|
|
port: number;
|
|
enableServiceLinks: boolean;
|
|
auth: { username: string; proxyPort: number; secretName: string; passwordKey: string };
|
|
};
|
|
logicalNamespaces: Array<{ name: string; retention: string }>;
|
|
rolloutTimeoutSeconds: number;
|
|
};
|
|
publicExposure: {
|
|
enabled: boolean;
|
|
publicBaseUrl: string;
|
|
hostname: string;
|
|
upstream: string;
|
|
healthPath: string;
|
|
expectedA: string;
|
|
};
|
|
webProbe: TemporalWebProbeSpec;
|
|
}
|
|
|
|
interface DatabaseMaterial {
|
|
user: string;
|
|
password: string;
|
|
database: string;
|
|
visibilityDatabase: string;
|
|
fingerprint: string;
|
|
}
|
|
|
|
export function temporalHelp(): Record<string, unknown> {
|
|
return {
|
|
command: "platform-infra temporal plan|apply|status|validate",
|
|
usage: [
|
|
"bun scripts/cli.ts platform-infra temporal plan --target NC01",
|
|
"bun scripts/cli.ts platform-infra temporal apply --target NC01 --dry-run",
|
|
"bun scripts/cli.ts platform-infra temporal apply --target NC01 --confirm",
|
|
"bun scripts/cli.ts platform-infra temporal status --target NC01",
|
|
"bun scripts/cli.ts platform-infra temporal validate --target NC01",
|
|
],
|
|
configTruth: configLabel,
|
|
databaseTruth: "config/platform-db/postgres-nc01.yaml",
|
|
publicUrl: "https://temporal.hwpod.com",
|
|
secretPolicy: "YAML sourceRef values are rendered into a Kubernetes Secret and never printed.",
|
|
};
|
|
}
|
|
|
|
export async function runTemporalCommand(config: UniDeskConfig, args: string[]): Promise<Record<string, unknown>> {
|
|
const [action = "plan"] = args;
|
|
if (action === "help" || action === "--help" || action === "-h") return temporalHelp();
|
|
if (action === "plan") return plan(parseOpsCommonOptions(args.slice(1)));
|
|
if (action === "apply") return await apply(config, parseOpsApplyOptions(args.slice(1)));
|
|
if (action === "status") return await observe(config, "status", parseOpsCommonOptions(args.slice(1)));
|
|
if (action === "validate") return await observe(config, "validate", parseOpsCommonOptions(args.slice(1)));
|
|
return { ok: false, error: "unsupported-platform-infra-temporal-command", args, help: temporalHelp() };
|
|
}
|
|
|
|
function plan(options: ReturnType<typeof parseOpsCommonOptions>): Record<string, unknown> {
|
|
const temporal = readTemporalConfig();
|
|
const target = resolveTemporalTarget(temporal, options.targetId);
|
|
const manifest = renderManifest(temporal, target, null);
|
|
const checks = policyChecks(temporal, target, manifest);
|
|
return {
|
|
ok: checks.every((check) => check.ok),
|
|
action: "platform-infra-temporal-plan",
|
|
mutation: false,
|
|
config: summary(temporal, target),
|
|
policy: checks,
|
|
rendered: { objectCount: manifest.split("\n---\n").length, secretValuesPrepared: false },
|
|
next: {
|
|
databasePlan: `bun scripts/cli.ts platform-db postgres plan --config ${temporal.database.configRef}`,
|
|
databaseApply: `bun scripts/cli.ts platform-db postgres apply --config ${temporal.database.configRef} --confirm`,
|
|
dryRun: `bun scripts/cli.ts platform-infra temporal apply --target ${target.id} --dry-run`,
|
|
apply: `bun scripts/cli.ts platform-infra temporal apply --target ${target.id} --confirm`,
|
|
publicEdge: `bun scripts/cli.ts platform-infra public-edge plan --target ${target.id}`,
|
|
webAdminSecret: "bun scripts/cli.ts secrets sync --config config/secrets-distribution.yaml --scope temporal --target temporal-nc01 --confirm",
|
|
},
|
|
};
|
|
}
|
|
|
|
async function apply(config: UniDeskConfig, options: ReturnType<typeof parseOpsApplyOptions>): Promise<Record<string, unknown>> {
|
|
const temporal = readTemporalConfig();
|
|
const target = resolveTemporalTarget(temporal, options.targetId);
|
|
const checks = policyChecks(temporal, target, renderManifest(temporal, target, null));
|
|
if (!checks.every((check) => check.ok)) return { ok: false, action: "platform-infra-temporal-apply", mode: "policy-blocked", policy: checks };
|
|
if (options.confirm && !options.wait) {
|
|
const job = startJob(
|
|
`platform_infra_temporal_apply_${target.id.toLowerCase()}`,
|
|
["bun", "scripts/cli.ts", "platform-infra", "temporal", "apply", "--target", target.id, "--confirm", "--wait"],
|
|
`Apply ${target.id} Temporal manifests and initialize YAML-declared logical namespaces`,
|
|
);
|
|
return {
|
|
ok: true,
|
|
action: "platform-infra-temporal-apply",
|
|
mode: "async-job",
|
|
mutation: true,
|
|
job,
|
|
statusCommand: `bun scripts/cli.ts job status ${job.id} --tail-bytes 12000`,
|
|
};
|
|
}
|
|
const material = options.confirm ? readDatabaseMaterial(temporal) : null;
|
|
const manifest = renderManifest(temporal, target, material);
|
|
const script = options.dryRun ? dryRunScript(target, manifest) : applyScript(temporal, target, manifest);
|
|
const result = await capture(config, target.route, ["sh"], script, { runtimeTimeoutMs: (temporal.runtime.rolloutTimeoutSeconds + 30) * 1000 });
|
|
const parsed = parseJsonOutput(result.stdout);
|
|
return {
|
|
ok: result.exitCode === 0 && parsed?.ok === true,
|
|
action: "platform-infra-temporal-apply",
|
|
mode: options.dryRun ? "dry-run" : "confirmed",
|
|
mutation: !options.dryRun && result.exitCode === 0,
|
|
target: targetSummary(target),
|
|
database: {
|
|
configRef: temporal.database.configRef,
|
|
sourceRef: temporal.database.sourceRef,
|
|
secretName: temporal.database.secretName,
|
|
fingerprint: material?.fingerprint ?? null,
|
|
valuesPrinted: false,
|
|
},
|
|
remote: parsed ?? compactCapture(result, { full: true }),
|
|
next: {
|
|
status: `bun scripts/cli.ts platform-infra temporal status --target ${target.id}`,
|
|
validate: `bun scripts/cli.ts platform-infra temporal validate --target ${target.id}`,
|
|
publicEdge: `bun scripts/cli.ts platform-infra public-edge status --target ${target.id}`,
|
|
},
|
|
};
|
|
}
|
|
|
|
async function observe(
|
|
config: UniDeskConfig,
|
|
action: "status" | "validate",
|
|
options: ReturnType<typeof parseOpsCommonOptions>,
|
|
): Promise<Record<string, unknown>> {
|
|
const temporal = readTemporalConfig();
|
|
const target = resolveTemporalTarget(temporal, options.targetId);
|
|
const result = await capture(config, target.route, ["sh"], statusScript(temporal, target, action === "validate"));
|
|
const parsed = parseJsonOutput(result.stdout);
|
|
return {
|
|
ok: result.exitCode === 0 && parsed?.ok === true,
|
|
action: `platform-infra-temporal-${action}`,
|
|
mutation: false,
|
|
target: targetSummary(target),
|
|
summary: parsed,
|
|
remote: compactCapture(result, { full: options.full || result.exitCode !== 0 }),
|
|
...(options.raw ? { raw: result } : {}),
|
|
};
|
|
}
|
|
|
|
function renderManifest(temporal: TemporalConfig, target: TemporalTarget, material: DatabaseMaterial | null): string {
|
|
const server = temporal.runtime.server;
|
|
const ui = temporal.runtime.ui;
|
|
const secretData = material === null
|
|
? { user: "dry-run", password: "dry-run", database: "dry-run", visibilityDatabase: "dry-run" }
|
|
: material;
|
|
const encoded = (value: string) => Buffer.from(value, "utf8").toString("base64");
|
|
const namespace = target.namespace;
|
|
return `apiVersion: v1
|
|
kind: Namespace
|
|
metadata:
|
|
name: ${namespace}
|
|
labels:
|
|
app.kubernetes.io/part-of: temporal
|
|
---
|
|
apiVersion: v1
|
|
kind: Secret
|
|
metadata:
|
|
name: ${temporal.database.secretName}
|
|
namespace: ${namespace}
|
|
labels:
|
|
app.kubernetes.io/part-of: temporal
|
|
type: Opaque
|
|
data:
|
|
user: ${encoded(secretData.user)}
|
|
password: ${encoded(secretData.password)}
|
|
database: ${encoded(secretData.database)}
|
|
visibilityDatabase: ${encoded(secretData.visibilityDatabase)}
|
|
---
|
|
apiVersion: v1
|
|
kind: Service
|
|
metadata:
|
|
name: ${server.serviceName}
|
|
namespace: ${namespace}
|
|
spec:
|
|
selector:
|
|
app.kubernetes.io/name: temporal
|
|
ports:
|
|
- name: grpc
|
|
port: ${server.frontendPort}
|
|
targetPort: grpc
|
|
---
|
|
apiVersion: apps/v1
|
|
kind: Deployment
|
|
metadata:
|
|
name: ${server.deploymentName}
|
|
namespace: ${namespace}
|
|
labels:
|
|
app.kubernetes.io/name: temporal
|
|
app.kubernetes.io/part-of: temporal
|
|
spec:
|
|
replicas: ${server.replicas}
|
|
selector:
|
|
matchLabels:
|
|
app.kubernetes.io/name: temporal
|
|
template:
|
|
metadata:
|
|
labels:
|
|
app.kubernetes.io/name: temporal
|
|
app.kubernetes.io/part-of: temporal
|
|
spec:
|
|
enableServiceLinks: ${server.enableServiceLinks}
|
|
containers:
|
|
- name: temporal
|
|
image: ${temporal.images.server}
|
|
imagePullPolicy: ${temporal.images.pullPolicy}
|
|
ports:
|
|
- name: grpc
|
|
containerPort: ${server.frontendPort}
|
|
env:
|
|
- name: DB
|
|
value: postgres12
|
|
- name: DB_PORT
|
|
value: "${temporal.database.port}"
|
|
- name: POSTGRES_SEEDS
|
|
value: ${temporal.database.host}
|
|
- name: POSTGRES_USER
|
|
valueFrom:
|
|
secretKeyRef:
|
|
name: ${temporal.database.secretName}
|
|
key: user
|
|
- name: POSTGRES_PWD
|
|
valueFrom:
|
|
secretKeyRef:
|
|
name: ${temporal.database.secretName}
|
|
key: password
|
|
- name: DBNAME
|
|
valueFrom:
|
|
secretKeyRef:
|
|
name: ${temporal.database.secretName}
|
|
key: database
|
|
- name: VISIBILITY_DBNAME
|
|
valueFrom:
|
|
secretKeyRef:
|
|
name: ${temporal.database.secretName}
|
|
key: visibilityDatabase
|
|
- name: POSTGRES_TLS_ENABLED
|
|
value: "true"
|
|
- name: POSTGRES_TLS_DISABLE_HOST_VERIFICATION
|
|
value: "${temporal.database.disableHostVerification}"
|
|
- name: SKIP_DB_CREATE
|
|
value: "${temporal.database.skipCreate}"
|
|
- name: DYNAMIC_CONFIG_FILE_PATH
|
|
value: ${server.dynamicConfigFilePath}
|
|
readinessProbe:
|
|
tcpSocket:
|
|
port: grpc
|
|
initialDelaySeconds: 10
|
|
periodSeconds: 5
|
|
livenessProbe:
|
|
tcpSocket:
|
|
port: grpc
|
|
initialDelaySeconds: 30
|
|
periodSeconds: 10
|
|
---
|
|
apiVersion: v1
|
|
kind: Service
|
|
metadata:
|
|
name: ${ui.serviceName}
|
|
namespace: ${namespace}
|
|
spec:
|
|
selector:
|
|
app.kubernetes.io/name: temporal-ui
|
|
ports:
|
|
- name: http
|
|
port: ${ui.port}
|
|
targetPort: auth
|
|
---
|
|
apiVersion: apps/v1
|
|
kind: Deployment
|
|
metadata:
|
|
name: ${ui.deploymentName}
|
|
namespace: ${namespace}
|
|
labels:
|
|
app.kubernetes.io/name: temporal-ui
|
|
app.kubernetes.io/part-of: temporal
|
|
spec:
|
|
replicas: ${ui.replicas}
|
|
selector:
|
|
matchLabels:
|
|
app.kubernetes.io/name: temporal-ui
|
|
template:
|
|
metadata:
|
|
labels:
|
|
app.kubernetes.io/name: temporal-ui
|
|
app.kubernetes.io/part-of: temporal
|
|
spec:
|
|
enableServiceLinks: ${ui.enableServiceLinks}
|
|
containers:
|
|
- name: temporal-ui
|
|
image: ${temporal.images.ui}
|
|
imagePullPolicy: ${temporal.images.pullPolicy}
|
|
ports:
|
|
- name: http
|
|
containerPort: ${ui.port}
|
|
env:
|
|
- name: TEMPORAL_ADDRESS
|
|
value: ${server.serviceName}:${server.frontendPort}
|
|
- name: TEMPORAL_CORS_ORIGINS
|
|
value: ${temporal.publicExposure.publicBaseUrl}
|
|
readinessProbe:
|
|
httpGet:
|
|
path: /
|
|
port: http
|
|
initialDelaySeconds: 5
|
|
periodSeconds: 5
|
|
livenessProbe:
|
|
httpGet:
|
|
path: /
|
|
port: http
|
|
initialDelaySeconds: 20
|
|
periodSeconds: 10
|
|
- name: auth-proxy
|
|
image: ${temporal.images.authProxy}
|
|
imagePullPolicy: ${temporal.images.pullPolicy}
|
|
command:
|
|
- sh
|
|
- -ec
|
|
- |
|
|
password="$(cat /run/secrets/admin-password)"
|
|
hash="$(caddy hash-password --plaintext "$password")"
|
|
cat >/tmp/Caddyfile <<EOF
|
|
:${ui.auth.proxyPort} {
|
|
handle /healthz {
|
|
respond 200
|
|
}
|
|
handle {
|
|
basic_auth {
|
|
${ui.auth.username} $hash
|
|
}
|
|
reverse_proxy 127.0.0.1:${ui.port}
|
|
}
|
|
}
|
|
EOF
|
|
exec caddy run --config /tmp/Caddyfile --adapter caddyfile
|
|
ports:
|
|
- name: auth
|
|
containerPort: ${ui.auth.proxyPort}
|
|
readinessProbe:
|
|
tcpSocket:
|
|
port: auth
|
|
initialDelaySeconds: 3
|
|
periodSeconds: 5
|
|
livenessProbe:
|
|
tcpSocket:
|
|
port: auth
|
|
initialDelaySeconds: 15
|
|
periodSeconds: 10
|
|
volumeMounts:
|
|
- name: web-admin-password
|
|
mountPath: /run/secrets/admin-password
|
|
subPath: ${ui.auth.passwordKey}
|
|
readOnly: true
|
|
volumes:
|
|
- name: web-admin-password
|
|
secret:
|
|
secretName: ${ui.auth.secretName}
|
|
`;
|
|
}
|
|
|
|
function dryRunScript(target: TemporalTarget, manifest: string): string {
|
|
return manifestScript(manifest, `
|
|
if kubectl get namespace ${target.namespace} >/dev/null 2>&1; then
|
|
kubectl apply --server-side --dry-run=server --field-manager=${fieldManager} -f "$manifest" >/dev/null
|
|
else
|
|
kubectl apply --dry-run=client --validate=false -f "$manifest" >/dev/null
|
|
fi`);
|
|
}
|
|
|
|
function applyScript(temporal: TemporalConfig, target: TemporalTarget, manifest: string): string {
|
|
const server = temporal.runtime.server;
|
|
const ui = temporal.runtime.ui;
|
|
const namespaceCommands = temporal.runtime.logicalNamespaces.map((item) => `
|
|
if ! kubectl -n ${target.namespace} exec deploy/${server.deploymentName} -- temporal operator namespace describe --address ${server.serviceName}:${server.frontendPort} --namespace ${item.name} >/dev/null 2>&1; then
|
|
kubectl -n ${target.namespace} exec deploy/${server.deploymentName} -- temporal operator namespace create --address ${server.serviceName}:${server.frontendPort} --namespace ${item.name} --retention ${item.retention} >/dev/null
|
|
fi`).join("\n");
|
|
const command = `
|
|
kubectl apply --server-side --force-conflicts --field-manager=${fieldManager} -f "$manifest" >/dev/null
|
|
kubectl -n ${target.namespace} rollout status deployment/${server.deploymentName} --timeout=${temporal.runtime.rolloutTimeoutSeconds}s >/dev/null
|
|
kubectl -n ${target.namespace} rollout status deployment/${ui.deploymentName} --timeout=${temporal.runtime.rolloutTimeoutSeconds}s >/dev/null
|
|
${namespaceCommands}
|
|
`;
|
|
return manifestScript(manifest, command);
|
|
}
|
|
|
|
function manifestScript(manifest: string, command: string): string {
|
|
const encoded = Buffer.from(manifest, "utf8").toString("base64");
|
|
return `
|
|
set -eu
|
|
tmp="$(mktemp -d)"
|
|
trap 'rm -rf "$tmp"' EXIT
|
|
manifest="$tmp/temporal.yaml"
|
|
printf '%s' ${shQuote(encoded)} | base64 -d >"$manifest"
|
|
${command}
|
|
printf '%s\n' '{"ok":true,"valuesPrinted":false}'
|
|
`;
|
|
}
|
|
|
|
function statusScript(temporal: TemporalConfig, target: TemporalTarget, validate: boolean): string {
|
|
const server = temporal.runtime.server;
|
|
const ui = temporal.runtime.ui;
|
|
const logicalNames = temporal.runtime.logicalNamespaces.map((item) => item.name);
|
|
const logicalArgs = logicalNames.map(shQuote).join(" ");
|
|
return `
|
|
set -u
|
|
server_ready="$(kubectl -n ${target.namespace} get deploy ${server.deploymentName} -o jsonpath='{.status.readyReplicas}' 2>/dev/null || true)"
|
|
ui_ready="$(kubectl -n ${target.namespace} get deploy ${ui.deploymentName} -o jsonpath='{.status.readyReplicas}' 2>/dev/null || true)"
|
|
endpoint="$(kubectl -n ${target.namespace} get endpoints ${server.serviceName} -o jsonpath='{.subsets[0].addresses[0].ip}' 2>/dev/null || true)"
|
|
ui_endpoint="$(kubectl -n ${target.namespace} get endpoints ${ui.serviceName} -o jsonpath='{.subsets[0].addresses[0].ip}' 2>/dev/null || true)"
|
|
secret="$(kubectl -n ${target.namespace} get secret ${temporal.database.secretName} -o name 2>/dev/null || true)"
|
|
logical_present=0
|
|
for logical in ${logicalArgs}; do
|
|
if kubectl -n ${target.namespace} exec deploy/${server.deploymentName} -- temporal operator namespace describe --address ${server.serviceName}:${server.frontendPort} --namespace "$logical" >/dev/null 2>&1; then
|
|
logical_present=$((logical_present + 1))
|
|
fi
|
|
done
|
|
python3 - "$server_ready" "$ui_ready" "$endpoint" "$ui_endpoint" "$secret" "$logical_present" "${logicalNames.length}" "${validate}" <<'PY'
|
|
import json, sys
|
|
server_ready, ui_ready, endpoint, ui_endpoint, secret = sys.argv[1:6]
|
|
logical_present, logical_expected = int(sys.argv[6]), int(sys.argv[7])
|
|
validate = sys.argv[8] == "true"
|
|
ready = int(server_ready or 0) >= ${server.replicas} and int(ui_ready or 0) >= ${ui.replicas}
|
|
ok = ready and bool(endpoint) and bool(ui_endpoint) and bool(secret) and logical_present == logical_expected
|
|
print(json.dumps({
|
|
"ok": ok,
|
|
"mode": "validate" if validate else "status",
|
|
"namespace": "${target.namespace}",
|
|
"server": {"readyReplicas": int(server_ready or 0), "endpointPresent": bool(endpoint), "serviceDns": "${server.serviceName}.${target.namespace}.svc.cluster.local:${server.frontendPort}"},
|
|
"ui": {"readyReplicas": int(ui_ready or 0), "endpointPresent": bool(ui_endpoint), "publicUrl": "${temporal.publicExposure.publicBaseUrl}"},
|
|
"database": {"mode": "nc01-host-native-postgresql", "host": "${temporal.database.host}", "secretPresent": bool(secret), "valuesPrinted": False},
|
|
"logicalNamespaces": {"expected": logical_expected, "present": logical_present, "names": ${JSON.stringify(logicalNames)}},
|
|
}, ensure_ascii=False))
|
|
sys.exit(0 if ok else 1)
|
|
PY
|
|
`;
|
|
}
|
|
|
|
function readDatabaseMaterial(temporal: TemporalConfig): DatabaseMaterial {
|
|
const source = readEnvSourceFile({
|
|
root: secretRoot,
|
|
sourceRef: temporal.database.sourceRef,
|
|
missingMessage: () => `Temporal database source is missing; run platform-db postgres apply --config ${temporal.database.configRef} --confirm first`,
|
|
});
|
|
const keys = temporal.database.sourceKeys;
|
|
const material = {
|
|
user: requiredEnvValue(source.values, keys.user, temporal.database.sourceRef),
|
|
password: requiredEnvValue(source.values, keys.password, temporal.database.sourceRef),
|
|
database: requiredEnvValue(source.values, keys.database, temporal.database.sourceRef),
|
|
visibilityDatabase: requiredEnvValue(source.values, keys.visibilityDatabase, temporal.database.sourceRef),
|
|
};
|
|
return { ...material, fingerprint: fingerprintSecretValues(material, Object.keys(material)) };
|
|
}
|
|
|
|
export function readTemporalConfig(): TemporalConfig {
|
|
const root = readYamlRecord(configPath, "platform-infra-temporal");
|
|
const defaults = recordField(root, "defaults", configLabel);
|
|
const images = recordField(root, "images", configLabel);
|
|
const database = recordField(root, "database", configLabel);
|
|
const sourceKeys = recordField(database, "sourceKeys", `${configLabel}.database`);
|
|
const runtime = recordField(root, "runtime", configLabel);
|
|
const server = recordField(runtime, "server", `${configLabel}.runtime`);
|
|
const ui = recordField(runtime, "ui", `${configLabel}.runtime`);
|
|
const uiAuth = recordField(ui, "auth", `${configLabel}.runtime.ui`);
|
|
const exposure = recordField(root, "publicExposure", configLabel);
|
|
const webProbe = recordField(root, "webProbe", configLabel);
|
|
const webProbeRunner = recordField(webProbe, "runner", `${configLabel}.webProbe`);
|
|
const webProbeOrigin = recordField(webProbe, "origin", `${configLabel}.webProbe`);
|
|
const webProbeAuth = recordField(webProbe, "authentication", `${configLabel}.webProbe`);
|
|
const webProbeCredentials = recordField(webProbeAuth, "credentials", `${configLabel}.webProbe.authentication`);
|
|
const webProbeProfiles = recordField(webProbe, "smokeProfiles", `${configLabel}.webProbe`);
|
|
const smokeProfiles = Object.fromEntries(Object.entries(webProbeProfiles).map(([name, value]) => {
|
|
const path = `${configLabel}.webProbe.smokeProfiles.${name}`;
|
|
const profile = recordField({ value }, "value", path);
|
|
const viewport = recordField(profile, "viewport", path);
|
|
const limits = recordField(profile, "outputLimits", path);
|
|
return [name, {
|
|
path: stringField(profile, "path", path),
|
|
readySelector: stringField(profile, "readySelector", path),
|
|
minimumBodyTextBytes: integerField(profile, "minimumBodyTextBytes", path),
|
|
screenshotName: stringField(profile, "screenshotName", path),
|
|
viewport: { width: integerField(viewport, "width", `${path}.viewport`), height: integerField(viewport, "height", `${path}.viewport`) },
|
|
navigationTimeoutMs: integerField(profile, "navigationTimeoutMs", path),
|
|
settleMs: integerField(profile, "settleMs", path),
|
|
commandTimeoutSeconds: integerField(profile, "commandTimeoutSeconds", path),
|
|
outputLimits: {
|
|
failures: integerField(limits, "failures", `${path}.outputLimits`),
|
|
network: integerField(limits, "network", `${path}.outputLimits`),
|
|
console: integerField(limits, "console", `${path}.outputLimits`),
|
|
},
|
|
} satisfies TemporalWebProbeSmokeProfile];
|
|
}));
|
|
const defaultSmokeProfile = stringField(webProbe, "defaultSmokeProfile", `${configLabel}.webProbe`);
|
|
if (smokeProfiles[defaultSmokeProfile] === undefined) throw new Error(`${configLabel}.webProbe.defaultSmokeProfile must reference smokeProfiles`);
|
|
const webProbeMode = stringField(webProbeAuth, "mode", `${configLabel}.webProbe.authentication`);
|
|
if (webProbeMode !== "basic-auth") throw new Error(`${configLabel}.webProbe.authentication.mode must be basic-auth`);
|
|
const productId = stringField(webProbe, "productId", `${configLabel}.webProbe`);
|
|
if (productId !== "temporal") throw new Error(`${configLabel}.webProbe.productId must be temporal`);
|
|
const browserProxyMode = stringField(webProbeOrigin, "browserProxyMode", `${configLabel}.webProbe.origin`);
|
|
if (browserProxyMode !== "auto" && browserProxyMode !== "direct") throw new Error(`${configLabel}.webProbe.origin.browserProxyMode must be auto or direct`);
|
|
return {
|
|
defaults: { targetId: stringField(defaults, "targetId", `${configLabel}.defaults`) },
|
|
images: {
|
|
server: stringField(images, "server", `${configLabel}.images`),
|
|
ui: stringField(images, "ui", `${configLabel}.images`),
|
|
authProxy: stringField(images, "authProxy", `${configLabel}.images`),
|
|
pullPolicy: stringField(images, "pullPolicy", `${configLabel}.images`),
|
|
},
|
|
targets: arrayField(root, "targets", configLabel).map((item, index) => ({
|
|
id: stringField(item, "id", `${configLabel}.targets[${index}]`),
|
|
route: stringField(item, "route", `${configLabel}.targets[${index}]`),
|
|
namespace: stringField(item, "namespace", `${configLabel}.targets[${index}]`),
|
|
createNamespace: booleanField(item, "createNamespace", `${configLabel}.targets[${index}]`),
|
|
enabled: booleanField(item, "enabled", `${configLabel}.targets[${index}]`),
|
|
})),
|
|
database: {
|
|
configRef: stringField(database, "configRef", `${configLabel}.database`),
|
|
sourceRef: stringField(database, "sourceRef", `${configLabel}.database`),
|
|
sourceKeys: {
|
|
user: stringField(sourceKeys, "user", `${configLabel}.database.sourceKeys`),
|
|
password: stringField(sourceKeys, "password", `${configLabel}.database.sourceKeys`),
|
|
database: stringField(sourceKeys, "database", `${configLabel}.database.sourceKeys`),
|
|
visibilityDatabase: stringField(sourceKeys, "visibilityDatabase", `${configLabel}.database.sourceKeys`),
|
|
},
|
|
host: stringField(database, "host", `${configLabel}.database`),
|
|
port: integerField(database, "port", `${configLabel}.database`),
|
|
sslMode: stringField(database, "sslMode", `${configLabel}.database`),
|
|
disableHostVerification: booleanField(database, "disableHostVerification", `${configLabel}.database`),
|
|
skipCreate: booleanField(database, "skipCreate", `${configLabel}.database`),
|
|
secretName: stringField(database, "secretName", `${configLabel}.database`),
|
|
},
|
|
runtime: {
|
|
server: {
|
|
...deploymentConfig(server, `${configLabel}.runtime.server`, "frontendPort"),
|
|
dynamicConfigFilePath: stringField(server, "dynamicConfigFilePath", `${configLabel}.runtime.server`),
|
|
enableServiceLinks: booleanField(server, "enableServiceLinks", `${configLabel}.runtime.server`),
|
|
},
|
|
ui: {
|
|
...deploymentConfig(ui, `${configLabel}.runtime.ui`, "port"),
|
|
enableServiceLinks: booleanField(ui, "enableServiceLinks", `${configLabel}.runtime.ui`),
|
|
auth: {
|
|
username: stringField(uiAuth, "username", `${configLabel}.runtime.ui.auth`),
|
|
proxyPort: integerField(uiAuth, "proxyPort", `${configLabel}.runtime.ui.auth`),
|
|
secretName: stringField(uiAuth, "secretName", `${configLabel}.runtime.ui.auth`),
|
|
passwordKey: stringField(uiAuth, "passwordKey", `${configLabel}.runtime.ui.auth`),
|
|
},
|
|
},
|
|
logicalNamespaces: arrayField(runtime, "logicalNamespaces", `${configLabel}.runtime`).map((item, index) => ({
|
|
name: stringField(item, "name", `${configLabel}.runtime.logicalNamespaces[${index}]`),
|
|
retention: stringField(item, "retention", `${configLabel}.runtime.logicalNamespaces[${index}]`),
|
|
})),
|
|
rolloutTimeoutSeconds: integerField(runtime, "rolloutTimeoutSeconds", `${configLabel}.runtime`),
|
|
},
|
|
publicExposure: {
|
|
enabled: booleanField(exposure, "enabled", `${configLabel}.publicExposure`),
|
|
publicBaseUrl: stringField(exposure, "publicBaseUrl", `${configLabel}.publicExposure`),
|
|
hostname: stringField(exposure, "hostname", `${configLabel}.publicExposure`),
|
|
upstream: stringField(exposure, "upstream", `${configLabel}.publicExposure`),
|
|
healthPath: stringField(exposure, "healthPath", `${configLabel}.publicExposure`),
|
|
expectedA: stringField(exposure, "expectedA", `${configLabel}.publicExposure`),
|
|
},
|
|
webProbe: {
|
|
enabled: booleanField(webProbe, "enabled", `${configLabel}.webProbe`),
|
|
productId,
|
|
runner: {
|
|
node: stringField(webProbeRunner, "node", `${configLabel}.webProbe.runner`),
|
|
lane: stringField(webProbeRunner, "lane", `${configLabel}.webProbe.runner`),
|
|
},
|
|
origin: { browserProxyMode },
|
|
authentication: {
|
|
mode: webProbeMode,
|
|
path: stringField(webProbeAuth, "path", `${configLabel}.webProbe.authentication`),
|
|
authenticatedSelector: stringField(webProbeAuth, "authenticatedSelector", `${configLabel}.webProbe.authentication`),
|
|
credentials: {
|
|
configRef: stringField(webProbeCredentials, "configRef", `${configLabel}.webProbe.authentication.credentials`),
|
|
targetId: stringField(webProbeCredentials, "targetId", `${configLabel}.webProbe.authentication.credentials`),
|
|
secretName: stringField(webProbeCredentials, "secretName", `${configLabel}.webProbe.authentication.credentials`),
|
|
passwordTargetKey: stringField(webProbeCredentials, "passwordTargetKey", `${configLabel}.webProbe.authentication.credentials`),
|
|
},
|
|
},
|
|
defaultSmokeProfile,
|
|
smokeProfiles,
|
|
},
|
|
};
|
|
}
|
|
|
|
function deploymentConfig(record: Record<string, unknown>, path: string, portKey: "frontendPort" | "port"): any {
|
|
return {
|
|
deploymentName: stringField(record, "deploymentName", path),
|
|
serviceName: stringField(record, "serviceName", path),
|
|
replicas: integerField(record, "replicas", path),
|
|
[portKey]: integerField(record, portKey, path),
|
|
};
|
|
}
|
|
|
|
export function resolveTemporalTarget(temporal: TemporalConfig, requested: string | null): TemporalTarget {
|
|
const id = requested ?? temporal.defaults.targetId;
|
|
const target = temporal.targets.find((item) => item.id === id);
|
|
if (target === undefined) throw new Error(`${configLabel}.targets does not contain ${id}`);
|
|
if (!target.enabled) throw new Error(`${configLabel}.targets[id=${id}] is disabled`);
|
|
return target;
|
|
}
|
|
|
|
function policyChecks(temporal: TemporalConfig, target: TemporalTarget, manifest: string): Array<{ name: string; ok: boolean; detail: string }> {
|
|
return [
|
|
{ name: "independent-namespace", ok: target.namespace === "temporal", detail: target.namespace },
|
|
{ name: "native-postgres", ok: temporal.database.configRef === "config/platform-db/postgres-nc01.yaml", detail: temporal.database.configRef },
|
|
{ name: "no-in-cluster-postgres", ok: !/kind: StatefulSet|PersistentVolumeClaim|image: .*postgres/iu.test(manifest), detail: "Temporal manifest contains no PostgreSQL workload or PVC." },
|
|
{ name: "cluster-internal-grpc", ok: !/type: (?:NodePort|LoadBalancer)/u.test(manifest), detail: `${temporal.runtime.server.serviceName}.${target.namespace}.svc.cluster.local` },
|
|
{ name: "public-ui-only", ok: temporal.publicExposure.publicBaseUrl === "https://temporal.hwpod.com", detail: temporal.publicExposure.publicBaseUrl },
|
|
{ name: "unified-web-admin", ok: temporal.runtime.ui.auth.secretName === "temporal-web-admin", detail: "Web password is distributed from unified-admin-password.txt through the secrets CLI." },
|
|
];
|
|
}
|
|
|
|
function targetSummary(target: TemporalTarget): Record<string, unknown> {
|
|
return { id: target.id, route: target.route, namespace: target.namespace };
|
|
}
|
|
|
|
function summary(temporal: TemporalConfig, target: TemporalTarget): Record<string, unknown> {
|
|
return {
|
|
target: targetSummary(target),
|
|
images: temporal.images,
|
|
database: { mode: "host-native", configRef: temporal.database.configRef, sourceRef: temporal.database.sourceRef, host: temporal.database.host, sslMode: temporal.database.sslMode },
|
|
frontend: `${temporal.runtime.server.serviceName}.${target.namespace}.svc.cluster.local:${temporal.runtime.server.frontendPort}`,
|
|
logicalNamespaces: temporal.runtime.logicalNamespaces,
|
|
publicExposure: temporal.publicExposure,
|
|
};
|
|
}
|