feat: Hasura as a service
This commit is contained in:
parent
d75d2880e5
commit
48fa4ff245
16
prisma/migrations/20220427133656_hasura/migration.sql
Normal file
16
prisma/migrations/20220427133656_hasura/migration.sql
Normal file
@ -0,0 +1,16 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Hasura" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"serviceId" TEXT NOT NULL,
|
||||
"postgresqlUser" TEXT NOT NULL,
|
||||
"postgresqlPassword" TEXT NOT NULL,
|
||||
"postgresqlDatabase" TEXT NOT NULL,
|
||||
"postgresqlPublicPort" INTEGER,
|
||||
"graphQLAdminPassword" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "Hasura_serviceId_fkey" FOREIGN KEY ("serviceId") REFERENCES "Service" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Hasura_serviceId_key" ON "Hasura"("serviceId");
|
@ -305,6 +305,7 @@ model Service {
|
||||
meiliSearch MeiliSearch?
|
||||
persistentStorage ServicePersistentStorage[]
|
||||
umami Umami?
|
||||
hasura Hasura?
|
||||
}
|
||||
|
||||
model PlausibleAnalytics {
|
||||
@ -403,3 +404,16 @@ model Umami {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Hasura {
|
||||
id String @id @default(cuid())
|
||||
serviceId String @unique
|
||||
postgresqlUser String
|
||||
postgresqlPassword String
|
||||
postgresqlDatabase String
|
||||
postgresqlPublicPort Int?
|
||||
graphQLAdminPassword String
|
||||
service Service @relation(fields: [serviceId], references: [id])
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
@ -1,6 +1,7 @@
|
||||
<script>
|
||||
export let service;
|
||||
import Ghost from './svg/services/Ghost.svelte';
|
||||
import Hasura from './svg/services/Hasura.svelte';
|
||||
import LanguageTool from './svg/services/LanguageTool.svelte';
|
||||
import MinIo from './svg/services/MinIO.svelte';
|
||||
import N8n from './svg/services/N8n.svelte';
|
||||
@ -57,4 +58,8 @@
|
||||
<a href="https://umami.is" target="_blank">
|
||||
<Umami />
|
||||
</a>
|
||||
{:else if service.type === 'hasura'}
|
||||
<a href="https://hasura.io" target="_blank">
|
||||
<Hasura />
|
||||
</a>
|
||||
{/if}
|
||||
|
@ -191,5 +191,16 @@ export const supportedServiceTypesAndVersions = [
|
||||
ports: {
|
||||
main: 3000
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'hasura',
|
||||
fancyName: 'Hasura',
|
||||
baseImage: 'hasura/graphql-engine',
|
||||
images: ['postgres:12-alpine'],
|
||||
versions: ['latest', 'v2.5.1'],
|
||||
recommendedVersion: 'v2.5.1',
|
||||
ports: {
|
||||
main: 8080
|
||||
}
|
||||
}
|
||||
];
|
||||
|
26
src/lib/components/svg/services/Hasura.svelte
Normal file
26
src/lib/components/svg/services/Hasura.svelte
Normal file
@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
export let isAbsolute = false;
|
||||
</script>
|
||||
|
||||
<svg
|
||||
class={isAbsolute ? 'w-10 h-10 absolute top-0 left-0 -m-5' : 'w-8 mx-auto'}
|
||||
viewBox="0 0 81 84"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g clip-path="url(#clip0_5273_21928)">
|
||||
<path
|
||||
d="M79.7186 28.6019C82.1218 21.073 80.6778 6.03601 76.0158 0.487861C75.4073 -0.238064 74.2624 -0.134361 73.757 0.664158L68.0121 9.72786C66.5887 11.5427 64.0308 11.9575 62.1124 10.6923C55.8827 6.59601 48.4359 4.21082 40.4322 4.21082C32.4285 4.21082 24.9817 6.59601 18.752 10.6923C16.8336 11.9575 14.2757 11.5323 12.8523 9.72786L7.10738 0.664158C6.60199 -0.134361 5.45712 -0.238064 4.84859 0.487861C0.186621 6.03601 -1.25735 21.073 1.14583 28.6019C1.94002 31.1012 2.16693 33.7456 1.69248 36.3279C1.22834 38.879 0.753897 41.9693 0.753897 44.1056C0.753897 66.1323 18.5251 84.0004 40.4322 84.0004C62.3497 84.0004 80.1105 66.1427 80.1105 44.1056C80.1105 41.959 79.6464 38.879 79.1719 36.3279C78.6975 33.7456 78.9244 31.1012 79.7186 28.6019ZM40.4322 75.0819C23.4965 75.0819 9.71684 61.2271 9.71684 44.199C9.71684 43.639 9.73747 43.0893 9.7581 42.5397C10.3769 30.9353 17.3802 21.0108 27.3024 16.2819C31.2836 14.3738 35.7393 13.316 40.4322 13.316C45.1251 13.316 49.5808 14.3842 53.5724 16.2923C63.4945 21.0212 70.4978 30.9456 71.1166 42.5397C71.1476 43.0893 71.1579 43.639 71.1579 44.199C71.1476 61.2271 57.3679 75.0819 40.4322 75.0819Z"
|
||||
fill="#1EB4D4"
|
||||
/>
|
||||
<path
|
||||
d="M53.7371 56.083L45.8881 42.4044L39.153 30.997C38.9983 30.7274 38.7095 30.5615 38.3898 30.5615H31.9538C31.634 30.5615 31.3452 30.7378 31.1905 31.0074C31.0358 31.2874 31.0358 31.6296 31.2008 31.8993L37.6368 42.7881L28.9936 56.0415C28.8183 56.3111 28.7977 56.6637 28.9524 56.9541C29.1071 57.2444 29.4062 57.4207 29.7259 57.4207H36.2032C36.5023 57.4207 36.7808 57.2652 36.9458 57.0163L41.6181 49.6741L45.8056 56.9748C45.9603 57.2548 46.2594 57.4207 46.5688 57.4207H52.9533C53.273 57.4207 53.5618 57.2548 53.7165 56.9748C53.9022 56.6948 53.9022 56.363 53.7371 56.083Z"
|
||||
fill="#1EB4D4"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_5273_21928">
|
||||
<rect width="81" height="84" fill="white" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
@ -14,7 +14,8 @@ const include: Prisma.ServiceInclude = {
|
||||
wordpress: true,
|
||||
ghost: true,
|
||||
meiliSearch: true,
|
||||
umami: true
|
||||
umami: true,
|
||||
hasura: true
|
||||
};
|
||||
export async function listServicesWithIncludes() {
|
||||
return await prisma.service.findMany({
|
||||
@ -97,6 +98,11 @@ export async function getService({ id, teamId }: { id: string; teamId: string })
|
||||
body.umami.umamiAdminPassword = decrypt(body.umami.umamiAdminPassword);
|
||||
if (body.umami?.hashSalt) body.umami.hashSalt = decrypt(body.umami.hashSalt);
|
||||
|
||||
if (body.hasura?.postgresqlPassword)
|
||||
body.hasura.postgresqlPassword = decrypt(body.hasura.postgresqlPassword);
|
||||
if (body.hasura?.graphQLAdminPassword)
|
||||
body.hasura.graphQLAdminPassword = decrypt(body.hasura.graphQLAdminPassword);
|
||||
|
||||
const settings = await prisma.setting.findFirst();
|
||||
|
||||
return { ...body, settings };
|
||||
@ -243,6 +249,25 @@ export async function configureServiceType({
|
||||
}
|
||||
}
|
||||
});
|
||||
} else if (type === 'hasura') {
|
||||
const postgresqlUser = cuid();
|
||||
const postgresqlPassword = encrypt(generatePassword());
|
||||
const postgresqlDatabase = 'hasura';
|
||||
const graphQLAdminPassword = encrypt(generatePassword());
|
||||
await prisma.service.update({
|
||||
where: { id },
|
||||
data: {
|
||||
type,
|
||||
hasura: {
|
||||
create: {
|
||||
postgresqlDatabase,
|
||||
postgresqlPassword,
|
||||
postgresqlUser,
|
||||
graphQLAdminPassword
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -400,6 +425,7 @@ export async function removeService({ id }: { id: string }): Promise<void> {
|
||||
await prisma.meiliSearch.deleteMany({ where: { serviceId: id } });
|
||||
await prisma.ghost.deleteMany({ where: { serviceId: id } });
|
||||
await prisma.umami.deleteMany({ where: { serviceId: id } });
|
||||
await prisma.hasura.deleteMany({ where: { serviceId: id } });
|
||||
await prisma.plausibleAnalytics.deleteMany({ where: { serviceId: id } });
|
||||
await prisma.minio.deleteMany({ where: { serviceId: id } });
|
||||
await prisma.vscodeserver.deleteMany({ where: { serviceId: id } });
|
||||
|
58
src/routes/services/[id]/_Services/_Hasura.svelte
Normal file
58
src/routes/services/[id]/_Services/_Hasura.svelte
Normal file
@ -0,0 +1,58 @@
|
||||
<script lang="ts">
|
||||
import CopyPasswordField from '$lib/components/CopyPasswordField.svelte';
|
||||
import Explainer from '$lib/components/Explainer.svelte';
|
||||
import { t } from '$lib/translations';
|
||||
export let service;
|
||||
</script>
|
||||
|
||||
<div class="flex space-x-1 py-5 font-bold">
|
||||
<div class="title">Hasura</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 items-center px-10">
|
||||
<label for="graphQLAdminPassword">GraphQL Admin Password</label>
|
||||
<CopyPasswordField
|
||||
name="graphQLAdminPassword"
|
||||
id="graphQLAdminPassword"
|
||||
isPasswordField
|
||||
value={service.hasura.graphQLAdminPassword}
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-1 py-5 font-bold">
|
||||
<div class="title">PostgreSQL</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 items-center px-10">
|
||||
<label for="postgresqlUser">{$t('forms.username')}</label>
|
||||
<CopyPasswordField
|
||||
name="postgresqlUser"
|
||||
id="postgresqlUser"
|
||||
value={service.hasura.postgresqlUser}
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 items-center px-10">
|
||||
<label for="postgresqlPassword">{$t('forms.password')}</label>
|
||||
<CopyPasswordField
|
||||
id="postgresqlPassword"
|
||||
isPasswordField
|
||||
readonly
|
||||
disabled
|
||||
name="postgresqlPassword"
|
||||
value={service.hasura.postgresqlPassword}
|
||||
/>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 items-center px-10">
|
||||
<label for="postgresqlDatabase">{$t('index.database')}</label>
|
||||
<CopyPasswordField
|
||||
name="postgresqlDatabase"
|
||||
id="postgresqlDatabase"
|
||||
value={service.hasura.postgresqlDatabase}
|
||||
readonly
|
||||
disabled
|
||||
/>
|
||||
</div>
|
@ -13,6 +13,7 @@
|
||||
import { t } from '$lib/translations';
|
||||
import { toast } from '@zerodevx/svelte-toast';
|
||||
import Ghost from './_Ghost.svelte';
|
||||
import Hasura from './_Hasura.svelte';
|
||||
import MeiliSearch from './_MeiliSearch.svelte';
|
||||
import MinIo from './_MinIO.svelte';
|
||||
import PlausibleAnalytics from './_PlausibleAnalytics.svelte';
|
||||
@ -172,6 +173,8 @@
|
||||
<MeiliSearch bind:service />
|
||||
{:else if service.type === 'umami'}
|
||||
<Umami bind:service />
|
||||
{:else if service.type === 'hasura'}
|
||||
<Hasura bind:service />
|
||||
{/if}
|
||||
</div>
|
||||
</form>
|
||||
|
@ -44,6 +44,7 @@
|
||||
import { t } from '$lib/translations';
|
||||
import MeiliSearch from '$lib/components/svg/services/MeiliSearch.svelte';
|
||||
import Umami from '$lib/components/svg/services/Umami.svelte';
|
||||
import Hasura from '$lib/components/svg/services/Hasura.svelte';
|
||||
|
||||
const { id } = $page.params;
|
||||
const from = $page.url.searchParams.get('from');
|
||||
@ -93,6 +94,8 @@
|
||||
<MeiliSearch isAbsolute />
|
||||
{:else if type.name === 'umami'}
|
||||
<Umami isAbsolute />
|
||||
{:else if type.name === 'hasura'}
|
||||
<Hasura isAbsolute />
|
||||
{/if}{type.fancyName}
|
||||
</button>
|
||||
</form>
|
||||
|
21
src/routes/services/[id]/hasura/index.json.ts
Normal file
21
src/routes/services/[id]/hasura/index.json.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { getUserDetails } from '$lib/common';
|
||||
import * as db from '$lib/database';
|
||||
import { ErrorHandler } from '$lib/database';
|
||||
import type { RequestHandler } from '@sveltejs/kit';
|
||||
|
||||
export const post: RequestHandler = async (event) => {
|
||||
const { status, body } = await getUserDetails(event);
|
||||
if (status === 401) return { status, body };
|
||||
|
||||
const { id } = event.params;
|
||||
|
||||
let { name, fqdn } = await event.request.json();
|
||||
if (fqdn) fqdn = fqdn.toLowerCase();
|
||||
|
||||
try {
|
||||
await db.updateService({ id, fqdn, name });
|
||||
return { status: 201 };
|
||||
} catch (error) {
|
||||
return ErrorHandler(error);
|
||||
}
|
||||
};
|
122
src/routes/services/[id]/hasura/start.json.ts
Normal file
122
src/routes/services/[id]/hasura/start.json.ts
Normal file
@ -0,0 +1,122 @@
|
||||
import { asyncExecShell, createDirectories, getEngine, getUserDetails } from '$lib/common';
|
||||
import * as db from '$lib/database';
|
||||
import { promises as fs } from 'fs';
|
||||
import yaml from 'js-yaml';
|
||||
import type { RequestHandler } from '@sveltejs/kit';
|
||||
import { ErrorHandler, getServiceImage } from '$lib/database';
|
||||
import { makeLabelForServices } from '$lib/buildPacks/common';
|
||||
import type { ComposeFile } from '$lib/types/composeFile';
|
||||
import type { Service, DestinationDocker, Prisma } from '@prisma/client';
|
||||
|
||||
export const post: RequestHandler = async (event) => {
|
||||
const { teamId, status, body } = await getUserDetails(event);
|
||||
if (status === 401) return { status, body };
|
||||
|
||||
const { id } = event.params;
|
||||
|
||||
try {
|
||||
const service: Service & Prisma.ServiceInclude & { destinationDocker: DestinationDocker } =
|
||||
await db.getService({ id, teamId });
|
||||
const {
|
||||
type,
|
||||
version,
|
||||
destinationDockerId,
|
||||
destinationDocker,
|
||||
serviceSecret,
|
||||
hasura: { postgresqlUser, postgresqlPassword, postgresqlDatabase }
|
||||
} = service;
|
||||
const network = destinationDockerId && destinationDocker.network;
|
||||
const host = getEngine(destinationDocker.engine);
|
||||
|
||||
const { workdir } = await createDirectories({ repository: type, buildId: id });
|
||||
const image = getServiceImage(type);
|
||||
|
||||
const config = {
|
||||
hasura: {
|
||||
image: `${image}:${version}`,
|
||||
environmentVariables: {
|
||||
HASURA_GRAPHQL_METADATA_DATABASE_URL: `postgresql://${postgresqlUser}:${postgresqlPassword}@${id}-postgresql:5432/${postgresqlDatabase}`
|
||||
}
|
||||
},
|
||||
postgresql: {
|
||||
image: 'postgres:12-alpine',
|
||||
volume: `${id}-postgresql-data:/var/lib/postgresql/data`,
|
||||
environmentVariables: {
|
||||
POSTGRES_USER: postgresqlUser,
|
||||
POSTGRES_PASSWORD: postgresqlPassword,
|
||||
POSTGRES_DB: postgresqlDatabase
|
||||
}
|
||||
}
|
||||
};
|
||||
if (serviceSecret.length > 0) {
|
||||
serviceSecret.forEach((secret) => {
|
||||
config.hasura.environmentVariables[secret.name] = secret.value;
|
||||
});
|
||||
}
|
||||
|
||||
const composeFile: ComposeFile = {
|
||||
version: '3.8',
|
||||
services: {
|
||||
[id]: {
|
||||
container_name: id,
|
||||
image: config.hasura.image,
|
||||
environment: config.hasura.environmentVariables,
|
||||
networks: [network],
|
||||
volumes: [],
|
||||
restart: 'always',
|
||||
labels: makeLabelForServices('hasura'),
|
||||
deploy: {
|
||||
restart_policy: {
|
||||
condition: 'on-failure',
|
||||
delay: '5s',
|
||||
max_attempts: 3,
|
||||
window: '120s'
|
||||
}
|
||||
},
|
||||
depends_on: [`${id}-postgresql`]
|
||||
},
|
||||
[`${id}-postgresql`]: {
|
||||
image: config.postgresql.image,
|
||||
container_name: `${id}-postgresql`,
|
||||
environment: config.postgresql.environmentVariables,
|
||||
networks: [network],
|
||||
volumes: [config.postgresql.volume],
|
||||
restart: 'always',
|
||||
deploy: {
|
||||
restart_policy: {
|
||||
condition: 'on-failure',
|
||||
delay: '5s',
|
||||
max_attempts: 3,
|
||||
window: '120s'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
networks: {
|
||||
[network]: {
|
||||
external: true
|
||||
}
|
||||
},
|
||||
volumes: {
|
||||
[config.postgresql.volume.split(':')[0]]: {
|
||||
name: config.postgresql.volume.split(':')[0]
|
||||
}
|
||||
}
|
||||
};
|
||||
const composeFileDestination = `${workdir}/docker-compose.yaml`;
|
||||
await fs.writeFile(composeFileDestination, yaml.dump(composeFile));
|
||||
|
||||
try {
|
||||
await asyncExecShell(`DOCKER_HOST=${host} docker compose -f ${composeFileDestination} pull`);
|
||||
await asyncExecShell(`DOCKER_HOST=${host} docker compose -f ${composeFileDestination} up -d`);
|
||||
return {
|
||||
status: 200
|
||||
};
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
return ErrorHandler(error);
|
||||
}
|
||||
} catch (error) {
|
||||
return ErrorHandler(error);
|
||||
}
|
||||
};
|
42
src/routes/services/[id]/hasura/stop.json.ts
Normal file
42
src/routes/services/[id]/hasura/stop.json.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { getUserDetails, removeDestinationDocker } from '$lib/common';
|
||||
import * as db from '$lib/database';
|
||||
import { ErrorHandler } from '$lib/database';
|
||||
import { checkContainer, stopTcpHttpProxy } from '$lib/haproxy';
|
||||
import type { RequestHandler } from '@sveltejs/kit';
|
||||
|
||||
export const post: RequestHandler = async (event) => {
|
||||
const { teamId, status, body } = await getUserDetails(event);
|
||||
if (status === 401) return { status, body };
|
||||
|
||||
const { id } = event.params;
|
||||
|
||||
try {
|
||||
const service = await db.getService({ id, teamId });
|
||||
const { destinationDockerId, destinationDocker } = service;
|
||||
if (destinationDockerId) {
|
||||
const engine = destinationDocker.engine;
|
||||
|
||||
try {
|
||||
const found = await checkContainer(engine, id);
|
||||
if (found) {
|
||||
await removeDestinationDocker({ id, engine });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
try {
|
||||
const found = await checkContainer(engine, `${id}-postgresql`);
|
||||
if (found) {
|
||||
await removeDestinationDocker({ id: `${id}-postgresql`, engine });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: 200
|
||||
};
|
||||
} catch (error) {
|
||||
return ErrorHandler(error);
|
||||
}
|
||||
};
|
@ -16,6 +16,7 @@
|
||||
import { session } from '$app/stores';
|
||||
import { getDomain } from '$lib/components/common';
|
||||
import Umami from '$lib/components/svg/services/Umami.svelte';
|
||||
import Hasura from '$lib/components/svg/services/Hasura.svelte';
|
||||
|
||||
export let services;
|
||||
async function newService() {
|
||||
@ -89,6 +90,8 @@
|
||||
<MeiliSearch isAbsolute />
|
||||
{:else if service.type === 'umami'}
|
||||
<Umami isAbsolute />
|
||||
{:else if service.type === 'hasura'}
|
||||
<Hasura isAbsolute />
|
||||
{/if}
|
||||
<div class="truncate text-center text-xl font-bold">
|
||||
{service.name}
|
||||
@ -138,6 +141,8 @@
|
||||
<MeiliSearch isAbsolute />
|
||||
{:else if service.type === 'umami'}
|
||||
<Umami isAbsolute />
|
||||
{:else if service.type === 'hasura'}
|
||||
<Hasura isAbsolute />
|
||||
{/if}
|
||||
<div class="truncate text-center text-xl font-bold">
|
||||
{service.name}
|
||||
|
Loading…
x
Reference in New Issue
Block a user