10 Commits

Author SHA1 Message Date
keyhan 58ab81469b Stabilize CI Kaniko tag and keep Harbor free of egress proxy.
Build and Deploy Platform / build-and-deploy (push) Failing after 3m18s
Use the seeded kaniko v1.27.6-debug image, retry npm ci, and push via harbor-core without HTTP_PROXY so Harbor UI metadata and blob uploads keep working.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 16:47:23 +03:30
keyhan fec9ec386f Push Kaniko artifacts via harbor-core for Harbor UI visibility.
Build and Deploy Platform / build-and-deploy (push) Failing after 12m36s
Add REGISTRY_PUSH_URL config, route CI Kaniko to harbor-core, and document dual-host kaniko auth for core push plus registry base-image pull.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-11 11:43:05 +03:30
keyhan ec72ee4fca Fix app image pulls and Harbor kubelet auth for user workloads.
Route k3s registry mirrors through harbor-core ClusterIP with hostname-only auth keys, use HTTP EXT_ENDPOINT so OAuth tokens work on port 80, extend deploy readiness timeout, and harden Kaniko build/dockerfile fallbacks.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 18:38:05 +03:30
keyhan 54ab2f2f05 Fix Kaniko registry auth and push target for Harbor builds.
Mount docker config as config.json (Kaniko requirement), push via
harbor-registry internal URL, and wire harbor_registry_user credentials
in Helm/GitOps values.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 12:29:23 +03:30
keyhan 214b617be0 Fix image pull/build failures on restricted egress clusters.
Use seeded abrban/ images instead of flaky proxy-gcr pulls, fix Kaniko dockerfile path for v1.27, correct docker auth host keys, route /v2/abrban/ through harbor-core, and prefer abrban/ for base images.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 20:08:16 +03:30
keyhan 2679c9d66e Fix Kaniko image refs to use seeded abrban/ images.
v1.27.6-debug does not exist on gcr.io; proxy-gcr pulls are flaky on kubelet. Seed and reference abrban/kaniko-executor:v1.23.2 instead.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 18:31:37 +03:30
keyhan 1ec4d07939 Add egress proxy to user-app Kaniko build jobs.
Inject registry-egress-proxy into Kaniko and network init containers so npm/apk/composer/pip/git clone work on restricted egress clusters.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 18:07:48 +03:30
keyhan 3d773a4a62 feat(platform): wire OTP SMS env from platform Secret via Helm
Build and Deploy Platform / build-and-deploy (push) Successful in 32m19s
Enable backend.sms in the chart so MizbanSMS credentials from the platform
Secret are injected into the backend deployment for production OTP delivery.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 20:05:08 +03:30
keyhan b2ecdad53b fix(logging): use ELASTIC_PASSWORD env in ES health probes
Build and Deploy Platform / build-and-deploy (push) Failing after 30m14s
Hardcoded Basic auth in probes broke after password rotation; exec probes
read the live secret so Elasticsearch stays healthy when credentials change.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 19:41:19 +03:30
keyhan a2fe61b1f6 fix(platform): inject ELASTIC_PASSWORD from platform secrets in production
Build and Deploy Platform / build-and-deploy (push) Successful in 16m13s
Backend validate-production-config requires a non-default ELASTIC_PASSWORD;
read it from abrban-platform-secrets.elastic-password like other credentials.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-03 19:24:34 +03:30
23 changed files with 611 additions and 133 deletions
+6 -3
View File
@@ -14,8 +14,8 @@ concurrency:
env: env:
# PULL_REGISTRY: kubelet pulls via k3s mirror → harbor-core (matches registry-pull-secret) # PULL_REGISTRY: kubelet pulls via k3s mirror → harbor-core (matches registry-pull-secret)
PULL_REGISTRY: registry.abrban.com PULL_REGISTRY: registry.abrban.com
# PUSH_REGISTRY: kaniko pushes directly to harbor-registry (internal, no TLS) # PUSH_REGISTRY: kaniko pushes via harbor-core (Harbor UI metadata + blob storage)
PUSH_REGISTRY: harbor-registry.cloudhost.svc.cluster.local:5000 PUSH_REGISTRY: harbor-core.cloudhost.svc.cluster.local
PROJECT: abrban PROJECT: abrban
BUILD_NS: cloudhost-builds BUILD_NS: cloudhost-builds
GITEA_HOST: gitea-http.gitea.svc.cluster.local:3000 GITEA_HOST: gitea-http.gitea.svc.cluster.local:3000
@@ -102,7 +102,10 @@ jobs:
apk add --no-cache git && apk add --no-cache git &&
git clone --depth=1 --branch main http://oauth2:${GITEA_TOKEN}@${GITEA_HOST}/${REPO_PATH} /workspace && git clone --depth=1 --branch main http://oauth2:${GITEA_TOKEN}@${GITEA_HOST}/${REPO_PATH} /workspace &&
cd /workspace/backend && cd /workspace/backend &&
npm ci --legacy-peer-deps && npm config set fetch-retries 5 &&
npm config set fetch-retry-mintimeout 20000 &&
npm config set fetch-retry-maxtimeout 120000 &&
(npm ci --legacy-peer-deps || (echo 'npm ci failed, retrying...' && sleep 5 && npm ci --legacy-peer-deps) || (echo 'npm ci failed again, retrying...' && sleep 10 && npm ci --legacy-peer-deps)) &&
npm run test -- --ci --runInBand npm run test -- --ci --runInBand
resources: resources:
requests: { cpu: 500m, memory: 1Gi } requests: { cpu: 500m, memory: 1Gi }
@@ -22,16 +22,17 @@ echo "==> [2/4] Scaling old registry deployment down (rollback-friendly)"
kubectl -n cloudhost scale deploy/registry --replicas=0 || true kubectl -n cloudhost scale deploy/registry --replicas=0 || true
echo "==> [3/4] Installing Harbor" echo "==> [3/4] Installing Harbor"
HTTP_PROXY="$(kubectl -n cloudhost get secret registry-egress-proxy -o jsonpath='{.data.HTTP_PROXY}' | base64 -d)" # IMPORTANT: do NOT set HTTP(S)_PROXY on harbor-core for in-cluster registry
HTTPS_PROXY="$(kubectl -n cloudhost get secret registry-egress-proxy -o jsonpath='{.data.HTTPS_PROXY}' | base64 -d)" # traffic. Egress proxy on core causes 502 on blob uploads via harbor-core
NO_PROXY="$(kubectl -n cloudhost get secret registry-egress-proxy -o jsonpath='{.data.NO_PROXY}' | base64 -d)" # (Kaniko/skopeo push fails; Harbor UI metadata never appears).
# Keep proxy empty here; Harbor proxy-cache projects can still use project-level
# proxy settings when needed. Expand noProxy for safety if proxy is re-enabled.
TMP_PROXY_VALUES="$(mktemp)" TMP_PROXY_VALUES="$(mktemp)"
cat > "${TMP_PROXY_VALUES}" <<EOF cat > "${TMP_PROXY_VALUES}" <<EOF
proxy: proxy:
httpProxy: "${HTTP_PROXY}" httpProxy: ""
httpsProxy: "${HTTPS_PROXY}" httpsProxy: ""
noProxy: "${NO_PROXY}" noProxy: "harbor-core,harbor-jobservice,harbor-database,harbor-registry,harbor-portal,.svc,.cluster.local,10.43.0.0/16,127.0.0.1,localhost,registry.abrban.com"
EOF EOF
helm upgrade --install harbor harbor/harbor \ helm upgrade --install harbor harbor/harbor \
@@ -43,6 +44,9 @@ helm upgrade --install harbor harbor/harbor \
rm -f "${TMP_PROXY_VALUES}" || true rm -f "${TMP_PROXY_VALUES}" || true
echo "==> [4/4] Done" echo "==> [4/4] Apply registry ingress path split (proxy-* → harbor-core)"
kubectl apply -f "$(dirname "$0")/../../../gitops/harbor/registry-ingress.yaml"
echo "==> Done"
kubectl -n cloudhost get ingress | grep -n registry || true kubectl -n cloudhost get ingress | grep -n registry || true
@@ -4,7 +4,7 @@
## Install: ## Install:
## helm upgrade --install harbor harbor/harbor -n cloudhost -f backend/helm/cloudhost-harbor/values-registry.abrban.com.yaml ## helm upgrade --install harbor harbor/harbor -n cloudhost -f backend/helm/cloudhost-harbor/values-registry.abrban.com.yaml
## ##
externalURL: https://registry.abrban.com externalURL: http://registry.abrban.com
proxy: proxy:
# Values are injected by install script from `cloudhost/registry-egress-proxy`. # Values are injected by install script from `cloudhost/registry-egress-proxy`.
@@ -1,4 +1,3 @@
{{- $auth := printf "elastic:%s" .Values.elasticPassword | b64enc }}
apiVersion: apps/v1 apiVersion: apps/v1
kind: StatefulSet kind: StatefulSet
metadata: metadata:
@@ -74,22 +73,20 @@ spec:
- name: es-data - name: es-data
mountPath: /usr/share/elasticsearch/data mountPath: /usr/share/elasticsearch/data
readinessProbe: readinessProbe:
httpGet: exec:
path: /_cluster/health?local=true command:
port: 9200 - sh
httpHeaders: - -c
- name: Authorization - curl -sf -u "elastic:${ELASTIC_PASSWORD}" http://127.0.0.1:9200/_cluster/health?local=true
value: Basic {{ $auth }}
initialDelaySeconds: 30 initialDelaySeconds: 30
periodSeconds: 10 periodSeconds: 10
timeoutSeconds: 5 timeoutSeconds: 5
livenessProbe: livenessProbe:
httpGet: exec:
path: /_cluster/health?local=true command:
port: 9200 - sh
httpHeaders: - -c
- name: Authorization - curl -sf -u "elastic:${ELASTIC_PASSWORD}" http://127.0.0.1:9200/_cluster/health?local=true
value: Basic {{ $auth }}
initialDelaySeconds: 60 initialDelaySeconds: 60
periodSeconds: 30 periodSeconds: 30
timeoutSeconds: 10 timeoutSeconds: 10
@@ -123,4 +123,8 @@ PLATFORM_DOMAIN / preview domain from the first entry only. The panel host
value: {{ .Values.build.images.alpineGit | quote }} value: {{ .Values.build.images.alpineGit | quote }}
- name: BASE_IMAGE_REGISTRY - name: BASE_IMAGE_REGISTRY
value: {{ .Values.build.baseImageRegistry | quote }} value: {{ .Values.build.baseImageRegistry | quote }}
{{- if .Values.build.egressProxySecret }}
- name: BUILD_EGRESS_PROXY_SECRET
value: {{ .Values.build.egressProxySecret | quote }}
{{- end }}
{{- end }} {{- end }}
@@ -98,8 +98,42 @@ spec:
secretKeyRef: secretKeyRef:
name: {{ include "cloudhost-platform.secretName" . }} name: {{ include "cloudhost-platform.secretName" . }}
key: cluster-kubeconfig-key key: cluster-kubeconfig-key
- name: ELASTIC_PASSWORD
valueFrom:
secretKeyRef:
name: {{ include "cloudhost-platform.secretName" . }}
key: elastic-password
- name: FRONTEND_URL - name: FRONTEND_URL
value: {{ include "cloudhost-platform.corsOrigins" . | quote }} value: {{ include "cloudhost-platform.corsOrigins" . | quote }}
{{- if .Values.backend.sms.enabled }}
- name: SMS_PROVIDER
value: {{ .Values.backend.sms.provider | default "mizbansms" | quote }}
- name: MIZBANSMS_FROM
value: {{ .Values.backend.sms.from | default "5000467254" | quote }}
- name: MIZBANSMS_API
value: {{ .Values.backend.sms.api | default "2016" | quote }}
- name: MIZBANSMS_USERTYPE
value: {{ .Values.backend.sms.userType | default "2" | quote }}
- name: MIZBANSMS_USERNAME
valueFrom:
secretKeyRef:
name: {{ include "cloudhost-platform.secretName" . }}
key: mizbansms-username
- name: MIZBANSMS_PASSWORD
valueFrom:
secretKeyRef:
name: {{ include "cloudhost-platform.secretName" . }}
key: mizbansms-password
{{- end }}
{{- if .Values.registry.credentialsSecret }}
- name: REGISTRY_USERNAME
value: {{ .Values.registry.username | default "harbor_registry_user" | quote }}
- name: REGISTRY_PASSWORD
valueFrom:
secretKeyRef:
name: {{ .Values.registry.credentialsSecret | quote }}
key: {{ .Values.registry.credentialsPasswordKey | default "REGISTRY_CREDENTIAL_PASSWORD" | quote }}
{{- end }}
{{- include "cloudhost-platform.buildEnv" . | nindent 12 }} {{- include "cloudhost-platform.buildEnv" . | nindent 12 }}
{{- range $key, $val := .Values.backend.env }} {{- range $key, $val := .Values.backend.env }}
- name: {{ $key }} - name: {{ $key }}
+23 -6
View File
@@ -28,15 +28,25 @@ images:
tag: "1.0.0" tag: "1.0.0"
pullPolicy: IfNotPresent pullPolicy: IfNotPresent
# Kaniko push credentials — harbor_registry_user for harbor-registry:5000 (Harbor production).
registry:
credentialsSecret: ""
credentialsPasswordKey: REGISTRY_CREDENTIAL_PASSWORD
username: harbor_registry_user
# Kaniko job images — defaults pull from Harbor proxy-cache. # Kaniko job images — defaults pull from Harbor proxy-cache.
# Override any line for a different registry/tag. # Override any line for a different registry/tag.
build: build:
images: images:
kaniko: registry.abrban.com/proxy-gcr/kaniko-project/executor:v1.23.2 # Seeded into abrban/ via gitops/jobs/seed-ci-images.yaml — avoid flaky proxy-gcr pulls.
alpine: registry.abrban.com/proxy-dockerhub/library/alpine:3.19 kaniko: registry.abrban.com/abrban/kaniko-executor:v1.27.6-debug
alpineGit: registry.abrban.com/proxy-dockerhub/alpine/git:2.43.0 alpine: registry.abrban.com/abrban/alpine:3.19
# Prefix for Docker Hub images in generated user-app Dockerfiles (node, php, …) alpineGit: registry.abrban.com/abrban/alpine-git:2.43.0
baseImageRegistry: registry.abrban.com/proxy-dockerhub/library # Seeded base images (gitops/jobs/seed-ci-images.yaml) — proxy-dockerhub cache can be corrupt on first pull.
baseImageRegistry: registry.abrban.com/abrban
# Secret with HTTP_PROXY/HTTPS_PROXY for Kaniko build jobs (npm, apk, git clone).
# Set to registry-egress-proxy in production; leave empty when nodes have direct egress.
egressProxySecret: ""
postgres: postgres:
enabled: true enabled: true
@@ -89,6 +99,13 @@ backend:
cpu: "2" cpu: "2"
memory: 2Gi memory: 2Gi
extraEnv: {} extraEnv: {}
# OTP SMS — credentials live in the platform Secret (mizbansms-username/password).
sms:
enabled: false
provider: mizbansms
from: "5000467254"
api: "2016"
userType: "2"
env: env:
NODE_ENV: production NODE_ENV: production
PORT: "4000" PORT: "4000"
@@ -124,7 +141,7 @@ secrets:
# Use a pre-created Secret instead of chart-managed one. Required for GitOps # Use a pre-created Secret instead of chart-managed one. Required for GitOps
# (Argo CD renders with `helm template`, so lookup/randAlphaNum regenerate on # (Argo CD renders with `helm template`, so lookup/randAlphaNum regenerate on
# every sync). Secret must contain keys: postgres-password, jwt-secret, # every sync). Secret must contain keys: postgres-password, jwt-secret,
# jwt-refresh-secret, cluster-kubeconfig-key. # jwt-refresh-secret, cluster-kubeconfig-key, redis-password, elastic-password.
existingSecret: "" existingSecret: ""
jwtSecret: "" jwtSecret: ""
jwtRefreshSecret: "" jwtRefreshSecret: ""
+10 -14
View File
@@ -181,24 +181,20 @@ spec:
- name: data - name: data
mountPath: /usr/share/elasticsearch/data mountPath: /usr/share/elasticsearch/data
readinessProbe: readinessProbe:
httpGet: exec:
path: /_cluster/health?local=true command:
port: 9200 - sh
scheme: HTTP - -c
httpHeaders: - curl -sf -u "elastic:${ELASTIC_PASSWORD}" http://127.0.0.1:9200/_cluster/health?local=true
- name: Authorization
value: "Basic ZWxhc3RpYzpDbG91ZEhvc3QyMDI0IVNlY3VyZQ=="
initialDelaySeconds: 30 initialDelaySeconds: 30
periodSeconds: 10 periodSeconds: 10
timeoutSeconds: 5 timeoutSeconds: 5
livenessProbe: livenessProbe:
httpGet: exec:
path: /_cluster/health?local=true command:
port: 9200 - sh
scheme: HTTP - -c
httpHeaders: - curl -sf -u "elastic:${ELASTIC_PASSWORD}" http://127.0.0.1:9200/_cluster/health?local=true
- name: Authorization
value: "Basic ZWxhc3RpYzpDbG91ZEhvc3QyMDI0IVNlY3VyZQ=="
initialDelaySeconds: 60 initialDelaySeconds: 60
periodSeconds: 30 periodSeconds: 30
timeoutSeconds: 10 timeoutSeconds: 10
+107
View File
@@ -49,6 +49,41 @@ describe('BuildService', () => {
service = module.get(BuildService); service = module.get(BuildService);
}); });
describe('baseImage', () => {
it('prefixes Docker Hub library images (tag colon must not block mirroring)', () => {
const config = (service as any).configService as { get: jest.Mock };
config.get.mockImplementation((key: string) => {
if (key === 'build.baseImageRegistry') return 'registry.abrban.com/abrban';
return undefined;
});
expect((service as any).baseImage('node:20-alpine')).toBe(
'registry.abrban.com/abrban/node:20-alpine',
);
expect((service as any).baseImage('alpine:3.19')).toBe(
'registry.abrban.com/abrban/alpine:3.19',
);
});
it('leaves images that already reference an external registry unchanged', () => {
const config = (service as any).configService as { get: jest.Mock };
config.get.mockImplementation((key: string) => {
if (key === 'build.baseImageRegistry') return 'registry.abrban.com/abrban';
return undefined;
});
expect((service as any).baseImage('mcr.microsoft.com/dotnet/sdk:8.0')).toBe(
'mcr.microsoft.com/dotnet/sdk:8.0',
);
expect((service as any).baseImage('registry.abrban.com/abrban/node:20-alpine')).toBe(
'registry.abrban.com/abrban/node:20-alpine',
);
expect((service as any).baseImage('localhost:5000/myapp:latest')).toBe(
'localhost:5000/myapp:latest',
);
});
});
describe('generateDockerfile', () => { describe('generateDockerfile', () => {
it('generates Go Dockerfile with requested runtime version', () => { it('generates Go Dockerfile with requested runtime version', () => {
const app = { const app = {
@@ -78,6 +113,53 @@ describe('BuildService', () => {
expect(dockerfile).toContain('go build -a -installsuffix cgo -ldflags="-w -s" -o main ./cmd/server'); expect(dockerfile).toContain('go build -a -installsuffix cgo -ldflags="-w -s" -o main ./cmd/server');
}); });
it('generates Node.js Dockerfile with mirrored base images when registry prefix is set', async () => {
const module = await Test.createTestingModule({
providers: [
BuildService,
{
provide: ConfigService,
useValue: {
get: jest.fn((key: string) => {
const map: Record<string, string> = {
'build.namespace': 'cloudhost-builds',
'build.serviceAccount': 'kaniko-builder',
'build.baseImageRegistry': 'registry.abrban.com/abrban',
'registry.url': 'registry.local:5000',
};
return map[key];
}),
},
},
{ provide: ClustersService, useValue: {} },
{
provide: BuildProgressStore,
useValue: { get: jest.fn(), set: jest.fn(), clear: jest.fn() },
},
{ provide: RegistryService, useValue: {} },
{
provide: SourceStorageService,
useValue: {
isObjectStorage: () => false,
materializeToTempFile: jest.fn(),
getSize: jest.fn(),
},
},
],
}).compile();
const mirrored = module.get(BuildService);
const app = {
runtime: AppRuntime.NODEJS,
runtimeVersion: '20',
} as Application;
const dockerfile = (mirrored as any).generateDockerfile(app) as string;
expect(dockerfile).toContain('FROM registry.abrban.com/abrban/node:20-alpine');
expect(dockerfile).toContain('EXPOSE 3000');
});
it('generates Node.js Dockerfile with default port', () => { it('generates Node.js Dockerfile with default port', () => {
const app = { const app = {
runtime: AppRuntime.NODEJS, runtime: AppRuntime.NODEJS,
@@ -137,4 +219,29 @@ describe('BuildService', () => {
expect(dockerfile).toContain('dotnet publish "$CSPROJ"'); expect(dockerfile).toContain('dotnet publish "$CSPROJ"');
}); });
}); });
describe('egressProxyEnvFrom', () => {
it('returns secretRef when BUILD_EGRESS_PROXY_SECRET is set', () => {
const config = (service as any).configService as { get: jest.Mock };
config.get.mockImplementation((key: string) => {
if (key === 'build.egressProxySecret') return 'registry-egress-proxy';
return undefined;
});
expect((service as any).egressProxyEnvFrom()).toEqual([
{ secretRef: { name: 'registry-egress-proxy' } },
]);
});
it('returns undefined when egress proxy is disabled', () => {
const config = (service as any).configService as { get: jest.Mock };
config.get.mockImplementation((key: string) => {
if (key === 'build.egressProxySecret') return '';
return undefined;
});
expect((service as any).egressProxyEnvFrom()).toBeUndefined();
expect((service as any).withEgressProxy({ name: 'kaniko' })).toEqual({ name: 'kaniko' });
});
});
}); });
+60 -29
View File
@@ -73,9 +73,19 @@ export class BuildService {
private baseImage(image: string): string { private baseImage(image: string): string {
const prefix = this.configService.get<string>('build.baseImageRegistry'); const prefix = this.configService.get<string>('build.baseImageRegistry');
if (!prefix) return image; if (!prefix) return image;
const firstSegment = image.split('/')[0];
const hasRegistry = firstSegment.includes('.') || firstSegment.includes(':'); // Official Docker Hub library images have no slash (node:20-alpine, alpine:3.19).
if (hasRegistry) return image; // Do not treat the tag colon as a registry port — that was skipping the mirror.
if (!image.includes('/')) {
return `${prefix}/${image}`;
}
const registryHost = image.split('/')[0];
if (registryHost.includes('.') || registryHost.includes(':') || registryHost === 'localhost') {
return image;
}
// Docker Hub org/user image (e.g. bitnami/redis:7) — mirror through the prefix.
return `${prefix}/${image}`; return `${prefix}/${image}`;
} }
@@ -91,6 +101,22 @@ export class BuildService {
return this.baseImage(dockerHubFallback); return this.baseImage(dockerHubFallback);
} }
/**
* Egress HTTP(S) proxy for build pods on restricted networks (Iran).
* Kaniko forwards these env vars into Dockerfile RUN steps (npm, apk, composer, pip).
*/
private egressProxyEnvFrom(): k8s.V1EnvFromSource[] | undefined {
const secretName = this.configService.get<string>('build.egressProxySecret');
if (!secretName?.trim()) return undefined;
return [{ secretRef: { name: secretName.trim() } }];
}
private withEgressProxy<T extends Record<string, unknown>>(container: T): T {
const envFrom = this.egressProxyEnvFrom();
if (!envFrom) return container;
return { ...container, envFrom };
}
/** /**
* Git branch names come from users and end up in a shell command — accept * Git branch names come from users and end up in a shell command — accept
* only conservative ref characters and reject anything option-like. * only conservative ref characters and reject anything option-like.
@@ -418,10 +444,10 @@ export class BuildService {
* Returns { imageUri, buildLog } — the full image URI and the build logs. * Returns { imageUri, buildLog } — the full image URI and the build logs.
*/ */
async buildImage(app: Application, deploymentId?: string): Promise<{ imageUri: string; buildLog: string }> { async buildImage(app: Application, deploymentId?: string): Promise<{ imageUri: string; buildLog: string }> {
const registryUrl = this.registryService.getRegistryUrl(); const registryPushUrl = this.registryService.getRegistryPushUrl();
const buildNamespace = this.registryService.getBuildNamespace(); const buildNamespace = this.registryService.getBuildNamespace();
const tag = `${Date.now()}`; const tag = `${Date.now()}`;
const imageUri = this.registryService.buildImageReference(app.userId, app.name, tag); const imageUri = this.registryService.buildPushImageReference(app.userId, app.name, tag);
this.logger.log(`Starting image build for ${app.name}${imageUri}`); this.logger.log(`Starting image build for ${app.name}${imageUri}`);
@@ -514,11 +540,11 @@ export class BuildService {
// Build the Kaniko Job spec // Build the Kaniko Job spec
// Always use dir context — init containers prepare /workspace/source // Always use dir context — init containers prepare /workspace/source
const kanikoArgs = [ const kanikoArgs = [
'--dockerfile=/workspace/Dockerfile', '--dockerfile=Dockerfile',
'--context=dir:///workspace/source', '--context=dir:///workspace/source',
`--destination=${imageUri}`, `--destination=${imageUri}`,
'--cache=true', '--cache=true',
`--cache-repo=${registryUrl}/${app.userId}/cache`, `--cache-repo=${registryPushUrl}/${app.userId}/cache`,
'--insecure', '--insecure',
'--skip-tls-verify', '--skip-tls-verify',
'--single-snapshot', '--single-snapshot',
@@ -528,7 +554,10 @@ export class BuildService {
const volumes: any[] = [ const volumes: any[] = [
{ {
name: 'docker-config', name: 'docker-config',
secret: { secretName: 'registry-credentials' }, secret: {
secretName: 'registry-credentials',
items: [{ key: '.dockerconfigjson', path: 'config.json' }],
},
}, },
{ {
name: 'dockerfile', name: 'dockerfile',
@@ -552,7 +581,7 @@ export class BuildService {
}); });
// Add init container that unzips the source code from PVC // Add init container that unzips the source code from PVC
initContainers.push({ initContainers.push(this.withEgressProxy({
name: 'unzip-source', name: 'unzip-source',
image: this.resolveBuildImage('alpine', 'alpine:3.19'), image: this.resolveBuildImage('alpine', 'alpine:3.19'),
imagePullPolicy: 'IfNotPresent', imagePullPolicy: 'IfNotPresent',
@@ -564,7 +593,6 @@ export class BuildService {
reject_unsafe_path() { reject_unsafe_path() {
case "$1" in ..|../*|*/../*|/*) echo "ERROR: unsafe archive path: $1" && exit 1;; esac case "$1" in ..|../*|*/../*|/*) echo "ERROR: unsafe archive path: $1" && exit 1;; esac
} && } &&
cp /workspace/Dockerfile /workspace-out/Dockerfile &&
mkdir -p /tmp/extract && mkdir -p /tmp/extract &&
cd /tmp/extract && cd /tmp/extract &&
if tar tzf /source-pvc/source.zip >/dev/null 2>&1; then if tar tzf /source-pvc/source.zip >/dev/null 2>&1; then
@@ -594,6 +622,7 @@ export class BuildService {
cp -a /tmp/extract/. /workspace-out/source/ cp -a /tmp/extract/. /workspace-out/source/
fi && fi &&
rm -rf /tmp/extract && rm -rf /tmp/extract &&
cp /dockerfile/Dockerfile /workspace-out/source/Dockerfile &&
echo "--- Final workspace contents ---" && echo "--- Final workspace contents ---" &&
ls -la /workspace-out/source/ ls -la /workspace-out/source/
`, `,
@@ -602,12 +631,12 @@ export class BuildService {
{ name: 'workspace', mountPath: '/workspace-out' }, { name: 'workspace', mountPath: '/workspace-out' },
{ {
name: 'dockerfile', name: 'dockerfile',
mountPath: '/workspace/Dockerfile', mountPath: '/dockerfile/Dockerfile',
subPath: 'Dockerfile', subPath: 'Dockerfile',
}, },
{ name: 'source-pvc', mountPath: '/source-pvc' }, { name: 'source-pvc', mountPath: '/source-pvc' },
], ],
}); }));
} else if (hasGitUrl) { } else if (hasGitUrl) {
// Validate user-controlled values before they get anywhere near a shell. // Validate user-controlled values before they get anywhere near a shell.
this.assertSafeGitUrl(app.gitUrl!); this.assertSafeGitUrl(app.gitUrl!);
@@ -632,7 +661,7 @@ export class BuildService {
} }
// Clone git repo into /workspace/source, then copy our generated Dockerfile // Clone git repo into /workspace/source, then copy our generated Dockerfile
initContainers.push({ initContainers.push(this.withEgressProxy({
name: 'git-clone', name: 'git-clone',
image: this.resolveBuildImage('alpineGit', 'alpine/git:2.43.0'), image: this.resolveBuildImage('alpineGit', 'alpine/git:2.43.0'),
imagePullPolicy: 'IfNotPresent', imagePullPolicy: 'IfNotPresent',
@@ -661,7 +690,7 @@ export class BuildService {
fi fi
echo ">>> Cloning branch '$GIT_BRANCH' from $GIT_URL" echo ">>> Cloning branch '$GIT_BRANCH' from $GIT_URL"
git clone --depth 1 --branch "$GIT_BRANCH" "$GIT_URL" /workspace-out/source git clone --depth 1 --branch "$GIT_BRANCH" "$GIT_URL" /workspace-out/source
cp /dockerfile/Dockerfile /workspace-out/Dockerfile cp /dockerfile/Dockerfile /workspace-out/source/Dockerfile
echo ">>> Workspace contents:" echo ">>> Workspace contents:"
ls -la /workspace-out/source/ ls -la /workspace-out/source/
`, `,
@@ -670,7 +699,7 @@ export class BuildService {
{ name: 'workspace', mountPath: '/workspace-out' }, { name: 'workspace', mountPath: '/workspace-out' },
{ name: 'dockerfile', mountPath: '/dockerfile' }, { name: 'dockerfile', mountPath: '/dockerfile' },
], ],
}); }));
} }
// Kaniko container volume mounts // Kaniko container volume mounts
@@ -692,7 +721,7 @@ export class BuildService {
'-c', '-c',
` `
mkdir -p /workspace-out/source && mkdir -p /workspace-out/source &&
cp /dockerfile/Dockerfile /workspace-out/Dockerfile && cp /dockerfile/Dockerfile /workspace-out/source/Dockerfile &&
echo ">>> Prepared empty workspace for fresh install" && echo ">>> Prepared empty workspace for fresh install" &&
ls -la /workspace-out/ ls -la /workspace-out/
`, `,
@@ -719,7 +748,7 @@ export class BuildService {
serviceAccountName: this.configService.get<string>('build.serviceAccount'), serviceAccountName: this.configService.get<string>('build.serviceAccount'),
initContainers: initContainers.length > 0 ? initContainers : undefined, initContainers: initContainers.length > 0 ? initContainers : undefined,
containers: [ containers: [
{ this.withEgressProxy({
name: 'kaniko', name: 'kaniko',
image: this.getKanikoImage(), image: this.getKanikoImage(),
imagePullPolicy: 'IfNotPresent', imagePullPolicy: 'IfNotPresent',
@@ -735,7 +764,7 @@ export class BuildService {
memory: this.configService.get<string>('build.kaniko.memoryLimit') || '4Gi', memory: this.configService.get<string>('build.kaniko.memoryLimit') || '4Gi',
}, },
}, },
}, }),
], ],
restartPolicy: 'Never', restartPolicy: 'Never',
volumes, volumes,
@@ -1118,25 +1147,27 @@ export class BuildService {
} }
} }
// 3. Ensure registry-credentials secret (docker config for Kaniko to push) // 3. Ensure registry-credentials secret (docker config for Kaniko push/pull)
const registrySecretName = 'registry-credentials'; const registrySecretName = 'registry-credentials';
const registrySecretBody = {
metadata: { name: registrySecretName, namespace },
type: 'kubernetes.io/dockerconfigjson',
data: {
'.dockerconfigjson': Buffer.from(this.registryService.buildDockerConfigJson()).toString('base64'),
},
};
try { try {
await coreApi.readNamespacedSecret({ await coreApi.replaceNamespacedSecret({
name: registrySecretName, name: registrySecretName,
namespace, namespace,
body: registrySecretBody,
}); });
} catch (err: any) { } catch (err: any) {
if (err.code === 404 || err.body?.code === 404) { if (err.code === 404 || err.body?.code === 404) {
this.logger.log(`Secret "${registrySecretName}" not found in "${namespace}" — creating it`); this.logger.log(`Secret "${registrySecretName}" not found in "${namespace}" — creating it`);
await coreApi.createNamespacedSecret({ await coreApi.createNamespacedSecret({
namespace, namespace,
body: { body: registrySecretBody,
metadata: { name: registrySecretName, namespace },
type: 'kubernetes.io/dockerconfigjson',
data: {
'.dockerconfigjson': Buffer.from(this.registryService.buildDockerConfigJson()).toString('base64'),
},
},
}); });
} else { } else {
throw err; throw err;
@@ -1174,8 +1205,8 @@ export class BuildService {
FROM ${this.baseImage(`node:${nodeVersion}-alpine`)} AS builder FROM ${this.baseImage(`node:${nodeVersion}-alpine`)} AS builder
WORKDIR /app WORKDIR /app
COPY package*.json ./ COPY package*.json ./
# Reproducible install from the lockfile when present # Prefer lockfile; fall back when package.json and lockfile are out of sync
RUN if [ -f package-lock.json ]; then npm ci --legacy-peer-deps; else npm install --legacy-peer-deps; fi \\ RUN if [ -f package-lock.json ]; then npm ci --legacy-peer-deps || npm install --legacy-peer-deps; else npm install --legacy-peer-deps; fi \\
&& npm cache clean --force && npm cache clean --force
COPY . . COPY . .
+46 -15
View File
@@ -870,7 +870,7 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
const buildNs = this.registryService.getBuildNamespace(); const buildNs = this.registryService.getBuildNamespace();
const saName = this.configService.get<string>('build.serviceAccount') || 'kaniko-builder'; const saName = this.configService.get<string>('build.serviceAccount') || 'kaniko-builder';
const registryUrl = this.registryService.getRegistryUrl(); const registryHost = this.registryService.getRegistryHost();
this.logger.log(`Bootstrapping cluster — namespace: ${buildNs}`); this.logger.log(`Bootstrapping cluster — namespace: ${buildNs}`);
@@ -1100,13 +1100,17 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
} }
} }
await this.ensureK3sRegistryMirrors(appsApi, registryUrl); await this.ensureK3sRegistryMirrors(coreApi, appsApi, registryHost);
this.logger.log(`✅ Cluster bootstrap complete — registry: ${registryUrl}`); this.logger.log(`✅ Cluster bootstrap complete — registry: ${registryHost}`);
} }
/** In-cluster registry mirror for k3s/containerd (HTTP). Removes legacy external-registry DaemonSet if present. */ /** In-cluster registry mirror for k3s/containerd (HTTP). Removes legacy external-registry DaemonSet if present. */
private async ensureK3sRegistryMirrors(appsApi: k8s.AppsV1Api, registryUrl: string): Promise<void> { private async ensureK3sRegistryMirrors(
coreApi: k8s.CoreV1Api,
appsApi: k8s.AppsV1Api,
registryHost: string,
): Promise<void> {
const namespace = 'kube-system'; const namespace = 'kube-system';
const legacyDs = 'cloudhost-k3s-registry-config'; const legacyDs = 'cloudhost-k3s-registry-config';
try { try {
@@ -1120,28 +1124,25 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
const { username, password } = this.registryService.getRegistryCredentials(); const { username, password } = this.registryService.getRegistryCredentials();
const dsName = 'cloudhost-k3s-registry-mirrors'; const dsName = 'cloudhost-k3s-registry-mirrors';
// The mirror endpoint must be reachable by the node's containerd, which does // containerd on the node does not use cluster DNS — mirror via ClusterIP (Harbor)
// NOT use cluster DNS — so we point it at the registry NodePort on loopback // or loopback NodePort (legacy in-cluster registry).
// (http://127.0.0.1:<nodePort>) instead of the in-cluster service DNS name. const mirrorEndpoint = await this.resolveK3sRegistryMirrorEndpoint(coreApi);
// Otherwise image pulls break whenever node-level resolution of const mirrorHost = mirrorEndpoint.replace(/^https?:\/\//, '');
// *.svc.cluster.local is unavailable (e.g. right after a node restart).
const registryNodePort = 30500;
const nodePortHost = `127.0.0.1:${registryNodePort}`;
const configureScript = [ const configureScript = [
'set -e', 'set -e',
'REG=/host/etc/rancher/k3s/registries.yaml', 'REG=/host/etc/rancher/k3s/registries.yaml',
'mkdir -p /host/etc/rancher/k3s', 'mkdir -p /host/etc/rancher/k3s',
'cat > /tmp/cloudhost-registries.yaml <<EOFREG', 'cat > /tmp/cloudhost-registries.yaml <<EOFREG',
'mirrors:', 'mirrors:',
` "${registryUrl}":`, ` "${registryHost}":`,
' endpoint:', ' endpoint:',
` - "http://${nodePortHost}"`, ` - "${mirrorEndpoint}"`,
'configs:', 'configs:',
` "${registryUrl}":`, ` "${registryHost}":`,
' auth:', ' auth:',
` username: ${JSON.stringify(username)}`, ` username: ${JSON.stringify(username)}`,
` password: ${JSON.stringify(password)}`, ` password: ${JSON.stringify(password)}`,
` "${nodePortHost}":`, ` "${mirrorHost}":`,
' auth:', ' auth:',
` username: ${JSON.stringify(username)}`, ` username: ${JSON.stringify(username)}`,
` password: ${JSON.stringify(password)}`, ` password: ${JSON.stringify(password)}`,
@@ -1206,6 +1207,36 @@ export class ClustersService implements OnModuleInit, OnModuleDestroy {
} }
} }
/**
* containerd on nodes cannot resolve *.svc.cluster.local — use ClusterIP for
* Harbor (harbor-core HTTP) or legacy registry NodePort on loopback.
*/
private async resolveK3sRegistryMirrorEndpoint(coreApi: k8s.CoreV1Api): Promise<string> {
const pushUrl = this.registryService.getRegistryPushUrl();
const platformNs = this.configService.get<string>('platform.namespace') || 'cloudhost';
const harborCoreService = this.configService.get<string>('registry.harborCoreService') || 'harbor-core';
if (pushUrl.includes('harbor-registry')) {
try {
const svc = await coreApi.readNamespacedService({
name: harborCoreService,
namespace: platformNs,
});
const clusterIp = svc.spec?.clusterIP;
if (clusterIp) {
return `http://${clusterIp}`;
}
} catch (err: any) {
this.logger.warn(
`Could not resolve ${harborCoreService} ClusterIP for k3s mirror: ${err.message}`,
);
}
}
const registryNodePort = 30500;
return `http://127.0.0.1:${registryNodePort}`;
}
private parseCpuToMillicores(cpu: string): number { private parseCpuToMillicores(cpu: string): number {
if (!cpu || cpu === '0') return 0; if (!cpu || cpu === '0') return 0;
if (cpu.endsWith('n')) return parseFloat(cpu) / 1_000_000; if (cpu.endsWith('n')) return parseFloat(cpu) / 1_000_000;
+17 -5
View File
@@ -124,11 +124,17 @@ export default () => ({
}, },
registry: { registry: {
/** In-cluster registry — Kaniko push and app image pull (same host). */ /** Kaniko push target — harbor-core when set (Harbor UI metadata); else in-cluster registry. */
url: process.env.REGISTRY_URL || 'registry.cloudhost-builds.svc.cluster.local:5000', url: process.env.REGISTRY_URL || 'registry.cloudhost-builds.svc.cluster.local:5000',
pushUrl:
process.env.REGISTRY_PUSH_URL ||
process.env.REGISTRY_URL ||
'registry.cloudhost-builds.svc.cluster.local:5000',
/** Kubelet / workload pull (external hostname on Harbor setups). */
pullUrl: process.env.REGISTRY_PULL_URL || process.env.REGISTRY_URL || 'registry.cloudhost-builds.svc.cluster.local:5000', pullUrl: process.env.REGISTRY_PULL_URL || process.env.REGISTRY_URL || 'registry.cloudhost-builds.svc.cluster.local:5000',
username: process.env.REGISTRY_USERNAME || 'admin', username: process.env.REGISTRY_USERNAME || 'admin',
password: process.env.REGISTRY_PASSWORD || '', password: process.env.REGISTRY_PASSWORD || '',
harborCoreService: process.env.HARBOR_CORE_SERVICE || 'harbor-core',
}, },
build: { build: {
@@ -139,17 +145,23 @@ export default () => ({
* and managed-service charts (e.g. `node:20-alpine` → * and managed-service charts (e.g. `node:20-alpine` →
* `registry.abrban.com/proxy-dockerhub/library/node:20-alpine`). * `registry.abrban.com/proxy-dockerhub/library/node:20-alpine`).
*/ */
baseImageRegistry: (process.env.BASE_IMAGE_REGISTRY || 'registry.abrban.com/proxy-dockerhub/library') baseImageRegistry: (process.env.BASE_IMAGE_REGISTRY || 'registry.abrban.com/abrban')
.trim() .trim()
.replace(/\/+$/, ''), .replace(/\/+$/, ''),
/** Full image refs for Kaniko jobs — override via Helm values or env. */ /** Full image refs for Kaniko jobs — override via Helm values or env. */
images: { images: {
kaniko: kaniko:
process.env.KANIKO_IMAGE || process.env.KANIKO_IMAGE ||
'registry.abrban.com/proxy-gcr/kaniko-project/executor:v1.23.2', 'registry.abrban.com/abrban/kaniko-executor:v1.27.6-debug',
alpine: (process.env.BUILD_ALPINE_IMAGE || 'registry.abrban.com/proxy-dockerhub/library/alpine:3.19').trim(), alpine: (process.env.BUILD_ALPINE_IMAGE || 'registry.abrban.com/abrban/alpine:3.19').trim(),
alpineGit: (process.env.BUILD_ALPINE_GIT_IMAGE || 'registry.abrban.com/proxy-dockerhub/alpine/git:2.43.0').trim(), alpineGit: (process.env.BUILD_ALPINE_GIT_IMAGE || 'registry.abrban.com/abrban/alpine-git:2.43.0').trim(),
}, },
/**
* Secret name with HTTP_PROXY / HTTPS_PROXY / NO_PROXY for build pods
* (Kaniko RUN steps: npm, apk, composer, pip; init containers: apk, git clone).
* Empty = disabled (clusters with direct egress).
*/
egressProxySecret: (process.env.BUILD_EGRESS_PROXY_SECRET || '').trim(),
/** Kaniko build container resources — tune for large images. */ /** Kaniko build container resources — tune for large images. */
kaniko: { kaniko: {
cpuRequest: process.env.KANIKO_CPU_REQUEST || '500m', cpuRequest: process.env.KANIKO_CPU_REQUEST || '500m',
@@ -182,7 +182,7 @@ export class DeploymentsService implements OnModuleInit {
}); });
await this.kubernetesService.waitForApplicationReady( await this.kubernetesService.waitForApplicationReady(
app, app,
600_000, 1_200_000,
() => this.isDeploymentCancelled(deploymentId), () => this.isDeploymentCancelled(deploymentId),
); );
@@ -295,7 +295,7 @@ export class DeploymentsService implements OnModuleInit {
}); });
await this.kubernetesService.waitForApplicationReady( await this.kubernetesService.waitForApplicationReady(
app, app,
600_000, 1_200_000,
() => this.isDeploymentCancelled(deploymentId), () => this.isDeploymentCancelled(deploymentId),
); );
+57 -11
View File
@@ -28,6 +28,30 @@ export class RegistryService {
return url.replace(/^https?:\/\//, ''); return url.replace(/^https?:\/\//, '');
} }
/** Docker auth key — hostname[:port] only, no repository path prefix. */
getRegistryHost(): string {
const url = this.getRegistryUrl();
const slash = url.indexOf('/');
return slash === -1 ? url : url.slice(0, slash);
}
/** Push target host[:port][/project] — Kaniko via harbor-core when configured (Harbor UI metadata). */
getRegistryPushUrl(): string {
const buildNs = this.getBuildNamespace();
const url =
this.configService.get<string>('registry.pushUrl') ||
this.configService.get<string>('registry.url') ||
`registry.${buildNs}.svc.cluster.local:5000`;
return url.replace(/^https?:\/\//, '');
}
/** Push target host:port only, no repository path prefix. */
getRegistryPushHost(): string {
const url = this.getRegistryPushUrl();
const slash = url.indexOf('/');
return slash === -1 ? url : url.slice(0, slash);
}
getRegistryCredentials(): { username: string; password: string } { getRegistryCredentials(): { username: string; password: string } {
return { return {
username: this.configService.get<string>('registry.username') || 'admin', username: this.configService.get<string>('registry.username') || 'admin',
@@ -39,6 +63,11 @@ export class RegistryService {
return `${this.getRegistryUrl()}/${userId}/${appName}:${tag}`; return `${this.getRegistryUrl()}/${userId}/${appName}:${tag}`;
} }
/** Kaniko push target — uses registry.url (in-cluster harbor-registry on Harbor setups). */
buildPushImageReference(userId: string, appName: string, tag: string): string {
return `${this.getRegistryPushUrl()}/${userId}/${appName}:${tag}`;
}
parseImageReference(imageRef: string): ParsedImageReference { parseImageReference(imageRef: string): ParsedImageReference {
const normalized = imageRef.replace(/^https?:\/\//, ''); const normalized = imageRef.replace(/^https?:\/\//, '');
const slashIdx = normalized.indexOf('/'); const slashIdx = normalized.indexOf('/');
@@ -56,24 +85,41 @@ export class RegistryService {
}; };
} }
/** Re-point any stored image (e.g. legacy external host) to the in-cluster registry. */ /** Re-point any stored image (e.g. push host) to the pull registry URL for kubelet. */
normalizeImageReference(imageRef: string): string { normalizeImageReference(imageRef: string): string {
const { repository, tag } = this.parseImageReference(imageRef); const { repository, tag } = this.parseImageReference(imageRef);
return `${this.getRegistryUrl()}/${repository}:${tag}`; const pullBase = this.getRegistryUrl().replace(/\/$/, '');
const slash = pullBase.indexOf('/');
const pullPath = slash === -1 ? '' : pullBase.slice(slash + 1);
let repo = repository;
if (pullPath && (repo === pullPath || repo.startsWith(`${pullPath}/`))) {
repo = repo === pullPath ? '' : repo.slice(pullPath.length + 1);
}
if (!repo) {
throw new Error(`Invalid image reference after normalization: ${imageRef}`);
}
return `${pullBase}/${repo}:${tag}`;
} }
buildDockerConfigJson(): string { buildDockerConfigJson(): string {
const { username, password } = this.getRegistryCredentials(); const { username, password } = this.getRegistryCredentials();
const auth = username && password ? Buffer.from(`${username}:${password}`).toString('base64') : ''; const auth = username && password ? Buffer.from(`${username}:${password}`).toString('base64') : '';
const host = this.getRegistryUrl(); const pullHost = this.getRegistryHost();
return JSON.stringify({ const pushHost = this.getRegistryPushHost();
auths: { const auths: Record<string, { auth: string }> = {
[host]: { auth }, [pullHost]: { auth },
[`registry.${this.getBuildNamespace()}.svc.cluster.local:5000`]: { [`registry.${this.getBuildNamespace()}.svc.cluster.local:5000`]: { auth },
auth, };
}, if (pushHost !== pullHost) {
}, auths[pushHost] = { auth };
}); }
// Legacy direct-registry push host (base image pulls during Kaniko build).
const directPushHost = this.configService.get<string>('registry.url')?.replace(/^https?:\/\//, '');
const directHostOnly = directPushHost?.includes('/') ? directPushHost.slice(0, directPushHost.indexOf('/')) : directPushHost;
if (directHostOnly && directHostOnly !== pushHost && directHostOnly !== pullHost) {
auths[directHostOnly] = { auth };
}
return JSON.stringify({ auths });
} }
async ensureRegistryPullSecret(coreApi: k8s.CoreV1Api, namespace: string): Promise<void> { async ensureRegistryPullSecret(coreApi: k8s.CoreV1Api, namespace: string): Promise<void> {
+124
View File
@@ -0,0 +1,124 @@
# registry.abrban.com — Traefik path split
#
# Proxy-cache projects (proxy-dockerhub, proxy-gcr, …) MUST hit harbor-core so
# Harbor can pull upstream on demand. harbor-registry only stores blobs; it does
# not run proxy-cache logic → 404 for uncached proxy paths.
#
# Direct pushes (abrban/, rook/) stay on harbor-registry where Kaniko/skopeo
# wrote the blobs.
#
# Apply: kubectl apply -f gitops/harbor/registry-ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: registry
namespace: cloudhost
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: websecure
traefik.ingress.kubernetes.io/router.middlewares: cloudhost-long-timeout@kubernetescrd
spec:
ingressClassName: traefik
tls:
- hosts:
- registry.abrban.com
secretName: abrban-wildcard-tls
rules:
- host: registry.abrban.com
http:
paths:
# ── Proxy-cache (harbor-core serves v2 + on-demand upstream pull) ──
- path: /v2/proxy-dockerhub/
pathType: Prefix
backend:
service:
name: harbor-core
port:
number: 80
- path: /v2/proxy-gcr/
pathType: Prefix
backend:
service:
name: harbor-core
port:
number: 80
- path: /v2/proxy-quay/
pathType: Prefix
backend:
service:
name: harbor-core
port:
number: 80
- path: /v2/proxy-k8s/
pathType: Prefix
backend:
service:
name: harbor-core
port:
number: 80
- path: /v2/proxy-gitea/
pathType: Prefix
backend:
service:
name: harbor-core
port:
number: 80
# ── abrban/rook project images (served by harbor-core; required for k3s mirror pulls) ──
- path: /v2/abrban/
pathType: Prefix
backend:
service:
name: harbor-core
port:
number: 80
- path: /v2/rook/
pathType: Prefix
backend:
service:
name: harbor-core
port:
number: 80
# ── Legacy registry (platform images pre-Harbor) ──
- path: /v2/
pathType: Prefix
backend:
service:
name: registry
port:
number: 5000
# ── Harbor UI / API ──
- path: /api/
pathType: Prefix
backend:
service:
name: harbor-core
port:
number: 80
- path: /service/
pathType: Prefix
backend:
service:
name: harbor-core
port:
number: 80
- path: /c/
pathType: Prefix
backend:
service:
name: harbor-core
port:
number: 80
- path: /chartrepo/
pathType: Prefix
backend:
service:
name: harbor-core
port:
number: 80
- path: /
pathType: Prefix
backend:
service:
name: harbor-portal
port:
number: 80
+13 -2
View File
@@ -31,7 +31,7 @@ spec:
mountPath: /workspace mountPath: /workspace
containers: containers:
- name: kaniko - name: kaniko
image: registry.abrban.com/proxy-gcr/kaniko-project/executor:v1.23.2 image: registry.abrban.com/abrban/kaniko-executor:v1.27.6-debug
env: env:
- name: IMAGE_TAG - name: IMAGE_TAG
value: bootstrap value: bootstrap
@@ -40,7 +40,10 @@ spec:
- -ec - -ec
- | - |
set -eux set -eux
REG="harbor-registry.cloudhost.svc.cluster.local:5000/abrban" REG="harbor-core.cloudhost.svc.cluster.local/abrban"
unset HTTP_PROXY HTTPS_PROXY http_proxy https_proxy || true
export NO_PROXY="harbor-core.cloudhost.svc.cluster.local,harbor-registry.cloudhost.svc.cluster.local,registry.abrban.com,10.43.0.0/16,.svc,.cluster.local"
export no_proxy="$NO_PROXY"
/kaniko/executor \ /kaniko/executor \
--dockerfile=/workspace/backend/Dockerfile \ --dockerfile=/workspace/backend/Dockerfile \
--context=dir:///workspace/backend \ --context=dir:///workspace/backend \
@@ -55,6 +58,14 @@ spec:
volumeMounts: volumeMounts:
- name: workspace - name: workspace
mountPath: /workspace mountPath: /workspace
- name: docker-config
mountPath: /kaniko/.docker
volumes: volumes:
- name: workspace - name: workspace
emptyDir: {} emptyDir: {}
- name: docker-config
secret:
secretName: kaniko-harbor-auth
items:
- key: .dockerconfigjson
path: config.json
+18 -14
View File
@@ -2,21 +2,15 @@
# The real secret is managed as a SealedSecret in the cloud-host-gitops repo # The real secret is managed as a SealedSecret in the cloud-host-gitops repo
# (sealed-secrets/kaniko-harbor-auth.yaml). # (sealed-secrets/kaniko-harbor-auth.yaml).
# #
# Kaniko pushes directly to the internal registry endpoint # Kaniko pushes via harbor-core (Harbor UI metadata). Pull base images may still
# (harbor-registry.cloudhost.svc.cluster.local:5000), which bypasses harbor-core. # use harbor-registry:5000 — include auth for both hosts in one dockerconfigjson.
# That endpoint only accepts the internal registry credential — Harbor robot
# accounts do NOT work there (their tokens are issued by harbor-core's token
# service). Use the harbor_registry_user credential from the harbor-core secret:
# #
# REG_PASS="$(kubectl -n cloudhost get secret harbor-core \ # ADMIN="$(kubectl -n cloudhost get secret harbor-core -o jsonpath='{.data.HARBOR_ADMIN_PASSWORD}' | base64 -d)"
# -o jsonpath='{.data.REGISTRY_CREDENTIAL_PASSWORD}' | base64 -d)" # REG_PASS="$(kubectl -n cloudhost get secret harbor-core -o jsonpath='{.data.REGISTRY_CREDENTIAL_PASSWORD}' | base64 -d)"
# kubectl -n cloudhost-builds create secret docker-registry kaniko-harbor-auth \ # kubectl -n cloudhost-builds create secret generic kaniko-harbor-auth \
# --docker-server=harbor-registry.cloudhost.svc.cluster.local:5000 \ # --from-literal=admin="${ADMIN}" --from-literal=reg_pass="${REG_PASS}" --dry-run=client -o yaml | ...
# --docker-username=harbor_registry_user \
# --docker-password="${REG_PASS}"
# #
# The build-deploy workflow mounts this secret at /kaniko/.docker/config.json # See RUNBOOK-CICD.fa.md for the full procedure.
# inside every Kaniko Job. See RUNBOOK-CICD.fa.md for the full procedure.
apiVersion: v1 apiVersion: v1
kind: Secret kind: Secret
metadata: metadata:
@@ -27,10 +21,20 @@ stringData:
.dockerconfigjson: | .dockerconfigjson: |
{ {
"auths": { "auths": {
"harbor-core.cloudhost.svc.cluster.local": {
"username": "admin",
"password": "<HARBOR_ADMIN_PASSWORD>",
"auth": "<base64 of admin:password>"
},
"harbor-registry.cloudhost.svc.cluster.local:5000": { "harbor-registry.cloudhost.svc.cluster.local:5000": {
"username": "harbor_registry_user", "username": "harbor_registry_user",
"password": "<REGISTRY_CREDENTIAL_PASSWORD>", "password": "<REGISTRY_CREDENTIAL_PASSWORD>",
"auth": "<base64 of username:password>" "auth": "<base64 of harbor_registry_user:password>"
},
"registry.abrban.com": {
"username": "harbor_registry_user",
"password": "<REGISTRY_CREDENTIAL_PASSWORD>",
"auth": "<base64>"
} }
} }
} }
+13 -2
View File
@@ -7,7 +7,7 @@
# kubectl -n cloudhost wait --for=condition=complete job/seed-ci-images --timeout=15m # kubectl -n cloudhost wait --for=condition=complete job/seed-ci-images --timeout=15m
# #
# Images copied (see RUNBOOK-CICD.fa.md): # Images copied (see RUNBOOK-CICD.fa.md):
# abrban/act-runner, abrban/alpine-git, abrban/node, abrban/kaniko-executor # abrban/act-runner, abrban/alpine, abrban/alpine-git, abrban/node, abrban/kaniko-executor
apiVersion: batch/v1 apiVersion: batch/v1
kind: Job kind: Job
metadata: metadata:
@@ -39,6 +39,9 @@ spec:
skopeo copy --dest-tls-verify=false --dest-creds="${CREDS}" \ skopeo copy --dest-tls-verify=false --dest-creds="${CREDS}" \
docker://docker.io/gitea/act_runner:0.2.11 \ docker://docker.io/gitea/act_runner:0.2.11 \
"${DEST}/act-runner:0.2.11" "${DEST}/act-runner:0.2.11"
skopeo copy --dest-tls-verify=false --dest-creds="${CREDS}" \
docker://docker.io/library/alpine:3.19 \
"${DEST}/alpine:3.19"
skopeo copy --dest-tls-verify=false --dest-creds="${CREDS}" \ skopeo copy --dest-tls-verify=false --dest-creds="${CREDS}" \
docker://docker.io/alpine/git:2.43.0 \ docker://docker.io/alpine/git:2.43.0 \
"${DEST}/alpine-git:2.43.0" "${DEST}/alpine-git:2.43.0"
@@ -46,6 +49,14 @@ spec:
docker://docker.io/library/node:24-alpine \ docker://docker.io/library/node:24-alpine \
"${DEST}/node:24-alpine" "${DEST}/node:24-alpine"
skopeo copy --dest-tls-verify=false --dest-creds="${CREDS}" \ skopeo copy --dest-tls-verify=false --dest-creds="${CREDS}" \
docker://gcr.io/kaniko-project/executor:v1.27.6-debug \ docker://docker.io/library/node:20-alpine \
"${DEST}/node:20-alpine"
# Tag present in Harbor abrban/ — seed via proxy-gcr (see seed-ci-images.yaml).
skopeo copy --dest-tls-verify=false --dest-creds="${CREDS}" \
docker://registry.abrban.com/proxy-gcr/kaniko-project/executor:v1.23.2 \
"${DEST}/kaniko-executor:v1.23.2"
# Alias for CI/configs that reference the debug tag name.
skopeo copy --dest-tls-verify=false --dest-creds="${CREDS}" \
docker://harbor-registry.cloudhost.svc.cluster.local:5000/abrban/kaniko-executor:v1.23.2 \
"${DEST}/kaniko-executor:v1.27.6-debug" "${DEST}/kaniko-executor:v1.27.6-debug"
echo SEED_OK echo SEED_OK
+2
View File
@@ -2,6 +2,8 @@
# Proxy-cache only works through harbor-core (not harbor-registry or Traefik /v2/ alone). # Proxy-cache only works through harbor-core (not harbor-registry or Traefik /v2/ alone).
# #
# Apply: ./scripts/apply-k3s-registries.sh # Apply: ./scripts/apply-k3s-registries.sh
# Harbor EXT_ENDPOINT should be http://registry.abrban.com so OAuth realm uses HTTP
# (kubelet mirror hits harbor-core on :80; https://<clusterIP>:443 times out).
mirrors: mirrors:
registry.abrban.com: registry.abrban.com:
+20 -4
View File
@@ -11,6 +11,11 @@
namespace: cloudhost namespace: cloudhost
createNamespace: false createNamespace: false
registry:
credentialsSecret: harbor-core
credentialsPasswordKey: REGISTRY_CREDENTIAL_PASSWORD
username: harbor_registry_user
global: global:
storageClass: local-path storageClass: local-path
@@ -31,10 +36,12 @@ images:
# Kaniko job images — Harbor proxy-cache (first pull is slow, no manual seed needed). # Kaniko job images — Harbor proxy-cache (first pull is slow, no manual seed needed).
build: build:
images: images:
kaniko: registry.abrban.com/proxy-gcr/kaniko-project/executor:v1.23.2 kaniko: registry.abrban.com/abrban/kaniko-executor:v1.27.6-debug
alpine: registry.abrban.com/proxy-dockerhub/library/alpine:3.19 alpine: registry.abrban.com/abrban/alpine:3.19
alpineGit: registry.abrban.com/proxy-dockerhub/alpine/git:2.43.0 alpineGit: registry.abrban.com/abrban/alpine-git:2.43.0
baseImageRegistry: registry.abrban.com/proxy-dockerhub/library baseImageRegistry: registry.abrban.com/abrban
# Kaniko + init containers (npm/apk/composer/pip/git clone) on restricted egress.
egressProxySecret: registry-egress-proxy
postgres: postgres:
enabled: true enabled: true
@@ -96,6 +103,8 @@ backend:
PLATFORM_DOMAIN: apps.abrban.com PLATFORM_DOMAIN: apps.abrban.com
PREVIEW_BASE_DOMAIN: apps.abrban.com PREVIEW_BASE_DOMAIN: apps.abrban.com
FRONTEND_URL: https://panel.abrban.com,https://abrban.com FRONTEND_URL: https://panel.abrban.com,https://abrban.com
# Push via harbor-core so artifacts appear in Harbor UI; pull stays on registry.abrban.com.
REGISTRY_PUSH_URL: harbor-core.cloudhost.svc.cluster.local/abrban
REGISTRY_URL: harbor-registry.cloudhost.svc.cluster.local:5000/abrban REGISTRY_URL: harbor-registry.cloudhost.svc.cluster.local:5000/abrban
REGISTRY_PULL_URL: registry.abrban.com/abrban REGISTRY_PULL_URL: registry.abrban.com/abrban
BUILD_NAMESPACE: cloudhost-builds BUILD_NAMESPACE: cloudhost-builds
@@ -112,6 +121,13 @@ backend:
KIBANA_SYSTEM_PASSWORD: "CHANGE_VIA_SEALEDSECRET_OR_KUBECTL" KIBANA_SYSTEM_PASSWORD: "CHANGE_VIA_SEALEDSECRET_OR_KUBECTL"
# Swagger disabled in production unless explicitly enabled # Swagger disabled in production unless explicitly enabled
# SWAGGER_ENABLED: "true" # SWAGGER_ENABLED: "true"
# OTP SMS — username/password in abrban-platform-secrets (SealedSecret).
sms:
enabled: true
provider: mizbansms
from: "5000467254"
api: "2016"
userType: "2"
frontend: frontend:
enabled: true enabled: true
@@ -3,7 +3,9 @@
# Real SealedSecret lives in cloud-host-gitops/sealed-secrets/ — never commit plaintext passwords. # Real SealedSecret lives in cloud-host-gitops/sealed-secrets/ — never commit plaintext passwords.
# #
# Required keys (must match backend Deployment + validate-production-config): # Required keys (must match backend Deployment + validate-production-config):
# postgres-password, jwt-secret, jwt-refresh-secret, cluster-kubeconfig-key, redis-password # postgres-password, jwt-secret, jwt-refresh-secret, cluster-kubeconfig-key,
# redis-password, elastic-password (must match elasticsearch-credentials in logging),
# mizbansms-username, mizbansms-password (OTP SMS — required when backend.sms.enabled)
# #
# Generate (replace CHANGE_ME_* with strong random values): # Generate (replace CHANGE_ME_* with strong random values):
# #
@@ -13,6 +15,9 @@
# --from-literal=jwt-refresh-secret='CHANGE_ME_REFRESH_32CHARS_MIN' \ # --from-literal=jwt-refresh-secret='CHANGE_ME_REFRESH_32CHARS_MIN' \
# --from-literal=cluster-kubeconfig-key='0123456789abcdef0123456789abcdef' \ # --from-literal=cluster-kubeconfig-key='0123456789abcdef0123456789abcdef' \
# --from-literal=redis-password='CHANGE_ME_REDIS' \ # --from-literal=redis-password='CHANGE_ME_REDIS' \
# --from-literal=elastic-password='CHANGE_ME_ELASTIC' \
# --from-literal=mizbansms-username='CHANGE_ME_SMS_USER' \
# --from-literal=mizbansms-password='CHANGE_ME_SMS_PASS' \
# --dry-run=client -o json \ # --dry-run=client -o json \
# | kubeseal \ # | kubeseal \
# --controller-name=sealed-secrets-controller \ # --controller-name=sealed-secrets-controller \
+12 -1
View File
@@ -8,6 +8,16 @@ HARBOR_CORE_IP="${HARBOR_CORE_IP:-$(kubectl -n cloudhost get svc harbor-core -o
REG_USER="${REG_USER:-harbor_registry_user}" REG_USER="${REG_USER:-harbor_registry_user}"
REG_PASS="${REG_PASS:-$(kubectl -n cloudhost get secret harbor-core -o jsonpath='{.data.REGISTRY_CREDENTIAL_PASSWORD}' | base64 -d)}" REG_PASS="${REG_PASS:-$(kubectl -n cloudhost get secret harbor-core -o jsonpath='{.data.REGISTRY_CREDENTIAL_PASSWORD}' | base64 -d)}"
REG_PASS_B64="$(printf '%s' "$REG_PASS" | base64 | tr -d '\n')"
kubectl -n "${NS}" delete pod k3s-registries-setup --ignore-not-found
kubectl -n "${NS}" create secret generic k3s-registries-setup-env \
--from-literal=HARBOR_CORE_IP="${HARBOR_CORE_IP}" \
--from-literal=REG_USER="${REG_USER}" \
--from-literal=REG_PASS_B64="${REG_PASS_B64}" \
--dry-run=client -o yaml | kubectl apply -f -
kubectl -n "${NS}" delete pod k3s-registries-setup --ignore-not-found kubectl -n "${NS}" delete pod k3s-registries-setup --ignore-not-found
kubectl -n "${NS}" run k3s-registries-setup \ kubectl -n "${NS}" run k3s-registries-setup \
@@ -23,7 +33,8 @@ kubectl -n "${NS}" run k3s-registries-setup \
"name": "setup", "name": "setup",
"image": "rancher/mirrored-library-busybox:1.36.1", "image": "rancher/mirrored-library-busybox:1.36.1",
"securityContext": {"privileged": true}, "securityContext": {"privileged": true},
"command": ["sh", "-ec", "mkdir -p /host/etc/rancher/k3s && cat > /host/etc/rancher/k3s/registries.yaml <<'REGEOF'\nmirrors:\n registry.abrban.com:\n endpoint:\n - http://${HARBOR_CORE_IP}\n \\\"registry.cloudhost-builds.svc.cluster.local:5000\\\":\n endpoint:\n - \\\"http://127.0.0.1:30500\\\"\nconfigs:\n registry.abrban.com:\n auth:\n username: ${REG_USER}\n password: ${REG_PASS}\n \\\"${HARBOR_CORE_IP}\\\":\n auth:\n username: ${REG_USER}\n password: ${REG_PASS}\n \\\"registry.cloudhost-builds.svc.cluster.local:5000\\\":\n auth:\n username: admin\n password: \\\"\\\"\n \\\"127.0.0.1:30500\\\":\n auth:\n username: admin\n password: \\\"\\\"\nREGEOF\nnsenter -t 1 -m -u -n -i -- systemctl restart k3s 2>/dev/null || true\necho k3s-restarted\nsleep 30"], "envFrom": [{"secretRef": {"name": "k3s-registries-setup-env"}}],
"command": ["sh", "-ec", "REG_PASS=\$(echo \"\$REG_PASS_B64\" | base64 -d); mkdir -p /host/etc/rancher/k3s && cat > /host/etc/rancher/k3s/registries.yaml <<REGEOF\nmirrors:\n registry.abrban.com:\n endpoint:\n - http://\${HARBOR_CORE_IP}\n \\\"registry.cloudhost-builds.svc.cluster.local:5000\\\":\n endpoint:\n - \\\"http://127.0.0.1:30500\\\"\nconfigs:\n registry.abrban.com:\n auth:\n username: \${REG_USER}\n password: \${REG_PASS}\n \\\"\${HARBOR_CORE_IP}\\\":\n auth:\n username: \${REG_USER}\n password: \${REG_PASS}\n \\\"registry.cloudhost-builds.svc.cluster.local:5000\\\":\n auth:\n username: admin\n password: \\\"\\\"\n \\\"127.0.0.1:30500\\\":\n auth:\n username: admin\n password: \\\"\\\"\nREGEOF\nnsenter -t 1 -m -u -n -i -- systemctl restart k3s 2>/dev/null || true\necho k3s-restarted\nsleep 30"],
"volumeMounts": [{"name": "host", "mountPath": "/host"}] "volumeMounts": [{"name": "host", "mountPath": "/host"}]
}], }],
"volumes": [{"name": "host", "hostPath": {"path": "/"}}] "volumes": [{"name": "host", "hostPath": {"path": "/"}}]
+14 -2
View File
@@ -40,13 +40,17 @@ spec:
mountPath: /workspace mountPath: /workspace
containers: containers:
- name: kaniko - name: kaniko
image: registry.abrban.com/proxy-gcr/kaniko-project/executor:v1.23.2 image: registry.abrban.com/abrban/kaniko-executor:v1.27.6-debug
command: command:
- sh - sh
- -ec - -ec
- | - |
set -eux set -eux
REG="harbor-registry.cloudhost.svc.cluster.local:5000/abrban" # Push via harbor-core so artifacts appear in Harbor UI
REG="harbor-core.cloudhost.svc.cluster.local/abrban"
unset HTTP_PROXY HTTPS_PROXY http_proxy https_proxy || true
export NO_PROXY="harbor-core.cloudhost.svc.cluster.local,harbor-registry.cloudhost.svc.cluster.local,registry.abrban.com,10.43.0.0/16,.svc,.cluster.local"
export no_proxy="$NO_PROXY"
/kaniko/executor \ /kaniko/executor \
--dockerfile=/workspace/backend/Dockerfile \ --dockerfile=/workspace/backend/Dockerfile \
--context=dir:///workspace/backend \ --context=dir:///workspace/backend \
@@ -62,9 +66,17 @@ spec:
volumeMounts: volumeMounts:
- name: workspace - name: workspace
mountPath: /workspace mountPath: /workspace
- name: docker-config
mountPath: /kaniko/.docker
volumes: volumes:
- name: workspace - name: workspace
emptyDir: {} emptyDir: {}
- name: docker-config
secret:
secretName: kaniko-harbor-auth
items:
- key: .dockerconfigjson
path: config.json
EOF EOF
echo "==> Waiting for build job..." echo "==> Waiting for build job..."