feat(goose): add local v1.49 canary routing, smoke gates, and migration docs
Wire Portal and TKMind proxy to loopback Goose v1.49 via canary env blocks, with verification scripts, Phase 2/3 evidence baselines, and rollback runbooks so local upgrade stays isolated from stable 1.41 and production. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* Local Goose v1.49 canary routing helpers (loopback only).
|
||||
* Production hosts are refused; see docs/goose-v149-canary-runbook.md.
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { createCanaryPolicy, resolveCanaryTarget } from '../release-gate/canary-routing.mjs';
|
||||
|
||||
const memindRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
|
||||
function loadEnvFile(filePath) {
|
||||
if (!fs.existsSync(filePath)) return;
|
||||
for (const line of fs.readFileSync(filePath, 'utf8').split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const eq = trimmed.indexOf('=');
|
||||
if (eq < 0) continue;
|
||||
const key = trimmed.slice(0, eq).trim();
|
||||
let value = trimmed.slice(eq + 1).trim();
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"'))
|
||||
|| (value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
if (!(key in process.env)) process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
loadEnvFile(path.join(memindRoot, '.env.local'));
|
||||
|
||||
export const GOOSE_V149_CANARY_BEGIN = '# GOOSE_V149_CANARY_BEGIN';
|
||||
export const GOOSE_V149_CANARY_END = '# GOOSE_V149_CANARY_END';
|
||||
|
||||
/** Force-apply GOOSE_V149 canary block from .env.local (overrides .env). */
|
||||
export function applyGooseV149CanaryBlockEnv(env = process.env, rootDir = memindRoot) {
|
||||
const envFile = path.join(rootDir, '.env.local');
|
||||
if (!fs.existsSync(envFile)) return;
|
||||
const lines = fs.readFileSync(envFile, 'utf8').split('\n');
|
||||
let inBlock = false;
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed === GOOSE_V149_CANARY_BEGIN) {
|
||||
inBlock = true;
|
||||
continue;
|
||||
}
|
||||
if (trimmed === GOOSE_V149_CANARY_END) break;
|
||||
if (!inBlock || !trimmed || trimmed.startsWith('#')) continue;
|
||||
const eq = trimmed.indexOf('=');
|
||||
if (eq < 0) continue;
|
||||
const key = trimmed.slice(0, eq).trim();
|
||||
let value = trimmed.slice(eq + 1).trim();
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"'))
|
||||
|| (value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
export const GOOSE_CANARY_MODES = new Set(['off', 'all', 'users']);
|
||||
|
||||
const BLOCKED_HOSTS = new Set([
|
||||
'58.38.22.103',
|
||||
'120.26.184.105',
|
||||
'103.tkmind.cn',
|
||||
'105.tkmind.cn',
|
||||
]);
|
||||
|
||||
export const DEFAULT_GOOSE_STABLE_TARGET = 'https://127.0.0.1:18006';
|
||||
export const DEFAULT_GOOSE_V149_TARGET = 'https://127.0.0.1:18049';
|
||||
|
||||
function csvSet(value) {
|
||||
return new Set(
|
||||
String(value ?? '')
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean),
|
||||
);
|
||||
}
|
||||
|
||||
export function parseGooseCanaryMode(env = process.env) {
|
||||
const mode = String(env.TKMIND_GOOSE_CANARY ?? 'off').trim().toLowerCase();
|
||||
if (!GOOSE_CANARY_MODES.has(mode)) {
|
||||
throw new Error(
|
||||
`Unsupported TKMIND_GOOSE_CANARY "${mode}". Expected: ${[...GOOSE_CANARY_MODES].join(', ')}`,
|
||||
);
|
||||
}
|
||||
return mode;
|
||||
}
|
||||
|
||||
export function assertLoopbackGooseTarget(rawTarget, label = 'goose target') {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(String(rawTarget ?? '').trim());
|
||||
} catch {
|
||||
throw new Error(`${label} must be a valid URL`);
|
||||
}
|
||||
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
||||
throw new Error(`${label} must use http or https`);
|
||||
}
|
||||
const host = parsed.hostname.toLowerCase();
|
||||
if (BLOCKED_HOSTS.has(host)) {
|
||||
throw new Error(`${label} refuses production host ${host}`);
|
||||
}
|
||||
if (!['127.0.0.1', 'localhost', '::1'].includes(host)) {
|
||||
throw new Error(`${label} must be loopback (127.0.0.1 / localhost / ::1)`);
|
||||
}
|
||||
parsed.pathname = parsed.pathname.replace(/\/+$/, '') || '';
|
||||
parsed.search = '';
|
||||
parsed.hash = '';
|
||||
return parsed.toString().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
export function gooseCanaryPolicyFromEnv(env = process.env) {
|
||||
const userIds = csvSet(env.TKMIND_GOOSE_CANARY_USER_IDS);
|
||||
const usernames = csvSet(env.TKMIND_GOOSE_CANARY_USERNAMES);
|
||||
const wechatUserIds = csvSet(env.TKMIND_GOOSE_CANARY_WECHAT_USER_IDS);
|
||||
if (!userIds.size && !usernames.size && !wechatUserIds.size) {
|
||||
return null;
|
||||
}
|
||||
return createCanaryPolicy({ userIds, usernames, wechatUserIds, candidateTarget: 'candidate' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve Portal → goosed API targets for local v1.49 canary.
|
||||
* Returns null when canary is off (caller keeps legacy TKMIND_API_TARGET parsing).
|
||||
*/
|
||||
export function resolveGooseApiTargetsFromEnv(env = process.env) {
|
||||
const mode = parseGooseCanaryMode(env);
|
||||
if (mode === 'off') return null;
|
||||
|
||||
const stable = assertLoopbackGooseTarget(
|
||||
env.TKMIND_API_TARGET_STABLE ?? DEFAULT_GOOSE_STABLE_TARGET,
|
||||
'TKMIND_API_TARGET_STABLE',
|
||||
);
|
||||
const v149 = assertLoopbackGooseTarget(
|
||||
env.TKMIND_API_TARGET_V149 ?? DEFAULT_GOOSE_V149_TARGET,
|
||||
'TKMIND_API_TARGET_V149',
|
||||
);
|
||||
|
||||
if (mode === 'all') {
|
||||
return Object.freeze({
|
||||
mode,
|
||||
stable,
|
||||
v149,
|
||||
primary: v149,
|
||||
targets: [v149],
|
||||
});
|
||||
}
|
||||
|
||||
const policy = gooseCanaryPolicyFromEnv(env);
|
||||
if (!policy) {
|
||||
throw new Error(
|
||||
'TKMIND_GOOSE_CANARY=users requires TKMIND_GOOSE_CANARY_USER_IDS and/or '
|
||||
+ 'TKMIND_GOOSE_CANARY_USERNAMES / TKMIND_GOOSE_CANARY_WECHAT_USER_IDS',
|
||||
);
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
mode,
|
||||
stable,
|
||||
v149,
|
||||
primary: stable,
|
||||
targets: [stable, v149],
|
||||
policy,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveGooseTargetForIdentity(identity, config) {
|
||||
if (!config || config.mode === 'off') {
|
||||
return config?.stable ?? DEFAULT_GOOSE_STABLE_TARGET;
|
||||
}
|
||||
if (config.mode === 'all') return config.v149;
|
||||
const targetKey = resolveCanaryTarget(identity, config.policy);
|
||||
return targetKey === 'candidate' ? config.v149 : config.stable;
|
||||
}
|
||||
|
||||
export function describeGooseCanaryConfig(env = process.env) {
|
||||
const config = resolveGooseApiTargetsFromEnv(env);
|
||||
if (!config) {
|
||||
return {
|
||||
enabled: false,
|
||||
mode: 'off',
|
||||
stable: assertLoopbackGooseTarget(
|
||||
env.TKMIND_API_TARGET ?? DEFAULT_GOOSE_STABLE_TARGET,
|
||||
'TKMIND_API_TARGET',
|
||||
),
|
||||
v149: assertLoopbackGooseTarget(
|
||||
env.TKMIND_API_TARGET_V149 ?? DEFAULT_GOOSE_V149_TARGET,
|
||||
'TKMIND_API_TARGET_V149',
|
||||
),
|
||||
};
|
||||
}
|
||||
return {
|
||||
enabled: true,
|
||||
mode: config.mode,
|
||||
stable: config.stable,
|
||||
v149: config.v149,
|
||||
primary: config.primary,
|
||||
targets: [...config.targets],
|
||||
policySelectors: config.policy
|
||||
? {
|
||||
userIds: [...config.policy.userIds],
|
||||
usernames: [...config.policy.usernames],
|
||||
wechatUserIds: [...config.policy.wechatUserIds],
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function printCliStatus() {
|
||||
const summary = describeGooseCanaryConfig(process.env);
|
||||
console.log(JSON.stringify(summary, null, 2));
|
||||
}
|
||||
|
||||
const modulePath = fileURLToPath(import.meta.url);
|
||||
const invokedPath = process.argv[1] ? path.resolve(process.argv[1]) : '';
|
||||
if (invokedPath && invokedPath === modulePath) {
|
||||
printCliStatus();
|
||||
}
|
||||
Reference in New Issue
Block a user