feat: add PgAdmin support and enhance config map management functions

This commit is contained in:
biersoeckli
2025-01-30 17:48:16 +00:00
parent d9a2b3d6be
commit 721f4b0513
9 changed files with 283 additions and 69 deletions

View File

@@ -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<ServerActionResult<unknown, DatabaseTemplateInfoModel>>;
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<ServerActionResult<unknown, boolean>>;
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<ServerActionResult<unknown, void>>;
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<ServerActionResult<unknown, { url: string; username: string, password: string }>>;
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<ServerActionResult<unknown, void>>;
export const downloadDbGateFilesForApp = async (appId: string) =>

View File

@@ -81,7 +81,7 @@ export default function DbGateDbTool({
return <>
<div className="flex gap-4 items-center">
<div className="flex items-center space-x-2">
<div className="flex items-center space-x-3">
<Switch id="canary-channel-mode" disabled={loading || isDbGateActive === undefined} checked={isDbGateActive} onCheckedChange={async (checked) => {
try {
setLoading(true);

View File

@@ -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({
</CardHeader>
<CardContent className="space-y-4">
<DbGateDbTool app={app} />
{['MYSQL', 'MARIADB'].includes(app.appType) && <PhpMyAdminDbTool app={app} />}
{['MYSQL', 'MARIADB'].includes(app.appType) && <DbToolSwitch app={app} toolId="phpmyadmin"
toolNameString="PHP My Admin" />}
{app.appType === 'POSTGRES' && <DbToolSwitch app={app} toolId="pgadmin" toolNameString="pgAdmin" />}
</CardContent>
</Card >
</>;

View File

@@ -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. <br />
{toolNameString} is ready and can be opened in a new tab. <br />
Use the following credentials to login:
<div className="pt-3 grid grid-cols-1 gap-1">
<Label>Username</Label>
@@ -45,7 +48,7 @@ export default function PhpMyAdminDbTool({
<div><Code>{credentials.password}</Code></div>
</div>
<div>
<Button variant='outline' onClick={() => window.open(credentials.url, '_blank')}>Open PHP My Admin</Button>
<Button variant='outline' onClick={() => window.open(credentials.url, '_blank')}>Open {toolNameString}</Button>
</div>
</>,
okButton: '',
@@ -65,25 +68,25 @@ export default function PhpMyAdminDbTool({
return <>
<div className="flex gap-4 items-center">
<div className="flex items-center space-x-2">
<div className="flex items-center space-x-3">
<Switch id="canary-channel-mode" disabled={loading || isDbToolActive === undefined} checked={isDbToolActive} onCheckedChange={async (checked) => {
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);
}
}} />
<Label htmlFor="airplane-mode">PHP My Admin</Label>
<Label htmlFor="airplane-mode">{toolNameString}</Label>
</div>
{isDbToolActive && <>
<Button variant='outline' onClick={() => openDbTool()}
disabled={!isDbToolActive || loading}>Open PHP My Admin</Button>
disabled={!isDbToolActive || loading}>Open {toolNameString}</Button>
</>}
{(loading || isDbToolActive === undefined) && <LoadingSpinner></LoadingSpinner>}
</div>

View File

@@ -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();

View File

@@ -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<V1Deployment>) {
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<V1Deployment>) {
const body = await deplyomentBuilder(app);
const toolAppName = this.appIdToToolNameConverter(app.id);
await deploymentService.applyDeployment(app.projectId, toolAppName, body);
}

View File

@@ -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;

View File

@@ -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));

View File

@@ -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}`;
}
}