Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

- 槽位「采集特征」和终端「系统特征」不再读到宿主机:Docker 槽的 CPU 型号、DMI、MAC 读槽位计算机(`/etc/kin-hardware`、`/etc/kin-dmi`),核数与内存读容器 cgroup 配额;没有配额时读计算机文件,都没有才回落 `/proc`、`/sys`。KVM 槽不变。(#673)
- `kin-computer` 默认造 Linux PC 计算机(Docker 槽原为 macOS 人设),`--os` 可选 `windows` / `macos`,KVM 支持 linux / windows。payload 改指 `payload-2026.10.22-computer-linux`,只换 `kin-computer`。已有槽位身份不重算。(#673)
- 一键升级与 fleet `action=telemetry` 刷新 Go 槽位服务时,数据库仍登记但本机已无 Docker 容器(`No such container: kin-NN`)的 VM 记为跳过并继续处理其余槽位,不再因此整批失败;真实容器上的刷新失败仍会硬失败。(#642,#628,REQ-0628)

已部署机升级:更新 Node 控制面并执行 `npm run fetch:payload`(或更新控制面镜像)换上新 `bin/kin-computer`,重启一次 Node。无迁移、无新依赖,不必 `wrap-cli/sync`,不必重建槽位容器。不要覆盖 `routing.json`、`vms/`、`data/`、`.env`。

Expand Down
7 changes: 7 additions & 0 deletions scripts/fleet-services.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,11 @@ const report = await runFleetUpdate(root, {
concurrency,
})
console.log(JSON.stringify(report))
if (report.skipped_count) {
for (const item of report.items) {
if (!item.skipped) continue
const container = item.telemetry?.container || item.id
console.warn(`[fleet-services] skipped ${item.id}: container unavailable (${container})`)
}
}
if (report.failed_count) process.exit(1)
6 changes: 6 additions & 0 deletions src/lib/cluster/remote-slot.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { isCodexVm } from '../vm/vm-kind.mjs'
import { proxyBlockedReason } from '../vm/proxy-policy.mjs'
import { isKvmRuntime, runtimeKind } from '../vm/runtime-kind.mjs'
import { slotMemory, slotDockerCpus, memoryMiB, normalizeVmConfig, VM_CONFIG_DEFAULTS } from '../vm/machine-spec.mjs'
import { logTelemetryContainerSkip, telemetryContainerSkip } from '../vm/docker-container.mjs'
import { slotExecArgv, slotExecCmd } from '../vm/slot-exec.mjs'
import {
CODEX_SLOT_ROLE,
Expand Down Expand Up @@ -1201,6 +1202,11 @@ export async function restartRemoteServices(vm, projectRoot, { telemetryEnabled
const remoteDir = remoteSlotDir(session.host, vm.id)
const slotDir = path.join(projectRoot, 'vms', vm.id)
const name = vm.runtime?.container || containerName(vm.id)
const slotInfo = await inspectContainerOrNull(session.docker, name)
if (!slotInfo) {
logTelemetryContainerSkip('fleet', vm.id, name)
return telemetryContainerSkip(name)
}
const migration = await migrateRemoteServiceContainers(vm, projectRoot)
if (!migration.ok) return migration
await pushSlotFiles(vm, slotDir, ['run', 'seed'], session)
Expand Down
27 changes: 27 additions & 0 deletions src/lib/vm/docker-container.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/** Shared helpers for docker slot containers on the control plane / node host. */

export function isMissingContainerMessage(message) {
return /no such container/i.test(String(message || ''))
}

/** Local `docker inspect` / Engine 404: container id or name is gone. */
export function isDockerInspectNotFound({ stderr = '', stdout = '' } = {}) {
const hay = `${stderr}\n${stdout}`
return /no such container|no such object/i.test(hay)
}

export function telemetryContainerSkip(container, detail) {
return {
ok: true,
skipped: true,
action: 'skipped',
reason: 'container_unavailable',
container: String(container || ''),
...(detail ? { detail: String(detail).slice(0, 300) } : {}),
}
}

export function logTelemetryContainerSkip(scope, vmId, container, detail) {
const suffix = detail ? `: ${detail}` : ''
console.warn(`[${scope}] Go slot service refresh skipped ${vmId}: no container ${container} on this host${suffix}`)
}
38 changes: 30 additions & 8 deletions src/lib/vm/fleet-update.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { restartTelemetrySidecar } from './slot-process-status.mjs'
import { isNativeNodeVm, vmNodeId } from '../cluster/placement.mjs'
import { slotHost } from './slot-host.mjs'
import { restartRemoteServices } from '../cluster/remote-slot.mjs'
import { isMissingContainerMessage, logTelemetryContainerSkip, telemetryContainerSkip } from './docker-container.mjs'
import {
drainEgressForProxyConfig,
egressPidsForProxyConfig,
Expand Down Expand Up @@ -62,7 +63,17 @@ async function restartLocalServices({ projectRoot, vm, enabled, egressOpts = {}
if (!telemetry.ok) return telemetry
const egress = await restartLocalEgress(projectRoot, vm, egressOpts)
if (!egress.ok) return egress
return { ...telemetry, egress: egress.egress }
return { ...telemetry, egress: egress.egress ?? (telemetry.skipped ? 'unchanged' : undefined) }
}

function normalizeTelemetryRefresh(changed, vm) {
if (changed?.ok || changed?.skipped) return changed
const message = String(changed?.error || changed?.code || '')
if (!isMissingContainerMessage(message)) return changed
const container =
String(vm?.runtime?.container || '').trim() || (vm?.id ? `kin-${String(vm.id).replace(/^vm-/, '')}` : '')
logTelemetryContainerSkip('fleet', vm.id, container, message)
return telemetryContainerSkip(container, message)
}

function sleep(ms) {
Expand Down Expand Up @@ -149,14 +160,23 @@ export async function updateOneSlot(
const enabled =
telemetryEnabled === undefined ? vm.seed_policy?.telemetry_disabled === false : telemetryEnabled === true
const vmRef = getVm(projectRoot, id) || vm
const changed = telemetryFn
? await telemetryFn({ projectRoot, vm: vmRef, telemetryEnabled: enabled, enabled })
: isNativeNodeVm(vmRef)
? await slotHost(vmRef).restartTelemetry(vmRef, { enabled })
: vmNodeId(vm)
? await restartRemoteServices(vmRef, projectRoot, { telemetryEnabled: enabled })
: await restartLocalServices({ projectRoot, vm: vmRef, enabled })
const changed = normalizeTelemetryRefresh(
telemetryFn
? await telemetryFn({ projectRoot, vm: vmRef, telemetryEnabled: enabled, enabled })
: isNativeNodeVm(vmRef)
? await slotHost(vmRef).restartTelemetry(vmRef, { enabled })
: vmNodeId(vm)
? await restartRemoteServices(vmRef, projectRoot, { telemetryEnabled: enabled })
: await restartLocalServices({ projectRoot, vm: vmRef, enabled }),
vmRef,
)
result.telemetry = changed
if (changed?.skipped) {
result.skipped = true
result.skip_reason = changed.reason || 'container_unavailable'
result.ok = true
return result
}
if (!changed?.ok) {
result.error = changed?.error || 'telemetry service update failed'
result.code = changed?.code || 'telemetry_update_failed'
Expand Down Expand Up @@ -211,11 +231,13 @@ export async function runFleetUpdate(
}),
)
const okCount = items.filter((row) => row.ok).length
const skippedCount = items.filter((row) => row.skipped).length
return {
action: nextAction,
concurrency: conc,
total: items.length,
ok_count: okCount,
skipped_count: skippedCount,
failed_count: items.length - okCount,
items,
}
Expand Down
34 changes: 31 additions & 3 deletions src/lib/vm/slot-process-status.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import { readFile } from 'node:fs/promises'
import path from 'node:path'
import { promisify } from 'node:util'
import { slotContainerName } from '../transport/rust-kernel-supervisor.mjs'
import { isMissingContainerMessage, logTelemetryContainerSkip, telemetryContainerSkip } from './docker-container.mjs'
import { inspectContainerOutcome } from './vm-runtime.mjs'
import { slotHost } from './slot-host.mjs'
import { runtimeKind } from './runtime-kind.mjs'
import { slotExecArgv } from './slot-exec.mjs'
Expand Down Expand Up @@ -108,14 +110,35 @@ export async function ensureTelemetrySidecar({ projectRoot, vm, run = exec, time
}

/** Replace only the Go telemetry child; the slot container and Rust process stay up. */
export async function restartTelemetrySidecar({ projectRoot, vm, enabled, run = exec, timeoutMs = 15_000 } = {}) {
export async function restartTelemetrySidecar({
projectRoot,
vm,
enabled,
run = exec,
timeoutMs = 15_000,
inspectContainerOutcome: inspectOutcome = inspectContainerOutcome,
} = {}) {
if (!projectRoot || !isSafeSlotId(vm?.id)) return { ok: false, action: 'invalid_vm' }
const kind = runtimeKind(vm)
if (kind !== 'docker' && kind !== 'kvm') return { ok: false, action: 'unsupported_runtime' }
const host = slotHost(vm)
if (host.native) return host.restartTelemetry(vm, { enabled })
const env = host.dockerEnv()
const target = { ...vm, runtime: { ...(vm.runtime || {}), container: slotContainerName({ vm }), type: kind } }
const container = slotContainerName({ vm })
const target = { ...vm, runtime: { ...(vm.runtime || {}), container, type: kind } }
const inspected = inspectOutcome(container)
if (inspected.status === 'missing') {
logTelemetryContainerSkip('fleet', vm.id, container)
return telemetryContainerSkip(container)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inspect failures skipped as missing

Medium Severity

The new pre-check treats any failed inspectContainer as a missing slot and returns a successful skip. inspectContainer returns null for daemon-down, permission, and timeout errors as well as a real absence, so those hard failures never reach the later No such container classifier. Fleet then reports ok with skipped_count, and install.sh refresh can exit 0 without restarting Go services.

Fix in Cursor Fix in Web

Triggered by learned rule: kin-egress reload waits for old process exit

Reviewed by Cursor Bugbot for commit 2a4e0c9. Configure here.

if (inspected.status === 'error') {
return {
ok: false,
action: 'inspect_failed',
code: 'docker_inspect_failed',
error: inspected.error,
}
}
const stop = 'pkill -TERM -x kin-worker 2>/dev/null || true'
try {
await run('docker', slotExecArgv(target, ['sh', '-c', stop]), {
Expand All @@ -137,6 +160,11 @@ export async function restartTelemetrySidecar({ projectRoot, vm, enabled, run =
)
return { ok: true, action: 'restarted' }
} catch (error) {
return { ok: false, action: 'restart_failed', error: String(error?.message || error) }
const message = String(error?.message || error)
if (isMissingContainerMessage(message)) {
logTelemetryContainerSkip('fleet', vm.id, container, message)
return telemetryContainerSkip(container, message)
}
return { ok: false, action: 'restart_failed', error: message }
}
}
35 changes: 25 additions & 10 deletions src/lib/vm/vm-runtime.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
import { slotMemory, slotDockerCpus, memoryMiB, VM_CONFIG_DEFAULTS } from './machine-spec.mjs'
import { probeLocalKvm } from './kvm-host.mjs'
import { slotExecArgv } from './slot-exec.mjs'
import { isDockerInspectNotFound } from './docker-container.mjs'

export const RUNTIME = 'docker'
const WORKER_BIN = process.env.KIN_WORKER_BIN || '/opt/kin-gateway/bin/kin-worker'
Expand Down Expand Up @@ -148,16 +149,8 @@ export function displayName(vmId) {
return String(vmId || '').replace(/^vm-/, '')
}

export function inspectContainer(name) {
const r = sh([
'docker',
'inspect',
'--format',
'{{.State.Running}}|{{.State.Pid}}|{{.HostConfig.NetworkMode}}|{{.State.StartedAt}}|{{.Config.Image}}|{{.Config.Hostname}}|{{index .Config.Labels "kin.vm.runtime"}}|{{index .Config.Labels "kin.vm.role"}}|{{.State.Restarting}}',
name,
])
if (!r.ok) return null
const [running, pid, networkMode, startedAt, image, hostname, runtime, role, restarting] = r.stdout.split('|')
function parseInspectStdout(name, stdout) {
const [running, pid, networkMode, startedAt, image, hostname, runtime, role, restarting] = stdout.split('|')
// Docker reports Running=true during a restart-policy backoff; nothing can be exec'd there.
const crashLooping = restarting === 'true'
return {
Expand All @@ -175,6 +168,28 @@ export function inspectContainer(name) {
}
}

const INSPECT_FORMAT =
'{{.State.Running}}|{{.State.Pid}}|{{.HostConfig.NetworkMode}}|{{.State.StartedAt}}|{{.Config.Image}}|{{.Config.Hostname}}|{{index .Config.Labels "kin.vm.runtime"}}|{{index .Config.Labels "kin.vm.role"}}|{{.State.Restarting}}'

/**
* @returns {{ status: 'found', container: object } | { status: 'missing' } | { status: 'error', error: string, code?: number }}
*/
export function inspectContainerOutcome(name, { run = sh } = {}) {
const r = run(['docker', 'inspect', '--format', INSPECT_FORMAT, name])
if (r.ok) return { status: 'found', container: parseInspectStdout(name, r.stdout) }
if (isDockerInspectNotFound(r)) return { status: 'missing' }
return {
status: 'error',
error: (r.stderr || r.stdout || 'docker inspect failed').trim(),
code: r.code,
}
}

export function inspectContainer(name, opts) {
const outcome = inspectContainerOutcome(name, opts)
return outcome.status === 'found' ? outcome.container : null
}

/** Label on GPT slot containers: their PID 1 idles; the Codex kernel runs on the host. */
export const CODEX_SLOT_ROLE = 'codex'

Expand Down
48 changes: 48 additions & 0 deletions test/unit/docker-inspect-outcome.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { inspectContainerOutcome } from '../../src/lib/vm/vm-runtime.mjs'
import { isDockerInspectNotFound } from '../../src/lib/vm/docker-container.mjs'

test('isDockerInspectNotFound matches docker inspect stderr for absent names', () => {
assert.equal(isDockerInspectNotFound({ stderr: 'Error: No such object: kin-05' }), true)
assert.equal(isDockerInspectNotFound({ stderr: 'Error response from daemon: No such container: kin-05' }), true)
assert.equal(isDockerInspectNotFound({ stderr: 'Cannot connect to the Docker daemon' }), false)
})

test('inspectContainerOutcome: missing container', () => {
const outcome = inspectContainerOutcome('kin-ghost', {
run: () => ({
ok: false,
stderr: 'Error: No such object: kin-ghost',
stdout: '',
code: 1,
}),
})
assert.deepEqual(outcome, { status: 'missing' })
})

test('inspectContainerOutcome: daemon down is an error, not missing', () => {
const outcome = inspectContainerOutcome('kin-01', {
run: () => ({
ok: false,
stderr: 'Cannot connect to the Docker daemon at unix:///var/run/docker.sock',
stdout: '',
code: 1,
}),
})
assert.equal(outcome.status, 'error')
assert.match(outcome.error, /Cannot connect/)
})

test('inspectContainerOutcome: found parses running state', () => {
const outcome = inspectContainerOutcome('kin-01', {
run: () => ({
ok: true,
stdout: 'true|12345|kin-px-net|2026-01-01T00:00:00Z|img|host|docker||false',
stderr: '',
}),
})
assert.equal(outcome.status, 'found')
assert.equal(outcome.container.running, true)
assert.equal(outcome.container.pid, 12345)
})
89 changes: 89 additions & 0 deletions test/unit/fleet-telemetry-inspect.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { runFleetUpdate } from '../../src/lib/vm/fleet-update.mjs'
import { restartTelemetrySidecar } from '../../src/lib/vm/slot-process-status.mjs'

function writeSlot(root, id) {
const dir = path.join(root, 'vms')
fs.mkdirSync(dir, { recursive: true })
fs.writeFileSync(
path.join(dir, `${id}.json`),
JSON.stringify({
id,
name: id.replace('vm-', ''),
status: 'running',
runtime: { type: 'docker', worker: 'go' },
}),
)
}

test('restartTelemetrySidecar skips only when inspect confirms the container is missing', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kin-tel-miss-'))
const skipped = await restartTelemetrySidecar({
projectRoot: root,
vm: { id: 'vm-11', runtime: { type: 'docker' } },
enabled: true,
inspectContainerOutcome: () => ({ status: 'missing' }),
run: async () => {
throw new Error('docker exec must not run when the container is absent')
},
})
assert.equal(skipped.skipped, true)
fs.rmSync(root, { recursive: true, force: true })
})

test('restartTelemetrySidecar fails when inspect cannot reach docker', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kin-tel-daemon-'))
const failed = await restartTelemetrySidecar({
projectRoot: root,
vm: { id: 'vm-12', runtime: { type: 'docker' } },
enabled: true,
inspectContainerOutcome: () => ({
status: 'error',
error: 'Cannot connect to the Docker daemon',
}),
})
assert.equal(failed.ok, false)
assert.equal(failed.code, 'docker_inspect_failed')
assert.equal(failed.skipped, undefined)
fs.rmSync(root, { recursive: true, force: true })
})

test('restartTelemetrySidecar fails when the container exists but exec fails', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kin-tel-exec-'))
const failed = await restartTelemetrySidecar({
projectRoot: root,
vm: { id: 'vm-13', runtime: { type: 'docker' } },
enabled: true,
inspectContainerOutcome: () => ({
status: 'found',
container: { name: 'kin-13', running: true },
}),
run: async () => {
throw new Error('permission denied')
},
})
assert.equal(failed.ok, false)
assert.equal(failed.action, 'restart_failed')
assert.match(failed.error, /permission denied/)
fs.rmSync(root, { recursive: true, force: true })
})

test('fleet telemetry fails the batch when local inspect errors', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'kin-fleet-inspect-'))
writeSlot(root, 'vm-14')
const report = await runFleetUpdate(root, {
action: 'telemetry',
telemetryFn: async () => ({
ok: false,
code: 'docker_inspect_failed',
error: 'Cannot connect to the Docker daemon',
}),
})
assert.equal(report.failed_count, 1)
assert.equal(report.skipped_count, 0)
fs.rmSync(root, { recursive: true, force: true })
})
Loading
Loading