|
|
|
@@ -76,6 +76,11 @@ interface DistributionTarget {
|
|
|
|
|
namespace: string;
|
|
|
|
|
scope: string;
|
|
|
|
|
enabled: boolean;
|
|
|
|
|
consumerRollout: {
|
|
|
|
|
deployments: string[];
|
|
|
|
|
timeoutSeconds: number;
|
|
|
|
|
pollIntervalSeconds: number;
|
|
|
|
|
} | null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface KubernetesSecretConfig {
|
|
|
|
@@ -263,8 +268,9 @@ async function sync(config: UniDeskConfig, options: SecretsOptions): Promise<Rec
|
|
|
|
|
mode: "confirmed",
|
|
|
|
|
mutation: true,
|
|
|
|
|
config: configSummary(distribution, options),
|
|
|
|
|
localSources: sourceSummary(sources),
|
|
|
|
|
desiredSecrets: desired.map(desiredSecretSummary),
|
|
|
|
|
...(options.full
|
|
|
|
|
? { localSources: sourceSummary(sources), desiredSecrets: desired.map(desiredSecretSummary) }
|
|
|
|
|
: { localSources: compactSourceSummary(sources), desiredSecrets: desired.map(compactDesiredSecretSummary) }),
|
|
|
|
|
targets: perTarget,
|
|
|
|
|
valuesPrinted: false,
|
|
|
|
|
};
|
|
|
|
@@ -370,12 +376,22 @@ function parseExternalSourceFile(record: Record<string, unknown>, path: string):
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function parseTarget(record: Record<string, unknown>, path: string): DistributionTarget {
|
|
|
|
|
const rollout = record.consumerRollout === undefined ? null : objectField(record, "consumerRollout", path);
|
|
|
|
|
const deployments = rollout === null
|
|
|
|
|
? []
|
|
|
|
|
: stringArrayField(rollout, "deployments", `${path}.consumerRollout`).map((value, index) => kubernetesNameValue(value, `${path}.consumerRollout.deployments[${index}]`));
|
|
|
|
|
const timeoutSeconds = rollout === null ? 0 : integerField(rollout, "timeoutSeconds", `${path}.consumerRollout`);
|
|
|
|
|
const pollIntervalSeconds = rollout === null ? 0 : integerField(rollout, "pollIntervalSeconds", `${path}.consumerRollout`);
|
|
|
|
|
if (rollout !== null && (deployments.length === 0 || timeoutSeconds <= 0 || pollIntervalSeconds <= 0)) {
|
|
|
|
|
throw new Error(`${path}.consumerRollout requires deployments, a positive timeoutSeconds, and a positive pollIntervalSeconds`);
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
id: simpleId(stringField(record, "id", path), `${path}.id`),
|
|
|
|
|
route: stringField(record, "route", path),
|
|
|
|
|
namespace: kubernetesNameField(record, "namespace", path),
|
|
|
|
|
scope: simpleId(stringField(record, "scope", path), `${path}.scope`),
|
|
|
|
|
enabled: booleanField(record, "enabled", path),
|
|
|
|
|
consumerRollout: rollout === null ? null : { deployments, timeoutSeconds, pollIntervalSeconds },
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
@@ -596,10 +612,17 @@ async function applyTargetSecrets(config: UniDeskConfig, target: DistributionTar
|
|
|
|
|
const yaml = renderSecretManifest(target, secrets);
|
|
|
|
|
const result = await capture(config, target.route, ["sh"], applySecretScript(target, secrets, yaml));
|
|
|
|
|
const parsed = parseJsonOutput(result.stdout);
|
|
|
|
|
const applied = result.exitCode === 0 && boolField(parsed, "ok", false);
|
|
|
|
|
const consumerRolloutStatus = applied && target.consumerRollout !== null
|
|
|
|
|
? await waitForConsumerRollout(config, target)
|
|
|
|
|
: null;
|
|
|
|
|
return {
|
|
|
|
|
ok: result.exitCode === 0 && boolField(parsed, "ok", false),
|
|
|
|
|
ok: applied && (consumerRolloutStatus === null || consumerRolloutStatus.ok === true),
|
|
|
|
|
target: targetSummary(target),
|
|
|
|
|
summary: parsed,
|
|
|
|
|
summary: {
|
|
|
|
|
...parsed,
|
|
|
|
|
...(consumerRolloutStatus === null ? {} : { consumerRolloutStatus }),
|
|
|
|
|
},
|
|
|
|
|
remote: secretCaptureSummary(result),
|
|
|
|
|
...(options.raw ? { rawCaptureOmitted: true, rawPolicy: "Secret distribution never returns raw SSH capture because remote output can contain credential-bearing diagnostics." } : {}),
|
|
|
|
|
};
|
|
|
|
@@ -646,6 +669,9 @@ ${Object.entries(secret.data).sort(([a], [b]) => a.localeCompare(b)).map(([key,
|
|
|
|
|
function applySecretScript(target: DistributionTarget, secrets: DesiredSecret[], yaml: string): string {
|
|
|
|
|
const manifestB64 = Buffer.from(yaml, "utf8").toString("base64");
|
|
|
|
|
const summaryB64 = Buffer.from(JSON.stringify(secrets.map(remoteSecretSummary)), "utf8").toString("base64");
|
|
|
|
|
const rollout = target.consumerRollout;
|
|
|
|
|
const rolloutDeployments = rollout?.deployments.join(" ") ?? "";
|
|
|
|
|
const rolloutSummaryB64 = Buffer.from(JSON.stringify(rollout), "utf8").toString("base64");
|
|
|
|
|
return `
|
|
|
|
|
set -u
|
|
|
|
|
tmp="$(mktemp -d)"
|
|
|
|
@@ -662,22 +688,38 @@ else
|
|
|
|
|
printf '%s\\n' 'skipped because namespace sync failed' >"$tmp/apply.err"
|
|
|
|
|
apply_rc=1
|
|
|
|
|
fi
|
|
|
|
|
python3 - "$ns_rc" "$apply_rc" "$tmp/ns.out" "$tmp/ns.err" "$tmp/apply.out" "$tmp/apply.err" <<'PY'
|
|
|
|
|
rollout_restart_rc=0
|
|
|
|
|
: >"$tmp/rollout-restart.out"
|
|
|
|
|
: >"$tmp/rollout-restart.err"
|
|
|
|
|
if [ "$apply_rc" -eq 0 ] && [ -n '${rolloutDeployments}' ]; then
|
|
|
|
|
for deployment in ${rolloutDeployments}; do
|
|
|
|
|
kubectl -n ${target.namespace} rollout restart "deployment/$deployment" >>"$tmp/rollout-restart.out" 2>>"$tmp/rollout-restart.err" || {
|
|
|
|
|
rollout_restart_rc=$?
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
done
|
|
|
|
|
elif [ "$apply_rc" -ne 0 ] && [ -n '${rolloutDeployments}' ]; then
|
|
|
|
|
rollout_restart_rc=1
|
|
|
|
|
printf '%s\\n' 'skipped because Secret apply failed' >"$tmp/rollout-restart.err"
|
|
|
|
|
fi
|
|
|
|
|
python3 - "$ns_rc" "$apply_rc" "$rollout_restart_rc" "$tmp/ns.out" "$tmp/ns.err" "$tmp/apply.out" "$tmp/apply.err" "$tmp/rollout-restart.out" "$tmp/rollout-restart.err" <<'PY'
|
|
|
|
|
import base64, json, os, sys
|
|
|
|
|
ns_rc, apply_rc = int(sys.argv[1]), int(sys.argv[2])
|
|
|
|
|
ns_rc, apply_rc, rollout_restart_rc = [int(value) for value in sys.argv[1:4]]
|
|
|
|
|
def byte_count(path):
|
|
|
|
|
try:
|
|
|
|
|
return os.path.getsize(path)
|
|
|
|
|
except FileNotFoundError:
|
|
|
|
|
return 0
|
|
|
|
|
payload = {
|
|
|
|
|
"ok": ns_rc == 0 and apply_rc == 0,
|
|
|
|
|
"ok": ns_rc == 0 and apply_rc == 0 and rollout_restart_rc == 0,
|
|
|
|
|
"namespace": "${target.namespace}",
|
|
|
|
|
"secrets": json.loads(base64.b64decode("${summaryB64}").decode("utf-8")),
|
|
|
|
|
"consumerRollout": json.loads(base64.b64decode("${rolloutSummaryB64}").decode("utf-8")),
|
|
|
|
|
"valuesPrinted": False,
|
|
|
|
|
"steps": {
|
|
|
|
|
"namespace": {"exitCode": ns_rc, "stdoutBytes": byte_count(sys.argv[3]), "stderrBytes": byte_count(sys.argv[4]), "outputOmitted": True},
|
|
|
|
|
"apply": {"exitCode": apply_rc, "stdoutBytes": byte_count(sys.argv[5]), "stderrBytes": byte_count(sys.argv[6]), "outputOmitted": True},
|
|
|
|
|
"namespace": {"exitCode": ns_rc, "stdoutBytes": byte_count(sys.argv[4]), "stderrBytes": byte_count(sys.argv[5]), "outputOmitted": True},
|
|
|
|
|
"apply": {"exitCode": apply_rc, "stdoutBytes": byte_count(sys.argv[6]), "stderrBytes": byte_count(sys.argv[7]), "outputOmitted": True},
|
|
|
|
|
"consumerRolloutRestart": {"exitCode": rollout_restart_rc, "stdoutBytes": byte_count(sys.argv[8]), "stderrBytes": byte_count(sys.argv[9]), "outputOmitted": True},
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
|
|
@@ -686,6 +728,105 @@ PY
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function waitForConsumerRollout(config: UniDeskConfig, target: DistributionTarget): Promise<Record<string, unknown>> {
|
|
|
|
|
const rollout = target.consumerRollout;
|
|
|
|
|
if (rollout === null) return { ok: true, mode: "not-declared", mutation: false };
|
|
|
|
|
const startedAt = Date.now();
|
|
|
|
|
const deadline = startedAt + rollout.timeoutSeconds * 1000;
|
|
|
|
|
let attempts = 0;
|
|
|
|
|
let lastSummary: Record<string, unknown> | null = null;
|
|
|
|
|
let lastRemote: Record<string, unknown> | null = null;
|
|
|
|
|
while (true) {
|
|
|
|
|
attempts += 1;
|
|
|
|
|
const result = await capture(config, target.route, ["sh"], consumerRolloutStatusScript(target));
|
|
|
|
|
const parsed = parseJsonOutput(result.stdout);
|
|
|
|
|
lastSummary = parsed;
|
|
|
|
|
lastRemote = secretCaptureSummary(result);
|
|
|
|
|
if (result.exitCode === 0 && boolField(parsed, "ok", false)) {
|
|
|
|
|
return {
|
|
|
|
|
ok: true,
|
|
|
|
|
mutation: false,
|
|
|
|
|
attempts,
|
|
|
|
|
elapsedSeconds: Math.ceil((Date.now() - startedAt) / 1000),
|
|
|
|
|
summary: parsed,
|
|
|
|
|
remote: lastRemote,
|
|
|
|
|
valuesPrinted: false,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
const remainingMs = deadline - Date.now();
|
|
|
|
|
if (remainingMs <= 0) {
|
|
|
|
|
return {
|
|
|
|
|
ok: false,
|
|
|
|
|
mutation: false,
|
|
|
|
|
attempts,
|
|
|
|
|
elapsedSeconds: Math.ceil((Date.now() - startedAt) / 1000),
|
|
|
|
|
reason: "consumer-rollout-timeout",
|
|
|
|
|
summary: lastSummary,
|
|
|
|
|
remote: lastRemote,
|
|
|
|
|
valuesPrinted: false,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
await Bun.sleep(Math.min(rollout.pollIntervalSeconds * 1000, remainingMs));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function consumerRolloutStatusScript(target: DistributionTarget): string {
|
|
|
|
|
const rollout = target.consumerRollout;
|
|
|
|
|
if (rollout === null) throw new Error(`target ${target.id} does not declare consumerRollout`);
|
|
|
|
|
const commands = rollout.deployments.map((deployment, index) => [
|
|
|
|
|
`kubectl -n ${target.namespace} get deployment ${deployment} -o json >"$tmp/deployment.${index}.json" 2>"$tmp/deployment.${index}.err"`,
|
|
|
|
|
`printf '%s' "$?" >"$tmp/deployment.${index}.rc"`,
|
|
|
|
|
].join("\n")).join("\n");
|
|
|
|
|
const expectedB64 = Buffer.from(JSON.stringify(rollout.deployments), "utf8").toString("base64");
|
|
|
|
|
return `
|
|
|
|
|
set -u
|
|
|
|
|
tmp="$(mktemp -d)"
|
|
|
|
|
trap 'rm -rf "$tmp"' EXIT
|
|
|
|
|
${commands}
|
|
|
|
|
python3 - "$tmp" <<'PY'
|
|
|
|
|
import base64, json, os, sys
|
|
|
|
|
tmp = sys.argv[1]
|
|
|
|
|
expected = json.loads(base64.b64decode("${expectedB64}").decode("utf-8"))
|
|
|
|
|
items = []
|
|
|
|
|
ok = True
|
|
|
|
|
for index, name in enumerate(expected):
|
|
|
|
|
try:
|
|
|
|
|
rc = int(open(os.path.join(tmp, f"deployment.{index}.rc"), encoding="utf-8").read() or "1")
|
|
|
|
|
except FileNotFoundError:
|
|
|
|
|
rc = 1
|
|
|
|
|
try:
|
|
|
|
|
observed = json.load(open(os.path.join(tmp, f"deployment.{index}.json"), encoding="utf-8"))
|
|
|
|
|
except Exception:
|
|
|
|
|
observed = None
|
|
|
|
|
metadata = (observed or {}).get("metadata") or {}
|
|
|
|
|
spec = (observed or {}).get("spec") or {}
|
|
|
|
|
status = (observed or {}).get("status") or {}
|
|
|
|
|
generation = int(metadata.get("generation") or 0)
|
|
|
|
|
observed_generation = int(status.get("observedGeneration") or 0)
|
|
|
|
|
desired = int(spec.get("replicas") or 0)
|
|
|
|
|
updated = int(status.get("updatedReplicas") or 0)
|
|
|
|
|
ready = int(status.get("readyReplicas") or 0)
|
|
|
|
|
available = int(status.get("availableReplicas") or 0)
|
|
|
|
|
item_ok = rc == 0 and desired > 0 and observed_generation >= generation and updated >= desired and ready >= desired and available >= desired
|
|
|
|
|
ok = ok and item_ok
|
|
|
|
|
items.append({
|
|
|
|
|
"name": name,
|
|
|
|
|
"exists": rc == 0,
|
|
|
|
|
"generation": generation,
|
|
|
|
|
"observedGeneration": observed_generation,
|
|
|
|
|
"desiredReplicas": desired,
|
|
|
|
|
"updatedReplicas": updated,
|
|
|
|
|
"readyReplicas": ready,
|
|
|
|
|
"availableReplicas": available,
|
|
|
|
|
"ready": item_ok,
|
|
|
|
|
})
|
|
|
|
|
payload = {"ok": ok, "namespace": "${target.namespace}", "deployments": items, "valuesPrinted": False}
|
|
|
|
|
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
|
|
|
sys.exit(0 if ok else 1)
|
|
|
|
|
PY
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function statusSecretScript(target: DistributionTarget, secrets: DesiredSecret[]): string {
|
|
|
|
|
const summaryB64 = Buffer.from(JSON.stringify(secrets.map(remoteSecretSummary)), "utf8").toString("base64");
|
|
|
|
|
const commands = secrets.map((secret, index) => [
|
|
|
|
@@ -767,6 +908,22 @@ function sourceSummary(sources: SourceInspection): Record<string, unknown> {
|
|
|
|
|
return { ok: sources.ok, root: sources.root, entries: sources.entries, valuesPrinted: false };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function compactSourceSummary(sources: SourceInspection): Record<string, unknown> {
|
|
|
|
|
return {
|
|
|
|
|
ok: sources.ok,
|
|
|
|
|
entries: sources.entries.map((entry) => ({
|
|
|
|
|
sourceRef: entry.sourceRef,
|
|
|
|
|
type: entry.type,
|
|
|
|
|
presence: entry.presence ?? entry.exists,
|
|
|
|
|
missingKeys: entry.missingKeys ?? [],
|
|
|
|
|
action: entry.action ?? "none",
|
|
|
|
|
fingerprint: entry.fingerprint ?? null,
|
|
|
|
|
valuesPrinted: false,
|
|
|
|
|
})),
|
|
|
|
|
valuesPrinted: false,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function desiredSecretSummary(secret: DesiredSecret): Record<string, unknown> {
|
|
|
|
|
return {
|
|
|
|
|
name: secret.name,
|
|
|
|
@@ -781,6 +938,17 @@ function desiredSecretSummary(secret: DesiredSecret): Record<string, unknown> {
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function compactDesiredSecretSummary(secret: DesiredSecret): Record<string, unknown> {
|
|
|
|
|
return {
|
|
|
|
|
name: secret.name,
|
|
|
|
|
secretName: secret.secretName,
|
|
|
|
|
keys: Object.keys(secret.data).sort(),
|
|
|
|
|
missingKeys: secret.missingKeys,
|
|
|
|
|
fingerprint: secret.fingerprint,
|
|
|
|
|
valuesPrinted: false,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function remoteSecretSummary(secret: DesiredSecret): Record<string, unknown> {
|
|
|
|
|
return {
|
|
|
|
|
name: secret.name,
|
|
|
|
@@ -792,7 +960,7 @@ function remoteSecretSummary(secret: DesiredSecret): Record<string, unknown> {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function targetSummary(target: DistributionTarget): Record<string, unknown> {
|
|
|
|
|
return { id: target.id, route: target.route, namespace: target.namespace, scope: target.scope };
|
|
|
|
|
return { id: target.id, route: target.route, namespace: target.namespace, scope: target.scope, consumerRollout: target.consumerRollout };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function readOptionValue(args: string[], index: number, option: string): string {
|
|
|
|
@@ -994,6 +1162,10 @@ function kubernetesNameField(obj: Record<string, unknown>, key: string, path: st
|
|
|
|
|
return yamlKubernetesNameField(obj, key, path, "");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function kubernetesNameValue(value: string, path: string): string {
|
|
|
|
|
return yamlKubernetesNameField({ value }, "value", path, "");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function kubernetesSecretKeyField(obj: Record<string, unknown>, key: string, path: string): string {
|
|
|
|
|
const value = stringField(obj, key, path);
|
|
|
|
|
if (!/^[A-Za-z0-9._-]+$/u.test(value)) throw new Error(`${path}.${key} must be a Kubernetes Secret key`);
|
|
|
|
|