Add GitOps stack for abrban.com with Gitea Actions CI/CD.
Build and Deploy Platform / build-push-deploy (push) Has been cancelled

Harbor in-cluster builds via Kaniko, ArgoCD auto-sync, and production Helm values for abrban.com domains.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
keyhan
2026-07-02 01:27:20 +03:30
parent ee5bd0a291
commit 5ed2ef0958
39 changed files with 1841 additions and 99 deletions
@@ -19,6 +19,10 @@ spec:
labels:
app: {{ include "cloudhost-platform.backend.fullname" . }}
spec:
{{- with .Values.backend.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
initContainers:
{{- if .Values.postgres.enabled }}
- name: wait-postgres
@@ -88,6 +92,11 @@ spec:
- name: {{ $key }}
value: {{ $val | quote }}
{{- end }}
{{- if .Values.backend.sourceStorage.enabled }}
envFrom:
- secretRef:
name: {{ .Values.backend.sourceStorage.existingSecret }}
{{- end }}
volumeMounts:
- name: uploads
mountPath: /app/uploads
@@ -17,6 +17,10 @@ spec:
labels:
app: {{ include "cloudhost-platform.frontend.fullname" . }}
spec:
{{- with .Values.frontend.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
containers:
- name: frontend
image: {{ include "cloudhost-platform.frontendImage" . | quote }}
@@ -7,7 +7,7 @@ metadata:
labels:
{{- include "cloudhost-platform.labels" . | nindent 4 }}
annotations:
{{- if .Values.ingress.tls.enabled }}
{{- if and .Values.ingress.tls.enabled (not .Values.ingress.tls.secretName) }}
cert-manager.io/cluster-issuer: {{ .Values.ingress.tls.clusterIssuer | quote }}
{{- end }}
{{- if and .Values.ingress.singleHost.enabled .Values.ingress.singleHost.apiPath }}
@@ -36,6 +36,10 @@ ingress:
clusterIssuer: letsencrypt-prod
backend:
# Enable after copying ceph-app-sources-credentials secret into the cloudhost namespace
sourceStorage:
enabled: false
existingSecret: ceph-app-sources-credentials
env:
PLATFORM_DOMAIN: apps.example.com
REGISTRY_URL: registry.cloudhost-builds.svc.cluster.local:5000
@@ -42,8 +42,13 @@ redis:
backend:
enabled: true
replicas: 1
imagePullSecrets:
- name: registry-pull-secret
uploads:
size: 20Gi
sourceStorage:
enabled: false
existingSecret: ceph-app-sources-credentials
resources: {}
extraEnv: {}
env:
@@ -66,6 +71,8 @@ backend:
frontend:
enabled: true
replicas: 1
imagePullSecrets:
- name: registry-pull-secret
resources: {}
# JWT secrets — set in production (values-production.example.yaml)
+248
View File
@@ -0,0 +1,248 @@
# Maddy mail server — lightweight full mail server (SMTP + IMAP + DKIM)
# Namespace: mail | Host: mail.abrban.com | Primary domain: abrban.com
#
# Exposed on node IP 78.157.39.52 via k3s servicelb (klipper).
# TLS uses the *.abrban.com wildcard cert (secret abrban-wildcard-tls, copied into ns mail).
#
# NOTE (Iran/IP reputation): inbound mail (receiving) works; outbound delivery to
# Gmail/Outlook may be blocked or land in spam, and outbound port 25 may be filtered
# by the ISP. Use a smarthost relay if real external delivery is required.
---
apiVersion: v1
kind: ConfigMap
metadata:
name: maddy-config
namespace: mail
data:
maddy.conf: |
## Maddy Mail Server - configuration (mail.abrban.com)
$(hostname) = mail.abrban.com
$(primary_domain) = abrban.com
$(local_domains) = $(primary_domain)
tls file /etc/maddy/tls/tls.crt /etc/maddy/tls/tls.key
# ---- Local storage & authentication ----
storage.imapsql local_mailboxes {
driver sqlite3
dsn imapsql.db
}
auth.pass_table local_authdb {
table sql_table {
driver sqlite3
dsn credentials.db
table_name passwords
}
}
# ---- Routing ----
hostname $(hostname)
table.chain local_rewrites {
optional_step regexp "(.+)\+(.+)@(.+)" "$1@$3"
optional_step static {
entry postmaster postmaster@$(primary_domain)
}
optional_step file /data/aliases
}
msgpipeline local_routing {
destination postmaster $(local_domains) {
modify {
replace_rcpt &local_rewrites
}
deliver_to &local_mailboxes
}
default_destination {
reject 550 5.1.1 "User doesn't exist"
}
}
# ---- Inbound SMTP (port 25) ----
smtp tcp://0.0.0.0:25 {
limits {
all rate 20 1s
all concurrency 10
}
dmarc yes
check {
require_mx_record
dkim
spf
}
source $(local_domains) {
reject 501 5.1.8 "Use Submission for outgoing SMTP"
}
default_source {
destination postmaster $(local_domains) {
deliver_to &local_routing
}
default_destination {
reject 550 5.1.1 "User doesn't exist"
}
}
}
# ---- Submission (ports 465 implicit-TLS, 587 STARTTLS) ----
submission tls://0.0.0.0:465 tcp://0.0.0.0:587 {
limits {
all rate 50 1s
}
auth &local_authdb
source $(local_domains) {
check {
authorize_sender {
prepare_email &local_rewrites
user_to_email identity
}
}
destination postmaster $(local_domains) {
deliver_to &local_routing
}
default_destination {
modify {
dkim $(primary_domain) $(hostname) default
}
deliver_to &remote_queue
}
}
default_source {
reject 501 5.1.8 "Non-local sender domain"
}
}
# ---- Outbound delivery queue ----
target.remote outbound_delivery {
limits {
destination rate 20 1s
destination concurrency 10
}
mx_auth {
dane
mtasts {
cache fs
fs_dir mtasts_cache/
}
local_policy {
min_tls_level encrypted
min_mx_level none
}
}
}
target.queue remote_queue {
target &outbound_delivery
autogenerated_msg_domain $(primary_domain)
bounce {
destination postmaster $(local_domains) {
deliver_to &local_routing
}
default_destination {
reject 550 5.0.0 "Refusing to send DSNs to non-local addresses"
}
}
}
# ---- IMAP (993 implicit-TLS, 143 STARTTLS) ----
imap tls://0.0.0.0:993 tcp://0.0.0.0:143 {
auth &local_authdb
storage &local_mailboxes
}
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: maddy-data
namespace: mail
spec:
accessModes: ["ReadWriteOnce"]
storageClassName: local-path
resources:
requests:
storage: 5Gi
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: maddy
namespace: mail
labels:
app: maddy
spec:
replicas: 1
strategy:
type: Recreate
selector:
matchLabels:
app: maddy
template:
metadata:
labels:
app: maddy
spec:
containers:
- name: maddy
image: foxcpp/maddy:0.7
imagePullPolicy: IfNotPresent
env:
- name: MADDY_HOSTNAME
value: mail.abrban.com
- name: MADDY_DOMAIN
value: abrban.com
ports:
- { name: smtp, containerPort: 25 }
- { name: submission, containerPort: 587 }
- { name: smtps, containerPort: 465 }
- { name: imap, containerPort: 143 }
- { name: imaps, containerPort: 993 }
volumeMounts:
- name: data
mountPath: /data
- name: config
mountPath: /data/maddy.conf
subPath: maddy.conf
- name: tls
mountPath: /etc/maddy/tls
readOnly: true
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: "1"
memory: 256Mi
livenessProbe:
tcpSocket: { port: 25 }
initialDelaySeconds: 15
periodSeconds: 30
volumes:
- name: data
persistentVolumeClaim:
claimName: maddy-data
- name: config
configMap:
name: maddy-config
- name: tls
secret:
secretName: abrban-wildcard-tls
---
apiVersion: v1
kind: Service
metadata:
name: maddy
namespace: mail
labels:
app: maddy
spec:
type: LoadBalancer
externalTrafficPolicy: Local # preserve client source IP (needed for SPF/spam checks)
selector:
app: maddy
ports:
- { name: smtp, port: 25, targetPort: 25 }
- { name: submission, port: 587, targetPort: 587 }
- { name: smtps, port: 465, targetPort: 465 }
- { name: imap, port: 143, targetPort: 143 }
- { name: imaps, port: 993, targetPort: 993 }
+425 -28
View File
@@ -1,13 +1,14 @@
{
"name": "cloudhost-backend",
"name": "abrban-backend",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "cloudhost-backend",
"name": "abrban-backend",
"version": "1.0.0",
"dependencies": {
"@aws-sdk/client-s3": "^3.1077.0",
"@kubernetes/client-node": "^1.4.0",
"@nestjs/bull": "^11.0.4",
"@nestjs/common": "^11.1.24",
@@ -180,6 +181,314 @@
"tslib": "^2.1.0"
}
},
"node_modules/@aws-sdk/checksums": {
"version": "3.1000.10",
"resolved": "https://registry.npmjs.org/@aws-sdk/checksums/-/checksums-3.1000.10.tgz",
"integrity": "sha512-OUNjNcyA8Ai2OdlRUxW5jHUf6XJmqqZk3UddL+mDiUCtXrVqdmIHHkdDFWBlBRjhore/3ZBMgRXgcS0ggtvD0Q==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.974.25",
"@aws-sdk/types": "^3.973.14",
"@smithy/core": "^3.28.0",
"@smithy/types": "^4.15.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/client-s3": {
"version": "3.1077.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1077.0.tgz",
"integrity": "sha512-yWK6jOMrMUgGarXlrQaAopWXva20NaOqTPApuE68SzsBFQ57fQ5E13BKUPLwtPgymk+8L6vG/O4h7Q30IBYr2w==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/checksums": "^3.1000.10",
"@aws-sdk/core": "^3.974.25",
"@aws-sdk/credential-provider-node": "^3.972.60",
"@aws-sdk/middleware-sdk-s3": "^3.972.56",
"@aws-sdk/signature-v4-multi-region": "^3.996.37",
"@aws-sdk/types": "^3.973.14",
"@smithy/core": "^3.28.0",
"@smithy/fetch-http-handler": "^5.6.1",
"@smithy/node-http-handler": "^4.9.1",
"@smithy/types": "^4.15.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/core": {
"version": "3.974.25",
"resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.25.tgz",
"integrity": "sha512-fJFkx6u6wCqGMV/v6EAxiwa2UzEukbvr1hNPv4MrD3yj4IFz011jZg42/eSTOP/u5kJ0tlILqEjCWtT8GiKZvA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.973.14",
"@aws-sdk/xml-builder": "^3.972.32",
"@aws/lambda-invoke-store": "^0.2.2",
"@smithy/core": "^3.28.0",
"@smithy/signature-v4": "^5.6.0",
"@smithy/types": "^4.15.0",
"bowser": "^2.11.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-env": {
"version": "3.972.51",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.51.tgz",
"integrity": "sha512-Xo+/zf5k5pZdo53X8aVXN4MJGfU/M1P7yMM/GbNY/x9fyRZGEzjhKqW38GA0FSQQ9TYKs+bfPyz5ja4bi6pjTQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.974.25",
"@aws-sdk/types": "^3.973.14",
"@smithy/core": "^3.28.0",
"@smithy/types": "^4.15.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-http": {
"version": "3.972.53",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.53.tgz",
"integrity": "sha512-7E9oFUcf9YWe+ttGiWhe/cCSI+pswwelzgQMoKXgPJi1AIfS27TK6et5ZULqEqHu30zbN+jh1RqlwcXqY/aXyg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.974.25",
"@aws-sdk/types": "^3.973.14",
"@smithy/core": "^3.28.0",
"@smithy/fetch-http-handler": "^5.6.1",
"@smithy/node-http-handler": "^4.9.1",
"@smithy/types": "^4.15.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-ini": {
"version": "3.972.58",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.58.tgz",
"integrity": "sha512-MPr0hD8pyDGfF3dWXvFOILhcKTB9ptqJOJK9JEuDQzpc2HgKisY16eR7IrKUXxSbz8LZj+LHz/CS8Y5G1ai7yw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.974.25",
"@aws-sdk/credential-provider-env": "^3.972.51",
"@aws-sdk/credential-provider-http": "^3.972.53",
"@aws-sdk/credential-provider-login": "^3.972.57",
"@aws-sdk/credential-provider-process": "^3.972.51",
"@aws-sdk/credential-provider-sso": "^3.972.57",
"@aws-sdk/credential-provider-web-identity": "^3.972.57",
"@aws-sdk/nested-clients": "^3.997.25",
"@aws-sdk/types": "^3.973.14",
"@smithy/core": "^3.28.0",
"@smithy/credential-provider-imds": "^4.4.4",
"@smithy/types": "^4.15.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-login": {
"version": "3.972.57",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.57.tgz",
"integrity": "sha512-kPWc/SCrl9agKeywxKwPEoQHanWag0LcNQrcZpEQpjNifkxq6tQENhgrrS9al317CF6yytyihlX+FhPHlk0QjA==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.974.25",
"@aws-sdk/nested-clients": "^3.997.25",
"@aws-sdk/types": "^3.973.14",
"@smithy/core": "^3.28.0",
"@smithy/types": "^4.15.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-node": {
"version": "3.972.60",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.60.tgz",
"integrity": "sha512-hE2hIBJQjCDRx8TbSqpVQ+/o2mIrJZQZbQ3LlwE2bJf7z47x5GmhcvGwZPqJH7Oq//SzTXEBGSZ4qSpK3yPbhw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/credential-provider-env": "^3.972.51",
"@aws-sdk/credential-provider-http": "^3.972.53",
"@aws-sdk/credential-provider-ini": "^3.972.58",
"@aws-sdk/credential-provider-process": "^3.972.51",
"@aws-sdk/credential-provider-sso": "^3.972.57",
"@aws-sdk/credential-provider-web-identity": "^3.972.57",
"@aws-sdk/types": "^3.973.14",
"@smithy/core": "^3.28.0",
"@smithy/credential-provider-imds": "^4.4.4",
"@smithy/types": "^4.15.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-process": {
"version": "3.972.51",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.51.tgz",
"integrity": "sha512-081dD2RlnmY+G05v6E73KfACvDjPjnttrLjGHE2SSglbID25UcuijbWpL4g+XR5T2Kl4oIJoVBXi64s+2f009Q==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.974.25",
"@aws-sdk/types": "^3.973.14",
"@smithy/core": "^3.28.0",
"@smithy/types": "^4.15.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-sso": {
"version": "3.972.57",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.57.tgz",
"integrity": "sha512-dC7ZyX3EHKHLOeVUEDzzGvk0L1s6N06YDrau7P0rGXL/j1cO+DzN2w1x9vcEh7zljVCR3019f5mi1Th+GGTURw==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.974.25",
"@aws-sdk/nested-clients": "^3.997.25",
"@aws-sdk/token-providers": "3.1077.0",
"@aws-sdk/types": "^3.973.14",
"@smithy/core": "^3.28.0",
"@smithy/types": "^4.15.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/credential-provider-web-identity": {
"version": "3.972.57",
"resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.57.tgz",
"integrity": "sha512-HtWM3FV2o7NJFJSUqFLBlxmV9RxQRHpzCvQaP1n1Qo4CxQSvwpJ8ERWHiLqXMFDgDXyELt+EZNFcpG6XQRcJbQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.974.25",
"@aws-sdk/nested-clients": "^3.997.25",
"@aws-sdk/types": "^3.973.14",
"@smithy/core": "^3.28.0",
"@smithy/types": "^4.15.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/middleware-sdk-s3": {
"version": "3.972.56",
"resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.56.tgz",
"integrity": "sha512-VFLd7bT8ef48H2n2iDrY/w9EPpXLmC0v4NEriXy2vuTgEP/FrKqrcwi7eobhcOrdO37uLbMt5AWZlY55R2/xqg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.974.25",
"@aws-sdk/signature-v4-multi-region": "^3.996.37",
"@aws-sdk/types": "^3.973.14",
"@smithy/core": "^3.28.0",
"@smithy/types": "^4.15.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/nested-clients": {
"version": "3.997.25",
"resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.25.tgz",
"integrity": "sha512-VpRQ3wR6l+fwRHV5veJL2ehtyQFrGyH/2CJG9DVtb8H3xyqqnZWSTSrq/CJJ7DvDlDgrPRiW2SkYA8pN6VWCFQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.974.25",
"@aws-sdk/signature-v4-multi-region": "^3.996.37",
"@aws-sdk/types": "^3.973.14",
"@smithy/core": "^3.28.0",
"@smithy/fetch-http-handler": "^5.6.1",
"@smithy/node-http-handler": "^4.9.1",
"@smithy/types": "^4.15.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/signature-v4-multi-region": {
"version": "3.996.37",
"resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.37.tgz",
"integrity": "sha512-u8qd064XsHzM0Mk+yH4IPKn/ZC9rdniEKs+neBHNlsPZirw3rcLvmrH4ImoKC4yF7A0I/MbcC3dseARnJLiAhg==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/types": "^3.973.14",
"@smithy/signature-v4": "^5.6.0",
"@smithy/types": "^4.15.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/token-providers": {
"version": "3.1077.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1077.0.tgz",
"integrity": "sha512-sRUkfZ3fpOco95jZHsQUQiXvuIVLvCmWVclFg6dRFDyfsYs6Pdr/NuZ2+yJxeHN+6WAfDh2aZ8nlZntnvuhZUQ==",
"license": "Apache-2.0",
"dependencies": {
"@aws-sdk/core": "^3.974.25",
"@aws-sdk/nested-clients": "^3.997.25",
"@aws-sdk/types": "^3.973.14",
"@smithy/core": "^3.28.0",
"@smithy/types": "^4.15.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/types": {
"version": "3.973.14",
"resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.14.tgz",
"integrity": "sha512-vH4pEu9YBEwr67yT+GVcmKX0GzfIrIYUn+MF5vXg9OspouVnAekuyVyawFvZHEK7WlcwVDwNrqI3ZBDUAiyu9A==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.15.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws-sdk/xml-builder": {
"version": "3.972.32",
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.32.tgz",
"integrity": "sha512-2loKuOMRFDg1nwdni5AtJ9S5juVbRNPNsPC7tWTfkHyycPwACMhxepspUHi8GhvfNlL2cQo3sPMod1uib+KZ0w==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.15.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/@aws/lambda-invoke-store": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz",
"integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@babel/code-frame": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -211,7 +520,6 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -742,6 +1050,31 @@
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"node_modules/@emnapi/core": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
"integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
"integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
@@ -749,6 +1082,7 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
@@ -2278,7 +2612,6 @@
"resolved": "https://registry.npmjs.org/@nestjs/common/-/common-11.1.26.tgz",
"integrity": "sha512-0VARQyzuGbprvjO+slWq9Jtj1P0jYCSKAUSv9LWFNWD39ZbDzXXM1pMs35kReVXwchra0urMfTQxw4uAOfdSzA==",
"license": "MIT",
"peer": true,
"dependencies": {
"file-type": "21.3.4",
"iterare": "1.2.1",
@@ -2325,7 +2658,6 @@
"resolved": "https://registry.npmjs.org/@nestjs/core/-/core-11.1.26.tgz",
"integrity": "sha512-K45zUwYpowEsVqm8qNIzsMcl4LJev0MK9zVhDnmym7YRTJ2/caslqVeKYhPRd5+Fh81IkvWUVu6vEo46uZ5mgQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"fast-safe-stringify": "2.1.1",
"iterare": "1.2.1",
@@ -2408,7 +2740,6 @@
"resolved": "https://registry.npmjs.org/@nestjs/platform-express/-/platform-express-11.1.26.tgz",
"integrity": "sha512-MJ5Kwe52Ag4nlIuLK2ekB6TVYu1a22uvDzc0Aq0wIzcLySIz4YK0fMcrDOKGdbGQWpfZtNu1PM3jhlf4hvf6Og==",
"license": "MIT",
"peer": true,
"dependencies": {
"cors": "2.8.6",
"express": "5.2.1",
@@ -2660,6 +2991,87 @@
"@sinonjs/commons": "^3.0.1"
}
},
"node_modules/@smithy/core": {
"version": "3.28.0",
"resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.28.0.tgz",
"integrity": "sha512-N/LoLG8pZ1zv5cIWpdF6vmSjtZtXKK9G0OqT5yYCOZU+CzPq1+nYA95VoKJBGWRScs7YbMugZ7lZx8Fj1vdHoA==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/types": "^4.15.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/credential-provider-imds": {
"version": "4.4.4",
"resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.4.tgz",
"integrity": "sha512-jT0WrDaM88L5na9FX1xRNywCS3B1n75wPY5Ksasjo0PHUtuI7d8FclksN1BbOSYTiaiKxUDqU23nUymH/V+AaQ==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.28.0",
"@smithy/types": "^4.15.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/fetch-http-handler": {
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.1.tgz",
"integrity": "sha512-fW6l9rWoyk1iyzfuZaERnZLNjB6WIojgGm6Bo9Hpfpy3RUpltjLikNlxTsS/YtxVobcfbCGBuAncREYqT4hvqQ==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.28.0",
"@smithy/types": "^4.15.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/node-http-handler": {
"version": "4.9.1",
"resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.1.tgz",
"integrity": "sha512-m/f15di58P6NtLQ7eVEb5N19NdJWn+4c7zfkFHMT/i3JH7U8UtknpPoy8o2tm2R3OdliYvsvQhZHIfACQDqT+Q==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.28.0",
"@smithy/types": "^4.15.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/signature-v4": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.0.tgz",
"integrity": "sha512-IkPHQdbyoebSwBCuMTzJ/2oIhKVqiZZAZxQYSlpDZqq/WhJUpmdgbHvP7ItddxsPzcDUJeI0V4PNMSNtlZ0aqA==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.28.0",
"@smithy/types": "^4.15.0",
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@smithy/types": {
"version": "4.15.0",
"resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.15.0.tgz",
"integrity": "sha512-Z5TAOxygoFvybJV3igo5SloFflSokHx2hu1eFA+DxDTcn+FtKxUSui+rbTRG1pAafMA888Z3MVvCWUuvCrTXjg==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.6.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@sqltools/formatter": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@sqltools/formatter/-/formatter-1.2.5.tgz",
@@ -2955,7 +3367,6 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz",
"integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==",
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.18.0"
}
@@ -3143,7 +3554,6 @@
"integrity": "sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.61.0",
"@typescript-eslint/types": "8.61.0",
@@ -3915,7 +4325,6 @@
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3974,7 +4383,6 @@
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
@@ -4498,6 +4906,12 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/bowser": {
"version": "2.14.1",
"resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz",
"integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==",
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
@@ -4529,7 +4943,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.10.12",
"caniuse-lite": "^1.0.30001782",
@@ -4609,7 +5022,6 @@
"resolved": "https://registry.npmjs.org/bull/-/bull-4.16.5.tgz",
"integrity": "sha512-lDsx2BzkKe7gkCYiT5Acj02DpTwDznl/VNN7Psn7M3USPG7Vs/BaClZJJTAG+ufAR9++N1/NiUTdaFBWDIl5TQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"cron-parser": "^4.9.0",
"get-port": "^5.1.1",
@@ -4762,7 +5174,6 @@
"integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"readdirp": "^4.0.1"
},
@@ -4810,15 +5221,13 @@
"version": "0.5.1",
"resolved": "https://registry.npmjs.org/class-transformer/-/class-transformer-0.5.1.tgz",
"integrity": "sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/class-validator": {
"version": "0.15.1",
"resolved": "https://registry.npmjs.org/class-validator/-/class-validator-0.15.1.tgz",
"integrity": "sha512-LqoS80HBBSCVhz/3KloUly0ovokxpdOLR++Al3J3+dHXWt9sTKlKd4eYtoxhxyUjoe5+UcIM+5k9MIxyBWnRTw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/validator": "^13.15.3",
"libphonenumber-js": "^1.11.1",
@@ -5504,7 +5913,6 @@
"integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -6935,7 +7343,6 @@
"integrity": "sha512-Yi1jqNC/Oq0N4hBgNH/YvBpP1P57QqundgytzYqy3yqAa7NZPNjSoi4SGbRAXDMdBzNE6xBCi5U7RgfrvMEUVQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@jest/core": "30.4.2",
"@jest/types": "30.4.1",
@@ -7649,7 +8056,6 @@
"resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz",
"integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 10.16.0"
}
@@ -8595,7 +9001,6 @@
"resolved": "https://registry.npmjs.org/passport/-/passport-0.7.0.tgz",
"integrity": "sha512-cPLl+qZpSc+ireUvt+IzqbED1cHHkDoVYMo30jbJIdOOjQ1MQYZBPiNvmi8UM6lJuOpTPXJGZQk0DtC4y61MYQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"passport-strategy": "1.x.x",
"pause": "0.0.1",
@@ -8717,7 +9122,6 @@
"resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz",
"integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==",
"license": "MIT",
"peer": true,
"dependencies": {
"pg-connection-string": "^2.13.0",
"pg-pool": "^3.14.0",
@@ -8965,7 +9369,6 @@
"integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -9321,7 +9724,6 @@
"integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -10327,7 +10729,6 @@
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
@@ -10473,7 +10874,6 @@
"resolved": "https://registry.npmjs.org/typeorm/-/typeorm-1.0.0.tgz",
"integrity": "sha512-2mSKNqucP8vo+xQLP59xlHUcqLvG6qajxA7q7tnhJgeZjTrA6lK/Ar7LRyiAxdXhyXmGbIPsArPmcUB9Xg+M7w==",
"license": "MIT",
"peer": true,
"dependencies": {
"@sqltools/formatter": "^1.2.5",
"ansis": "^4.2.0",
@@ -10687,7 +11087,6 @@
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -10950,7 +11349,6 @@
"integrity": "sha512-wGN3qcrBQIFmQ/c0AiOAQBvrZ5lmY8vbbMv4Mxfgzqd/B6+9pXtLo73WuS1dSGXM5QYY3hZnIbvx+K1xxe6FyA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@types/eslint-scope": "^3.7.7",
"@types/estree": "^1.0.8",
@@ -11186,7 +11584,6 @@
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10.0.0"
},
+2 -1
View File
@@ -1,5 +1,5 @@
{
"name": "cloudhost-backend",
"name": "abrban-backend",
"version": "1.0.0",
"description": "CloudHost PaaS Backend API",
"private": true,
@@ -24,6 +24,7 @@
"sync:migrations": "node scripts/sync-helm-migrations.mjs"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1077.0",
"@kubernetes/client-node": "^1.4.0",
"@nestjs/bull": "^11.0.4",
"@nestjs/common": "^11.1.24",
+2
View File
@@ -18,6 +18,7 @@ import { LifecycleModule } from './lifecycle/lifecycle.module';
import { ApplicationMigrationsModule } from './application-migrations/application-migrations.module';
import { AdminModule } from './admin/admin.module';
import { HealthModule } from './health/health.module';
import { StorageModule } from './storage/storage.module';
import configuration from './config/configuration';
@Module({
@@ -66,6 +67,7 @@ import configuration from './config/configuration';
]),
// Feature modules
StorageModule,
AuthModule,
UsersModule,
ApplicationsModule,
@@ -21,6 +21,8 @@ import {
assertRuntimeMatch,
detectRuntimeFromArchive,
} from '../build/runtime-detector';
import { SourceStorageService } from '../storage/source-storage.service';
import * as os from 'os';
@Injectable()
export class ApplicationsService {
@@ -31,6 +33,7 @@ export class ApplicationsService {
private appsRepository: Repository<Application>,
private clustersService: ClustersService,
private configService: ConfigService,
private sourceStorage: SourceStorageService,
) {}
private toDnsLabel(value: string): string {
@@ -208,17 +211,12 @@ export class ApplicationsService {
async delete(id: string, userId: string): Promise<Application> {
const app = await this.findOne(id, userId);
// Delete uploaded files
// Delete uploaded source files
if (app.codePath) {
try {
const uploadDir = this.configService.get<string>('platform.uploadDir') || './uploads';
const appDir = path.join(uploadDir, app.userId, app.id);
if (fs.existsSync(appDir)) {
fs.rmSync(appDir, { recursive: true, force: true });
this.logger.log(`Deleted upload directory: ${appDir}`);
}
await this.sourceStorage.deleteSource(app.userId, app.id, app.codePath);
} catch (e: any) {
this.logger.warn(`Failed to delete upload dir for ${app.name}: ${e.message}`);
this.logger.warn(`Failed to delete source for ${app.name}: ${e.message}`);
}
}
@@ -265,21 +263,15 @@ export class ApplicationsService {
}
const app = await this.findOne(id, userId);
const uploadDir = this.configService.get<string>('platform.uploadDir') || './uploads';
const appDir = path.join(uploadDir, app.userId, app.id);
// Ensure directory exists
fs.mkdirSync(appDir, { recursive: true });
// Save the zip file
const zipPath = path.join(appDir, 'source.zip');
fs.writeFileSync(zipPath, file.buffer);
const tempPath = path.join(os.tmpdir(), `upload-${app.id}-${Date.now()}.zip`);
fs.writeFileSync(tempPath, file.buffer);
try {
const detected = await detectRuntimeFromArchive(zipPath);
const detected = await detectRuntimeFromArchive(tempPath);
assertRuntimeMatch(app.runtime, detected);
app.codePath = zipPath;
const storedPath = await this.sourceStorage.putSource(app.userId, app.id, file.buffer);
app.codePath = storedPath;
const saved = await this.appsRepository.save(app);
if (detected.confidence === 'low') {
@@ -289,13 +281,19 @@ export class ApplicationsService {
});
}
this.logger.log(`Uploaded code for ${app.name}${zipPath} (${(file.size / 1024).toFixed(1)} KB)`);
this.logger.log(`Uploaded code for ${app.name}${storedPath} (${(file.size / 1024).toFixed(1)} KB)`);
return saved;
} catch (err) {
if (fs.existsSync(zipPath)) {
fs.unlinkSync(zipPath);
try {
await this.sourceStorage.deleteSource(app.userId, app.id);
} catch {
// ignore rollback errors
}
throw err;
} finally {
if (fs.existsSync(tempPath)) {
fs.unlinkSync(tempPath);
}
}
}
+9
View File
@@ -7,6 +7,7 @@ import { AppRuntime } from '../common/enums';
import { ClustersService } from '../clusters/clusters.service';
import { RegistryService } from '../kubernetes/registry.service';
import { BuildProgressStore } from './build-progress.store';
import { SourceStorageService } from '../storage/source-storage.service';
describe('BuildService', () => {
let service: BuildService;
@@ -34,6 +35,14 @@ describe('BuildService', () => {
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();
+22 -7
View File
@@ -11,6 +11,7 @@ import { AppRuntime } from '../common/enums';
import { ClustersService } from '../clusters/clusters.service';
import { RegistryService } from '../kubernetes/registry.service';
import { BuildProgressStore } from './build-progress.store';
import { SourceStorageService } from '../storage/source-storage.service';
import {
detectDjangoSettingsModule,
detectGoBuildTarget,
@@ -64,6 +65,7 @@ export class BuildService {
private clustersService: ClustersService,
private registryService: RegistryService,
private progressStore: BuildProgressStore,
private sourceStorage: SourceStorageService,
) {}
private beginBuildSession(deploymentId: string): void {
@@ -321,13 +323,23 @@ export class BuildService {
this.beginBuildSession(deploymentId);
}
const codePath = app.codePath ? path.resolve(app.codePath) : null;
const hasUploadedCode = codePath && fs.existsSync(codePath);
const hasUploadedCode = !!app.codePath;
let localZipPath: string | null = null;
let cleanupSource: (() => void) | null = null;
if (hasUploadedCode) {
await validateRuntimeFromArchive(app.runtime, codePath);
try {
const materialized = await this.sourceStorage.materializeToTempFile(app.codePath!);
localZipPath = materialized.path;
cleanupSource = materialized.cleanup;
await validateRuntimeFromArchive(app.runtime, localZipPath);
} catch (e) {
cleanupSource?.();
throw e;
}
}
const archiveEntries = hasUploadedCode ? await listArchiveEntries(codePath!) : [];
const archiveEntries = hasUploadedCode && localZipPath ? await listArchiveEntries(localZipPath) : [];
// Determine Dockerfile based on runtime
const dockerfileContent = this.generateDockerfile(app, archiveEntries);
@@ -377,16 +389,18 @@ export class BuildService {
// If we have uploaded code, create a PVC and upload via kubectl cp
let sourcePvcName: string | undefined;
if (hasUploadedCode) {
if (hasUploadedCode && localZipPath) {
sourcePvcName = `${buildPodName}-source`;
if (deploymentId) {
this.updateBuildSession(deploymentId, { sourcePvcName });
}
const zipSize = fs.statSync(codePath!).size;
const zipSize = await this.sourceStorage.getSize(app.codePath!);
// Allocate PVC size = zip size * 3 (zip + extracted), min 1Gi
const pvcSizeGi = Math.max(1, Math.ceil((zipSize * 3) / (1024 * 1024 * 1024)));
await this.uploadSourceViaPVC(kc, coreApi, buildNamespace!, sourcePvcName, codePath!, pvcSizeGi, deploymentId);
await this.uploadSourceViaPVC(kc, coreApi, buildNamespace!, sourcePvcName, localZipPath, pvcSizeGi, deploymentId);
cleanupSource?.();
cleanupSource = null;
}
// Build the Kaniko Job spec
@@ -653,6 +667,7 @@ export class BuildService {
this.logger.warn(`Failed to clean up ConfigMap: ${e.message}`);
}
this.endBuildSession(deploymentId);
cleanupSource?.();
}
}
+8
View File
@@ -159,6 +159,14 @@ export default () => ({
},
},
sourceStorage: {
endpoint: process.env.SOURCE_STORAGE_ENDPOINT,
region: process.env.SOURCE_STORAGE_REGION || 'us-east-1',
bucket: process.env.SOURCE_STORAGE_BUCKET,
accessKey: process.env.SOURCE_STORAGE_ACCESS_KEY,
secretKey: process.env.SOURCE_STORAGE_SECRET_KEY,
},
platform: {
domain: resolvePlatformDomainFromEnv(),
previewRootDomain: resolvePreviewRootDomainFromEnv(),
+19 -14
View File
@@ -15,6 +15,7 @@ import * as path from 'path';
import { AppSnapshot, SnapshotType, SnapshotStatus } from './entities/snapshot.entity';
import { ApplicationsService } from '../applications/applications.service';
import { KubernetesService } from '../kubernetes/kubernetes.service';
import { SourceStorageService } from '../storage/source-storage.service';
import { AppRuntime, DatabaseType, ProductType } from '../common/enums';
const MAX_SNAPSHOTS = 10;
@@ -30,6 +31,7 @@ export class SnapshotsService implements OnModuleInit {
private applicationsService: ApplicationsService,
private kubernetesService: KubernetesService,
private configService: ConfigService,
private sourceStorage: SourceStorageService,
) {}
async onModuleInit(): Promise<void> {
@@ -122,13 +124,18 @@ export class SnapshotsService implements OnModuleInit {
if (!managedDbOnly) {
// 1. Copy current source code zip
if (app.codePath && fs.existsSync(app.codePath)) {
if (app.codePath && (await this.sourceStorage.exists(app.codePath))) {
await this.setSnapshotProgress(snapshotId, 12);
const destPath = path.join(snapshotDir, 'source.zip');
fs.copyFileSync(app.codePath, destPath);
updates.appArchivePath = destPath;
updates.appArchiveSize = fs.statSync(destPath).size;
this.logger.log(`Snapshot ${snapshotId}: copied source code (${(updates.appArchiveSize / 1024).toFixed(1)} KB)`);
const { path: tempPath, cleanup } = await this.sourceStorage.materializeToTempFile(app.codePath);
try {
const destPath = path.join(snapshotDir, 'source.zip');
fs.copyFileSync(tempPath, destPath);
updates.appArchivePath = destPath;
updates.appArchiveSize = fs.statSync(destPath).size;
this.logger.log(`Snapshot ${snapshotId}: copied source code (${(updates.appArchiveSize / 1024).toFixed(1)} KB)`);
} finally {
cleanup();
}
}
// 2. Archive wp-content for WordPress apps
@@ -315,8 +322,9 @@ export class SnapshotsService implements OnModuleInit {
async downloadCurrentSource(applicationId: string, userId: string): Promise<{ filePath: string; fileName: string } | null> {
const app = await this.applicationsService.findOne(applicationId, userId);
if (app.codePath && fs.existsSync(app.codePath)) {
return { filePath: app.codePath, fileName: `${app.name}-current-source.zip` };
if (app.codePath && (await this.sourceStorage.exists(app.codePath))) {
const { path } = await this.sourceStorage.materializeToTempFile(app.codePath);
return { filePath: path, fileName: `${app.name}-current-source.zip` };
}
return null;
}
@@ -382,12 +390,9 @@ export class SnapshotsService implements OnModuleInit {
} else {
// Revision not found — fall back to restoring source code and redeploying
if (snapshot.appArchivePath && fs.existsSync(snapshot.appArchivePath)) {
const uploadDir = this.configService.get<string>('platform.uploadDir') || './uploads';
const appDir = path.join(uploadDir, app.userId, app.id);
const destPath = path.join(appDir, 'source.zip');
fs.mkdirSync(appDir, { recursive: true });
fs.copyFileSync(snapshot.appArchivePath, destPath);
await this.applicationsService.update(app.id, app.userId, { codePath: destPath } as any);
const buffer = fs.readFileSync(snapshot.appArchivePath);
const storedPath = await this.sourceStorage.putSource(app.userId, app.id, buffer);
await this.applicationsService.update(app.id, app.userId, { codePath: storedPath } as any);
details.push('✅ Source code restored (K8s revision expired — will need redeploy)');
this.logger.log(`Rollback ${snapshotId}: source code restored (revision not found)`);
} else {
@@ -0,0 +1,133 @@
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { SourceStorageService } from './source-storage.service';
jest.mock('@aws-sdk/client-s3', () => {
const send = jest.fn();
return {
S3Client: jest.fn().mockImplementation(() => ({ send })),
PutObjectCommand: jest.fn().mockImplementation((input) => ({ input })),
GetObjectCommand: jest.fn(),
HeadObjectCommand: jest.fn(),
DeleteObjectCommand: jest.fn(),
__mockSend: send,
};
});
const s3Module = jest.requireMock('@aws-sdk/client-s3');
const mockSend = s3Module.__mockSend as jest.Mock;
describe('SourceStorageService', () => {
let service: SourceStorageService;
let uploadDir: string;
const createModule = (config: Record<string, string | undefined>) => {
return Test.createTestingModule({
providers: [
SourceStorageService,
{
provide: ConfigService,
useValue: {
get: (key: string) => {
const map: Record<string, string | undefined> = {
'platform.uploadDir': uploadDir,
'sourceStorage.endpoint': config.endpoint,
'sourceStorage.region': config.region,
'sourceStorage.bucket': config.bucket,
'sourceStorage.accessKey': config.accessKey,
'sourceStorage.secretKey': config.secretKey,
};
return map[key];
},
},
},
],
}).compile();
};
beforeEach(async () => {
uploadDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cloudhost-upload-'));
mockSend.mockReset();
jest.clearAllMocks();
});
afterEach(() => {
fs.rmSync(uploadDir, { recursive: true, force: true });
});
describe('local mode', () => {
beforeEach(async () => {
const module = await createModule({});
service = module.get(SourceStorageService);
});
it('stores source zip on local disk', async () => {
const buffer = Buffer.from('zip-content');
const storedPath = await service.putSource('user-1', 'app-1', buffer);
expect(service.isObjectStorage()).toBe(false);
expect(storedPath).toBe(path.join(uploadDir, 'user-1', 'app-1', 'source.zip'));
expect(fs.readFileSync(storedPath, 'utf8')).toBe('zip-content');
});
it('checks existence and size for local files', async () => {
const storedPath = await service.putSource('user-1', 'app-1', Buffer.from('abc'));
expect(await service.exists(storedPath)).toBe(true);
expect(await service.getSize(storedPath)).toBe(3);
});
it('deletes local upload directory', async () => {
const storedPath = await service.putSource('user-1', 'app-1', Buffer.from('abc'));
expect(fs.existsSync(storedPath)).toBe(true);
await service.deleteSource('user-1', 'app-1', storedPath);
expect(fs.existsSync(path.dirname(storedPath))).toBe(false);
});
});
describe('object storage mode', () => {
beforeEach(async () => {
const module = await createModule({
endpoint: 'http://rgw.local:80',
region: 'us-east-1',
bucket: 'app-sources',
accessKey: 'access',
secretKey: 'secret',
});
service = module.get(SourceStorageService);
});
it('uploads source zip to S3', async () => {
mockSend.mockResolvedValue({});
const buffer = Buffer.from('zip-content');
const key = await service.putSource('user-1', 'app-1', buffer);
expect(service.isObjectStorage()).toBe(true);
expect(key).toBe('user-1/app-1/source.zip');
expect(PutObjectCommand).toHaveBeenCalledWith({
Bucket: 'app-sources',
Key: 'user-1/app-1/source.zip',
Body: buffer,
ContentType: 'application/zip',
});
expect(mockSend).toHaveBeenCalled();
expect(S3Client).toHaveBeenCalledWith(
expect.objectContaining({
endpoint: 'http://rgw.local:80',
forcePathStyle: true,
}),
);
});
it('treats object keys as non-local paths', () => {
expect(service.isLocalPath('user-1/app-1/source.zip')).toBe(false);
expect(service.isLocalPath(path.join(uploadDir, 'user-1/app-1/source.zip'))).toBe(true);
});
});
});
@@ -0,0 +1,162 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
DeleteObjectCommand,
GetObjectCommand,
HeadObjectCommand,
PutObjectCommand,
S3Client,
} from '@aws-sdk/client-s3';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { pipeline } from 'stream/promises';
import { Readable } from 'stream';
@Injectable()
export class SourceStorageService {
private readonly logger = new Logger(SourceStorageService.name);
private readonly s3Client: S3Client | null;
private readonly bucket: string | undefined;
private readonly uploadDir: string;
constructor(private readonly configService: ConfigService) {
const endpoint = this.configService.get<string>('sourceStorage.endpoint');
const region = this.configService.get<string>('sourceStorage.region') || 'us-east-1';
const accessKey = this.configService.get<string>('sourceStorage.accessKey');
const secretKey = this.configService.get<string>('sourceStorage.secretKey');
this.bucket = this.configService.get<string>('sourceStorage.bucket');
this.uploadDir = this.configService.get<string>('platform.uploadDir') || './uploads';
if (endpoint && this.bucket && accessKey && secretKey) {
this.s3Client = new S3Client({
endpoint,
region,
credentials: { accessKeyId: accessKey, secretAccessKey: secretKey },
forcePathStyle: true,
});
this.logger.log(`Object storage enabled (bucket=${this.bucket})`);
} else {
this.s3Client = null;
}
}
isObjectStorage(): boolean {
return this.s3Client !== null;
}
sourceKey(userId: string, appId: string, filename = 'source.zip'): string {
return `${userId}/${appId}/${filename}`;
}
isLocalPath(codePath: string): boolean {
if (!codePath) return false;
if (path.isAbsolute(codePath)) return true;
if (codePath.startsWith('./') || codePath.startsWith('../')) return true;
const normalizedUploadDir = path.resolve(this.uploadDir);
return path.resolve(codePath).startsWith(normalizedUploadDir);
}
localPath(userId: string, appId: string, filename = 'source.zip'): string {
return path.join(this.uploadDir, userId, appId, filename);
}
async putSource(userId: string, appId: string, buffer: Buffer): Promise<string> {
if (this.isObjectStorage()) {
const key = this.sourceKey(userId, appId);
await this.s3Client!.send(
new PutObjectCommand({
Bucket: this.bucket,
Key: key,
Body: buffer,
ContentType: 'application/zip',
}),
);
this.logger.log(`Uploaded source to S3 → s3://${this.bucket}/${key} (${(buffer.length / 1024).toFixed(1)} KB)`);
return key;
}
const zipPath = this.localPath(userId, appId);
fs.mkdirSync(path.dirname(zipPath), { recursive: true });
fs.writeFileSync(zipPath, buffer);
this.logger.log(`Uploaded source to local → ${zipPath} (${(buffer.length / 1024).toFixed(1)} KB)`);
return zipPath;
}
async exists(codePath: string): Promise<boolean> {
if (!codePath) return false;
if (this.isLocalPath(codePath)) {
return fs.existsSync(codePath);
}
if (!this.s3Client) return false;
try {
await this.s3Client.send(new HeadObjectCommand({ Bucket: this.bucket, Key: codePath }));
return true;
} catch {
return false;
}
}
async getSize(codePath: string): Promise<number> {
if (this.isLocalPath(codePath)) {
return fs.statSync(codePath).size;
}
const head = await this.s3Client!.send(new HeadObjectCommand({ Bucket: this.bucket, Key: codePath }));
return head.ContentLength ?? 0;
}
/**
* Returns a local filesystem path for reading the archive.
* For S3 keys, downloads to a temp file and returns { path, cleanup }.
*/
async materializeToTempFile(codePath: string): Promise<{ path: string; cleanup: () => void }> {
if (this.isLocalPath(codePath)) {
if (!fs.existsSync(codePath)) {
throw new Error(`Source file not found: ${codePath}`);
}
return { path: codePath, cleanup: () => {} };
}
const response = await this.s3Client!.send(
new GetObjectCommand({ Bucket: this.bucket, Key: codePath }),
);
const tempPath = path.join(
os.tmpdir(),
`cloudhost-source-${Date.now()}-${path.basename(codePath)}`,
);
const body = response.Body;
if (!body || typeof (body as Readable).pipe !== 'function') {
throw new Error(`Empty S3 response for key ${codePath}`);
}
await pipeline(body as Readable, fs.createWriteStream(tempPath));
return {
path: tempPath,
cleanup: () => {
try {
if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath);
} catch {
// ignore cleanup errors
}
},
};
}
async deleteSource(userId: string, appId: string, codePath?: string | null): Promise<void> {
if (this.isObjectStorage()) {
const key = codePath && !this.isLocalPath(codePath) ? codePath : this.sourceKey(userId, appId);
try {
await this.s3Client!.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: key }));
this.logger.log(`Deleted S3 source → s3://${this.bucket}/${key}`);
} catch (e: any) {
this.logger.warn(`Failed to delete S3 source ${key}: ${e.message}`);
}
return;
}
const appDir = path.join(this.uploadDir, userId, appId);
if (fs.existsSync(appDir)) {
fs.rmSync(appDir, { recursive: true, force: true });
this.logger.log(`Deleted local upload directory: ${appDir}`);
}
}
}
+9
View File
@@ -0,0 +1,9 @@
import { Global, Module } from '@nestjs/common';
import { SourceStorageService } from './source-storage.service';
@Global()
@Module({
providers: [SourceStorageService],
exports: [SourceStorageService],
})
export class StorageModule {}