349 lines
14 KiB
TypeScript
349 lines
14 KiB
TypeScript
export type RuntimeEndpointKind = "web" | "api";
|
|
|
|
type EnvLike = Record<string, string | undefined>;
|
|
type ParsedArgs = Record<string, unknown>;
|
|
|
|
type RuntimeHints = {
|
|
lane: string | null;
|
|
rawLane: string | null;
|
|
namespace: string | null;
|
|
rawNamespace: string | null;
|
|
};
|
|
|
|
type EndpointCandidate = {
|
|
value: string | null;
|
|
source: string;
|
|
sourceName: string;
|
|
explicitOverride?: boolean;
|
|
};
|
|
|
|
export type RuntimeEndpoint = {
|
|
kind: RuntimeEndpointKind;
|
|
baseUrl: string;
|
|
source: string;
|
|
sourceName: string;
|
|
explicitOverride: boolean;
|
|
lane: string | null;
|
|
namespace: string | null;
|
|
urlLane: string | null;
|
|
urlNamespace: string | null;
|
|
warnings: Array<Record<string, unknown>>;
|
|
};
|
|
|
|
export class RuntimeEndpointError extends Error {
|
|
code: string;
|
|
details: Record<string, unknown>;
|
|
|
|
constructor(code: string, message: string, details: Record<string, unknown> = {}) {
|
|
super(message);
|
|
this.name = "RuntimeEndpointError";
|
|
this.code = code;
|
|
this.details = details;
|
|
}
|
|
}
|
|
|
|
export function resolveRuntimeEndpoint({ kind, parsed = {}, env = {} }: { kind: RuntimeEndpointKind; parsed?: ParsedArgs; env?: EnvLike }): RuntimeEndpoint {
|
|
const hints = runtimeHints(parsed, env);
|
|
assertNoManualUrlWhenLocked(kind, parsed, env);
|
|
for (const candidate of endpointCandidates(kind, parsed, env, hints)) {
|
|
const baseUrl = normalizeUrl(candidate.value);
|
|
if (!baseUrl) continue;
|
|
return validateEndpoint({ kind, baseUrl, candidate, hints });
|
|
}
|
|
throw new RuntimeEndpointError(
|
|
"runtime_endpoint_required",
|
|
`HWLAB ${kind} endpoint could not be resolved from the current runtime context`,
|
|
{
|
|
kind,
|
|
lane: hints.lane,
|
|
rawLane: hints.rawLane,
|
|
namespace: hints.namespace,
|
|
rawNamespace: hints.rawNamespace,
|
|
required: kind === "web"
|
|
? ["HWLAB_RUNTIME_WEB_URL", "HWLAB_RUNTIME_NAMESPACE", "HWLAB_CLOUD_WEB_URL"]
|
|
: ["HWLAB_RUNTIME_API_URL", "HWLAB_RUNTIME_NAMESPACE", "HWLAB_DEVICE_POD_API_URL", "HWLAB_CLOUD_API_URL"],
|
|
suggestion: "let WEB/AgentRun assemble HWLAB_RUNTIME_* for the current lane; do not pass endpoint URLs in locked HWLAB runners"
|
|
}
|
|
);
|
|
}
|
|
|
|
export function runtimeEndpointVisibility(endpoint: RuntimeEndpoint | null | undefined) {
|
|
if (!endpoint) return null;
|
|
return pruneUndefined({
|
|
kind: endpoint.kind,
|
|
baseUrl: endpoint.baseUrl,
|
|
source: endpoint.source,
|
|
sourceName: endpoint.sourceName,
|
|
explicitOverride: endpoint.explicitOverride,
|
|
lane: endpoint.lane,
|
|
namespace: endpoint.namespace,
|
|
urlLane: endpoint.urlLane,
|
|
urlNamespace: endpoint.urlNamespace,
|
|
warnings: endpoint.warnings.length > 0 ? endpoint.warnings : undefined
|
|
});
|
|
}
|
|
|
|
export function runtimeEndpointScopeForUrl(value: unknown) {
|
|
const baseUrl = normalizeUrl(value);
|
|
if (!baseUrl) return { baseUrl: null, lane: null, namespace: null };
|
|
const namespace = namespaceForUrl(baseUrl);
|
|
const lane = laneForUrl(baseUrl) ?? laneForNamespace(namespace);
|
|
return { baseUrl, lane, namespace };
|
|
}
|
|
|
|
export function sameRuntimeEndpointScope(left: unknown, right: unknown) {
|
|
const leftScope = runtimeEndpointScopeForUrl(left);
|
|
const rightScope = runtimeEndpointScopeForUrl(right);
|
|
if (!leftScope.baseUrl || !rightScope.baseUrl) return false;
|
|
if (leftScope.baseUrl === rightScope.baseUrl) return true;
|
|
if (leftScope.namespace && rightScope.namespace) return leftScope.namespace === rightScope.namespace;
|
|
return Boolean(leftScope.lane && rightScope.lane && leftScope.lane === rightScope.lane);
|
|
}
|
|
|
|
export function runtimeHints(parsed: ParsedArgs = {}, env: EnvLike = {}): RuntimeHints {
|
|
const rawNamespace = firstText(
|
|
parsed.runtimeNamespace,
|
|
parsed.hwlabNamespace,
|
|
env.HWLAB_RUNTIME_NAMESPACE,
|
|
env.HWLAB_NAMESPACE,
|
|
validHwlabNamespace(env.POD_NAMESPACE),
|
|
validHwlabNamespace(env.KUBERNETES_NAMESPACE)
|
|
);
|
|
const rawLane = firstText(
|
|
parsed.runtimeLane,
|
|
parsed.runtimeProfile,
|
|
parsed.hwlabLane,
|
|
env.HWLAB_RUNTIME_LANE,
|
|
env.HWLAB_RUNTIME_PROFILE,
|
|
env.HWLAB_GITOPS_LANE,
|
|
env.HWLAB_ARTIFACT_LANE,
|
|
env.HWLAB_GITOPS_PROFILE,
|
|
env.HWLAB_ENVIRONMENT,
|
|
env.HWLAB_BOOT_REF
|
|
);
|
|
const laneFromRaw = normalizeLane(rawLane);
|
|
const namespace = normalizeNamespace(rawNamespace) ?? namespaceForLane(laneFromRaw);
|
|
return {
|
|
lane: laneFromRaw ?? laneForNamespace(namespace),
|
|
rawLane: rawLane || null,
|
|
namespace,
|
|
rawNamespace: rawNamespace || null
|
|
};
|
|
}
|
|
|
|
function endpointCandidates(kind: RuntimeEndpointKind, parsed: ParsedArgs, env: EnvLike, hints: RuntimeHints): EndpointCandidate[] {
|
|
const namespaceUrl = hints.namespace ? runtimeUrlForNamespace(kind, hints.namespace, parsed, env) : null;
|
|
if (kind === "web") {
|
|
return withLocalDebugOverride([
|
|
candidate(firstText(env.HWLAB_RUNTIME_WEB_URL, env.HWLAB_CURRENT_WEB_URL), "runtime-env", "HWLAB_RUNTIME_WEB_URL"),
|
|
candidate(namespaceUrl, "runtime-namespace", "HWLAB_RUNTIME_NAMESPACE"),
|
|
candidate(firstText(env.HWLAB_PUBLIC_WEB_URL, env.HWLAB_CLOUD_WEB_PUBLIC_URL), "public-env", "HWLAB_PUBLIC_WEB_URL"),
|
|
candidate(firstText(env.HWLAB_CLIENT_BASE_URL, env.HWLAB_CLOUD_WEB_URL, env.HWLAB_CLI_BASE_URL), "env", "HWLAB_CLIENT_BASE_URL/HWLAB_CLOUD_WEB_URL/HWLAB_CLI_BASE_URL")
|
|
], candidate(parsed.baseUrl, "explicit-override", "--base-url", true), env);
|
|
}
|
|
return withLocalDebugOverride([
|
|
candidate(firstText(env.HWLAB_RUNTIME_API_URL, env.HWLAB_CURRENT_API_URL), "runtime-env", "HWLAB_RUNTIME_API_URL"),
|
|
candidate(namespaceUrl, "runtime-namespace", "HWLAB_RUNTIME_NAMESPACE"),
|
|
candidate(firstText(env.HWLAB_PUBLIC_API_URL, env.HWLAB_PUBLIC_ENDPOINT), "public-env", "HWLAB_PUBLIC_API_URL/HWLAB_PUBLIC_ENDPOINT"),
|
|
candidate(firstText(env.HWLAB_CODE_AGENT_DEVICE_POD_API_URL, env.HWLAB_DEVICE_POD_API_URL, env.HWLAB_CLOUD_API_URL, env.HWLAB_CLOUD_API_BASE_URL), "env", "HWLAB_DEVICE_POD_API_URL/HWLAB_CLOUD_API_URL")
|
|
], candidate(firstText(parsed.apiBaseUrl, parsed.apiUrl, parsed.cloudApiUrl, parsed.baseUrl), "explicit-override", "--api-base-url", true), env);
|
|
}
|
|
|
|
function withLocalDebugOverride(candidates: EndpointCandidate[], manual: EndpointCandidate, env: EnvLike) {
|
|
if (endpointLocked(env)) return candidates;
|
|
return manual.value ? [manual, ...candidates] : candidates;
|
|
}
|
|
|
|
function assertNoManualUrlWhenLocked(kind: RuntimeEndpointKind, parsed: ParsedArgs, env: EnvLike) {
|
|
if (!endpointLocked(env)) return;
|
|
const manualValue = kind === "web"
|
|
? firstText(parsed.baseUrl)
|
|
: firstText(parsed.apiBaseUrl, parsed.apiUrl, parsed.cloudApiUrl, parsed.baseUrl);
|
|
if (!manualValue) return;
|
|
throw new RuntimeEndpointError(
|
|
"runtime_endpoint_manual_url_forbidden",
|
|
`HWLAB ${kind} endpoint is assembled by runtime context; manual URL parameters are forbidden in locked runner environments`,
|
|
{
|
|
kind,
|
|
forbidden: kind === "web" ? ["--base-url"] : ["--api-base-url", "--api-url", "--cloud-api-url", "--base-url"],
|
|
source: "HWLAB_RUNTIME_ENDPOINT_LOCKED",
|
|
suggestion: "drop the URL argument and let device-pod-cli/hwlab-cli auto-locate from the assembled runtime env"
|
|
}
|
|
);
|
|
}
|
|
|
|
function endpointLocked(env: EnvLike) {
|
|
return [env.HWLAB_RUNTIME_ENDPOINT_LOCKED, env.HWLAB_DEVICE_POD_RUNTIME_LOCKED, env.HWLAB_CODE_AGENT_ASSEMBLED_RUNTIME].some((value) => ["1", "true", "yes", "locked"].includes(firstText(value).toLowerCase()));
|
|
}
|
|
|
|
function runtimeUrlForNamespace(kind: RuntimeEndpointKind, namespace: string, parsed: ParsedArgs, env: EnvLike) {
|
|
if (runningInsideKubernetes(env)) return serviceUrlForNamespace(kind, namespace, parsed, env);
|
|
return publicUrlForNamespace(kind, namespace);
|
|
}
|
|
|
|
function runningInsideKubernetes(env: EnvLike) {
|
|
return Boolean(firstText(env.KUBERNETES_SERVICE_HOST, env.KUBERNETES_PORT, env.POD_NAMESPACE, env.KUBERNETES_NAMESPACE));
|
|
}
|
|
|
|
function publicUrlForNamespace(kind: RuntimeEndpointKind, namespace: string) {
|
|
const lane = laneForNamespace(namespace);
|
|
if (lane === "v02") return kind === "web" ? "http://74.48.78.17:19666" : "http://74.48.78.17:19667";
|
|
if (lane === "prod") return kind === "web" ? "http://74.48.78.17:18666" : "http://74.48.78.17:18667";
|
|
if (lane === "dev") return kind === "web" ? "http://74.48.78.17:17666" : "http://74.48.78.17:17667";
|
|
return null;
|
|
}
|
|
|
|
function serviceUrlForNamespace(kind: RuntimeEndpointKind, namespace: string, parsed: ParsedArgs, env: EnvLike) {
|
|
if (kind === "web") {
|
|
const serviceName = firstText(parsed.webServiceName, env.HWLAB_CLOUD_WEB_SERVICE_NAME) || "hwlab-cloud-web";
|
|
const port = positiveInteger(firstText(parsed.webServicePort, env.HWLAB_CLOUD_WEB_PORT), 8080);
|
|
return `http://${serviceName}.${namespace}.svc.cluster.local:${port}`;
|
|
}
|
|
// Device-pod/API CLI calls use the Web service as the in-cluster proxy by default because undici blocks port 6667.
|
|
const serviceName = firstText(parsed.apiServiceName, env.HWLAB_CODE_AGENT_INTERNAL_API_SERVICE_NAME, env.HWLAB_CLOUD_WEB_SERVICE_NAME) || "hwlab-cloud-web";
|
|
const port = positiveInteger(firstText(parsed.apiServicePort, env.HWLAB_CODE_AGENT_INTERNAL_API_PORT, env.HWLAB_CLOUD_WEB_PORT), 8080);
|
|
return `http://${serviceName}.${namespace}.svc.cluster.local:${port}`;
|
|
}
|
|
|
|
function validateEndpoint({ kind, baseUrl, candidate, hints }: { kind: RuntimeEndpointKind; baseUrl: string; candidate: EndpointCandidate; hints: RuntimeHints }): RuntimeEndpoint {
|
|
const urlLane = laneForUrl(baseUrl);
|
|
const urlNamespace = namespaceForUrl(baseUrl);
|
|
const explicitOverride = candidate.explicitOverride === true;
|
|
const warnings: Array<Record<string, unknown>> = [];
|
|
if (!explicitOverride) {
|
|
if (hints.namespace && urlNamespace && urlNamespace !== hints.namespace) {
|
|
throw endpointMismatch(kind, baseUrl, candidate, hints, { urlLane, urlNamespace, field: "namespace" });
|
|
}
|
|
if (hints.lane && urlLane && urlLane !== hints.lane) {
|
|
throw endpointMismatch(kind, baseUrl, candidate, hints, { urlLane, urlNamespace, field: "lane" });
|
|
}
|
|
if (!hints.lane && !hints.namespace && candidate.source === "env" && urlLane === "dev") {
|
|
throw new RuntimeEndpointError(
|
|
"runtime_context_required",
|
|
`HWLAB ${kind} endpoint ${candidate.sourceName} points at the legacy DEV lane but no current runtime lane/namespace is set`,
|
|
{
|
|
kind,
|
|
baseUrl,
|
|
source: candidate.source,
|
|
sourceName: candidate.sourceName,
|
|
urlLane,
|
|
suggestion: "use assembled HWLAB_RUNTIME_* env for the current lane; manual URL is only a local debug override outside locked runners"
|
|
}
|
|
);
|
|
}
|
|
} else if ((hints.lane && urlLane && urlLane !== hints.lane) || (hints.namespace && urlNamespace && urlNamespace !== hints.namespace)) {
|
|
warnings.push({
|
|
code: "runtime_endpoint_explicit_mismatch",
|
|
message: "explicit endpoint override does not match current runtime hints",
|
|
expectedLane: hints.lane,
|
|
expectedNamespace: hints.namespace,
|
|
urlLane,
|
|
urlNamespace
|
|
});
|
|
}
|
|
return pruneUndefined({
|
|
kind,
|
|
baseUrl,
|
|
source: candidate.source,
|
|
sourceName: candidate.sourceName,
|
|
explicitOverride,
|
|
lane: hints.lane,
|
|
namespace: hints.namespace,
|
|
urlLane,
|
|
urlNamespace,
|
|
warnings
|
|
}) as RuntimeEndpoint;
|
|
}
|
|
|
|
function endpointMismatch(kind: RuntimeEndpointKind, baseUrl: string, candidate: EndpointCandidate, hints: RuntimeHints, observed: Record<string, unknown>) {
|
|
return new RuntimeEndpointError(
|
|
"runtime_endpoint_mismatch",
|
|
`HWLAB ${kind} endpoint source ${candidate.sourceName} does not match the current runtime lane/namespace`,
|
|
{
|
|
kind,
|
|
baseUrl,
|
|
source: candidate.source,
|
|
sourceName: candidate.sourceName,
|
|
expectedLane: hints.lane,
|
|
expectedNamespace: hints.namespace,
|
|
rawLane: hints.rawLane,
|
|
rawNamespace: hints.rawNamespace,
|
|
...observed,
|
|
suggestion: "fix the runtime env injected by WEB/AgentRun; manual URL is only a local debug override outside locked runners"
|
|
}
|
|
);
|
|
}
|
|
|
|
function candidate(value: unknown, source: string, sourceName: string, explicitOverride = false): EndpointCandidate {
|
|
return { value: firstText(value), source, sourceName, explicitOverride };
|
|
}
|
|
|
|
function normalizeUrl(value: unknown) {
|
|
const raw = firstText(value);
|
|
if (!raw) return null;
|
|
return raw.replace(/\/+$/u, "");
|
|
}
|
|
|
|
function laneForUrl(value: string) {
|
|
const text = value.toLowerCase();
|
|
if (/hwlab-v02|:19666(?:\/|$)|:19667(?:\/|$)/u.test(text)) return "v02";
|
|
if (/hwlab-prod|:18666(?:\/|$)|:18667(?:\/|$)/u.test(text)) return "prod";
|
|
if (/hwlab-dev|:17666(?:\/|$)|:17667(?:\/|$)|:16666(?:\/|$)|:16667(?:\/|$)/u.test(text)) return "dev";
|
|
return null;
|
|
}
|
|
|
|
function namespaceForUrl(value: string) {
|
|
const match = value.match(/\.((?:hwlab-[a-z0-9-]+))\.svc\.cluster\.local(?::|\/|$)/iu);
|
|
return normalizeNamespace(match?.[1]);
|
|
}
|
|
|
|
function normalizeNamespace(value: unknown) {
|
|
const namespace = firstText(value);
|
|
if (!namespace || !/^hwlab-[a-z0-9-]+$/u.test(namespace)) return null;
|
|
return namespace;
|
|
}
|
|
|
|
function validHwlabNamespace(value: unknown) {
|
|
return normalizeNamespace(value) ?? null;
|
|
}
|
|
|
|
function normalizeLane(value: unknown) {
|
|
const raw = firstText(value).toLowerCase();
|
|
if (!raw) return null;
|
|
if (["v02", "v0.2", "0.2", "v2", "v0_2", "v0-2"].includes(raw)) return "v02";
|
|
if (["g14", "dev", "development"].includes(raw)) return "dev";
|
|
if (["prod", "production"].includes(raw)) return "prod";
|
|
return null;
|
|
}
|
|
|
|
function namespaceForLane(lane: string | null) {
|
|
if (lane === "v02") return "hwlab-v02";
|
|
if (lane === "prod") return "hwlab-prod";
|
|
if (lane === "dev") return "hwlab-dev";
|
|
return null;
|
|
}
|
|
|
|
function laneForNamespace(namespace: string | null) {
|
|
if (namespace === "hwlab-v02") return "v02";
|
|
if (namespace === "hwlab-prod") return "prod";
|
|
if (namespace === "hwlab-dev") return "dev";
|
|
return null;
|
|
}
|
|
|
|
function positiveInteger(value: unknown, fallback: number) {
|
|
const parsed = Number.parseInt(firstText(value), 10);
|
|
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
|
}
|
|
|
|
function firstText(...values: unknown[]) {
|
|
for (const value of values) {
|
|
const text = String(value ?? "").trim();
|
|
if (text) return text;
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function pruneUndefined<T extends Record<string, unknown>>(value: T): T {
|
|
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined)) as T;
|
|
}
|