feat: 接入 selfmedia 受控交付
This commit is contained in:
@@ -0,0 +1,356 @@
|
||||
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { resolve } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
import {
|
||||
resolveSelfMediaDeliveryTarget,
|
||||
selfMediaDeliveryConfigRef,
|
||||
selfMediaEffectiveDeployment,
|
||||
type SelfMediaDeliveryTarget,
|
||||
} from "./selfmedia-config";
|
||||
import { stableJsonSha256 } from "./stable-json";
|
||||
|
||||
export interface SelfMediaRendererBinding {
|
||||
readonly consumer: {
|
||||
readonly id: string;
|
||||
readonly node: string;
|
||||
readonly lane: string;
|
||||
readonly namespace: string;
|
||||
readonly pipeline: string;
|
||||
readonly pipelineRunPrefix: string;
|
||||
readonly sourceArtifact: {
|
||||
readonly configRef: string;
|
||||
readonly mode: string;
|
||||
readonly renderer: string;
|
||||
};
|
||||
};
|
||||
readonly repository: {
|
||||
readonly id: string;
|
||||
readonly params: Readonly<Record<string, string>>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SelfMediaRenderedPipeline {
|
||||
readonly pipeline: Record<string, unknown>;
|
||||
readonly configRef: string;
|
||||
readonly effectiveConfigSha256: string;
|
||||
readonly sourceWorktreeRemote: string;
|
||||
}
|
||||
|
||||
export function renderSelfMediaDesiredPipeline(binding: SelfMediaRendererBinding, sourceWorktree: string): SelfMediaRenderedPipeline {
|
||||
const target = resolveSelfMediaDeliveryTarget(binding.consumer.node);
|
||||
const configRef = selfMediaDeliveryConfigRef(target.id);
|
||||
if (binding.consumer.sourceArtifact.renderer !== "selfmedia-runtime" || binding.consumer.sourceArtifact.mode !== "embedded-pipeline-spec") {
|
||||
throw new Error("selfmedia-runtime requires embedded-pipeline-spec");
|
||||
}
|
||||
if (binding.consumer.sourceArtifact.configRef !== configRef) {
|
||||
throw new Error(`sourceArtifact.configRef must equal resolved owning selector ${configRef}; observed ${binding.consumer.sourceArtifact.configRef}`);
|
||||
}
|
||||
assertBinding(target, binding);
|
||||
assertServiceRepositoryContract(sourceWorktree);
|
||||
const effectiveDeployment = selfMediaEffectiveDeployment(target);
|
||||
validateRuntimeRender(sourceWorktree, effectiveDeployment);
|
||||
const effectiveDeploymentB64 = Buffer.from(`${Bun.YAML.stringify(effectiveDeployment).trim()}\n`, "utf8").toString("base64");
|
||||
return {
|
||||
pipeline: pipeline(target, effectiveDeploymentB64),
|
||||
configRef,
|
||||
effectiveConfigSha256: stableJsonSha256(target),
|
||||
sourceWorktreeRemote: target.source.worktreeRemote,
|
||||
};
|
||||
}
|
||||
|
||||
export function selfMediaSourceWorktreeRemote(targetId: string): string {
|
||||
return resolveSelfMediaDeliveryTarget(targetId).source.worktreeRemote;
|
||||
}
|
||||
|
||||
function pipeline(target: SelfMediaDeliveryTarget, effectiveDeploymentB64: string): Record<string, unknown> {
|
||||
const params = [
|
||||
"revision",
|
||||
"source-snapshot-prefix",
|
||||
"git-read-url",
|
||||
"gitops-read-url",
|
||||
"gitops-write-url",
|
||||
"gitops-username",
|
||||
"gitops-secret-name",
|
||||
];
|
||||
const taskParams = params.map((name) => ({ name, type: "string" }));
|
||||
const taskParamBindings = params.map((name) => ({ name, value: `$(params.${name})` }));
|
||||
return {
|
||||
apiVersion: "tekton.dev/v1",
|
||||
kind: "Pipeline",
|
||||
metadata: {
|
||||
name: target.ci.pipeline,
|
||||
namespace: target.ci.namespace,
|
||||
labels: {
|
||||
"app.kubernetes.io/name": target.ci.pipeline,
|
||||
"app.kubernetes.io/part-of": "selfmedia-factory",
|
||||
"app.kubernetes.io/managed-by": "unidesk",
|
||||
},
|
||||
},
|
||||
spec: {
|
||||
params: taskParams,
|
||||
workspaces: [],
|
||||
tasks: [{
|
||||
name: "build-and-publish",
|
||||
params: taskParamBindings,
|
||||
taskSpec: {
|
||||
params: taskParams,
|
||||
volumes: [
|
||||
{ name: "workspace", emptyDir: { sizeLimit: target.ci.workspaceSize } },
|
||||
{ name: "buildkit-state", emptyDir: { sizeLimit: "12Gi" } },
|
||||
{ name: "tmp", emptyDir: { sizeLimit: "4Gi" } },
|
||||
{
|
||||
name: "gitops-token",
|
||||
secret: {
|
||||
secretName: "$(params.gitops-secret-name)",
|
||||
defaultMode: 288,
|
||||
items: [{ key: target.gitops.credentialTokenKey, path: "token" }],
|
||||
},
|
||||
},
|
||||
],
|
||||
steps: [
|
||||
sourceStep(target, effectiveDeploymentB64),
|
||||
prepareBuildkitStep(target),
|
||||
imageBuildStep(target),
|
||||
gitOpsPublishStep(target),
|
||||
],
|
||||
},
|
||||
}],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function sourceStep(target: SelfMediaDeliveryTarget, effectiveDeploymentB64: string): Record<string, unknown> {
|
||||
const proxy = target.build.proxy;
|
||||
return {
|
||||
name: "immutable-source",
|
||||
image: target.ci.toolImage,
|
||||
imagePullPolicy: "IfNotPresent",
|
||||
workingDir: "/workspace",
|
||||
env: [
|
||||
{ name: "HTTP_PROXY", value: requiredString(proxy.http, "build.proxy.http") },
|
||||
{ name: "HTTPS_PROXY", value: requiredString(proxy.https, "build.proxy.https") },
|
||||
{ name: "ALL_PROXY", value: requiredString(proxy.all, "build.proxy.all") },
|
||||
{ name: "NO_PROXY", value: requiredStringArray(proxy.noProxy, "build.proxy.noProxy").join(",") },
|
||||
],
|
||||
script: `#!/bin/sh
|
||||
set -eu
|
||||
source_commit="$(params.revision)"
|
||||
snapshot_prefix="$(params.source-snapshot-prefix)"
|
||||
rm -rf /workspace/source /workspace/release
|
||||
askpass=/tmp/selfmedia-source-git-askpass
|
||||
cat >"$askpass" <<'ASKPASS'
|
||||
#!/bin/sh
|
||||
case "$1" in
|
||||
*Username*) printf '%s' "$SELFMEDIA_GIT_USERNAME" ;;
|
||||
*Password*) cat /var/run/selfmedia-gitops/token ;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
ASKPASS
|
||||
chmod 700 "$askpass"
|
||||
export SELFMEDIA_GIT_USERNAME="$(params.gitops-username)"
|
||||
export GIT_ASKPASS="$askpass"
|
||||
export GIT_TERMINAL_PROMPT=0
|
||||
git clone --filter=blob:none --no-checkout "$(params.git-read-url)" /workspace/source
|
||||
cd /workspace/source
|
||||
git fetch --depth=1 --filter=blob:none origin "+$snapshot_prefix/$source_commit:refs/remotes/origin/selfmedia-source-snapshot"
|
||||
git checkout --detach "$source_commit"
|
||||
test "$(git rev-parse HEAD)" = "$source_commit"
|
||||
rm -f "$askpass"
|
||||
printf '%s' '${effectiveDeploymentB64}' | base64 -d > /workspace/effective-deployment.yaml
|
||||
bun install --frozen-lockfile
|
||||
bun deploy/scripts/prepare-build.ts \\
|
||||
--config /workspace/effective-deployment.yaml \\
|
||||
--source-root /workspace/source \\
|
||||
--source-commit "$source_commit" \\
|
||||
--source-snapshot-prefix "$snapshot_prefix" \\
|
||||
--output-dir /workspace/release
|
||||
`,
|
||||
securityContext: nonRootSecurity(),
|
||||
volumeMounts: [
|
||||
{ name: "workspace", mountPath: "/workspace" },
|
||||
{ name: "tmp", mountPath: "/tmp" },
|
||||
{ name: "gitops-token", mountPath: "/var/run/selfmedia-gitops", readOnly: true },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function prepareBuildkitStep(target: SelfMediaDeliveryTarget): Record<string, unknown> {
|
||||
return {
|
||||
name: "prepare-buildkit-state",
|
||||
image: target.ci.toolImage,
|
||||
imagePullPolicy: "IfNotPresent",
|
||||
script: "#!/bin/sh\nset -eu\nmkdir -p /home/user/.local/share/buildkit\nchown -R 1000:1000 /home/user/.local/share/buildkit\n",
|
||||
securityContext: { runAsUser: 0, runAsGroup: 0 },
|
||||
volumeMounts: [{ name: "buildkit-state", mountPath: "/home/user/.local/share/buildkit" }],
|
||||
};
|
||||
}
|
||||
|
||||
function imageBuildStep(target: SelfMediaDeliveryTarget): Record<string, unknown> {
|
||||
return {
|
||||
name: "image-build",
|
||||
image: target.ci.buildkitImage,
|
||||
imagePullPolicy: "IfNotPresent",
|
||||
workingDir: "/workspace",
|
||||
env: [
|
||||
{ name: "SOURCE_COMMIT", value: "$(params.revision)" },
|
||||
{ name: "BUILDKITD_FLAGS", value: "--oci-worker-no-process-sandbox --oci-worker-net=host --allow-insecure-entitlement network.host" },
|
||||
],
|
||||
script: "#!/bin/sh\nset -eu\nexec /bin/sh /workspace/source/deploy/scripts/build-image.sh\n",
|
||||
securityContext: { privileged: true, runAsUser: 1000, runAsGroup: 1000 },
|
||||
volumeMounts: [
|
||||
{ name: "workspace", mountPath: "/workspace" },
|
||||
{ name: "buildkit-state", mountPath: "/home/user/.local/share/buildkit" },
|
||||
{ name: "tmp", mountPath: "/tmp" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function gitOpsPublishStep(target: SelfMediaDeliveryTarget): Record<string, unknown> {
|
||||
return {
|
||||
name: "gitops-publish",
|
||||
image: target.ci.toolImage,
|
||||
imagePullPolicy: "IfNotPresent",
|
||||
workingDir: "/workspace",
|
||||
env: [{ name: "SOURCE_COMMIT", value: "$(params.revision)" }],
|
||||
script: `#!/bin/sh
|
||||
set -eu
|
||||
askpass=/tmp/selfmedia-git-askpass
|
||||
cat >"$askpass" <<'ASKPASS'
|
||||
#!/bin/sh
|
||||
case "$1" in
|
||||
*Username*) printf '%s' "$SELFMEDIA_GIT_USERNAME" ;;
|
||||
*Password*) cat /var/run/selfmedia-gitops/token ;;
|
||||
*) exit 1 ;;
|
||||
esac
|
||||
ASKPASS
|
||||
chmod 700 "$askpass"
|
||||
export SELFMEDIA_GIT_USERNAME="$(params.gitops-username)"
|
||||
export GIT_ASKPASS="$askpass"
|
||||
export GIT_TERMINAL_PROMPT=0
|
||||
bun /workspace/source/deploy/scripts/publish-gitops.ts \\
|
||||
--config /workspace/effective-deployment.yaml \\
|
||||
--source-root /workspace/source \\
|
||||
--metadata /workspace/build-metadata.json \\
|
||||
--source-commit "$SOURCE_COMMIT" \\
|
||||
--worktree /workspace/selfmedia-gitops
|
||||
rm -f "$askpass"
|
||||
`,
|
||||
securityContext: nonRootSecurity(),
|
||||
volumeMounts: [
|
||||
{ name: "workspace", mountPath: "/workspace" },
|
||||
{ name: "tmp", mountPath: "/tmp" },
|
||||
{ name: "gitops-token", mountPath: "/var/run/selfmedia-gitops", readOnly: true },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function assertBinding(target: SelfMediaDeliveryTarget, binding: SelfMediaRendererBinding): void {
|
||||
const expected: Record<string, string> = {
|
||||
node: target.node,
|
||||
lane: target.lane,
|
||||
namespace: target.ci.namespace,
|
||||
pipeline: target.ci.pipeline,
|
||||
pipelineRunPrefix: target.ci.pipelineRunPrefix,
|
||||
};
|
||||
const observed: Record<string, string> = {
|
||||
node: binding.consumer.node,
|
||||
lane: binding.consumer.lane,
|
||||
namespace: binding.consumer.namespace,
|
||||
pipeline: binding.consumer.pipeline,
|
||||
pipelineRunPrefix: binding.consumer.pipelineRunPrefix,
|
||||
};
|
||||
for (const [key, value] of Object.entries(expected)) {
|
||||
if (observed[key] !== value) throw new Error(`PaC ${binding.consumer.id} ${key}=${observed[key]} does not match selfmedia owner ${value}`);
|
||||
}
|
||||
const requiredParams: Readonly<Record<string, string>> = {
|
||||
node: target.node,
|
||||
source_branch: target.source.branch,
|
||||
source_snapshot_prefix: target.source.snapshotPrefix,
|
||||
git_read_url: target.source.readUrl,
|
||||
gitops_read_url: target.gitops.readUrl,
|
||||
gitops_write_url: target.gitops.writeUrl,
|
||||
gitops_branch: target.gitops.branch,
|
||||
gitops_username: target.gitops.credentialUsername,
|
||||
gitops_secret_name: target.gitops.credentialSecretName,
|
||||
pipeline_name: target.ci.pipeline,
|
||||
pipeline_run_prefix: target.ci.pipelineRunPrefix,
|
||||
service_account: target.ci.serviceAccountName,
|
||||
image_repository: target.build.imageRepository,
|
||||
pipeline_timeout: target.ci.pipelineTimeout,
|
||||
};
|
||||
for (const [key, value] of Object.entries(requiredParams)) {
|
||||
if (binding.repository.params[key] !== value) throw new Error(`PaC ${binding.consumer.id} repository params.${key} must match selfmedia owner`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertServiceRepositoryContract(sourceWorktree: string): void {
|
||||
const required = [
|
||||
"deploy/Dockerfile",
|
||||
"deploy/scripts/prepare-build.ts",
|
||||
"deploy/scripts/build-image.sh",
|
||||
"deploy/scripts/publish-gitops.ts",
|
||||
"deploy/scripts/render-runtime.ts",
|
||||
];
|
||||
for (const file of required) {
|
||||
if (!existsSync(resolve(sourceWorktree, file))) throw new Error(`selfmedia source repository is missing renderer contract file ${file}`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateRuntimeRender(sourceWorktree: string, effectiveDeployment: Record<string, unknown>): void {
|
||||
const temporary = mkdtempSync(resolve(tmpdir(), "unidesk-selfmedia-render-"));
|
||||
const configPath = resolve(temporary, "effective-deployment.yaml");
|
||||
try {
|
||||
writeFileSync(configPath, `${Bun.YAML.stringify(effectiveDeployment).trim()}\n`, "utf8");
|
||||
const result = spawnSync("bun", [
|
||||
"deploy/scripts/render-runtime.ts",
|
||||
"--config", configPath,
|
||||
"--image", `127.0.0.1:5000/selfmedia/newsroom@sha256:${"a".repeat(64)}`,
|
||||
"--source-commit", "b".repeat(40),
|
||||
], {
|
||||
cwd: sourceWorktree,
|
||||
encoding: "utf8",
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
timeout: 60_000,
|
||||
});
|
||||
if (result.error !== undefined) throw result.error;
|
||||
if (result.status !== 0) {
|
||||
const evidence = `${result.stderr || result.stdout || "render-runtime failed"}`.trim().slice(-2000);
|
||||
throw new Error(`selfmedia effective deployment failed service renderer validation: ${evidence}`);
|
||||
}
|
||||
const output = result.stdout;
|
||||
const required = ["kind: Deployment", "kind: Service", "kind: PersistentVolumeClaim", "kind: NetworkPolicy", "hostPort: 4317", "automountServiceAccountToken: false"];
|
||||
for (const marker of required) {
|
||||
if (!output.includes(marker)) throw new Error(`selfmedia service renderer output is missing ${marker}`);
|
||||
}
|
||||
const forbidden = ["kind: Secret", "kind: Role\n", "kind: RoleBinding", "kind: ClusterRole", "kind: ClusterRoleBinding"];
|
||||
for (const marker of forbidden) {
|
||||
if (output.includes(marker)) throw new Error(`selfmedia service renderer output contains forbidden ${marker.trim()}`);
|
||||
}
|
||||
if ((output.match(/kind: PersistentVolumeClaim/gu) ?? []).length !== 2) throw new Error("selfmedia service renderer must produce exactly two PVCs");
|
||||
if ((output.match(/kind: NetworkPolicy/gu) ?? []).length < 3) throw new Error("selfmedia service renderer must produce at least three NetworkPolicies");
|
||||
} finally {
|
||||
rmSync(temporary, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function nonRootSecurity(): Record<string, unknown> {
|
||||
return {
|
||||
runAsUser: 1000,
|
||||
runAsGroup: 1000,
|
||||
runAsNonRoot: true,
|
||||
allowPrivilegeEscalation: false,
|
||||
capabilities: { drop: ["ALL"] },
|
||||
};
|
||||
}
|
||||
|
||||
function requiredString(value: unknown, path: string): string {
|
||||
if (typeof value !== "string" || value.length === 0) throw new Error(`${path} must be a non-empty string`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function requiredStringArray(value: unknown, path: string): string[] {
|
||||
if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item.length === 0)) throw new Error(`${path} must be an array of non-empty strings`);
|
||||
return value as string[];
|
||||
}
|
||||
Reference in New Issue
Block a user