/**
 * upgrade-kubernetes.ts — 8-step kubernetes upgrade path for olam upgrade.
 *
 * Phase 1b C2 of olam-host-suite-phase-1b-k3s-beta-flavour (plan
 * ~/.claude/plans/olam-host-suite-phase-1b-k3s-beta-flavour.md).
 *
 * Phase 2 Phase C extensions:
 *   C1  — step 2.5: in-memory ConfigMap substitution for inter-peripheral K8s DNS URLs (D4)
 *   C2  — step 2.6: per-peripheral Secret pre-check (iterates PERIPHERALS; D12 pattern)
 *   C3  — step 2.7: CoreDNS warm-up wait + extend steps 3/4/5 to iterate peripherals
 *
 * Phase 2 Phase D extensions:
 *   D5  — OLAM_PHASE_2_BETA guard removed; all peripherals deploy unconditionally (Phase 2 GA)
 *
 * Decisions consumed:
 *   D4  — K8s DNS URL form for inter-peripheral service URLs
 *   D10 — context-allowlist + OLAM_K8S_CONTEXT_ACK strict-equality check
 *   D12 — Secret pre-check (base64-decode + key-name + placeholder exact match)
 *   D14 — --force-refresh-manifests flag + --accept-security-regression guard
 *   D15 — kubectl rollout status --timeout=180s; state snapshot on failure
 *   D17 — port-forward spawn via flock (spawnPortForward from port-forward.ts)
 *   D22 — probeKubernetesApiReachable pre-flight via kubectl-wrap (5s timeout)
 *   D27 — audit log entry (phase2.flag_removed) emitted per upgrade run
 *
 * Step order (Phase D — kubernetes substrate, Phase 2 GA):
 *   0    probeKubernetesApiReachable — 5s timeout kubectl cluster-info
 *         (Step 0.5 D4 k3d node bind-mount preflight REMOVED in
 *         olam-k3d-on-mac-substrate-decision Phase B B3 — R3-A retracted.)
 *   0.5b B4 ensureK8sBootstrap (namespace + RBAC + ConfigMap + PVC + secrets)
 *   0.6  R3-C create/update ghcr-pull imagePullSecret in olam namespace (when GH_TOKEN available)
 *         NOTE: relocated from pre-step 0.4 to post-bootstrap (R4-W2-A) — the
 *         `olam` namespace must exist before kubectl can create the secret.
 *   0.7  olam-k3d-on-mac-substrate-decision Phase A — host-side docker-
 *         socket-proxy auto-start on macOS (Decision #1 + #3). Linux substrate
 *         is a no-op. Resolves R4-W2-F (virtiofs ENOTSUP on stat of docker.sock
 *         hostPath bind mounts) by routing host-cp's docker access through a
 *         host-side proxy container reachable via in-cluster ExternalName
 *         Service (host.k3d.internal:2375).
 *   1    D10 context-allowlist + OLAM_K8S_CONTEXT_ACK strict-equality byte-for-byte
 *   2    D12 Secret pre-check (olam-host-cp-secret; base64-decode; key-name check)
 *   2.6  C2 per-peripheral Secret pre-check (iterates PERIPHERALS; unconditional — D5)
 *   2.7  C3 CoreDNS warm-up wait (kubectl wait --for=condition=Available deployment/coredns -n kube-system)
 *   3    kubectl apply host-cp manifests + all 4 peripheral manifest dirs (alphabetical)
 *   3.5  C1 in-memory ConfigMap substitution — K8s DNS URLs AFTER step 3 (B7 fix — last-write-wins)
 *   4    D15 kubectl rollout status (all 5 deployments in parallel)
 *   5    D17 port-forward spawn for host-cp + spawnAllPeripheralPortForwards in parallel
 *   6    verify /health returns X-Olam-Engine: kubernetes
 *   7    emit upgrade.complete instrumentation event
 *   8    success message
 *
 * Manifests NOT auto-rolled-back on failure (D15 spec). Operator must
 * manually intervene; state snapshot is printed on failure.
 */
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { parse as yamlParse, stringify as yamlStringify } from 'yaml';
import ora from 'ora';
import pc from 'picocolors';
import { printError, printSuccess, printInfo, printWarning } from '../output.js';
// buildDockerSocketRemedy + defaultDetectK8sClusterType removed in
// olam-k3d-on-mac-substrate-decision Phase B B3 — Pre-step 0b retracted.
import { kubectlWrap } from './kubectl-wrap.js';
import { spawnPortForward, spawnAllPeripheralPortForwards, probePortForwardLiveness } from './port-forward.js';
import { emitUpgradeComplete } from './instrumentation.js';
import { runManifestRefresh, seedManifestsFromBundle } from './manifest-refresh.js';
import { OLAM_HOME, OLAM_STATE_DIR } from './config.js';
import { PERIPHERALS } from './peripheral-registry.js';
import { resolveKubectlContext } from './kubectl-context.js';
import { ensureK8sBootstrap, resolveK8sAssetsRoot } from './k8s-bootstrap.js';
import { startHostSideProxy } from './host-side-proxy.js';
/**
 * Resolve the bundled docker-socket-proxy.compose.yaml path. Looks under
 * the same k8s-assets root that ships the in-cluster manifests. Returns
 * null when the bundle layout is unexpected (dev contexts without a built
 * tarball). Step 0.7's caller treats null as a continue-with-warning.
 */
function resolveHostSideProxyComposePath() {
    const root = resolveK8sAssetsRoot();
    if (root === null)
        return null;
    const candidate = path.join(root, 'host-side', 'docker-socket-proxy.compose.yaml');
    return fs.existsSync(candidate) ? candidate : null;
}
export const OLAM_K8S_MANIFESTS_DIR = path.join(OLAM_HOME, 'k8s', 'manifests');
export const K8S_NAMESPACE = 'olam';
export const HOST_CP_SECRET_NAME = 'olam-host-cp-secret';
export const HOST_CP_DEPLOYMENT_NAME = 'olam-host-cp';
export const PORT_FORWARD_TARGET = 'service/olam-host-cp';
export const HOST_CP_HEALTH_URL = 'http://127.0.0.1:19000/health';
/** Audit log for substrate upgrade events (D18). */
export const SUBSTRATE_AUDIT_LOG = path.join(OLAM_STATE_DIR, 'substrate-audit.jsonl');
/** Placeholder values that indicate the Secret has not been configured (D12). */
const PLACEHOLDER_VALUES = new Set(['OLAM_AUTH_SECRET', 'GH_TOKEN']);
/** Required keys in the olam-host-cp-secret (D12). */
const REQUIRED_SECRET_KEYS = ['OLAM_AUTH_SECRET', 'GH_TOKEN'];
/**
 * Peripheral secret metadata: name and required keys with their placeholder patterns.
 * Placeholder pattern: starts with REPLACE_ME_ (as per the template files in
 * packages/host-cp/k8s/templates/).
 */
