diff --git a/src/app/project/app/[appId]/credentials/actions.ts b/src/app/project/app/[appId]/credentials/actions.ts index e2149d8..5804733 100644 --- a/src/app/project/app/[appId]/credentials/actions.ts +++ b/src/app/project/app/[appId]/credentials/actions.ts @@ -2,6 +2,7 @@ import appService from "@/server/services/app.service"; import dbGateService from "@/server/services/db-tool-services/dbgate.service"; +import pgAdminService from "@/server/services/db-tool-services/pgadmin.service"; import phpMyAdminService from "@/server/services/db-tool-services/phpmyadmin.service"; import { getAuthUserSession, simpleAction } from "@/server/utils/action-wrapper.utils"; import { AppTemplateUtils } from "@/server/utils/app-template.utils"; @@ -9,6 +10,14 @@ import { DatabaseTemplateInfoModel } from "@/shared/model/database-template-info import { ServerActionResult, SuccessActionResult } from "@/shared/model/server-action-error-return.model"; import { ServiceException } from "@/shared/model/service.exception.model"; +export type DbToolIds = 'dbgate' | 'phpmyadmin' | 'pgadmin'; + +const dbToolClasses = new Map([ + ['dbgate', dbGateService], + ['phpmyadmin', phpMyAdminService], + ['pgadmin', pgAdminService] +]) + export const getDatabaseCredentials = async (appId: string) => simpleAction(async () => { await getAuthUserSession(); @@ -17,58 +26,52 @@ export const getDatabaseCredentials = async (appId: string) => return new SuccessActionResult(credentials); }) as Promise>; -export const getIsDbToolActive = async (appId: string, dbTool: 'dbgate' | 'phpmyadmin') => +export const getIsDbToolActive = async (appId: string, dbTool: DbToolIds) => simpleAction(async () => { await getAuthUserSession(); - if (dbTool === 'dbgate') { - const isActive = await dbGateService.isDbToolRunning(appId); - return new SuccessActionResult(isActive); - } else if (dbTool === 'phpmyadmin') { - const isActive = await phpMyAdminService.isDbToolRunning(appId); - return new SuccessActionResult(isActive); - } else { + if (!dbToolClasses.has(dbTool)) { throw new ServiceException('Unknown db tool'); } + const isActive = dbToolClasses.get(dbTool)!.isDbToolRunning(appId); + return new SuccessActionResult(isActive); }) as Promise>; -export const deployDbTool = async (appId: string, dbTool: 'dbgate' | 'phpmyadmin') => +export const deployDbTool = async (appId: string, dbTool: DbToolIds) => simpleAction(async () => { await getAuthUserSession(); - if (dbTool === 'dbgate') { - await dbGateService.deploy(appId); - return new SuccessActionResult(); - } else if (dbTool === 'phpmyadmin') { - await phpMyAdminService.deploy(appId); - return new SuccessActionResult(); - } else { + + const currentDbTool = dbToolClasses.get(dbTool); + if (!currentDbTool) { throw new ServiceException('Unknown db tool'); } + await currentDbTool.deploy(appId); + return new SuccessActionResult(); + }) as Promise>; -export const getLoginCredentialsForRunningDbTool = async (appId: string, dbTool: 'dbgate' | 'phpmyadmin') => +export const getLoginCredentialsForRunningDbTool = async (appId: string, dbTool: DbToolIds) => simpleAction(async () => { await getAuthUserSession(); - if (dbTool === 'dbgate') { - return new SuccessActionResult(await dbGateService.getLoginCredentialsForRunningDbGate(appId)); - } else if (dbTool === 'phpmyadmin') { - return new SuccessActionResult(await phpMyAdminService.getLoginCredentialsForRunningDbGate(appId)); - } else { + + const currentDbTool = dbToolClasses.get(dbTool); + if (!currentDbTool) { throw new ServiceException('Unknown db tool'); } + return new SuccessActionResult(await currentDbTool.getLoginCredentialsForRunningDbGate(appId)); + }) as Promise>; -export const deleteDbToolDeploymentForAppIfExists = async (appId: string, dbTool: 'dbgate' | 'phpmyadmin') => +export const deleteDbToolDeploymentForAppIfExists = async (appId: string, dbTool: DbToolIds) => simpleAction(async () => { await getAuthUserSession(); - if (dbTool === 'dbgate') { - await dbGateService.deleteToolForAppIfExists(appId); - return new SuccessActionResult(); - } else if (dbTool === 'phpmyadmin') { - await phpMyAdminService.deleteToolForAppIfExists(appId); - return new SuccessActionResult(); - } else { + + const currentDbTool = dbToolClasses.get(dbTool); + if (!currentDbTool) { throw new ServiceException('Unknown db tool'); } + await currentDbTool.deleteToolForAppIfExists(appId); + return new SuccessActionResult(); + }) as Promise>; export const downloadDbGateFilesForApp = async (appId: string) => diff --git a/src/app/project/app/[appId]/credentials/db-gate-db-tool.tsx b/src/app/project/app/[appId]/credentials/db-gate-db-tool.tsx index 50acac2..76ed835 100644 --- a/src/app/project/app/[appId]/credentials/db-gate-db-tool.tsx +++ b/src/app/project/app/[appId]/credentials/db-gate-db-tool.tsx @@ -81,7 +81,7 @@ export default function DbGateDbTool({ return <>
-
+
{ try { setLoading(true); diff --git a/src/app/project/app/[appId]/credentials/db-tools.tsx b/src/app/project/app/[appId]/credentials/db-tools.tsx index ab56755..3c91575 100644 --- a/src/app/project/app/[appId]/credentials/db-tools.tsx +++ b/src/app/project/app/[appId]/credentials/db-tools.tsx @@ -1,7 +1,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { AppExtendedModel } from "@/shared/model/app-extended.model"; import DbGateDbTool from "./db-gate-db-tool"; -import PhpMyAdminDbTool from "./phpmyadmin-db-tool"; +import DbToolSwitch from "./phpmyadmin-db-tool"; export default function DbToolsCard({ app @@ -17,7 +17,9 @@ export default function DbToolsCard({ - {['MYSQL', 'MARIADB'].includes(app.appType) && } + {['MYSQL', 'MARIADB'].includes(app.appType) && } + {app.appType === 'POSTGRES' && } ; diff --git a/src/app/project/app/[appId]/credentials/phpmyadmin-db-tool.tsx b/src/app/project/app/[appId]/credentials/phpmyadmin-db-tool.tsx index 85d49bd..d2b32a6 100644 --- a/src/app/project/app/[appId]/credentials/phpmyadmin-db-tool.tsx +++ b/src/app/project/app/[appId]/credentials/phpmyadmin-db-tool.tsx @@ -4,17 +4,20 @@ import { Button } from "@/components/ui/button"; import { useConfirmDialog } from "@/frontend/states/zustand.states"; import { Toast } from "@/frontend/utils/toast.utils"; import { Actions } from "@/frontend/utils/nextjs-actions.utils"; -import { deleteDbToolDeploymentForAppIfExists, deployDbTool, getIsDbToolActive, getLoginCredentialsForRunningDbTool } from "./actions"; +import { DbToolIds, deleteDbToolDeploymentForAppIfExists, deployDbTool, getIsDbToolActive, getLoginCredentialsForRunningDbTool } from "./actions"; import { Label } from "@/components/ui/label"; -import FullLoadingSpinner from "@/components/ui/full-loading-spinnter"; import { Switch } from "@/components/ui/switch"; import { Code } from "@/components/custom/code"; import LoadingSpinner from "@/components/ui/loading-spinner"; -export default function PhpMyAdminDbTool({ - app +export default function DbToolSwitch({ + app, + toolId, + toolNameString }: { app: AppExtendedModel; + toolId: DbToolIds; + toolNameString: string; }) { const { openConfirmDialog } = useConfirmDialog(); @@ -22,19 +25,19 @@ export default function PhpMyAdminDbTool({ const [loading, setLoading] = useState(false); const loadIdDbToolActive = async (appId: string) => { - const response = await Actions.run(() => getIsDbToolActive(appId, 'phpmyadmin')); + const response = await Actions.run(() => getIsDbToolActive(appId, toolId)); setIsDbToolActive(response); } const openDbTool = async () => { try { setLoading(true); - const credentials = await Actions.run(() => getLoginCredentialsForRunningDbTool(app.id, 'phpmyadmin')); + const credentials = await Actions.run(() => getLoginCredentialsForRunningDbTool(app.id, toolId)); setLoading(false); await openConfirmDialog({ title: "Open DB Tool", description: <> - PHP My Admin is ready and can be opened in a new tab.
+ {toolNameString} is ready and can be opened in a new tab.
Use the following credentials to login:
@@ -45,7 +48,7 @@ export default function PhpMyAdminDbTool({
{credentials.password}
- +
, okButton: '', @@ -65,25 +68,25 @@ export default function PhpMyAdminDbTool({ return <>
-
+
{ try { setLoading(true); if (checked) { - await Toast.fromAction(() => deployDbTool(app.id, 'phpmyadmin'), 'PHP My Admin is now activated', 'activating PHP My Admin...'); + await Toast.fromAction(() => deployDbTool(app.id, toolId), `${toolNameString} is now activated`, `activating ${toolNameString}...`); } else { - await Toast.fromAction(() => deleteDbToolDeploymentForAppIfExists(app.id, 'phpmyadmin'), 'PHP My Admin has been deactivated', 'Deactivating PHP My Admin...'); + await Toast.fromAction(() => deleteDbToolDeploymentForAppIfExists(app.id, toolId), `${toolNameString} has been deactivated`, `Deactivating ${toolNameString}...`); } await loadIdDbToolActive(app.id); } finally { setLoading(false); } }} /> - +
{isDbToolActive && <> + disabled={!isDbToolActive || loading}>Open {toolNameString} } {(loading || isDbToolActive === undefined) && }
diff --git a/src/server/services/config-map.service.ts b/src/server/services/config-map.service.ts index cbb2bee..ed2a611 100644 --- a/src/server/services/config-map.service.ts +++ b/src/server/services/config-map.service.ts @@ -18,8 +18,6 @@ class ConfigMapService { async createOrUpdateConfigMapForApp(app: AppExtendedModel) { - const existingConfigMaps = await this.getConfigMapsForApp(app.projectId, app.id); - if (app.appFileMounts.length === 0) { return { fileVolumeMounts: [], fileVolumes: [] }; } @@ -52,30 +50,49 @@ class ConfigMapService { }, }; - if (existingConfigMaps.some(cm => cm.metadata!.name === currentConfigMapName)) { - await k3s.core.replaceNamespacedConfigMap(currentConfigMapName, app.projectId, configMapManifest); - } else { - await k3s.core.createNamespacedConfigMap(app.projectId, configMapManifest); - } + await this.createOrUpdateConfigMap(app.projectId, configMapManifest); + const containerMountPath = fileMount.containerMountPath; - fileVolumeMounts.push({ - name: currentConfigMapName, - mountPath: fileMount.containerMountPath, - subPath: filePath, - readOnly: true - }); + const { fileVolumeMount, fileVolume } = this.createFileVolumeConfig(currentConfigMapName, containerMountPath, filePath); - fileVolumes.push({ - name: currentConfigMapName, - configMap: { - name: currentConfigMapName, - } - }); + fileVolumeMounts.push(fileVolumeMount); + fileVolumes.push(fileVolume); } return { fileVolumeMounts, fileVolumes }; } + createFileVolumeConfig(currentConfigMapName: string, containerMountPath: string, fileName: string, readOnly = true) { + const fileVolumeMount = { + name: currentConfigMapName, + mountPath: containerMountPath, + subPath: fileName, + readOnly + } as k8s.V1VolumeMount; + + const fileVolume = { + name: currentConfigMapName, + configMap: { + name: currentConfigMapName, + } + } as k8s.V1Volume; + return { fileVolumeMount, fileVolume }; + } + + async getExistingConfigMap(namespace: string, configMapName: string) { + const configMaps = await k3s.core.listNamespacedConfigMap(namespace); + return configMaps.body.items.find(cm => cm.metadata?.name === configMapName); + } + + async createOrUpdateConfigMap(namespace: string, configMapManifest: k8s.V1ConfigMap) { + const currentConfigMapName = configMapManifest.metadata!.name!; + const existingConfigMaps = await this.getExistingConfigMap(namespace, currentConfigMapName); + if (!!existingConfigMaps) { + await k3s.core.replaceNamespacedConfigMap(currentConfigMapName, namespace, configMapManifest); + } else { + await k3s.core.createNamespacedConfigMap(namespace, configMapManifest); + } + } async deleteUnusedConfigMaps(app: AppExtendedModel) { const existingConfigMaps = await this.getConfigMapsForApp(app.projectId, app.id); @@ -85,6 +102,13 @@ class ConfigMapService { } } } + + async deleteConfigMapIfExists(namespace: string, configMapName: string) { + const existingConfigMap = await this.getExistingConfigMap(namespace, configMapName); + if (!!existingConfigMap) { + await k3s.core.deleteNamespacedConfigMap(configMapName, namespace); + } + } } const configMapService = new ConfigMapService(); diff --git a/src/server/services/db-tool-services/base-db-tool.service.ts b/src/server/services/db-tool-services/base-db-tool.service.ts index cf9bd8f..b4c76d3 100644 --- a/src/server/services/db-tool-services/base-db-tool.service.ts +++ b/src/server/services/db-tool-services/base-db-tool.service.ts @@ -64,7 +64,7 @@ export class BaseDbToolService { return { url: `https://${traefikHostname}`, username, password }; } - async deployToolForDatabase(appId: string, appPort: number, deplyomentBuilder: (app: AppExtendedModel) => V1Deployment) { + async deployToolForDatabase(appId: string, appPort: number, deplyomentBuilder: (app: AppExtendedModel) => V1Deployment | Promise) { const app = await appService.getExtendedById(appId); const toolAppName = this.appIdToToolNameConverter(appId); @@ -97,8 +97,8 @@ export class BaseDbToolService { } - private async createOrUpdateDbGateDeployment(app: AppExtendedModel, deplyomentBuilder: (app: AppExtendedModel) => V1Deployment) { - const body = deplyomentBuilder(app); + private async createOrUpdateDbGateDeployment(app: AppExtendedModel, deplyomentBuilder: (app: AppExtendedModel) => V1Deployment | Promise) { + const body = await deplyomentBuilder(app); const toolAppName = this.appIdToToolNameConverter(app.id); await deploymentService.applyDeployment(app.projectId, toolAppName, body); } diff --git a/src/server/services/db-tool-services/pgadmin.service.ts b/src/server/services/db-tool-services/pgadmin.service.ts new file mode 100644 index 0000000..5d14d7b --- /dev/null +++ b/src/server/services/db-tool-services/pgadmin.service.ts @@ -0,0 +1,171 @@ +import { ServiceException } from "@/shared/model/service.exception.model"; +import { KubeObjectNameUtils } from "../../utils/kube-object-name.utils"; +import { randomBytes } from "crypto"; +import { V1Deployment, V1EnvVar } from "@kubernetes/client-node"; +import { Constants } from "@/shared/utils/constants"; +import { AppTemplateUtils } from "../../utils/app-template.utils"; +import appService from "../app.service"; +import { BaseDbToolService } from "./base-db-tool.service"; +import configMapService from "../config-map.service"; +import { AppExtendedModel } from "@/shared/model/app-extended.model"; + +class PgAdminService extends BaseDbToolService { + + readonly pgPassPath = '/pgadmin-config/pgpass'; + readonly pgAdminConfigPath = '/pgadmin-config/servers.json'; + + constructor() { + super((app) => KubeObjectNameUtils.toPgAdminId(app)); + } + + async getLoginCredentialsForRunningDbGate(appId: string) { + return await this.getLoginCredentialsForRunningTool(appId, (deployment) => { + const username = deployment.spec?.template.spec?.containers[0].env?.find(e => e.name === 'PGADMIN_DEFAULT_EMAIL')?.value; + const password = deployment.spec?.template.spec?.containers[0].env?.find(e => e.name === 'PGADMIN_DEFAULT_PASSWORD')?.value; + if (!username || !password) { + throw new ServiceException('Could not find login credentials for PGAdmin, please restart PGAdmin'); + } + return { username, password }; + }); + } + + async deleteToolForAppIfExists(appId: string) { + const app = await appService.getExtendedById(appId); + await configMapService.deleteConfigMapIfExists(app.projectId, KubeObjectNameUtils.getConfigMapName(this.appIdToToolNameConverter(app.id))); + await configMapService.deleteConfigMapIfExists(app.projectId, 'pgpass-' + this.appIdToToolNameConverter(app.id)); + await super.deleteToolForAppIfExists(appId); + } + + async deploy(appId: string) { + await this.deployToolForDatabase(appId, 80, async (app) => { + + const projectId = app.projectId; + const appName = this.appIdToToolNameConverter(app.id); + const configMapName = KubeObjectNameUtils.getConfigMapName(appName); + + const volumeConfigServerJsonFile = await this.createServerJsonConfigMap(configMapName, app); + const volumeConfigPgPassFile = await this.createPgPassConfigMap(appName, app); + + const authPassword = randomBytes(15).toString('hex'); + + const body: V1Deployment = { + metadata: { + name: appName + }, + spec: { + replicas: 1, + selector: { + matchLabels: { + app: appName + } + }, + template: { + metadata: { + labels: { + app: appName + }, + annotations: { + [Constants.QS_ANNOTATION_APP_ID]: app.id, + [Constants.QS_ANNOTATION_PROJECT_ID]: projectId, + deploymentTimestamp: new Date().getTime() + "", + "kubernetes.io/change-cause": `Deployment ${new Date().toISOString()}` + } + }, + spec: { + containers: [ + { + name: appName, + image: 'dpage/pgadmin4:latest', + imagePullPolicy: 'Always', + env: [ + { + name: 'PGADMIN_DEFAULT_EMAIL', + value: 'quickstack@quickstack.dev' + }, + { + name: 'PGADMIN_DEFAULT_PASSWORD', + value: authPassword + }, + { + name: 'PGADMIN_SERVER_JSON_FILE', + value: this.pgAdminConfigPath + }, + { + name: 'PGPASS_FILE', + value: this.pgPassPath // todo has to be chmod 0600 + }, + ], + readinessProbe: { + httpGet: { + path: '/misc/ping', + port: 80 + }, + initialDelaySeconds: 30, + periodSeconds: 15, + failureThreshold: 5, + }, + volumeMounts: [volumeConfigServerJsonFile.fileVolumeMount, volumeConfigPgPassFile.fileVolumeMount] + } + ], + volumes: [volumeConfigServerJsonFile.fileVolume, volumeConfigPgPassFile.fileVolume] + } + } + } + }; + return body; + }); + } + + private async createServerJsonConfigMap(configMapName: string, app: AppExtendedModel) { + const dbCredentials = AppTemplateUtils.getDatabaseModelFromApp(app); + const configMapManifest = { + apiVersion: 'v1', + kind: 'ConfigMap', + metadata: { + name: configMapName, + namespace: app.projectId, + }, + data: { + 'servers.json': JSON.stringify({ + "Servers": { + "1": { + "Name": app.name, + "Group": "Servers", + "Host": dbCredentials.hostname, + "Port": dbCredentials.port, + "MaintenanceDB": 'postgres', + "Username": dbCredentials.username, + "SSLMode": "prefer", + "PasswordExecCommand": `echo '${dbCredentials.password}'`, // todo does not work?! + } + } + }) + }, + }; + + await configMapService.createOrUpdateConfigMap(app.projectId, configMapManifest); + const volumeConfigServerJsonFile = configMapService.createFileVolumeConfig(configMapName, this.pgAdminConfigPath, 'servers.json'); + return volumeConfigServerJsonFile; + } + + private async createPgPassConfigMap(appName: string, app: AppExtendedModel) { + const dbCredentials = AppTemplateUtils.getDatabaseModelFromApp(app); + const pgPassConfigMapName = 'pgpass-' + appName; + const configMapManifestPgPass = { + apiVersion: 'v1', + kind: 'ConfigMap', + metadata: { + name: pgPassConfigMapName, + namespace: app.projectId, + }, + data: { + 'pgpass': `${dbCredentials.hostname}:${dbCredentials.port}:postgres:${dbCredentials.username}:${dbCredentials.password}`, + }, + }; + await configMapService.createOrUpdateConfigMap(app.projectId, configMapManifestPgPass); + const volumeConfigPgPassFile = configMapService.createFileVolumeConfig(pgPassConfigMapName, this.pgPassPath, 'pgpass'); + return volumeConfigPgPassFile; + } +} +const pgAdminService = new PgAdminService(); +export default pgAdminService; \ No newline at end of file diff --git a/src/server/services/standalone-services/standalone-pod.service.ts b/src/server/services/standalone-services/standalone-pod.service.ts index 0ae0f2b..a17af03 100644 --- a/src/server/services/standalone-services/standalone-pod.service.ts +++ b/src/server/services/standalone-services/standalone-pod.service.ts @@ -15,7 +15,14 @@ class SetupPodService { while (tries < maxTries) { const pod = await this.getPodOrUndefined(projectId, podName); if (pod && ['Running', 'Failed', 'Succeeded'].includes(pod.status?.phase!)) { - return true; + // check if running and ready (when passing readiness probe) + if (pod.status?.phase === 'Running') { + if (pod.status?.containerStatuses?.[0].ready) { + return true; + } + } else { + return true; + } } await new Promise(resolve => setTimeout(resolve, interval)); diff --git a/src/server/utils/kube-object-name.utils.ts b/src/server/utils/kube-object-name.utils.ts index a93333d..d218d88 100644 --- a/src/server/utils/kube-object-name.utils.ts +++ b/src/server/utils/kube-object-name.utils.ts @@ -76,4 +76,8 @@ export class KubeObjectNameUtils { static toPhpMyAdminId(appId: string): `phpma-${string}` { return `phpma-${appId}`; } + + static toPgAdminId(appId: string): `pga-${string}` { + return `pga-${appId}`; + } } \ No newline at end of file