664 lines
23 KiB
TypeScript
664 lines
23 KiB
TypeScript
// SPEC: PJ2026-01060307 控制面模块化 draft-2026-06-25-p0. manifest module for scripts/src/platform-infra.ts.
|
|
|
|
// Moved mechanically from scripts/src/platform-infra.ts:937-1587 for #903.
|
|
|
|
import { createHash, randomBytes } from "node:crypto";
|
|
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
import { dirname, isAbsolute, join } from "node:path";
|
|
import type { UniDeskConfig } from "../config";
|
|
import { rootPath } from "../config";
|
|
import { startJob } from "../jobs";
|
|
import type { RenderedCliResult } from "../output";
|
|
import { pk01CaddyMergeManagedBlocksPython, renderCaddyManagedBlock, renderSimpleReverseProxyCaddySiteBlock } from "../pk01-caddy";
|
|
import { capture, compactCapture, parseJsonOutput, prepareFrpcSecret, shQuote } from "../platform-infra-public-service";
|
|
import { yamlBooleanField, yamlFieldLabel, yamlIntegerField } from "../platform-infra-ops-library";
|
|
import { fingerprintSecretValues, parseEnvFile, readEnvSourceFile, readTextFile, redactRepoPath, requiredEnvValue } from "../secrets";
|
|
|
|
import type { ManagedResourceCleanupPlan, Sub2ApiConfig, Sub2ApiTargetConfig } from "./entry";
|
|
import { isKubernetesName, stringField } from "./config";
|
|
import { codexPoolConfigPath, configPath, manifestPath } from "./entry";
|
|
|
|
export function normalizePublicBaseUrl(value: string, path: string): string {
|
|
let parsed: URL;
|
|
try {
|
|
parsed = new URL(value);
|
|
} catch {
|
|
throw new Error(`${configPath}.${path} must be a valid URL`);
|
|
}
|
|
parsed.pathname = parsed.pathname.replace(/\/+$/u, "");
|
|
if (parsed.search || parsed.hash) throw new Error(`${configPath}.${path} must not include query or hash`);
|
|
return parsed.toString().replace(/\/+$/u, "");
|
|
}
|
|
|
|
export function validateHostname(value: string, path: string): void {
|
|
if (!/^[A-Za-z0-9.-]+$/u.test(value) || !value.includes(".")) throw new Error(`${configPath}.${path} must be a hostname`);
|
|
}
|
|
|
|
export function validateKubernetesResourceName(value: string, path: string): void {
|
|
if (!isKubernetesName(value)) throw new Error(`${configPath}.${path} must be a Kubernetes resource name`);
|
|
}
|
|
|
|
export function validateHostOrIp(value: string, path: string): void {
|
|
if (!/^[A-Za-z0-9._:-]+$/u.test(value)) throw new Error(`${configPath}.${path} has an unsupported format`);
|
|
}
|
|
|
|
export function validatePort(value: number, path: string): void {
|
|
if (value < 1 || value > 65535) throw new Error(`${configPath}.${path} must be a TCP port`);
|
|
}
|
|
|
|
export function validateProxyName(value: string, path: string): void {
|
|
if (!/^[A-Za-z0-9._-]+$/u.test(value)) throw new Error(`${configPath}.${path} has an unsupported format`);
|
|
}
|
|
|
|
export function validateProxyUrl(value: string, path: string): void {
|
|
let url: URL;
|
|
try {
|
|
url = new URL(value);
|
|
} catch {
|
|
throw new Error(`${configPath}.${path} must be a valid proxy URL`);
|
|
}
|
|
if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error(`${configPath}.${path} must use http:// or https://`);
|
|
if (url.username || url.password || url.search || url.hash) throw new Error(`${configPath}.${path} must not include credentials, query, or hash`);
|
|
}
|
|
|
|
export function resolveTarget(sub2api: Sub2ApiConfig, targetId: string): Sub2ApiTargetConfig {
|
|
const target = sub2api.targets.find((item) => item.id.toLowerCase() === targetId.toLowerCase());
|
|
if (target === undefined) {
|
|
const known = sub2api.targets.map((item) => item.id).join(", ");
|
|
throw new Error(`unknown Sub2API target ${targetId}; known targets: ${known}`);
|
|
}
|
|
if (!target.enabled) throw new Error(`Sub2API target ${target.id} is disabled in ${configPath}`);
|
|
return target;
|
|
}
|
|
|
|
export function targetImage(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): Sub2ApiConfig["image"] {
|
|
return {
|
|
repository: target.image.repository ?? sub2api.image.repository,
|
|
tag: target.image.tag ?? sub2api.image.tag,
|
|
pullPolicy: target.image.pullPolicy ?? sub2api.image.pullPolicy,
|
|
};
|
|
}
|
|
|
|
export function targetDependencyImages(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): Sub2ApiConfig["dependencyImages"] {
|
|
return {
|
|
postgres: target.dependencyImages.postgres ?? sub2api.dependencyImages.postgres,
|
|
redis: target.dependencyImages.redis ?? sub2api.dependencyImages.redis,
|
|
};
|
|
}
|
|
|
|
export function imageRef(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): string {
|
|
const image = targetImage(sub2api, target);
|
|
return `${image.repository}:${image.tag}`;
|
|
}
|
|
|
|
export function isExternalTarget(target: Sub2ApiTargetConfig): boolean {
|
|
return target.databaseMode === "external-pending" || target.databaseMode === "external-active";
|
|
}
|
|
|
|
export function manifest(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): string {
|
|
if (isExternalTarget(target)) return externalPendingManifest(sub2api, target);
|
|
const template = readFileSync(manifestPath, "utf8");
|
|
const urlAllowlist = sub2api.security.urlAllowlist;
|
|
const configHash = configHashFor(sub2api, target);
|
|
const image = targetImage(sub2api, target);
|
|
const dependencyImages = targetDependencyImages(sub2api, target);
|
|
return template
|
|
.replaceAll("__SUB2API_IMAGE__", imageRef(sub2api, target))
|
|
.replaceAll("__SUB2API_IMAGE_PULL_POLICY__", image.pullPolicy)
|
|
.replaceAll("postgres:18-alpine", dependencyImages.postgres)
|
|
.replaceAll("redis:8-alpine", dependencyImages.redis)
|
|
.replaceAll("__SUB2API_CONFIG_HASH__", configHash)
|
|
.replaceAll("__SUB2API_SECURITY_URL_ALLOWLIST_ENABLED__", String(urlAllowlist.enabled))
|
|
.replaceAll("__SUB2API_SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP__", String(urlAllowlist.allowInsecureHttp))
|
|
.replaceAll("__SUB2API_SECURITY_URL_ALLOWLIST_ALLOW_PRIVATE_HOSTS__", String(urlAllowlist.allowPrivateHosts))
|
|
.replaceAll("__SUB2API_SECURITY_URL_ALLOWLIST_UPSTREAM_HOSTS__", urlAllowlist.upstreamHosts.join(","));
|
|
}
|
|
|
|
export function configHashFor(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): string {
|
|
return createHash("sha256")
|
|
.update(JSON.stringify({
|
|
image: targetImage(sub2api, target),
|
|
dependencyImages: targetDependencyImages(sub2api, target),
|
|
security: sub2api.security,
|
|
target,
|
|
runtime: sub2api.runtime,
|
|
}))
|
|
.digest("hex")
|
|
.slice(0, 16);
|
|
}
|
|
|
|
export function targetHasSentinel(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): boolean {
|
|
return sub2api.runtime.sentinel.enabledOnTargets.some((id) => id.toLowerCase() === target.id.toLowerCase());
|
|
}
|
|
|
|
export function managedResourceCleanupPlan(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): ManagedResourceCleanupPlan {
|
|
const sentinel = codexPoolSentinelResourceNames();
|
|
return {
|
|
externalDbState: {
|
|
enabled: isExternalTarget(target),
|
|
...sub2api.defaults.cleanup.externalDbState,
|
|
},
|
|
redisPersistentState: {
|
|
enabled: target.redisMode === "local-ephemeral",
|
|
...sub2api.defaults.cleanup.redisPersistentState,
|
|
},
|
|
publicExposure: {
|
|
enabled: target.publicExposure?.enabled === true,
|
|
deploymentName: target.publicExposure?.frpc.deploymentName ?? sub2api.defaults.cleanup.publicExposure.deploymentName,
|
|
configMapName: sub2api.defaults.cleanup.publicExposure.configMapName,
|
|
secretName: target.publicExposure?.frpc.secretName ?? sub2api.defaults.cleanup.publicExposure.secretName,
|
|
},
|
|
egressProxy: {
|
|
enabled: target.egressProxy?.enabled === true,
|
|
deploymentName: target.egressProxy?.deploymentName ?? sub2api.defaults.cleanup.egressProxy.deploymentName,
|
|
serviceName: target.egressProxy?.serviceName ?? sub2api.defaults.cleanup.egressProxy.serviceName,
|
|
secretName: target.egressProxy?.secretName ?? sub2api.defaults.cleanup.egressProxy.secretName,
|
|
},
|
|
sentinel: {
|
|
...sentinel,
|
|
enabled: targetHasSentinel(sub2api, target),
|
|
},
|
|
};
|
|
}
|
|
|
|
export function codexPoolSentinelResourceNames(): ManagedResourceCleanupPlan["sentinel"] {
|
|
if (!existsSync(codexPoolConfigPath)) throw new Error(`${codexPoolConfigPath} is required for Sub2API codex-pool sentinel cleanup resource names`);
|
|
const parsed = Bun.YAML.parse(readFileSync(codexPoolConfigPath, "utf8")) as unknown;
|
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error(`${codexPoolConfigPath} must be an object`);
|
|
const sentinel = (parsed as Record<string, unknown>).sentinel;
|
|
if (typeof sentinel !== "object" || sentinel === null || Array.isArray(sentinel)) throw new Error(`${codexPoolConfigPath}.sentinel must be an object`);
|
|
const record = sentinel as Record<string, unknown>;
|
|
const resourceName = (key: string): string => {
|
|
const value = stringField(record, key, "sentinel");
|
|
validateKubernetesResourceName(value, `sentinel.${key}`);
|
|
return value;
|
|
};
|
|
return {
|
|
enabled: false,
|
|
cronJobName: resourceName("cronJobName"),
|
|
configMapName: resourceName("configMapName"),
|
|
credentialsSecretName: resourceName("credentialsSecretName"),
|
|
stateConfigMapName: resourceName("stateConfigMapName"),
|
|
serviceAccountName: resourceName("serviceAccountName"),
|
|
roleName: resourceName("roleName"),
|
|
roleBindingName: resourceName("roleBindingName"),
|
|
};
|
|
}
|
|
|
|
export function externalPendingManifest(sub2api: Sub2ApiConfig, target: Sub2ApiTargetConfig): string {
|
|
const urlAllowlist = sub2api.security.urlAllowlist;
|
|
const database = sub2api.runtime.database;
|
|
const databaseStatus = target.databaseMode === "external-active" ? "external-db-active" : "pending-external-db";
|
|
const redisService = sub2api.runtime.redis.serviceName;
|
|
const appReplicas = target.appReplicas;
|
|
const redisReplicas = target.redisReplicas;
|
|
const image = targetImage(sub2api, target);
|
|
const dependencyImages = targetDependencyImages(sub2api, target);
|
|
const publicExposure = renderPublicExposureManifest(target);
|
|
const egressProxy = renderEgressProxyManifest(target);
|
|
const proxyEnv = sub2ApiProxyEnv(target);
|
|
return `apiVersion: v1
|
|
kind: Namespace
|
|
metadata:
|
|
name: ${target.namespace}
|
|
labels:
|
|
app.kubernetes.io/name: platform-infra
|
|
app.kubernetes.io/managed-by: unidesk
|
|
unidesk.ai/runtime-node: ${target.id}
|
|
---
|
|
apiVersion: networking.k8s.io/v1
|
|
kind: NetworkPolicy
|
|
metadata:
|
|
name: allow-all
|
|
namespace: ${target.namespace}
|
|
labels:
|
|
app.kubernetes.io/name: platform-infra
|
|
app.kubernetes.io/part-of: platform-infra
|
|
app.kubernetes.io/managed-by: unidesk
|
|
spec:
|
|
podSelector: {}
|
|
policyTypes:
|
|
- Ingress
|
|
- Egress
|
|
ingress:
|
|
- {}
|
|
egress:
|
|
- {}
|
|
---
|
|
apiVersion: v1
|
|
kind: ConfigMap
|
|
metadata:
|
|
name: sub2api-config
|
|
namespace: ${target.namespace}
|
|
labels:
|
|
app.kubernetes.io/name: sub2api
|
|
app.kubernetes.io/part-of: platform-infra
|
|
app.kubernetes.io/managed-by: unidesk
|
|
unidesk.ai/runtime-node: ${target.id}
|
|
data:
|
|
AUTO_SETUP: "true"
|
|
SERVER_HOST: "0.0.0.0"
|
|
SERVER_PORT: "8080"
|
|
SERVER_MODE: "release"
|
|
RUN_MODE: "standard"
|
|
DATABASE_HOST: "${database.host}"
|
|
DATABASE_PORT: "${database.port}"
|
|
DATABASE_USER: "${database.user}"
|
|
DATABASE_DBNAME: "${database.dbName}"
|
|
DATABASE_SSLMODE: "${database.sslMode}"
|
|
DATABASE_MAX_OPEN_CONNS: "10"
|
|
DATABASE_MAX_IDLE_CONNS: "2"
|
|
DATABASE_CONN_MAX_LIFETIME_MINUTES: "30"
|
|
DATABASE_CONN_MAX_IDLE_TIME_MINUTES: "5"
|
|
REDIS_HOST: "${redisService}"
|
|
REDIS_PORT: "6379"
|
|
REDIS_PASSWORD: ""
|
|
REDIS_DB: "0"
|
|
REDIS_POOL_SIZE: "32"
|
|
REDIS_MIN_IDLE_CONNS: "2"
|
|
REDIS_ENABLE_TLS: "false"
|
|
ADMIN_EMAIL: "admin@sub2api.platform-infra.local"
|
|
JWT_EXPIRE_HOUR: "24"
|
|
TZ: "Asia/Shanghai"
|
|
SECURITY_URL_ALLOWLIST_ENABLED: "${urlAllowlist.enabled}"
|
|
SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP: "${urlAllowlist.allowInsecureHttp}"
|
|
SECURITY_URL_ALLOWLIST_ALLOW_PRIVATE_HOSTS: "${urlAllowlist.allowPrivateHosts}"
|
|
SECURITY_URL_ALLOWLIST_UPSTREAM_HOSTS: "${urlAllowlist.upstreamHosts.join(",")}"
|
|
UPDATE_PROXY_URL: "${proxyEnv.httpProxy}"
|
|
HTTP_PROXY: "${proxyEnv.httpProxy}"
|
|
HTTPS_PROXY: "${proxyEnv.httpProxy}"
|
|
ALL_PROXY: "${proxyEnv.httpProxy}"
|
|
http_proxy: "${proxyEnv.httpProxy}"
|
|
https_proxy: "${proxyEnv.httpProxy}"
|
|
all_proxy: "${proxyEnv.httpProxy}"
|
|
NO_PROXY: "${proxyEnv.noProxy}"
|
|
no_proxy: "${proxyEnv.noProxy}"
|
|
GATEWAY_OPENAI_RESPONSE_HEADER_TIMEOUT: "0"
|
|
GATEWAY_OPENAI_HTTP2_ENABLED: "true"
|
|
GATEWAY_OPENAI_HTTP2_ALLOW_PROXY_FALLBACK_TO_HTTP1: "true"
|
|
GATEWAY_OPENAI_HTTP2_FALLBACK_ERROR_THRESHOLD: "2"
|
|
GATEWAY_OPENAI_HTTP2_FALLBACK_WINDOW_SECONDS: "60"
|
|
GATEWAY_OPENAI_HTTP2_FALLBACK_TTL_SECONDS: "600"
|
|
GATEWAY_IMAGE_STREAM_DATA_INTERVAL_TIMEOUT: "900"
|
|
GATEWAY_IMAGE_STREAM_KEEPALIVE_INTERVAL: "10"
|
|
GATEWAY_IMAGE_CONCURRENCY_ENABLED: "false"
|
|
GATEWAY_IMAGE_CONCURRENCY_MAX_CONCURRENT_REQUESTS: "0"
|
|
GATEWAY_IMAGE_CONCURRENCY_OVERFLOW_MODE: "reject"
|
|
GATEWAY_IMAGE_CONCURRENCY_WAIT_TIMEOUT_SECONDS: "30"
|
|
GATEWAY_IMAGE_CONCURRENCY_MAX_WAITING_REQUESTS: "100"
|
|
---
|
|
apiVersion: v1
|
|
kind: Service
|
|
metadata:
|
|
name: ${redisService}
|
|
namespace: ${target.namespace}
|
|
labels:
|
|
app.kubernetes.io/name: sub2api-redis
|
|
app.kubernetes.io/component: redis
|
|
app.kubernetes.io/part-of: platform-infra
|
|
app.kubernetes.io/managed-by: unidesk
|
|
unidesk.ai/runtime-node: ${target.id}
|
|
unidesk.ai/state-mode: ephemeral-cache
|
|
spec:
|
|
selector:
|
|
app.kubernetes.io/name: sub2api-redis
|
|
app.kubernetes.io/component: redis
|
|
ports:
|
|
- name: redis
|
|
port: 6379
|
|
targetPort: redis
|
|
---
|
|
apiVersion: v1
|
|
kind: Service
|
|
metadata:
|
|
name: sub2api
|
|
namespace: ${target.namespace}
|
|
labels:
|
|
app.kubernetes.io/name: sub2api
|
|
app.kubernetes.io/component: app
|
|
app.kubernetes.io/part-of: platform-infra
|
|
app.kubernetes.io/managed-by: unidesk
|
|
unidesk.ai/runtime-node: ${target.id}
|
|
spec:
|
|
selector:
|
|
app.kubernetes.io/name: sub2api
|
|
app.kubernetes.io/component: app
|
|
ports:
|
|
- name: http
|
|
port: 8080
|
|
targetPort: http
|
|
---
|
|
apiVersion: apps/v1
|
|
kind: Deployment
|
|
metadata:
|
|
name: sub2api-redis
|
|
namespace: ${target.namespace}
|
|
labels:
|
|
app.kubernetes.io/name: sub2api-redis
|
|
app.kubernetes.io/component: redis
|
|
app.kubernetes.io/part-of: platform-infra
|
|
app.kubernetes.io/managed-by: unidesk
|
|
unidesk.ai/runtime-node: ${target.id}
|
|
unidesk.ai/state-mode: ephemeral-cache
|
|
spec:
|
|
replicas: ${redisReplicas}
|
|
strategy:
|
|
type: Recreate
|
|
selector:
|
|
matchLabels:
|
|
app.kubernetes.io/name: sub2api-redis
|
|
app.kubernetes.io/component: redis
|
|
template:
|
|
metadata:
|
|
labels:
|
|
app.kubernetes.io/name: sub2api-redis
|
|
app.kubernetes.io/component: redis
|
|
app.kubernetes.io/part-of: platform-infra
|
|
spec:
|
|
securityContext:
|
|
fsGroup: 999
|
|
containers:
|
|
- name: redis
|
|
image: ${dependencyImages.redis}
|
|
imagePullPolicy: IfNotPresent
|
|
command:
|
|
- sh
|
|
- -c
|
|
args:
|
|
- redis-server --save "" --appendonly no
|
|
ports:
|
|
- name: redis
|
|
containerPort: 6379
|
|
env:
|
|
- name: TZ
|
|
value: Asia/Shanghai
|
|
readinessProbe:
|
|
exec:
|
|
command:
|
|
- redis-cli
|
|
- ping
|
|
initialDelaySeconds: 5
|
|
periodSeconds: 10
|
|
timeoutSeconds: 5
|
|
failureThreshold: 6
|
|
livenessProbe:
|
|
exec:
|
|
command:
|
|
- redis-cli
|
|
- ping
|
|
initialDelaySeconds: 30
|
|
periodSeconds: 20
|
|
timeoutSeconds: 5
|
|
failureThreshold: 6
|
|
volumeMounts:
|
|
- name: redis-data
|
|
mountPath: /data
|
|
volumes:
|
|
- name: redis-data
|
|
emptyDir: {}
|
|
---
|
|
apiVersion: apps/v1
|
|
kind: Deployment
|
|
metadata:
|
|
name: sub2api
|
|
namespace: ${target.namespace}
|
|
labels:
|
|
app.kubernetes.io/name: sub2api
|
|
app.kubernetes.io/component: app
|
|
app.kubernetes.io/part-of: platform-infra
|
|
app.kubernetes.io/managed-by: unidesk
|
|
unidesk.ai/runtime-node: ${target.id}
|
|
unidesk.ai/database-status: ${databaseStatus}
|
|
spec:
|
|
replicas: ${appReplicas}
|
|
strategy:
|
|
type: Recreate
|
|
selector:
|
|
matchLabels:
|
|
app.kubernetes.io/name: sub2api
|
|
app.kubernetes.io/component: app
|
|
template:
|
|
metadata:
|
|
annotations:
|
|
unidesk.ai/sub2api-config-hash: "${configHashFor(sub2api, target)}"
|
|
unidesk.ai/database-source-ref: "${database.sourceRef}"
|
|
unidesk.ai/database-status: "${databaseStatus}"
|
|
labels:
|
|
app.kubernetes.io/name: sub2api
|
|
app.kubernetes.io/component: app
|
|
app.kubernetes.io/part-of: platform-infra
|
|
spec:
|
|
securityContext:
|
|
fsGroup: 1000
|
|
initContainers:
|
|
- name: wait-postgres
|
|
image: ${dependencyImages.postgres}
|
|
imagePullPolicy: IfNotPresent
|
|
command:
|
|
- sh
|
|
- -c
|
|
- until pg_isready -h ${database.host} -p ${database.port} -U ${database.user} -d ${database.dbName}; do sleep 2; done
|
|
- name: wait-redis
|
|
image: ${dependencyImages.redis}
|
|
imagePullPolicy: IfNotPresent
|
|
command:
|
|
- sh
|
|
- -c
|
|
- until redis-cli -h ${redisService} ping | grep -q PONG; do sleep 2; done
|
|
containers:
|
|
- name: sub2api
|
|
image: ${imageRef(sub2api, target)}
|
|
imagePullPolicy: ${image.pullPolicy}
|
|
ports:
|
|
- name: http
|
|
containerPort: 8080
|
|
envFrom:
|
|
- configMapRef:
|
|
name: sub2api-config
|
|
env:
|
|
- name: DATABASE_PASSWORD
|
|
valueFrom:
|
|
secretKeyRef:
|
|
name: ${database.secretName}
|
|
key: ${database.passwordKey}
|
|
- name: ADMIN_PASSWORD
|
|
valueFrom:
|
|
secretKeyRef:
|
|
name: ${database.secretName}
|
|
key: ADMIN_PASSWORD
|
|
- name: JWT_SECRET
|
|
valueFrom:
|
|
secretKeyRef:
|
|
name: ${database.secretName}
|
|
key: JWT_SECRET
|
|
- name: TOTP_ENCRYPTION_KEY
|
|
valueFrom:
|
|
secretKeyRef:
|
|
name: ${database.secretName}
|
|
key: TOTP_ENCRYPTION_KEY
|
|
readinessProbe:
|
|
httpGet:
|
|
path: /health
|
|
port: http
|
|
initialDelaySeconds: 10
|
|
periodSeconds: 10
|
|
timeoutSeconds: 5
|
|
failureThreshold: 6
|
|
livenessProbe:
|
|
httpGet:
|
|
path: /health
|
|
port: http
|
|
initialDelaySeconds: 30
|
|
periodSeconds: 20
|
|
timeoutSeconds: 5
|
|
failureThreshold: 6
|
|
startupProbe:
|
|
httpGet:
|
|
path: /health
|
|
port: http
|
|
periodSeconds: 10
|
|
timeoutSeconds: 5
|
|
failureThreshold: 30
|
|
volumeMounts:
|
|
- name: sub2api-data
|
|
mountPath: /app/data
|
|
volumes:
|
|
- name: sub2api-data
|
|
emptyDir: {}
|
|
${publicExposure}
|
|
${egressProxy}
|
|
`;
|
|
}
|
|
|
|
export function sub2ApiProxyEnv(target: Sub2ApiTargetConfig): { httpProxy: string; noProxy: string } {
|
|
const proxy = target.egressProxy;
|
|
if (proxy === null || !proxy.enabled || !proxy.applyToSub2Api) return { httpProxy: "", noProxy: "" };
|
|
return {
|
|
httpProxy: `http://${proxy.serviceName}.${target.namespace}.svc.cluster.local:${proxy.listenPort}`,
|
|
noProxy: proxy.noProxy.join(","),
|
|
};
|
|
}
|
|
|
|
export function renderPublicExposureManifest(target: Sub2ApiTargetConfig): string {
|
|
const exposure = target.publicExposure;
|
|
if (exposure === null || !exposure.enabled) return "";
|
|
return `---
|
|
apiVersion: apps/v1
|
|
kind: Deployment
|
|
metadata:
|
|
name: ${exposure.frpc.deploymentName}
|
|
namespace: ${target.namespace}
|
|
labels:
|
|
app.kubernetes.io/name: ${exposure.frpc.deploymentName}
|
|
app.kubernetes.io/component: tunnel
|
|
app.kubernetes.io/part-of: platform-infra
|
|
app.kubernetes.io/managed-by: unidesk
|
|
unidesk.ai/runtime-node: ${target.id}
|
|
unidesk.ai/public-hostname: ${exposure.dns.hostname}
|
|
spec:
|
|
replicas: 1
|
|
selector:
|
|
matchLabels:
|
|
app.kubernetes.io/name: ${exposure.frpc.deploymentName}
|
|
app.kubernetes.io/component: tunnel
|
|
template:
|
|
metadata:
|
|
labels:
|
|
app.kubernetes.io/name: ${exposure.frpc.deploymentName}
|
|
app.kubernetes.io/component: tunnel
|
|
app.kubernetes.io/part-of: platform-infra
|
|
annotations:
|
|
unidesk.ai/public-base-url: "${exposure.publicBaseUrl}"
|
|
unidesk.ai/frp-server: "${exposure.frpc.serverAddr}:${exposure.frpc.serverPort}"
|
|
unidesk.ai/frp-remote-port: "${exposure.frpc.remotePort}"
|
|
spec:
|
|
containers:
|
|
- name: frpc
|
|
image: ${exposure.frpc.image}
|
|
imagePullPolicy: IfNotPresent
|
|
args:
|
|
- -c
|
|
- /etc/frp/frpc.toml
|
|
volumeMounts:
|
|
- name: frpc-config
|
|
mountPath: /etc/frp/frpc.toml
|
|
subPath: ${exposure.frpc.secretKey}
|
|
readOnly: true
|
|
volumes:
|
|
- name: frpc-config
|
|
secret:
|
|
secretName: ${exposure.frpc.secretName}
|
|
`;
|
|
}
|
|
|
|
export function renderEgressProxyManifest(target: Sub2ApiTargetConfig): string {
|
|
const proxy = target.egressProxy;
|
|
if (proxy === null || !proxy.enabled) return "";
|
|
return `---
|
|
apiVersion: v1
|
|
kind: Service
|
|
metadata:
|
|
name: ${proxy.serviceName}
|
|
namespace: ${target.namespace}
|
|
labels:
|
|
app.kubernetes.io/name: ${proxy.deploymentName}
|
|
app.kubernetes.io/component: egress-proxy
|
|
app.kubernetes.io/part-of: platform-infra
|
|
app.kubernetes.io/managed-by: unidesk
|
|
unidesk.ai/runtime-node: ${target.id}
|
|
unidesk.ai/proxy-source: ${proxy.sourceType}
|
|
spec:
|
|
selector:
|
|
app.kubernetes.io/name: ${proxy.deploymentName}
|
|
app.kubernetes.io/component: egress-proxy
|
|
ports:
|
|
- name: http
|
|
port: ${proxy.listenPort}
|
|
targetPort: proxy
|
|
---
|
|
apiVersion: apps/v1
|
|
kind: Deployment
|
|
metadata:
|
|
name: ${proxy.deploymentName}
|
|
namespace: ${target.namespace}
|
|
labels:
|
|
app.kubernetes.io/name: ${proxy.deploymentName}
|
|
app.kubernetes.io/component: egress-proxy
|
|
app.kubernetes.io/part-of: platform-infra
|
|
app.kubernetes.io/managed-by: unidesk
|
|
unidesk.ai/runtime-node: ${target.id}
|
|
unidesk.ai/proxy-source: ${proxy.sourceType}
|
|
spec:
|
|
replicas: 1
|
|
selector:
|
|
matchLabels:
|
|
app.kubernetes.io/name: ${proxy.deploymentName}
|
|
app.kubernetes.io/component: egress-proxy
|
|
template:
|
|
metadata:
|
|
labels:
|
|
app.kubernetes.io/name: ${proxy.deploymentName}
|
|
app.kubernetes.io/component: egress-proxy
|
|
app.kubernetes.io/part-of: platform-infra
|
|
annotations:
|
|
unidesk.ai/proxy-source-ref: "${proxy.sourceRef}"
|
|
unidesk.ai/proxy-source-type: "${proxy.sourceType}"
|
|
unidesk.ai/proxy-selected-outbound: "${proxy.sourceType === "subscription-url" ? proxy.preferredOutbound : "shadowsocks"}"
|
|
spec:
|
|
containers:
|
|
- name: proxy
|
|
image: ${proxy.image}
|
|
imagePullPolicy: ${proxy.imagePullPolicy}
|
|
args:
|
|
- run
|
|
- -c
|
|
- /etc/sing-box/${proxy.secretKey}
|
|
ports:
|
|
- name: proxy
|
|
containerPort: ${proxy.listenPort}
|
|
readinessProbe:
|
|
tcpSocket:
|
|
port: proxy
|
|
initialDelaySeconds: 5
|
|
periodSeconds: 10
|
|
timeoutSeconds: 5
|
|
failureThreshold: 6
|
|
livenessProbe:
|
|
tcpSocket:
|
|
port: proxy
|
|
initialDelaySeconds: 30
|
|
periodSeconds: 20
|
|
timeoutSeconds: 5
|
|
failureThreshold: 6
|
|
volumeMounts:
|
|
- name: proxy-config
|
|
mountPath: /etc/sing-box/${proxy.secretKey}
|
|
subPath: ${proxy.secretKey}
|
|
readOnly: true
|
|
volumes:
|
|
- name: proxy-config
|
|
secret:
|
|
secretName: ${proxy.secretName}
|
|
`;
|
|
}
|