const PERIPHERAL_SECRETS = [
    { name: 'auth-service', secretName: 'olam-auth-service-secret', keys: ['OLAM_AUTH_DB_SECRET'] },
    { name: 'mcp-auth-service', secretName: 'olam-mcp-auth-service-secret', keys: ['OLAM_MCP_AUTH_JWT_SECRET'] },
    { name: 'kg-service', secretName: 'olam-kg-service-secret', keys: ['OLAM_KG_BEARER_TOKEN'] },
    { name: 'memory-service', secretName: 'olam-memory-service-secret', keys: ['OLAM_MEMORY_BEARER_SECRET'] },
];
/** K8s cluster DNS base domain for the olam namespace. */
const K8S_DNS_SUFFIX = 'olam.svc.cluster.local';
/**
 * Build the K8s in-cluster DNS URL for a peripheral service.
 * Form: http://<k8sResourceName>.olam.svc.cluster.local:<port>
 *
 * The PERIPHERALS registry stores the full k8s resource name (with `olam-` prefix)
 * in `k8sResourceName`. The DNS hostname MUST match the Service metadata.name in
 * 60-service.yaml (e.g. `olam-auth-service`).
 *
 * C3 (R3-F): fix — previous form `http://auth-service.olam.svc.cluster.local`
 * resolved to nothing; correct form is `http://olam-auth-service.olam.svc.cluster.local`.
 */
function buildK8sDnsUrl(k8sResourceName, port) {
    return `http://${k8sResourceName}.${K8S_DNS_SUFFIX}:${port}`;
}
/**
 * Append a JSONL audit entry to the substrate audit log (D18).
 * Best-effort: errors are logged to stderr but do not abort the upgrade.
 */
function appendSubstrateAuditEntry(entry, stderr) {
    try {
        const line = JSON.stringify(entry) + '\n';
        const dir = path.dirname(SUBSTRATE_AUDIT_LOG);
        if (!fs.existsSync(dir))
            fs.mkdirSync(dir, { recursive: true });
        fs.writeFileSync(SUBSTRATE_AUDIT_LOG, line, { encoding: 'utf8', flag: 'a', mode: 0o600 });
    }
    catch (err) {
        stderr.write(`${pc.yellow('[warn]')} could not write substrate audit log: ${err instanceof Error ? err.message : String(err)}\n`);
    }
}
/** D10 — allowed kubectl context patterns. Currently: anything non-empty.
 * The allowlist is enforced by OLAM_K8S_CONTEXT_ACK strict-equality. */
function isContextAllowed(context) {
    return typeof context === 'string' && context.length > 0;
}
/**
 * D6 — Managed-k8s cluster server URL patterns (Decision #18 active retraction).
 *
 * Matches the cluster server URL (from `kubectl config view --minify`).
 * These patterns indicate managed Kubernetes providers where hostPath
 * /var/run/docker.sock does not reach the operator's docker daemon.
 * Operators on managed k8s must use a local k3d/k3s cluster instead.
 *
 * Pattern sources:
 *   - EKS: *.amazonaws.com — AWS API server endpoint
 *   - GKE: *.googleapis.com — Google API server; also *.gke.io
 *   - AKS: *.azmk8s.io — Azure managed k8s endpoint
 *   - DO:  *.do-user-*.k8s.ondigitalocean.com — DigitalOcean
 *   - Civo: *.civo.com — Civo cloud
 */
const MANAGED_K8S_URL_PATTERNS = [
    { pattern: /\.amazonaws\.com(:\d+)?$/, provider: 'EKS (Amazon)' },
    { pattern: /\.googleapis\.com(:\d+)?$/, provider: 'GKE (Google)' },
    { pattern: /\.gke\.io(:\d+)?$/, provider: 'GKE (Google)' },
    { pattern: /\.azmk8s\.io(:\d+)?$/, provider: 'AKS (Azure)' },
    { pattern: /\.k8s\.ondigitalocean\.com(:\d+)?$/, provider: 'DOKS (DigitalOcean)' },
    { pattern: /\.civo\.com(:\d+)?$/, provider: 'Civo' },
];
/**
 * Detect whether the active kubectl context is a managed-k8s provider.
 * Returns the detected provider name if managed, null if local/unknown.
 * Fail-open: unrecognized cluster URLs return null (no false positives).
 */
async function detectManagedK8sProvider(deps) {
    if (deps.getClusterServerUrlImpl) {
        const url = await deps.getClusterServerUrlImpl();
        if (!url)
            return null;
        for (const { pattern, provider } of MANAGED_K8S_URL_PATTERNS) {
            if (pattern.test(url))
                return provider;
        }
        return null;
    }
    // Default: run kubectl config view --minify to get the server URL.
    const wrap = deps.kubectlWrapImpl ?? kubectlWrap;
    const result = await wrap(['config', 'view', '--minify', '-o', 'jsonpath={.clusters[0].cluster.server}'], { timeout: 5_000 });
    if (!result.ok || !result.stdout.trim())
        return null;
    const url = result.stdout.trim();
    for (const { pattern, provider } of MANAGED_K8S_URL_PATTERNS) {
        if (pattern.test(url))
            return provider;
    }
    return null;
}
/**
 * Step 0 — D22: probe Kubernetes API reachability (5s timeout).
 * Uses kubectl cluster-info with a tight timeout to detect unreachable API server.
 */
