Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ AUTH_ENCRYPTION_KEY=encryptionkey
DATABASE_URL=postgresql://postgres:postgres@127.0.0.1/postgres
DATABASE_POOL_URL=postgresql://postgres:postgres@127.0.0.1:6453/postgres
DATABASE_CONNECTION_TIMEOUT=3000
# Set to false to route through direct PostgreSQL and skip the Database Watt application.
DATABASE_WATT_APPLICATION_ENABLED=false
# Set to true to run healthchecks without the scoped transaction path.
DATABASE_HEALTHCHECK_UNSCOPED=false
DATABASE_SEARCH_PATH=
Expand Down
19 changes: 16 additions & 3 deletions .github/workflows/acceptance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ concurrency:

jobs:
acceptance_local:
name: Local / ${{ matrix.storage_backend }} / ${{ matrix.database }} / ${{ matrix.tenancy }}
name: Local / ${{ matrix.runtime }} / ${{ matrix.storage_backend }} / ${{ matrix.database }} / ${{ matrix.tenancy }}
if: ${{ github.event_name != 'workflow_dispatch' || inputs.acceptance_environment == 'local' }}
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 45
Expand All @@ -57,6 +57,17 @@ jobs:
tenancy:
- single
- multitenant
runtime:
- direct
include:
- storage_backend: s3
database: pg
tenancy: single
runtime: watt
- storage_backend: s3
database: pg
tenancy: multitenant
runtime: watt
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Node dependencies
Expand All @@ -74,6 +85,7 @@ jobs:
- name: Run local acceptance profile
env:
ACCEPTANCE_ADMIN_URL: ${{ matrix.tenancy == 'multitenant' && 'http://127.0.0.1:5001' || '' }}
ACCEPTANCE_COMMAND: ${{ matrix.runtime == 'watt' && 'acceptance:watt' || 'acceptance' }}
ACCEPTANCE_ENABLE_ADMIN: ${{ matrix.tenancy == 'multitenant' && 'true' || 'false' }}
ACCEPTANCE_ENABLE_VECTOR: "true"
ACCEPTANCE_INFRA_RESTART_SCRIPT: ${{ matrix.database == 'oriole' && 'infra:restart:ci:oriole:pgvector' || matrix.database == 'multigres' && 'infra:restart:ci:multigres' || 'infra:restart:ci' }}
Expand All @@ -90,15 +102,16 @@ jobs:
VECTOR_DATABASE_URL: ${{ matrix.tenancy == 'single' && 'postgresql://postgres:postgres@127.0.0.1:5432/postgres' || '' }}
VECTOR_ENABLED: "true"
VECTOR_STORE_MIGRATIONS_ENABLED: "true"
WORKERS_NUM: ${{ matrix.runtime == 'watt' && '2' || '1' }}
run: |
mkdir -p data coverage/acceptance
chmod -R 777 data
npm run acceptance -- --profile "${ACCEPTANCE_PROFILE}"
npm run "${ACCEPTANCE_COMMAND}" -- --profile "${ACCEPTANCE_PROFILE}"
- name: Upload acceptance artifacts
if: ${{ always() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: acceptance-local-${{ matrix.storage_backend }}-${{ matrix.database }}-${{ matrix.tenancy }}
name: acceptance-local-${{ matrix.runtime }}-${{ matrix.storage_backend }}-${{ matrix.database }}-${{ matrix.tenancy }}
path: coverage/acceptance
if-no-files-found: ignore

Expand Down
2 changes: 2 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ COPY --from=production-deps /app/node_modules node_modules
# Copy build artifacts from the build stage
COPY --from=build /app/dist dist
COPY --from=build /app/watt.json /app
COPY --from=build /app/watt-db.json /app
COPY --from=build /app/watt.storage.json /app
COPY --from=build /app/watt.database.json /app

EXPOSE 5000
CMD ["node", "dist/start/server.js"]
17 changes: 17 additions & 0 deletions acceptance/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,23 @@ This restarts local infra, seeds dummy data, starts the TypeScript server from `
The sample env and local CI default to `full`, so enabled capability-gated tests such as Iceberg
run by default. Use `--profile smoke` for a faster sanity run.

### Database Watt Runtime

Run Storage and the Database application together through `watt-db.json` with:

```bash
npm run acceptance:watt -- --profile core acceptance/specs/database-watt.test.ts
```

The acceptance spec only calls Storage's external HTTP API. The Database application loopback
coverage lives beside its source as
`src/applications/database/application.integration.test.ts`; after starting and seeding local
infrastructure, run it with:

```bash
npm run test:integration -- src/applications/database/application.integration.test.ts
```

For local backend variants, put server/runtime changes in `.env` or `.env.test`. Keep
`.env.acceptance` limited to acceptance runner inputs such as target URLs, client credentials,
capability gates, and resource naming.
Expand Down
160 changes: 142 additions & 18 deletions acceptance/scripts/run-managed-local.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,47 @@ import dotenv from 'dotenv'
const inheritedEnv = { ...process.env }
loadAcceptanceEnvFile()

const args = process.argv.slice(2)
const profile = readArg('profile') ?? acceptanceEnv('ACCEPTANCE_PROFILE') ?? 'smoke'
const { acceptanceArgs: args, managedRuntime } = parseManagedRuntimeArgs(process.argv.slice(2))
const usesDatabaseWatt = managedRuntime === 'watt'
const profile =
readArg('profile') ?? acceptanceEnv('ACCEPTANCE_PROFILE') ?? (usesDatabaseWatt ? 'core' : 'smoke')
const serverEnv = loadServerEnvFiles(inheritedEnv)

if (usesDatabaseWatt) {
delete serverEnv.ELECTRON_RUN_AS_NODE
serverEnv.LOG_LEVEL ||= 'info'
serverEnv.NODE_ENV = 'test'
serverEnv.UPLOAD_FILE_SIZE_LIMIT ||= '524288000'
serverEnv.WORKERS_NUM ||= '2'
serverEnv.PLT_MANAGEMENT_API ||= 'true'
serverEnv.WATT_HEALTH_ENABLED ||= 'false'
serverEnv.DATABASE_WATT_APPLICATION_ENABLED ||= 'true'
}

configureManagedLocalQueueEnv(serverEnv)
const serverPort = serverEnv.SERVER_PORT || serverEnv.PORT || '5000'
const baseUrl = acceptanceEnv('ACCEPTANCE_BASE_URL') ?? `http://127.0.0.1:${serverPort}`
const serverIsMultitenant = isMultitenantServer(serverEnv)
const acceptanceRunEnv: NodeJS.ProcessEnv = {
...process.env,
ACCEPTANCE_BASE_URL: baseUrl,
ACCEPTANCE_DATABASE_WATT: String(usesDatabaseWatt),
ACCEPTANCE_PROFILE: profile,
ACCEPTANCE_S3_ENDPOINT: acceptanceEnv('ACCEPTANCE_S3_ENDPOINT') ?? `${baseUrl}/s3`,
STORAGE_BACKEND: acceptanceEnv('STORAGE_BACKEND') ?? serverEnv.STORAGE_BACKEND,
ACCEPTANCE_TUS_ENDPOINT:
acceptanceEnv('ACCEPTANCE_TUS_ENDPOINT') ?? `${baseUrl}/upload/resumable`,
ACCEPTANCE_TUS_ENDPOINT: usesDatabaseWatt
? `${baseUrl}/upload/resumable`
: (acceptanceEnv('ACCEPTANCE_TUS_ENDPOINT') ?? `${baseUrl}/upload/resumable`),
}

if (usesDatabaseWatt) {
acceptanceRunEnv.ACCEPTANCE_SERVICE_KEY =
acceptanceEnv('ACCEPTANCE_SERVICE_KEY') ?? serverEnv.SERVICE_KEY
acceptanceRunEnv.ACCEPTANCE_S3_ACCESS_KEY_ID =
acceptanceEnv('ACCEPTANCE_S3_ACCESS_KEY_ID') ?? serverEnv.S3_PROTOCOL_ACCESS_KEY_ID
acceptanceRunEnv.ACCEPTANCE_S3_SECRET_ACCESS_KEY =
acceptanceEnv('ACCEPTANCE_S3_SECRET_ACCESS_KEY') ?? serverEnv.S3_PROTOCOL_ACCESS_KEY_SECRET
delete acceptanceRunEnv.ELECTRON_RUN_AS_NODE
}

let server: ChildProcess | undefined
Expand All @@ -35,6 +61,45 @@ main().catch((error) => {
process.exit(1)
})

async function waitForWattApplication(
runtimePid: number,
applicationId: string,
timeoutMs: number
) {
const { RuntimeApiClient } = await import('@platformatic/control')
const client = new RuntimeApiClient()
const started = Date.now()
let lastError: unknown

try {
while (Date.now() - started < timeoutMs) {
try {
const application = await client
.getRuntimeApplications(runtimePid)
.then(({ applications }) => {
return applications.find((application) => application.id === applicationId)
})

if (application?.status === 'started') {
return
}

lastError = new Error(
`Application ${applicationId} status is ${application?.status ?? 'unknown'}`
)
} catch (error) {
lastError = error
}

await new Promise((resolve) => setTimeout(resolve, 500))
}
} finally {
await client.close()
}

throw new Error(`Timed out waiting for Watt application ${applicationId}: ${String(lastError)}`)
}

async function main() {
try {
if (process.env.ACCEPTANCE_SKIP_INFRA !== 'true') {
Expand All @@ -48,15 +113,35 @@ async function main() {
serverEnv.CDN_PURGE_ENDPOINT_URL = purge.url
}

server = spawn(localBin('tsx'), ['src/start/server.ts'], {
detached: process.platform !== 'win32',
env: serverEnv,
stdio: ['ignore', 'pipe', 'pipe'],
})
prefixOutput(server.stdout, '[storage] ')
prefixOutput(server.stderr, '[storage] ')
if (usesDatabaseWatt) {
await run('npm', ['run', 'build'], serverEnv)
}

await waitForStatus(`${baseUrl}/status`, 60_000)
server = spawn(
localBin(usesDatabaseWatt ? 'wattpm' : 'tsx'),
usesDatabaseWatt ? ['start', '--config', 'watt-db.json'] : ['src/start/server.ts'],
{
detached: process.platform !== 'win32',
env: serverEnv,
stdio: ['ignore', 'pipe', 'pipe'],
}
)
const outputPrefix = usesDatabaseWatt ? '[watt] ' : '[storage] '
prefixOutput(server.stdout, outputPrefix)
prefixOutput(server.stderr, outputPrefix)

if (usesDatabaseWatt) {
const runtimePid = server.pid
if (!runtimePid) {
throw new Error('Managed Watt process did not expose a PID')
}

await waitForStatus(`${baseUrl}/status`, 60_000)
await waitForWattApplication(runtimePid, 'storage', 60_000)
await waitForWattApplication(runtimePid, 'database', 60_000)
} else {
await waitForStatus(`${baseUrl}/status`, 60_000)
}

if (serverIsMultitenant) {
provisionedS3Credential = await provisionLocalMultitenantTenant(serverEnv)
Expand All @@ -70,7 +155,9 @@ async function main() {
acceptanceRunEnv.ACCEPTANCE_ADMIN_URL = ''
acceptanceRunEnv.ACCEPTANCE_ADMIN_API_KEY = ''
process.stderr.write(
'[acceptance] disabled admin acceptance for managed single-tenant server\n'
`[acceptance] disabled admin acceptance for managed single-tenant${
usesDatabaseWatt ? ' Watt' : ''
} server\n`
)
}

Expand All @@ -85,7 +172,7 @@ async function main() {
}

if (server) {
await stopServer(server)
await stopServer(server, usesDatabaseWatt ? '[watt]' : '[storage]')
}

if (cdnPurgeServer) {
Expand Down Expand Up @@ -393,7 +480,7 @@ async function waitForStatus(url: string, timeoutMs: number) {
throw new Error(`Timed out waiting for ${url}: ${String(lastError)}`)
}

async function stopServer(child: ChildProcess) {
async function stopServer(child: ChildProcess, logPrefix: string) {
if (hasExited(child)) {
return
}
Expand All @@ -403,7 +490,7 @@ async function stopServer(child: ChildProcess) {
child.kill()

if (!(await exitedAfterKill)) {
process.stderr.write('[storage] server did not exit after kill\n')
process.stderr.write(`${logPrefix} server did not exit after kill\n`)
}
return
}
Expand All @@ -415,13 +502,50 @@ async function stopServer(child: ChildProcess) {
return
}

process.stderr.write('[storage] server did not exit after SIGTERM; sending SIGKILL\n')
process.stderr.write(`${logPrefix} server did not exit after SIGTERM; sending SIGKILL\n`)

const exitedAfterKill = waitForExit(child, 2_000)
killProcessTree(child, 'SIGKILL')

if (!(await exitedAfterKill)) {
process.stderr.write('[storage] server did not exit after SIGKILL\n')
process.stderr.write(`${logPrefix} server did not exit after SIGKILL\n`)
}
}

function parseManagedRuntimeArgs(inputArgs: string[]): {
acceptanceArgs: string[]
managedRuntime: 'direct' | 'watt'
} {
const acceptanceArgs: string[] = []
let runtimeValue: string | undefined

for (let index = 0; index < inputArgs.length; index++) {
const arg = inputArgs[index]

if (arg === '--managed-runtime') {
runtimeValue = inputArgs[index + 1]
if (!runtimeValue || runtimeValue.startsWith('--')) {
throw new Error('Missing value for --managed-runtime')
}
index++
continue
}

if (arg.startsWith('--managed-runtime=')) {
runtimeValue = arg.slice('--managed-runtime='.length)
continue
}

acceptanceArgs.push(arg)
}

if (runtimeValue !== undefined && runtimeValue !== 'direct' && runtimeValue !== 'watt') {
throw new Error(`Unsupported managed acceptance runtime: ${runtimeValue}`)
}

return {
acceptanceArgs,
managedRuntime: runtimeValue ?? 'direct',
}
}

Expand Down
68 changes: 68 additions & 0 deletions acceptance/specs/database-watt.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { describeAcceptance, encodePathSegments, getAcceptanceConfig } from '../support/config'
import { createRestClient } from '../support/http'
import {
cleanupRestResources,
createRestBucket,
requireServiceKey,
uniqueBucketName,
uniqueObjectKey,
uploadRestObject,
} from '../support/resources'

interface ListObjectsV2Response {
objects: Array<{ name: string }>
}

const describeDatabaseWattAcceptance =
process.env.ACCEPTANCE_DATABASE_WATT === 'true' ? describeAcceptance : describe.skip

describeDatabaseWattAcceptance(
'Storage through the Database Watt runtime',
{
destructive: true,
profiles: ['core'],
},
() => {
it('persists tenant metadata through the external Storage API', async () => {
const config = getAcceptanceConfig()
const client = createRestClient()
const bucketName = uniqueBucketName('dbwatt')
const objectKey = uniqueObjectKey('dbwatt')
const payload = `database-watt-acceptance-${config.runId}`

try {
await createRestBucket(bucketName)
await uploadRestObject(bucketName, objectKey, payload)

const listed = await client.request<ListObjectsV2Response>(
'POST',
`/object/list-v2/${bucketName}`,
{
body: {
limit: 100,
prefix: `${config.resourcePrefix}/`,
with_delimiter: false,
},
expectedStatus: 200,
token: requireServiceKey(config),
}
)

expect(listed.json?.objects.map((object) => object.name)).toContain(objectKey)

const downloaded = await client.request(
'GET',
`/object/authenticated/${bucketName}/${encodePathSegments(objectKey)}`,
{
expectedStatus: 200,
token: requireServiceKey(config),
}
)

expect(downloaded.body).toBe(payload)
} finally {
await cleanupRestResources(bucketName, [objectKey], client)
}
})
}
)
Loading