Repository navigation
feat(vm): update Go slot services without VM rebuild #600
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| # VM telemetry service | ||
|
|
||
| Telemetry is an explicit seed-policy opt-in. A newly created VM is disabled; | ||
| setting `telemetry_disabled` to `false` writes the VM's `worker.json` identity | ||
| and starts the Go `kin-worker telemetry` service. Setting it back to `true` | ||
| stops that child process and leaves the VM and its Rust kernel running. | ||
|
|
||
| The service reads credentials only from the VM's own Claude home. Its feature | ||
| payload uses facts collected from that VM (including its timezone, OS, | ||
| hardware, shell and runtime versions), never controller-host defaults or a | ||
| synthetic fingerprint. Requests leave through the VM's bound egress, so the | ||
| service observes the VM's actual network address. Uploads are periodic and are idle-gated | ||
| by `run/telemetry.touch`. | ||
|
|
||
| `claude.setting` is a plain, editable seed file beside `settings.json` and is | ||
| initialized with: | ||
|
|
||
| ```text | ||
| CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 | ||
| ``` | ||
|
|
||
| ## Service-only updates | ||
|
|
||
| Go payloads are copied atomically into each slot's persistent `slot-bin` | ||
| directory. Cluster containers bind that directory at `/opt/kin-live` (and the | ||
| KVM guest payload at `/opt/kin-live`). The panel/fleet `telemetry` action | ||
| restarts only the telemetry child and the `kin-egress` helper; it does not | ||
| rebuild a VM, replace `vms/`, rotate credentials, or restart the Rust kernel. | ||
|
|
||
| An update must verify the new binary hash and the replacement process before | ||
| reporting success. A failed service restart is reported per slot so the old VM | ||
| identity remains intact and can be rolled back by republishing the previous | ||
| binary. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| import { runFleetUpdate } from '../src/lib/vm/fleet-update.mjs' | ||
|
|
||
| const root = process.argv[2] || process.env.KIN_PROJECT_ROOT || '/opt/vm2api' | ||
| const concurrency = Number(process.argv[3] || 4) | ||
| const report = await runFleetUpdate(root, { | ||
| action: 'telemetry', | ||
| concurrency, | ||
| }) | ||
| console.log(JSON.stringify(report)) | ||
| if (report.failed_count) process.exit(1) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -18,9 +18,11 @@ | |
| */ | ||
|
|
||
| import fs from 'node:fs' | ||
| import crypto from 'node:crypto' | ||
| import path from 'node:path' | ||
| import { nodeSession, remoteSlotDir, remoteSlotOwner, vmNodeId } from './placement.mjs' | ||
| import { hasCredentialPresence, mirrorWorkerCredentialsToVm } from '../oauth/oauth-credentials.mjs' | ||
| import { EGRESS_BIN } from '../vm/egress.mjs' | ||
| import { readJsonCached } from '../vm/vm-file.mjs' | ||
| import { | ||
| openSftp, | ||
|
|
@@ -33,11 +35,22 @@ import { | |
|
|
||
| export const SLOT_FILE_SETS = Object.freeze({ | ||
| run: ['run/kernel.json', 'run/worker.json', 'run/internal.token'], | ||
| seed: ['cli-home/.claude/settings.json', 'cli-home/.claude/kin-seed.json', 'machine-id'], | ||
| seed: [ | ||
| 'cli-home/.claude/settings.json', | ||
| 'cli-home/.claude/claude.setting', | ||
| 'cli-home/.claude/kin-seed.json', | ||
| 'machine-id', | ||
| ], | ||
| touch: ['run/telemetry.touch'], | ||
| }) | ||
| const CREDENTIALS = 'cli-home/.claude/credentials.json' | ||
| const TOUCH_PUSH_MS = 30_000 | ||
| const SLOT_BINARIES = Object.freeze(['kin-worker', 'kin-egress']) | ||
|
|
||
| function clusterBin(envKey, fallback) { | ||
| const value = process.env[envKey] | ||
| return value && String(value).trim() ? String(value).trim() : fallback | ||
| } | ||
|
|
||
| /** Local vms/<id>/ path → node path: the home's `.claude` lives in the host-owned `claude/` mount. */ | ||
| export function remoteSlotPath(rel) { | ||
|
|
@@ -57,7 +70,7 @@ async function prepareRemoteDir(session, vm) { | |
| const dir = remoteSlotDir(session.host, vm.id) | ||
| const owner = remoteSlotOwner(session.host, vm) | ||
| // Mount roots only: nothing here resolves through a path the guest can rewrite. | ||
| const roots = [`${dir}/run`, `${dir}/claude`, `${dir}/cli-home`].map(shellQuote).join(' ') | ||
| const roots = [`${dir}/run`, `${dir}/claude`, `${dir}/cli-home`, `${dir}/slot-bin`].map(shellQuote).join(' ') | ||
| // Docker creates a missing bind source as root:root, which the slot uid cannot write. | ||
| const chown = session.host.uid === 0 ? ` && chown ${owner.uid}:${owner.gid} ${shellQuote(dir)} ${roots}` : '' | ||
| await runRemote(session.client, `umask 077 && mkdir -p ${roots} && chmod 700 ${shellQuote(dir)} ${roots}${chown}`, { | ||
|
|
@@ -97,6 +110,37 @@ export async function pushSlotFiles(vm, slotDir, sets = ['run', 'seed'], session | |
| return { pushed } | ||
| } | ||
|
|
||
| /** Publish Go service payloads into the persistent per-slot mount. */ | ||
| export async function pushSlotBinaries(vm, projectRoot, session = null) { | ||
| const s = session || (await nodeSession(vmNodeId(vm))) | ||
| await prepareRemoteDir(s, vm) | ||
| const sftp = await openSftp(s.client) | ||
| const remote = `${remoteSlotDir(s.host, vm.id)}/slot-bin` | ||
| const owner = ownerFor(s, vm) | ||
| const pushed = [] | ||
| const digest = crypto.createHash('sha256') | ||
| for (const name of SLOT_BINARIES) { | ||
| const defaultBin = path.join(projectRoot, 'bin', name) | ||
| const local = | ||
| name === 'kin-worker' | ||
| ? clusterBin('KIN_CLUSTER_WORKER_BIN', clusterBin('KIN_WORKER_BIN', defaultBin)) | ||
| : clusterBin( | ||
| 'KIN_CLUSTER_EGRESS_BIN', | ||
| clusterBin('KIN_EGRESS_BIN', fs.existsSync(EGRESS_BIN) ? EGRESS_BIN : defaultBin), | ||
| ) | ||
| let data | ||
| try { | ||
| data = fs.readFileSync(local) | ||
| } catch { | ||
| continue | ||
| } | ||
| await writeRemoteFile(sftp, `${remote}/${name}`, data, { mode: 0o755, owner }) | ||
| digest.update(name).update(data) | ||
| pushed.push(name) | ||
| } | ||
| return { pushed, digest: digest.digest('hex') } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Cluster binary digest is out of scopeHigh Severity
Additional Locations (1)Reviewed by Cursor Bugbot for commit a7c3dfb. Configure here. |
||
| } | ||
|
|
||
| /** Explicit import only: replace the remote credential with the local one. */ | ||
| export async function pushSlotCredentials(vm, slotDir, session = null) { | ||
| const s = session || (await nodeSession(vmNodeId(vm))) | ||
|
|
||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Seed save toasts success after service failure
Medium Severity
PUT /seed-settingsstill returns HTTP 200okwhen the nested telemetry fleet update fails, and the seed card always toasts已播种. Operators can believe the sidecar restarted after toggling telemetry even thoughservice.okis false.Reviewed by Cursor Bugbot for commit a7c3dfb. Configure here.