async function probeKubernetesApiReachable(context, deps) {
    const wrap = deps.kubectlWrapImpl ?? kubectlWrap;
    const result = await wrap(['--context', context, 'cluster-info'], { timeout: 5_000 });
    return result.ok;
}
/**
 * Step 2 — D12: Secret pre-check.
 *
 * Fetches the olam-host-cp-secret, base64-decodes each data value, and:
 *   a) Asserts OLAM_AUTH_SECRET and GH_TOKEN keys are present.
 *   b) EXACT-EQUALS check against placeholder strings — if any decoded value
 *      equals the literal placeholder name ('OLAM_AUTH_SECRET' or 'GH_TOKEN'),
 *      the secret has not been configured and the upgrade is refused.
 *
 * Returns null on success; error message string on failure.
 */
async function checkSecretPreCondition(context, deps) {
    const wrap = deps.kubectlWrapImpl ?? kubectlWrap;
    const result = await wrap([
        '--context', context,
        'get', 'secret', HOST_CP_SECRET_NAME,
        '-n', K8S_NAMESPACE,
        '-o', 'json',
    ], { timeout: 15_000 });
    if (!result.ok) {
        return (`Secret "${HOST_CP_SECRET_NAME}" not found in namespace "${K8S_NAMESPACE}".\n` +
            `  Create it first: kubectl --context ${context} apply -f <your-secret.yaml>\n` +
            `  ${result.stderr.split('\n')[0] ?? ''}`);
    }
    let secretJson;
    try {
        secretJson = JSON.parse(result.stdout);
    }
    catch {
        return `Failed to parse Secret JSON: ${result.stdout.slice(0, 200)}`;
    }
    const data = secretJson.data ?? {};
    for (const key of REQUIRED_SECRET_KEYS) {
        if (!(key in data)) {
            return (`Secret "${HOST_CP_SECRET_NAME}" is missing required key "${key}".\n` +
                `  Keys found: ${Object.keys(data).join(', ') || '(none)'}\n` +
                `  Add the key and re-run.`);
        }
        const b64 = data[key] ?? '';
        const decoded = Buffer.from(b64, 'base64').toString('utf8');
        // EXACT-EQUALS: decoded value === placeholder name means unconfigured (D12).
        if (PLACEHOLDER_VALUES.has(decoded)) {
            return (`Secret "${HOST_CP_SECRET_NAME}" key "${key}" still holds its placeholder value.\n` +
                `  Replace the placeholder with a real value, then re-run.`);
        }
    }
    return null; // all checks passed
}
/**
 * Step 4 — D15: kubectl rollout status with state snapshot on failure.
 * Wrapper timeout: 185s (5s padding over the kubectl --timeout=180s).
 * 180s budget: fresh CI clusters with cold image pulls need more time.
 */
async function waitForRollout(context, deps, stderr) {
    const wrap = deps.kubectlWrapImpl ?? kubectlWrap;
    const result = await wrap([
        '--context', context,
        'rollout', 'status',
        `deployment/${HOST_CP_DEPLOYMENT_NAME}`,
        '-n', K8S_NAMESPACE,
        '--timeout=180s',
    ], { timeout: 185_000 });
    if (result.ok)
        return true;
    // State snapshot on failure (D15).
    stderr.write(`${pc.red('error:')} kubectl rollout status timed out or failed.\n` +
        `  reason: ${result.reason ?? 'nonzero'} exitCode=${result.exitCode}\n\n`);
    stderr.write(`${pc.dim('--- kubectl get pods -n olam -o wide ---')}\n`);
    const podsResult = await wrap(['--context', context, 'get', 'pods', '-n', K8S_NAMESPACE, '-o', 'wide'], { timeout: 10_000 });
    stderr.write((podsResult.stdout || podsResult.stderr || '(no output)') + '\n');
    stderr.write(`\n${pc.dim('--- kubectl describe deployment/olam-host-cp -n olam ---')}\n`);
    const descResult = await wrap(['--context', context, 'describe', `deployment/${HOST_CP_DEPLOYMENT_NAME}`, '-n', K8S_NAMESPACE], { timeout: 10_000 });
    stderr.write((descResult.stdout || descResult.stderr || '(no output)') + '\n');
    stderr.write(`\n${pc.yellow('note:')} Manifests are NOT auto-rolled-back. Inspect the state above and remediate manually.\n`);
    return false;
}
/**
 * Step 2.5 — C1: in-memory ConfigMap substitution for inter-peripheral K8s DNS URLs (D4).
 *
 * Reads the bundled host-cp ConfigMap YAML from manifestsDir/30-configmap.yaml,
 * patches the inter-peripheral URL values to K8s cluster-DNS form, then applies
 * the patched manifest via `kubectl apply -f -` on stdin.
 *
 * Does NOT rewrite any file on disk.
 * Only runs on kubernetes substrate (called after substrate check).
 *
 * Returns null on success; error message string on failure.
 */
async function applyConfigMapSubstitution(context, manifestsDir, deps) {
    const wrap = deps.kubectlWrapImpl ?? kubectlWrap;
    const readFileSync = deps.readFileSyncImpl ?? fs.readFileSync;
    const configMapPath = path.join(manifestsDir, '30-configmap.yaml');
    let rawYaml;
    try {
        rawYaml = readFileSync(configMapPath, 'utf8');
    }
    catch (err) {
        return `Failed to read ConfigMap at ${configMapPath}: ${err instanceof Error ? err.message : String(err)}`;
    }
    // Parse → patch data values → re-serialize.
    let parsed;
    try {
        parsed = yamlParse(rawYaml);
    }
    catch (err) {
        return `Failed to parse ConfigMap YAML at ${configMapPath}: ${err instanceof Error ? err.message : String(err)}`;
    }
    const data = (parsed['data'] ?? {});
    // Substitute each peripheral's ConfigMap key to K8s DNS form.
    for (const peripheral of PERIPHERALS) {
        data[peripheral.configMapKeyInHostCp] = buildK8sDnsUrl(peripheral.k8sResourceName, peripheral.port);
    }
    parsed['data'] = data;
    const patchedYaml = yamlStringify(parsed);
    // Apply via stdin pipe (D20 compliance: no value in argv).
    const result = await wrap(['--context', context, 'apply', '-f', '-'], { timeout: 30_000, stdin: patchedYaml });
    if (!result.ok) {
        return `kubectl apply (ConfigMap substitution) failed: ${result.stderr.split('\n')[0] ?? ''}`;
    }
    return null;
}
/**
 * Step 2.6 — C2: per-peripheral Secret pre-check.
 *
 * Iterates PERIPHERAL_SECRETS; for each, fetches the Secret via kubectl,
 * base64-decodes each value, and refuses if:
 *   (a) Secret does not exist → names the peripheral + Secret resource.
 *   (b) Any key is missing → names the peripheral + key.
 *   (c) Any value starts with REPLACE_ME_ (placeholder not configured) → names key.
 *
 * Mirrors Phase 1b's D12 host-cp Secret pre-check pattern, generalised to N peripherals.
 *
 * Returns null on success; error message string on first failure.
 */
