Serve preview URLs over Traefik+TLS, stabilize them, and speed up builds.

Ingress / preview URLs:
- Default the app Ingress class and ACME HTTP-01 solver to Traefik
  (k3s default) via a new INGRESS_CLASS env, instead of hardcoding nginx —
  fixes 404s on clusters without ingress-nginx.
- Only put public, real-TLD hosts (custom domain + preview) in the TLS
  block; the internal *.apps.cloudhost.local host no longer poisons the
  Let's Encrypt order, so certs actually issue.
- Make the per-app preview number stable across redeploys so URLs stop
  breaking, and let PREVIEW_BASE_DOMAIN configure the base domain.

Registry pulls:
- Point the k3s registries.yaml mirror endpoint at the registry NodePort on
  loopback so node containerd never depends on cluster DNS (image pulls
  survive node restarts).

Builds:
- Pin the Kaniko image, use IfNotPresent pull policy, drop the dead build
  queue/processor, and retry transient Kubernetes API errors while polling
  build jobs.

Logs & apps list:
- fluent-bit reads log files from head so startup output reaches
  Elasticsearch.
- Order joined deployments newest-first so the apps list shows the latest
  deployment status.

Allocation:
- Reserve in-flight (pending/building) capacity and stop globally degrading
  the cluster on a single allocation failure, so concurrent deploys don't
  starve or wrongly report "no healthy cluster".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
keyhan
2026-06-02 15:44:08 +03:30
parent 786689e0fd
commit 4301277b48
13 changed files with 244 additions and 109 deletions
+42 -15
View File
@@ -45,6 +45,12 @@ export class BuildService {
private readonly logger = new Logger(BuildService.name);
private readonly progressMap = new Map<string, BuildProgress>();
private readonly activeBuilds = new Map<string, ActiveBuildSession>();
/**
* Kaniko executor image. Pinned (not `:latest`) so it can be cached on the node
* with imagePullPolicy=IfNotPresent — avoids re-pulling the ~250MB image on every build.
*/
private readonly kanikoImage =
process.env.KANIKO_IMAGE || 'gcr.io/kaniko-project/executor:v1.23.2';
constructor(
private configService: ConfigService,
@@ -332,6 +338,7 @@ export class BuildService {
initContainers.push({
name: 'unzip-source',
image: 'alpine:3.19',
imagePullPolicy: 'IfNotPresent',
command: ['sh', '-c', `
apk add --no-cache unzip tar gzip &&
cp /workspace/Dockerfile /workspace-out/Dockerfile &&
@@ -390,6 +397,7 @@ export class BuildService {
initContainers.push({
name: 'git-clone',
image: 'alpine/git:2.43.0',
imagePullPolicy: 'IfNotPresent',
command: ['sh', '-c', `
echo ">>> Cloning branch '${branch}' from ${app.gitUrl}" &&
git clone --depth 1 --branch ${branch} ${cloneUrl} /workspace-out/source &&
@@ -417,6 +425,7 @@ export class BuildService {
initContainers.push({
name: 'prepare-workspace',
image: 'alpine:3.19',
imagePullPolicy: 'IfNotPresent',
command: ['sh', '-c', `
mkdir -p /workspace-out/source &&
cp /dockerfile/Dockerfile /workspace-out/Dockerfile &&
@@ -447,7 +456,8 @@ export class BuildService {
containers: [
{
name: 'kaniko',
image: 'gcr.io/kaniko-project/executor:latest',
image: this.kanikoImage,
imagePullPolicy: 'IfNotPresent',
args: kanikoArgs,
volumeMounts: kanikoVolumeMounts,
resources: {
@@ -519,17 +529,6 @@ export class BuildService {
}
}
/**
* Poll the helper pod until netcat finishes writing and reports the expected byte count.
*/
private async waitForRemoteUploadDone(
kubeconfig: string,
namespace: string,
podName: string,
expectedSize: number,
deploymentId?: string,
): Promise<void> { /* unused — kept for API compat */ }
/**
* Upload a local file to the helper pod using kubectl cp with progress tracking.
* kubectl cp uses tar over the k8s exec API — reliable for any file size.
@@ -668,6 +667,7 @@ export class BuildService {
containers: [{
name: 'helper',
image: 'alpine:3.19',
imagePullPolicy: 'IfNotPresent',
command: ['sh', '-c', 'sleep 3600'],
volumeMounts: [{ name: 'source', mountPath: '/data' }],
resources: {
@@ -1467,6 +1467,30 @@ CMD ["sh", "-c", "DLL=$(find . -maxdepth 1 -name '*.dll' ! -name '*.deps.dll' !
`;
}
/**
* Whether a K8s API error is a transient connectivity/availability blip that
* should be retried rather than failing the operation. Covers socket-level
* errors, request timeouts (incl. the apiserver "request did not complete
* within the allotted timeout" message), DNS hiccups and 5xx/429 responses.
*/
private isTransientK8sError(err: any): boolean {
const statusCode = err?.statusCode ?? err?.response?.statusCode ?? err?.body?.code;
if (typeof statusCode === 'number' && (statusCode >= 500 || statusCode === 429)) {
return true;
}
const haystack = [
err?.code,
err?.message,
err?.body?.message,
err?.cause?.code,
]
.filter(Boolean)
.join(' ');
return /ECONNRESET|ECONNREFUSED|ETIMEDOUT|ESOCKETTIMEDOUT|EPIPE|EAI_AGAIN|ENOTFOUND|ENETUNREACH|socket hang up|timed? ?out|allotted timeout|did not complete|Client network socket disconnected/i.test(
haystack,
);
}
private async waitForJobCompletion(
batchApi: k8s.BatchV1Api,
coreApi: k8s.CoreV1Api,
@@ -1493,9 +1517,12 @@ CMD ["sh", "-c", "DLL=$(find . -maxdepth 1 -name '*.dll' ! -name '*.deps.dll' !
try {
job = await batchApi.readNamespacedJob(jobName, namespace);
} catch (pollErr: any) {
const code = pollErr?.code || pollErr?.message || '';
if (/ECONNRESET|ECONNREFUSED|ETIMEDOUT|socket hang up/i.test(String(code))) {
this.logger.warn(`Transient K8s API error polling job ${jobName}: ${code} — retrying in 5s`);
// The Kaniko job keeps running independently of these status polls.
// A single API blip (timeout, reset, 5xx, DNS) must NOT abort a build
// that is still progressing — just retry on the next poll tick.
if (this.isTransientK8sError(pollErr)) {
const detail = pollErr?.code || pollErr?.message || pollErr?.statusCode || 'unknown';
this.logger.warn(`Transient K8s API error polling job ${jobName}: ${detail} — retrying in 5s`);
await new Promise(r => setTimeout(r, 5000));
continue;
}