diff --git a/README.md b/README.md index e1ef4c9..229bedf 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,22 @@ Example: clone-certificate: 90051 ``` +### Databases + +This action creates a new database for each preview site and deletes the database when the preview site is deleted. If your Forge server has one of the default supported database engines installed (MySQL, MariaDB, or PostgreSQL), that database engine will be used and no additional configuration is necessary. + +To use SQLite, set your preview sites’ `DB_CONNECTION` environment variable to `sqlite` using [the `environment` input](#environment): + +```yaml +- uses: bakerkretzmar/laravel-deploy-preview@v2 + with: + forge-token: ${{ secrets.FORGE_TOKEN }} + servers: | + qa-1.acme.dev 60041 + environment: | + DB_CONNECTION=sqlite +``` + ## Development This action is loosely based on GitHub's [hello-world-javascript-action](https://github.com/actions/hello-world-javascript-action) and [typescript-action](https://github.com/actions/typescript-action) templates. It's written in TypeScript and compiled with [`ncc`](https://github.com/vercel/ncc) into a single JavaScript file. diff --git a/package.json b/package.json index ca56e7a..6fd96e1 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,7 @@ "build": "ncc build src/index.ts --source-map --license licenses.txt", "debug": "ncc run src/debug.ts", "test": "vitest unit", - "test:integration": "vitest run integration --test-timeout=30000" + "test:integration": "vitest run integration --test-timeout=60000" }, "dependencies": { "@actions/core": "^1.10.1", diff --git a/src/action.ts b/src/action.ts index a4639f0..c0d0af5 100644 --- a/src/action.ts +++ b/src/action.ts @@ -14,7 +14,7 @@ export async function createPreview({ repository: string; servers: { id: number; domain: string }[]; afterDeploy?: string; - environment?: Record; + environment: Record; certificate?: { type: 'clone'; certificate: number } | { type: 'existing'; certificate: string; key: string }; }) { core.info(`Creating preview site for branch: ${branch}.`); @@ -32,7 +32,11 @@ export async function createPreview({ } core.info(`Creating site: ${siteName}.`); - site = await Site.create(servers[0].id, siteName, normalizeDatabaseName(branch)); + site = await Site.create( + servers[0].id, + siteName, + environment.DB_CONNECTION === 'sqlite' ? '' : normalizeDatabaseName(branch), + ); if (certificate?.type === 'existing') { core.info('Installing existing SSL certificate.'); @@ -48,9 +52,21 @@ export async function createPreview({ core.info(`Installing repository: ${repository}.`); await site.installRepository(repository, branch); + const sqliteEnvironment = + environment.DB_CONNECTION === 'sqlite' + ? { + DB_HOST: undefined, + DB_PORT: undefined, + DB_DATABASE: undefined, + DB_USERNAME: undefined, + DB_PASSWORD: undefined, + } + : {}; + core.info('Updating `.env` file.'); await site.setEnvironmentVariables({ DB_DATABASE: normalizeDatabaseName(branch), + ...sqliteEnvironment, ...environment, }); @@ -77,9 +93,11 @@ export async function createPreview({ export async function destroyPreview({ branch, servers, + environment = {}, }: { branch: string; servers: { id: number; domain: string }[]; + environment: Record; }) { core.info(`Removing preview site: ${branch}.`); @@ -103,6 +121,8 @@ export async function destroyPreview({ core.info('Deleting site.'); await site.delete(); - core.info('Deleting database.'); - await site.deleteDatabase(normalizeDatabaseName(branch)); + if (environment.DB_CONNECTION !== 'sqlite') { + core.info('Deleting database.'); + await site.deleteDatabase(normalizeDatabaseName(branch)); + } } diff --git a/src/forge.ts b/src/forge.ts index 7ba8c11..45c157e 100644 --- a/src/forge.ts +++ b/src/forge.ts @@ -1,5 +1,5 @@ import axios, { AxiosError, AxiosInstance, AxiosResponse } from 'axios'; -import { sleep, until } from './lib.js'; +import { sleep, until, updateDotEnvString } from './lib.js'; type ServerPayload = { id: number; @@ -34,6 +34,13 @@ type DatabasePayload = { name: string; }; +type CommandPayload = { + id: number; + command: string; + status: string; + duration: string; +}; + export class ForgeError extends Error { axiosError: AxiosError; data?: unknown; @@ -194,6 +201,17 @@ export class Forge { await this.post(`servers/${server}/sites/${site}/certificates/${certificate}/activate`); } + static async runCommand(server: number, site: number, command: string) { + return (await this.post<{ command: CommandPayload }>(`servers/${server}/sites/${site}/commands`, { command })).data + .command; + } + + static async getCommand(server: number, site: number, command: number) { + return ( + await this.get<{ command: CommandPayload; output: string }>(`servers/${server}/sites/${site}/commands/${command}`) + ).data; + } + static token(token: string) { this.#token = token; } @@ -308,16 +326,9 @@ export class Site { ); } - async setEnvironmentVariables(variables: Record) { - let env = await Forge.getEnvironmentFile(this.server_id, this.id); - Object.entries(variables).map(([key, value]) => { - if (new RegExp(`${key}=`).test(env)) { - env = env.replace(new RegExp(`${key}=.*`), `${key}=${value}`); - } else { - env += `\n${key}=${value}\n`; - } - }); - await Forge.updateEnvironmentFile(this.server_id, this.id, env); + async setEnvironmentVariables(variables: Record) { + const env = await Forge.getEnvironmentFile(this.server_id, this.id); + await Forge.updateEnvironmentFile(this.server_id, this.id, updateDotEnvString(env, variables)); } async installScheduler() { diff --git a/src/lib.ts b/src/lib.ts index 200fa07..401e503 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -30,6 +30,17 @@ export function normalizeDomainName(input: string) { return input.replace(/\W+/g, '-').substring(0, 63).replace(/^-|-$/g, ''); } +export function updateDotEnvString(env: string, variables: Record) { + Object.entries(variables).map(([key, value]) => { + if (new RegExp(`${key}=`).test(env)) { + env = env.replace(new RegExp(`${key}=.*\n?`), value === undefined ? '' : `${key}=${value}\n`); + } else { + env += `\n${key}=${value}\n`; + } + }); + return env; +} + // function serverWithFewestSites(servers: Server[], sites: Site[]): Server { // const serverSites = sites.reduce((carry: { [_: string]: number }, site: Site) => { // carry[site.server_id] ??= 0; diff --git a/src/main.ts b/src/main.ts index 6486aa9..bf915ce 100644 --- a/src/main.ts +++ b/src/main.ts @@ -106,6 +106,7 @@ export async function run() { await destroyPreview({ branch: pr.pull_request.head.ref, servers, + environment, }); } } catch (error) { diff --git a/tests/integration/forge.test.ts b/tests/integration/forge.test.ts index 0bc6f10..0bd7768 100644 --- a/tests/integration/forge.test.ts +++ b/tests/integration/forge.test.ts @@ -1,7 +1,7 @@ import * as crypto from 'node:crypto'; import { afterAll, describe, expect, test } from 'vitest'; import { Forge, ForgeError } from '../../src/forge'; -import { normalizeDatabaseName, until } from '../../src/lib'; +import { normalizeDatabaseName, until, updateDotEnvString } from '../../src/lib'; // @ts-expect-error import.meta not set up Forge.token(import.meta.env.VITE_FORGE_TOKEN); @@ -129,38 +129,6 @@ describe('sites', () => { expect.assertions(3); }); - // Needs a repo or smth installed first or Forge 500s - test.todo('enable quick deploy', async () => { - const name = `test-${id()}.laravel-deploy-preview.com`; - - let site = await Forge.createSite(server, name, ''); - - expect(site).toMatchObject({ - server_id: server, - name: name, - status: 'installing', - }); - - await until( - () => site.status === 'installed', - async () => (site = await Forge.getSite(server, site.id)), - ); - - expect(site).toMatchObject({ - server_id: server, - name: name, - status: 'installed', - }); - - site = await Forge.enableQuickDeploy(server, site.id); - - expect(site).toMatchObject({ - server_id: server, - name: name, - quick_deploy: 'true', - }); - }); - test('create SSL certificate', async () => { const name = `test-${id()}.laravel-deploy-preview.com`; @@ -374,4 +342,125 @@ describe('sites', () => { active: true, }); }); + + test.todo('install repository'); + + test.todo('enable quick deploy'); + // site = await Forge.enableQuickDeploy(server, site.id); + // expect(site).toMatchObject({ + // server_id: server, + // name: name, + // quick_deploy: 'true', + // }); + + test('handle failing to enable quick deploy', async () => { + const name = `test-${id()}.laravel-deploy-preview.com`; + + let site = await Forge.createSite(server, name, ''); + + expect(site).toMatchObject({ + server_id: server, + name: name, + status: 'installing', + }); + + await until( + () => site.status === 'installed', + async () => (site = await Forge.getSite(server, site.id)), + ); + + expect(site).toMatchObject({ + server_id: server, + name: name, + status: 'installed', + }); + + try { + await Forge.enableQuickDeploy(server, site.id); + } catch (e) { + expect(e).toBeInstanceOf(ForgeError); + expect(e.message).toBe('Forge API request failed with status code 400.'); + expect(e.data).toMatchObject({ + message: 'The site does not yet have an application installed. Please install an application and try again.', + }); + } + + expect.assertions(5); + }); + + test.todo('update environment file'); + + test.todo('deploy site'); + + test.todo('run command'); + + test.todo('handle failed command'); + // status will be 'failed' + + test('use sqlite', async () => { + const name = `test-${id()}.laravel-deploy-preview.com`; + + let site = await Forge.createSite(server, name, ''); + + await until( + () => site.status === 'installed', + async () => (site = await Forge.getSite(server, site.id)), + ); + + await Forge.createGitProject(server, site.id, 'bakerkretzmar/laravel-deploy-preview-app', 'main'); + + await until( + () => site.repository_status === 'installed', + async () => (site = await Forge.getSite(server, site.id)), + 3, + ); + + const env = await Forge.getEnvironmentFile(server, site.id); + + await Forge.updateEnvironmentFile( + server, + site.id, + updateDotEnvString(env, { + DB_CONNECTION: 'sqlite', + DB_DATABASE: undefined, + }), + ); + + let command1 = await Forge.runCommand(server, site.id, 'ls database'); + let output1 = ''; + + await until( + () => command1.status === 'finished', + async () => ({ command: command1, output: output1 } = await Forge.getCommand(server, site.id, command1.id)), + ); + + expect(output1).not.toContain('database.sqlite'); + + await Forge.deploy(server, site.id); + + await until( + () => site.deployment_status === null, + async () => (site = await Forge.getSite(server, site.id)), + ); + + let command2 = await Forge.runCommand(server, site.id, 'ls database'); + let output2 = ''; + + await until( + () => command2.status === 'finished', + async () => ({ command: command2, output: output2 } = await Forge.getCommand(server, site.id, command2.id)), + ); + + expect(output2).toContain('database.sqlite'); + + let command3 = await Forge.runCommand(server, site.id, 'php artisan db:monitor --databases=sqlite'); + let output3 = ''; + + await until( + () => command3.status === 'finished', + async () => ({ command: command3, output: output3 } = await Forge.getCommand(server, site.id, command3.id)), + ); + + expect(output3).toMatch(/sqlite \.+ \[\] OK/); + }); }); diff --git a/tests/unit/lib.test.ts b/tests/unit/lib.test.ts index a4548c0..91b62d9 100644 --- a/tests/unit/lib.test.ts +++ b/tests/unit/lib.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test, vi } from 'vitest'; -import { normalizeDatabaseName, normalizeDomainName, until } from '../../src/lib'; +import { normalizeDatabaseName, normalizeDomainName, until, updateDotEnvString } from '../../src/lib'; describe('until', () => { test('run attempt callback once immediately', async () => { @@ -78,3 +78,73 @@ describe('normalizeDomainName', () => { expect(normalizeDomainName(input)).toBe(output); }); }); + +describe('updateDotEnvString', () => { + test.each([ + [ + 'set empty variable', + { + DB_DATABASE: 'foobar', + }, + `APP_NAME= +DB_DATABASE="" +QUEUE_CONNECTION=`, + `APP_NAME= +DB_DATABASE=foobar +QUEUE_CONNECTION=`, + ], + [ + 'add variable', + { + SENTRY_DSN: '12345', + }, + `APP_NAME= +`, + `APP_NAME= + +SENTRY_DSN=12345 +`, + ], + [ + 'update existing weird value', + { + APP_DEBUG: 'true', + DB_DATABASE: 'foobar', + }, + `APP_DEBUG=false +APP_NAME=Test +DB_DATABASE= "-- \'something weird" !  +REDIS_PORT=6379`, + `APP_DEBUG=true +APP_NAME=Test +DB_DATABASE=foobar +REDIS_PORT=6379`, + ], + [ + 'update existing value to be empty', + { + DB_CONNECTION: 'sqlite', + DB_DATABASE: '', + }, + `DB_CONNECTION=mysql +DB_DATABASE=forge`, + `DB_CONNECTION=sqlite +DB_DATABASE= +`, + ], + [ + 'remove variable using undefined', + { + DB_CONNECTION: 'sqlite', + DB_DATABASE: undefined, + }, + `DB_CONNECTION=mysql +DB_DATABASE=forge +DB_USERNAME=forge`, + `DB_CONNECTION=sqlite +DB_USERNAME=forge`, + ], + ])('%s (%j)', (name, variables, initial, output) => { + expect(updateDotEnvString(initial, variables)).toBe(output); + }); +});