async function checkPeripheralSecrets(context, deps) {
    const wrap = deps.kubectlWrapImpl ?? kubectlWrap;
    for (const { name, secretName, keys } of PERIPHERAL_SECRETS) {
        const result = await wrap([
            '--context', context,
            'get', 'secret', secretName,
            '-n', K8S_NAMESPACE,
            '-o', 'json',
        ], { timeout: 15_000 });
        if (!result.ok) {
            return (`Peripheral Secret "${secretName}" (${name}) not found in namespace "${K8S_NAMESPACE}".\n` +
                `  Create it first: kubectl --context ${context} apply -f <your-${name}-secret.yaml>\n` +
                `  ${result.stderr.split('\n')[0] ?? ''}`);
        }
        let secretJson;
        try {
            secretJson = JSON.parse(result.stdout);
        }
        catch {
            return `Failed to parse Secret JSON for "${secretName}" (${name}): ${result.stdout.slice(0, 200)}`;
        }
        const data = secretJson.data ?? {};
        for (const key of keys) {
            if (!(key in data)) {
                return (`Peripheral Secret "${secretName}" (${name}) is missing required key "${key}".\n` +
                    `  Keys found: ${Object.keys(data).join(', ') || '(none)'}\n` +
                    `  Add the key and re-run.`);
            }
            const b64 = data[key] ?? '';
            const decoded = Buffer.from(b64, 'base64').toString('utf8');
            if (decoded.startsWith('REPLACE_ME_')) {
                return (`Peripheral Secret "${secretName}" (${name}) key "${key}" still holds its placeholder value.\n` +
                    `  Replace the placeholder with a real value, then re-run.`);
            }
        }
    }
    return null; // all peripheral secrets verified
}
/**
 * Step 2.7 — C3: CoreDNS warm-up wait.
 *
 * Runs `kubectl wait --for=condition=Available deployment/coredns -n kube-system --timeout=30s`
 * before step 3 to ensure DNS resolution is available for peripheral service discovery.
 *
 * Returns true on success; false on timeout or failure.
 */
async function waitForCoreDns(context, deps) {
    const wrap = deps.kubectlWrapImpl ?? kubectlWrap;
    const result = await wrap([
        '--context', context,
        'wait',
        '--for=condition=Available',
        'deployment/coredns',
        '-n', 'kube-system',
        '--timeout=30s',
    ], { timeout: 35_000 });
    return result.ok;
}
/**
 * Step 6 — verify /health returns X-Olam-Engine: kubernetes.
 */
async function verifyHealthHeader(deps) {
    const fetchImpl = deps.fetchImpl ?? fetch;
    try {
        const res = await fetchImpl(HOST_CP_HEALTH_URL, {
            signal: AbortSignal.timeout(5_000),
        });
        const engine = res.headers.get('x-olam-engine') ?? res.headers.get('X-Olam-Engine');
        return { ok: engine === 'kubernetes', engineHeader: engine };
    }
    catch {
        return { ok: false, engineHeader: null };
    }
}
/**
 * Step 0.4 — R3-C: create/update ghcr-pull imagePullSecret (Decision R3-#3).
 *
 * Creates a kubernetes.io/dockerconfigjson Secret named `ghcr-pull` in the
 * `olam` namespace so all 5 Deployment specs (host-cp + 4 peripherals) can
 * pull private images from ghcr.io/pleri/* without anonymous rate limits.
 *
 * Token resolution order:
 *   1. deps.ghTokenOverride (test injection — ghTokenOverrideActive must be true)
 *   2. host.gh_token in ~/.olam/config.json (operator config, not yet wired here —
 *      future: read via resolveKubectlContext deps; skipped for Phase B scope)
 *   3. GH_TOKEN environment variable
 *
 * Returns { skipped: true } when no token is available (not a hard failure —
 * operators without a GH token fall back to anonymous pulls which may rate-limit).
 *
 * Idempotent: `kubectl apply -f -` (stdin) is a no-op when the Secret already
 * exists with the same content (kubectl reports "configured" or "unchanged").
 */
async function createGhcrPullSecret(context, deps, stderr) {
    // Resolve GH token from override (test injection) or environment.
    let ghToken;
    if (deps.ghTokenOverrideActive === true) {
        ghToken = deps.ghTokenOverride;
    }
    else {
        ghToken = process.env['GH_TOKEN'];
    }
    if (!ghToken) {
        stderr.write(`${pc.yellow('[warn]')} GH_TOKEN not found — skipping ghcr-pull Secret creation.\n` +
            `  Image pulls from ghcr.io/pleri/* will use anonymous access (may rate-limit).\n` +
            `  Set GH_TOKEN env var or add host.gh_token to ~/.olam/config.json to enable.\n`);
        return { skipped: true, reason: 'no GH_TOKEN in env or config' };
    }
    const dockerConfigJson = JSON.stringify({
        auths: {
            'ghcr.io': {
                auth: Buffer.from(`pleri:${ghToken}`).toString('base64'),
            },
        },
    });
    const secretManifest = {
        apiVersion: 'v1',
        kind: 'Secret',
        type: 'kubernetes.io/dockerconfigjson',
        metadata: { name: 'ghcr-pull', namespace: K8S_NAMESPACE },
        data: {
            '.dockerconfigjson': Buffer.from(dockerConfigJson).toString('base64'),
        },
    };
    const secretYaml = JSON.stringify(secretManifest); // kubectl apply accepts JSON as YAML
    const wrap = deps.kubectlWrapImpl ?? kubectlWrap;
    const result = await wrap(['--context', context, 'apply', '-f', '-'], { timeout: 30_000, stdin: secretYaml });
    if (!result.ok) {
        return { skipped: false, reason: `kubectl apply ghcr-pull failed: ${result.stderr.split('\n')[0] ?? ''}` };
    }
    return { skipped: false };
}
// K3D_NODE_CONTAINER, defaultCheckK3dNodeMounts, preflightK3dNodeMounts
// REMOVED in olam-k3d-on-mac-substrate-decision Phase B B3 (2026-05-21).
// They were the Decision #11 backward-compat preflight for the R3-A
// two-volume hostPath approach, which is fully retracted (Phase B B2).
// host-cp now reaches docker via TCP through the docker-socket-proxy
// ExternalName Service — no docker socket bind into k3d nodes at all.
/**
 * Main entrypoint for the kubernetes upgrade path.
 *
 * Called from upgrade.ts when host.substrate === 'kubernetes'.
 */
