|
|
|
@@ -19,6 +19,14 @@ interface Sub2ApiConfig {
|
|
|
|
|
tag: string;
|
|
|
|
|
pullPolicy: "Always" | "IfNotPresent" | "Never";
|
|
|
|
|
};
|
|
|
|
|
security: {
|
|
|
|
|
urlAllowlist: {
|
|
|
|
|
enabled: boolean;
|
|
|
|
|
allowInsecureHttp: boolean;
|
|
|
|
|
allowPrivateHosts: boolean;
|
|
|
|
|
upstreamHosts: string[];
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function platformInfraHelp(): unknown {
|
|
|
|
@@ -131,7 +139,23 @@ function readSub2ApiConfig(): Sub2ApiConfig {
|
|
|
|
|
if (pullPolicy !== "Always" && pullPolicy !== "IfNotPresent" && pullPolicy !== "Never") throw new Error(`${configPath}.image.pullPolicy must be Always, IfNotPresent, or Never`);
|
|
|
|
|
if (!/^[a-z0-9._/-]+(?::[0-9]+)?$/u.test(repository)) throw new Error(`${configPath}.image.repository has an unsupported format`);
|
|
|
|
|
if (!/^[A-Za-z0-9._-]+$/u.test(tag)) throw new Error(`${configPath}.image.tag has an unsupported format`);
|
|
|
|
|
return { image: { repository, tag, pullPolicy } };
|
|
|
|
|
const security = objectField(parsed as Record<string, unknown>, "security", "");
|
|
|
|
|
const urlAllowlist = objectField(security, "urlAllowlist", "security");
|
|
|
|
|
const enabled = booleanField(urlAllowlist, "enabled", "security.urlAllowlist");
|
|
|
|
|
const allowInsecureHttp = booleanField(urlAllowlist, "allowInsecureHttp", "security.urlAllowlist");
|
|
|
|
|
const allowPrivateHosts = booleanField(urlAllowlist, "allowPrivateHosts", "security.urlAllowlist");
|
|
|
|
|
const upstreamHosts = stringArrayField(urlAllowlist, "upstreamHosts", "security.urlAllowlist");
|
|
|
|
|
return {
|
|
|
|
|
image: { repository, tag, pullPolicy },
|
|
|
|
|
security: {
|
|
|
|
|
urlAllowlist: {
|
|
|
|
|
enabled,
|
|
|
|
|
allowInsecureHttp,
|
|
|
|
|
allowPrivateHosts,
|
|
|
|
|
upstreamHosts,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function stringField(obj: Record<string, unknown>, key: string, path: string): string {
|
|
|
|
@@ -140,6 +164,27 @@ function stringField(obj: Record<string, unknown>, key: string, path: string): s
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function objectField(obj: Record<string, unknown>, key: string, path: string): Record<string, unknown> {
|
|
|
|
|
const value = obj[key];
|
|
|
|
|
const prefix = path.length > 0 ? `${path}.` : "";
|
|
|
|
|
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${configPath}.${prefix}${key} must be an object`);
|
|
|
|
|
return value as Record<string, unknown>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function booleanField(obj: Record<string, unknown>, key: string, path: string): boolean {
|
|
|
|
|
const value = obj[key];
|
|
|
|
|
if (typeof value !== "boolean") throw new Error(`${configPath}.${path}.${key} must be a boolean`);
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function stringArrayField(obj: Record<string, unknown>, key: string, path: string): string[] {
|
|
|
|
|
const value = obj[key];
|
|
|
|
|
if (!Array.isArray(value) || !value.every((item) => typeof item === "string" && item.trim().length > 0)) {
|
|
|
|
|
throw new Error(`${configPath}.${path}.${key} must be an array of non-empty strings`);
|
|
|
|
|
}
|
|
|
|
|
return value.map((item) => item.trim());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function imageRef(sub2api: Sub2ApiConfig): string {
|
|
|
|
|
return `${sub2api.image.repository}:${sub2api.image.tag}`;
|
|
|
|
|
}
|
|
|
|
@@ -147,9 +192,14 @@ function imageRef(sub2api: Sub2ApiConfig): string {
|
|
|
|
|
function manifest(): string {
|
|
|
|
|
const sub2api = readSub2ApiConfig();
|
|
|
|
|
const template = readFileSync(manifestPath, "utf8");
|
|
|
|
|
const urlAllowlist = sub2api.security.urlAllowlist;
|
|
|
|
|
return template
|
|
|
|
|
.replaceAll("__SUB2API_IMAGE__", imageRef(sub2api))
|
|
|
|
|
.replaceAll("__SUB2API_IMAGE_PULL_POLICY__", sub2api.image.pullPolicy);
|
|
|
|
|
.replaceAll("__SUB2API_IMAGE_PULL_POLICY__", sub2api.image.pullPolicy)
|
|
|
|
|
.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(","));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function plan(): Record<string, unknown> {
|
|
|
|
@@ -170,6 +220,7 @@ function plan(): Record<string, unknown> {
|
|
|
|
|
config: {
|
|
|
|
|
image: imageRef(sub2api),
|
|
|
|
|
pullPolicy: sub2api.image.pullPolicy,
|
|
|
|
|
security: sub2api.security,
|
|
|
|
|
},
|
|
|
|
|
decision: {
|
|
|
|
|
owner: "UniDesk",
|
|
|
|
@@ -178,6 +229,7 @@ function plan(): Record<string, unknown> {
|
|
|
|
|
exposure: "ClusterIP only; no public ingress or node-level exposure.",
|
|
|
|
|
resourcePolicy: "No Kubernetes CPU/memory requests or limits, matching issue #220.",
|
|
|
|
|
imageVersionControl: "Sub2API image repository/tag/pullPolicy are controlled by config/platform-infra/sub2api.yaml in the UniDesk repository.",
|
|
|
|
|
urlAllowlistControl: "Sub2API upstream URL validation options are controlled by config/platform-infra/sub2api.yaml and rendered to SECURITY_URL_ALLOWLIST_* env vars.",
|
|
|
|
|
dataStores: ["PostgreSQL 18", "Redis 8"],
|
|
|
|
|
appPoolCaps: {
|
|
|
|
|
databaseMaxOpenConns: 10,
|
|
|
|
@@ -253,7 +305,8 @@ async function apply(config: UniDeskConfig, options: ApplyOptions): Promise<Reco
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function status(config: UniDeskConfig, options: DisclosureOptions): Promise<Record<string, unknown>> {
|
|
|
|
|
const result = await capture(config, g14K3sRoute, ["script"], statusScript(imageRef(readSub2ApiConfig())));
|
|
|
|
|
const sub2api = readSub2ApiConfig();
|
|
|
|
|
const result = await capture(config, g14K3sRoute, ["script"], statusScript(sub2api));
|
|
|
|
|
const parsed = parseJsonOutput(result.stdout);
|
|
|
|
|
if (options.raw) {
|
|
|
|
|
return {
|
|
|
|
@@ -472,7 +525,9 @@ PY
|
|
|
|
|
`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function statusScript(expectedImage: string): string {
|
|
|
|
|
function statusScript(sub2api: Sub2ApiConfig): string {
|
|
|
|
|
const expectedImage = imageRef(sub2api);
|
|
|
|
|
const expectedUrlAllowlist = sub2api.security.urlAllowlist;
|
|
|
|
|
return `
|
|
|
|
|
set -u
|
|
|
|
|
tmp="$(mktemp -d)"
|
|
|
|
@@ -491,6 +546,7 @@ capture_json pods kubectl -n ${namespace} get pods -l app.kubernetes.io/part-of=
|
|
|
|
|
capture_json services kubectl -n ${namespace} get services -l app.kubernetes.io/part-of=platform-infra
|
|
|
|
|
capture_json pvc kubectl -n ${namespace} get pvc -l app.kubernetes.io/part-of=platform-infra
|
|
|
|
|
capture_json secrets kubectl -n ${namespace} get secret ${secretName}
|
|
|
|
|
capture_json configmap kubectl -n ${namespace} get configmap sub2api-config
|
|
|
|
|
capture_json ingresses kubectl -n ${namespace} get ingress
|
|
|
|
|
capture_json quotas kubectl -n ${namespace} get resourcequota
|
|
|
|
|
capture_json limitranges kubectl -n ${namespace} get limitrange
|
|
|
|
@@ -650,6 +706,8 @@ services = items("services")
|
|
|
|
|
pods = items("pods")
|
|
|
|
|
pvcs = items("pvc")
|
|
|
|
|
secret = load("secrets")
|
|
|
|
|
configmap = load("configmap")
|
|
|
|
|
configmap_data = (configmap or {}).get("data") or {}
|
|
|
|
|
secret_keys = sorted(((secret or {}).get("data") or {}).keys())
|
|
|
|
|
missing_secret_keys = [key for key in ${JSON.stringify(requiredSecretKeys)} if key not in secret_keys]
|
|
|
|
|
service_violations = []
|
|
|
|
@@ -662,8 +720,21 @@ for svc in services:
|
|
|
|
|
service_violations.append({"name": svc["metadata"]["name"], "nodePort": port.get("nodePort")})
|
|
|
|
|
resource_violations = resource_findings("Deployment", deployments) + resource_findings("StatefulSet", statefulsets)
|
|
|
|
|
expected_image = "${expectedImage}"
|
|
|
|
|
expected_url_allowlist = json.loads(${JSON.stringify(JSON.stringify(expectedUrlAllowlist))})
|
|
|
|
|
sub2api_deployment = next((deployment_summary(item) for item in deployments if item["metadata"]["name"] == "${serviceName}"), None)
|
|
|
|
|
image_aligned = sub2api_deployment is not None and expected_image in sub2api_deployment.get("images", [])
|
|
|
|
|
url_allowlist_runtime = {
|
|
|
|
|
"enabled": configmap_data.get("SECURITY_URL_ALLOWLIST_ENABLED"),
|
|
|
|
|
"allowInsecureHttp": configmap_data.get("SECURITY_URL_ALLOWLIST_ALLOW_INSECURE_HTTP"),
|
|
|
|
|
"allowPrivateHosts": configmap_data.get("SECURITY_URL_ALLOWLIST_ALLOW_PRIVATE_HOSTS"),
|
|
|
|
|
"upstreamHosts": configmap_data.get("SECURITY_URL_ALLOWLIST_UPSTREAM_HOSTS"),
|
|
|
|
|
}
|
|
|
|
|
url_allowlist_aligned = (
|
|
|
|
|
url_allowlist_runtime["enabled"] == str(expected_url_allowlist.get("enabled")).lower()
|
|
|
|
|
and url_allowlist_runtime["allowInsecureHttp"] == str(expected_url_allowlist.get("allowInsecureHttp")).lower()
|
|
|
|
|
and url_allowlist_runtime["allowPrivateHosts"] == str(expected_url_allowlist.get("allowPrivateHosts")).lower()
|
|
|
|
|
and url_allowlist_runtime["upstreamHosts"] == ",".join(expected_url_allowlist.get("upstreamHosts") or [])
|
|
|
|
|
)
|
|
|
|
|
boundary = {
|
|
|
|
|
"internalOnly": len(service_violations) == 0 and len(items("ingresses")) == 0,
|
|
|
|
|
"serviceViolations": service_violations,
|
|
|
|
@@ -674,7 +745,7 @@ boundary = {
|
|
|
|
|
}
|
|
|
|
|
workload_ready = all(d["ready"] for d in map(deployment_summary, deployments)) and all(s["ready"] for s in map(statefulset_summary, statefulsets))
|
|
|
|
|
payload = {
|
|
|
|
|
"ok": rc("ns") == 0 and workload_ready and image_aligned and boundary["internalOnly"] and len(resource_violations) == 0 and boundary["resourceQuotaCount"] == 0 and boundary["limitRangeCount"] == 0 and len(missing_secret_keys) == 0,
|
|
|
|
|
"ok": rc("ns") == 0 and workload_ready and image_aligned and url_allowlist_aligned and boundary["internalOnly"] and len(resource_violations) == 0 and boundary["resourceQuotaCount"] == 0 and boundary["limitRangeCount"] == 0 and len(missing_secret_keys) == 0,
|
|
|
|
|
"namespace": "${namespace}",
|
|
|
|
|
"namespaceExists": rc("ns") == 0,
|
|
|
|
|
"deployments": [deployment_summary(item) for item in deployments],
|
|
|
|
@@ -695,6 +766,15 @@ payload = {
|
|
|
|
|
"aligned": image_aligned,
|
|
|
|
|
"runningImages": sub2api_deployment.get("images", []) if sub2api_deployment else [],
|
|
|
|
|
},
|
|
|
|
|
"security": {
|
|
|
|
|
"urlAllowlist": {
|
|
|
|
|
"configPath": "config/platform-infra/sub2api.yaml",
|
|
|
|
|
"expected": expected_url_allowlist,
|
|
|
|
|
"runtime": url_allowlist_runtime,
|
|
|
|
|
"aligned": url_allowlist_aligned,
|
|
|
|
|
"configMapExists": rc("configmap") == 0,
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
"boundary": boundary,
|
|
|
|
|
"serviceDns": "${serviceName}.${namespace}.svc.cluster.local:8080",
|
|
|
|
|
"next": {
|
|
|
|
|