export async function runUpgradeKubernetes(opts = {}, deps = {}) {
    const stdout = deps.stdout ?? process.stdout;
    const stderr = deps.stderr ?? process.stderr;
    const manifestsDir = deps.manifestsDir ?? OLAM_K8S_MANIFESTS_DIR;
    const startMs = Date.now();
    // ── Pre-step 0: B5 — seed manifests from bundle if dir absent ────
    // Closes T2/B2: ENOENT at step 2.5 (30-configmap.yaml) on fresh installs.
    // Runs FIRST so manifests are available for all subsequent steps.
    {
        const seedImpl = deps.seedManifestsImpl ?? seedManifestsFromBundle;
        const seedResult = seedImpl(opts.forceRefreshManifests === true, deps.seedManifestsDeps ?? {});
        if (seedResult.seeded) {
            stdout.write(`${pc.dim('[seed]')} ${seedResult.message}\n`);
        }
        else if (!seedResult.skipped) {
            // Hard error: copy failed (permission denied, ENOSPC, etc.).
            stderr.write(`${pc.red('error:')} Manifest seed failed: ${seedResult.error}\n`);
            return { exitCode: 1, summary: 'manifest seed failed' };
        }
        // skipped: dir exists or bundle absent (dev context) — no output, continue.
    }
    // ── Step 0.4: R3-C — create/update ghcr-pull imagePullSecret ────────
    // RELOCATED (R4-W2-A): this step previously ran before ensureK8sBootstrap,
    // but the `olam` namespace doesn't exist yet on a fresh cluster — `kubectl
    // apply` failed with `namespaces "olam" not found` and `(continuing)`
    // silently dropped the secret. Subsequent host-cp rollouts then 401'd on
    // image pull. Step 0.4 now runs AFTER Step 0.5 (B4 bootstrap creates the
    // namespace + RBAC) — see below.
    // Step 0.5 REMOVED in olam-k3d-on-mac-substrate-decision Phase B B3
    // (2026-05-21). The k3d node docker socket bind-mount preflight checked
    // whether the cluster was created with the R3-A two-volume hostPath form,
    // which is fully retracted in Phase B B2. There is no longer any docker
    // socket binding into k3d node containers — host-cp reaches docker via
    // TCP through the docker-socket-proxy ExternalName Service, served by a
    // host-side proxy container started by Step 0.7. No preflight needed.
    // ── Pre-step 0a-prelude: B3 — resolve kubectl context ONCE (config + env) ──
    // The context is needed by ALL kubectl-invoking pre-steps and steps below.
    // Resolution policy lives in `kubectl-context.ts` (single source of truth):
    //   1. host.kubectl_context_pinned from ~/.olam/config.json (preferred)
    //   2. OLAM_K8S_CONTEXT_ACK env var (deprecated, emits warning)
    //   3. neither → error with actionable remediation
    //
    // Pre-B3 bug: step 0 read OLAM_K8S_CONTEXT_ACK directly and fell back to
    // 'default' when unset, so operators who had only pinned the config got
    // cryptic `cluster-info failed` failures pointing at the wrong remediation.
    const resolved = resolveKubectlContext(deps.configPath);
    if (resolved.error !== undefined) {
        stderr.write(`${pc.red('error:')} ${resolved.error}\n`);
        return { exitCode: 1, summary: 'kubectl context not configured' };
    }
    const pinnedContext = resolved.context;
    if (resolved.deprecationWarning !== undefined) {
        stderr.write(`${pc.yellow('[warn]')} ${resolved.deprecationWarning}\n`);
    }
    // ── Pre-step 0a: D6 — managed-k8s context refusal (Decision #18) ──────
    // Detects managed-k8s cluster server URL patterns and refuses BEFORE any
    // kubectl apply. Fail-open: unrecognized cluster URLs proceed normally.
    {
        const managedProvider = await detectManagedK8sProvider(deps);
        if (managedProvider !== null) {
            stderr.write(`${pc.red('error:')} olam upgrade supports local k3d/k3s clusters only.\n` +
                `  Detected managed-k8s context: ${managedProvider}\n` +
                `  hostPath /var/run/docker.sock is not accessible on managed clusters — the docker\n` +
                `  socket does not reach the operator's host daemon on cloud-managed nodes.\n` +
                `  See Decision #18 and docs/operator/kubernetes-substrate-beta.md\n` +
                `  for supported cluster types and the active retraction of managed-k8s support.\n`);
            return { exitCode: 1, summary: 'managed-k8s context detected (Decision #18 retraction)' };
        }
    }
    // Pre-step 0b D7 (docker socket bind-mount preflight) REMOVED in
    // olam-k3d-on-mac-substrate-decision Phase B B3 (2026-05-21). The
    // preflight checked /var/run/docker.sock inside host-cp via `kubectl exec`,
    // which is obsolete: host-cp no longer has a docker socket volumeMount
    // (see packages/host-cp/k8s/manifests/50-deployment.yaml). Docker access
    // is via TCP to docker-socket-proxy. Step 0.7 (above) handles the new
    // substrate's UX; the doctor probe for proxy reachability lives in
    // Phase C C1.
    // ── Step 0: D22 — probe Kubernetes API reachability (5s) ─────────
    const step0Spinner = ora('Probing Kubernetes API reachability').start();
    const reachable = await probeKubernetesApiReachable(pinnedContext, deps);
    if (!reachable) {
        step0Spinner.fail('Kubernetes API not reachable');
        stderr.write(`${pc.red('error:')} kubectl cluster-info failed (5s timeout) for context ${pc.bold(pinnedContext)}.\n` +
            `  Ensure the cluster is reachable for this context, or update host.kubectl_context_pinned\n` +
            `  in ~/.olam/config.json to a working context.\n`);
        return { exitCode: 1, summary: 'kubernetes api not reachable' };
    }
    step0Spinner.succeed('Kubernetes API reachable');
    // ── Step 0.5: B4 — ensureK8sBootstrap ────────────────────────────
    // Apply namespace + RBAC + ConfigMap + PVC + Secret set BEFORE the
    // existing 8-step flow's step 2 Secret pre-check. Without this an npm-only
    // operator running `olam upgrade --substrate=kubernetes` against a fresh
    // cluster hit "namespaces \"olam\" not found" / "Secret olam-host-cp-secret
    // not found". Idempotent: reapply on subsequent runs reuses tokens from
    // ~/.olam/k8s-secrets-state.json so worlds with cached values don't break.
    // --rotate-secrets opts in to fresh value generation.
    if (opts.runK8sBootstrap === true) {
        const step05Spinner = ora('Bootstrapping olam namespace + RBAC + secrets (B4)').start();
        const bootstrapImpl = deps.k8sBootstrapImpl ?? ensureK8sBootstrap;
        // Thread the same kubectlWrap that the rest of upgrade-kubernetes uses so
        // existing tests that inject `kubectlWrapImpl` don't reach the real kubectl
        // binary via the bootstrap module's default.
        const bootstrap = await bootstrapImpl({
            context: pinnedContext,
            namespace: K8S_NAMESPACE,
            rotateSecrets: opts.rotateSecrets === true,
        }, {
            kubectlWrapImpl: deps.kubectlWrapImpl,
            ...(deps.k8sBootstrapDeps ?? {}),
        });
        if (bootstrap.exitCode !== 0) {
            step05Spinner.fail('k8s bootstrap failed');
            stderr.write(`${pc.red('error:')} ${bootstrap.error ?? 'unknown bootstrap error'}\n`);
            return { exitCode: 1, summary: 'k8s bootstrap failed' };
        }
        const appliedCount = bootstrap.result.applied.length;
        const skippedCount = bootstrap.result.skipped.length;
        const note = skippedCount > 0 ? ` (${skippedCount} skipped — see warnings above)` : '';
        step05Spinner.succeed(`k8s bootstrap: ${appliedCount} applied${note}`);
    }
    // ── Step 0.6: R3-C — create/update ghcr-pull imagePullSecret ────────
    // Relocated from before Step 0.5 (see R4-W2-A retest finding). Must run
    // AFTER ensureK8sBootstrap because the secret is namespaced to `olam` and
    // ensureK8sBootstrap is what creates that namespace on a fresh cluster.
    //
    // Failure mode is still soft-warn rather than abort: rollouts that depend
    // on the secret (private GHCR image pulls) surface their own clear 401
    // ImagePullBackOff downstream, which is preferable to blocking operators
    // who don't have GH_TOKEN configured but also aren't pulling private
    // images (e.g. local dev with mirrored public copies).
    {
        const step06Spinner = ora('Creating ghcr-pull imagePullSecret (R3-C)').start();
        const pullSecretResult = await createGhcrPullSecret(pinnedContext, deps, stderr);
        if (pullSecretResult.skipped) {
            step06Spinner.warn(`ghcr-pull Secret skipped: ${pullSecretResult.reason ?? 'no token'}`);
        }
        else if (pullSecretResult.reason) {
            const warnMsg = `ghcr-pull Secret apply failed (continuing): ${pullSecretResult.reason}`;
            step06Spinner.warn(warnMsg);
            stderr.write(`${pc.yellow('[warn]')} ${warnMsg}\n`);
        }
        else {
            step06Spinner.succeed('ghcr-pull imagePullSecret created/updated');
        }
    }
    // ── Step 0.7: olam-k3d-on-mac-substrate-decision Phase A — host-side ────
    //    docker-socket-proxy auto-start on macOS (Decisions #1 + #3 + #7).
    //
    // Resolves R4-W2-F: on macOS + colima + virtiofs, containerd's OCI runtime
    // spec generator calls stat() on docker.sock hostPath bind mounts; virtiofs
    // returns ENOTSUP on stat/statx for socket files. The R3-A two-volume
    // hostPath approach is unrecoverable. This step routes around it by
    // ensuring a host-side `tecnativa/docker-socket-proxy` container is
    // running on the operator's docker daemon (sibling to k3d). The
    // in-cluster Service (packages/host-cp/k8s/manifests/docker-socket-proxy/
    // 60-service.yaml) is `type: ExternalName` aliased to host.k3d.internal,
    // which routes traffic to this host-side container.
    //
    // Decision #7 (Universal): runs on all host OSes regardless of substrate.
    // This means Linux operators also get the proxy. The per-Pod cost is
    // invisible against the maintenance tax of OS-conditional Service generation.
    //
    // Idempotent: `docker compose up -d` on a running container is a no-op
    // success. Failure surfaces an actionable remediation string but does
    // NOT abort the upgrade — host-cp will then fail at Step 4 rollout with
    // a clearer "proxy unreachable" symptom that the new doctor probe
    // (olam-k3d-on-mac-substrate-decision Phase C C1) diagnoses.
    if (process.platform === 'darwin') {
        const step07Spinner = ora('Starting host-side docker-socket-proxy (R4-W2-F)').start();
        const composePath = resolveHostSideProxyComposePath();
        if (composePath === null) {
            step07Spinner.warn('host-side docker-socket-proxy compose yaml not found (bundle layout?)');
            stderr.write(`${pc.yellow('[warn]')} could not locate docker-socket-proxy.compose.yaml in the bundled k8s assets.\n` +
                `  This is a packaging bug — please report. Continuing without auto-start.\n`);
        }
        else {
            const startResult = startHostSideProxy(composePath);
            if (!startResult.ok) {
                step07Spinner.warn(`host-side docker-socket-proxy start failed (continuing): ${startResult.reason ?? 'unknown'}`);
                stderr.write(`${pc.yellow('[warn]')} ${startResult.reason ?? 'unknown error'}\n`);
            }
            else {
                step07Spinner.succeed('docker-socket-proxy ready on host');
            }
        }
    }
    // ── Step 1: D10 — context-allowlist validation ───────────────────
    // The context was already resolved (config-first, env-fallback) before
    // pre-step 0a. Step 1 keeps the D10 allowlist gate + the audit WARN so
    // observability of which context is being used is preserved.
    const step1Spinner = ora('Verifying kubectl context (D10)').start();
    if (!isContextAllowed(pinnedContext)) {
        step1Spinner.fail('Context disallowed');
        stderr.write(`${pc.red('error:')} kubectl context ${pc.bold(pinnedContext)} is not allowed by the D10 allowlist.\n` +
            `  See GATES.md G-001 for the context-allowlist requirement.\n`);
        return { exitCode: 1, summary: 'context disallowed' };
    }
    // Audit WARN on bypass (D10: operator explicitly acknowledged a potentially unsafe context).
    process.stderr.write(`${pc.yellow('[WARN]')} OLAM_K8S_UNSAFE_CONTEXT_ACK ctx=${pinnedContext}\n`);
    step1Spinner.succeed(`Context pinned: ${pc.bold(pinnedContext)}`);
    // ── D14: --force-refresh-manifests flag ──────────────────────────
    if (opts.forceRefreshManifests) {
        const step14Spinner = ora('Refreshing manifests (D14)').start();
        const refreshImpl = deps.manifestRefreshImpl ?? runManifestRefresh;
        const refreshResult = await refreshImpl(manifestsDir, opts.acceptSecurityRegression === true, {});
        if (!refreshResult.ok) {
            step14Spinner.fail('Manifest refresh refused');
            stderr.write(`${pc.red('error:')} ${refreshResult.message}\n`);
            return { exitCode: 1, summary: 'manifest refresh refused' };
        }
        step14Spinner.succeed(refreshResult.message);
    }
    // ── Step 2: D12 — Secret pre-check ───────────────────────────────
    const step2Spinner = ora(`Checking Secret ${HOST_CP_SECRET_NAME} (D12)`).start();
    const secretError = await checkSecretPreCondition(pinnedContext, deps);
    if (secretError !== null) {
        step2Spinner.fail('Secret pre-check failed');
        stderr.write(`${pc.red('error:')} ${secretError}\n`);
        return { exitCode: 1, summary: 'secret pre-check failed' };
    }
    step2Spinner.succeed(`Secret ${HOST_CP_SECRET_NAME} verified`);
    // ── D5: Phase 2 GA — OLAM_PHASE_2_BETA guard removed (D27) ──────
    // Emit phase2.flag_removed audit log entry per upgrade run (D27).
    appendSubstrateAuditEntry({
        ts: new Date(deps.nowImpl ? deps.nowImpl() : Date.now()).toISOString(),
        op: 'upgrade',
        substrate: 'kubernetes',
        phase2: { flag_removed: true },
    }, stderr);
    // ── Step 2.6: C2 — per-peripheral Secret pre-check ──────────────
    const step26Spinner = ora('Checking peripheral Secrets (C2)').start();
    const peripheralSecretError = await checkPeripheralSecrets(pinnedContext, deps);
    if (peripheralSecretError !== null) {
        step26Spinner.fail('Peripheral Secret pre-check failed');
        stderr.write(`${pc.red('error:')} ${peripheralSecretError}\n`);
        return { exitCode: 1, summary: 'peripheral secret pre-check failed' };
    }
    step26Spinner.succeed('All peripheral Secrets verified');
    // ── Step 2.7: C3 — CoreDNS warm-up wait ────────────────────────
    const step27Spinner = ora('Waiting for CoreDNS (C3, 30s timeout)').start();
    const coreDnsOk = await waitForCoreDns(pinnedContext, deps);
    if (!coreDnsOk) {
        step27Spinner.warn('CoreDNS not Available within 30s — continuing (DNS may be degraded)');
    }
    else {
        step27Spinner.succeed('CoreDNS Available');
    }
    // ── Step 3: kubectl apply ─────────────────────────────────────────
    const step3Spinner = ora('Applying manifests').start();
    const wrap = deps.kubectlWrapImpl ?? kubectlWrap;
    // Always apply host-cp manifests (top-level manifests dir).
    // kubectl -f <dir> does NOT recurse by default — peripheral subdirs are ignored here.
    const applyResult = await wrap(['--context', pinnedContext, 'apply', '-f', manifestsDir], { timeout: 120_000 });
    if (!applyResult.ok) {
        step3Spinner.fail('kubectl apply failed');
        stderr.write(`${pc.red('error:')} kubectl apply failed (exit ${applyResult.exitCode}):\n` +
            `  ${applyResult.stderr.split('\n').slice(0, 5).join('\n  ')}\n`);
        return { exitCode: 1, summary: 'kubectl apply failed' };
    }
    // Apply each peripheral's manifest directory (alphabetical order).
    const peripheralNames = [...PERIPHERALS.map((p) => p.name)].sort();
    for (const name of peripheralNames) {
        const peripheralManifestsDir = path.join(manifestsDir, name);
        const peripheralApplyResult = await wrap(['--context', pinnedContext, 'apply', '-f', peripheralManifestsDir], { timeout: 120_000 });
        if (!peripheralApplyResult.ok) {
            step3Spinner.fail(`kubectl apply failed for peripheral "${name}"`);
            stderr.write(`${pc.red('error:')} kubectl apply failed for peripheral "${name}" (exit ${peripheralApplyResult.exitCode}):\n` +
                `  ${peripheralApplyResult.stderr.split('\n').slice(0, 5).join('\n  ')}\n`);
            return { exitCode: 1, summary: `kubectl apply failed for peripheral ${name}` };
        }
    }
    step3Spinner.succeed('All manifests applied (host-cp + 4 peripherals)');
    // ── Step 3.5: C1 — in-memory ConfigMap substitution (K8s DNS URLs) ──
    // B7 fix: applied AFTER step 3's bulk kubectl apply -f manifestsDir so the
    // patched ConfigMap is the last write (kubectl last-write-wins). The on-disk
    // 30-configmap.yaml carries compose-substrate URLs; step 3 applies them then
    // step 3.5 overwrites with K8s DNS form. Decision #3.
    const step35Spinner = ora('Re-applying patched ConfigMap with K8s DNS URLs (C1/D4 — B7 fix)').start();
    const configMapError = await applyConfigMapSubstitution(pinnedContext, manifestsDir, deps);
    if (configMapError !== null) {
        step35Spinner.fail('ConfigMap substitution failed');
        stderr.write(`${pc.red('error:')} ${configMapError}\n`);
        return { exitCode: 1, summary: 'configmap substitution failed' };
    }
    step35Spinner.succeed('ConfigMap patched with K8s DNS URLs (survives bulk apply)');
    // ── Step 4: D15 — kubectl rollout status (300s timeout, 305s wrap) ──
    // 300s budget — fresh clusters do TWO rollouts per deployment: bootstrap apply
    // (with manifest-default images) then upgrade patch (with canonical
    // image-digests.json refs), which triggers a second replicaset rollout
    // including a fresh image pull. With cold images at GHCR (host-cp ~380MB,
    // memory-service ~610MB) the combined pull+ready timeline lands close to
    // 180s on CI runners. Bumped from 180s to 300s for headroom.
    const step4Spinner = ora('Waiting for rollout (all 5 deployments, 300s each)').start();
    const deploymentNames = [
        HOST_CP_DEPLOYMENT_NAME,
        ...PERIPHERALS.map((p) => p.k8sResourceName),
    ];
    const rolloutResults = await Promise.all(deploymentNames.map((deploymentName) => (deps.kubectlWrapImpl ?? kubectlWrap)([
        '--context', pinnedContext,
        'rollout', 'status',
        `deployment/${deploymentName}`,
        '-n', K8S_NAMESPACE,
        '--timeout=300s',
    ], { timeout: 305_000 })));
    const failedDeployments = deploymentNames.filter((_, i) => !rolloutResults[i]?.ok);
    if (failedDeployments.length > 0) {
        step4Spinner.fail(`Rollout failed for: ${failedDeployments.join(', ')}`);
        // Emit state snapshot for failed deployments (mirrors D15 pattern).
        for (const [i, name] of failedDeployments.entries()) {
            const result = rolloutResults[deploymentNames.indexOf(name)];
            stderr.write(`${pc.red('error:')} rollout status failed for deployment/${name}.\n`);
            // Emit the actual kubectl output so CI logs show the failure reason.
            if (result && (result.stdout || result.stderr)) {
                stderr.write(`${pc.dim('--- kubectl rollout status output ---')}\n`);
                if (result.stdout)
                    stderr.write(result.stdout + '\n');
                if (result.stderr)
                    stderr.write(result.stderr + '\n');
                stderr.write(`${pc.dim(`exit code: ${result.exitCode ?? '(none)'}, reason: ${'reason' in result ? result.reason : 'ok'}`)}\n`);
            }
            void i; // suppress unused-var lint
            stderr.write(`${pc.dim(`--- kubectl get pods -n ${K8S_NAMESPACE} -o wide ---`)}\n`);
            const podsResult = await wrap(['--context', pinnedContext, 'get', 'pods', '-n', K8S_NAMESPACE, '-o', 'wide'], { timeout: 10_000 });
            stderr.write((podsResult.stdout || podsResult.stderr || '(no output)') + '\n');
        }
        stderr.write(`\n${pc.yellow('note:')} Manifests are NOT auto-rolled-back. Inspect the state above and remediate manually.\n`);
        return { exitCode: 1, summary: 'rollout status timed out' };
    }
    step4Spinner.succeed('All 5 deployments rolled out');
    // ── Step 5: D17 — port-forward spawn via flock ───────────────────
    // Spawn host-cp and all peripheral port-forwards in parallel.
    const step5Spinner = ora('Establishing port-forward').start();
    const pfSpawn = deps.spawnPortForwardImpl ?? spawnPortForward;
    const spawnAllPeripheral = deps.spawnAllPeripheralPortForwardsImpl ?? spawnAllPeripheralPortForwards;
    const [pfResult] = await Promise.all([
        pfSpawn(pinnedContext, K8S_NAMESPACE, PORT_FORWARD_TARGET, 19000, 19000, deps.portForwardDeps ?? {}),
        spawnAllPeripheral(pinnedContext, K8S_NAMESPACE, deps.peripheralPortForwardDeps ?? {}),
    ]);
    if (pfResult.spawned) {
        step5Spinner.succeed(`Port-forward spawned (pid ${pfResult.pid}) + all peripheral port-forwards`);
    }
    else if (pfResult.reason === 'live') {
        step5Spinner.succeed('Port-forward already live + peripheral port-forwards spawned');
    }
    else {
        step5Spinner.warn('Port-forward lock held by concurrent caller; skipping spawn');
    }
    // Brief wait for port-forward tunnel to be ready before health check.
    await new Promise((r) => setTimeout(r, 1_500));
    // ── Step 6: verify /health returns X-Olam-Engine: kubernetes ─────
    const step6Spinner = ora('Verifying host-cp health (X-Olam-Engine: kubernetes)').start();
    const healthCheck = await verifyHealthHeader(deps);
    if (!healthCheck.ok) {
        step6Spinner.fail(healthCheck.engineHeader !== null
            ? `X-Olam-Engine header is "${healthCheck.engineHeader}" (expected "kubernetes")`
            : 'host-cp /health not reachable via port-forward');
        stderr.write(`${pc.red('error:')} host-cp did not report engine=kubernetes.\n` +
            `  Check that the deployment rolled out with the correct image.\n`);
        return { exitCode: 1, summary: 'health check failed — engine mismatch' };
    }
    step6Spinner.succeed('host-cp reports X-Olam-Engine: kubernetes');
    // ── Step 7: emit upgrade.complete instrumentation event ───────────
    const emitImpl = deps.emitImpl ?? emitUpgradeComplete;
    emitImpl({
        substrate: 'kubernetes',
        duration_ms: Date.now() - startMs,
        flavor: 'k3s',
    }, deps.emitOpts ?? {});
    // ── Step 8: success message ────────────────────────────────────────
    printSuccess('olam upgrade (kubernetes) complete');
    stdout.write(`  context: ${pc.bold(pinnedContext)}\n`);
    stdout.write(`  substrate: ${pc.bold('kubernetes')} (flavor=k3s)\n`);
    stdout.write(`  port-forward: 127.0.0.1:19000 → ${PORT_FORWARD_TARGET}\n`);
    return { exitCode: 0, summary: 'kubernetes upgrade complete' };
}
//# sourceMappingURL=upgrade-kubernetes.js.map