ReviewOS

also looking at this

stacks/ts-cloud

fix(deploy): keep SQLite across deploys, and preflight an attached host's services

#174
Merged glennmichael123 wants to merge fix/sqlite-shared-path-and-attach-preflight into main
16 files +916 -12

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

docs/config.mdmodified+48-0
Changes to docs/config.md
@@ -344,6 +344,54 @@ clean. If a site targets a server (`deploy: 'server'`, or `start` set) but no
344344actionable error instead of failing silently at runtime — set `deploy: 'bucket'`
345345or add a server.
346346
347### State that must survive a deploy
348
349Each deploy unpacks into a NEW `releases/<id>` directory and flips `current` at
350it; old releases are pruned. Anything the app writes and must keep therefore has
351to live in the site's `shared/` directory and be symlinked in, which is what
352`site.sharedPaths` declares. `.env` is always shared.
353
354```typescript
355sites: {
356 app: {
357 start: 'bun run server.ts',
358 sharedPaths: ['storage', 'public/uploads'],
359 },
360}
361```
362
363A **SQLite database is shared automatically**. The deploy already knows the
364connection and the file path from the environment it writes to the box, so when
365`DB_CONNECTION` is `sqlite` and `DB_DATABASE` names a path inside the release,
366that file is added to `sharedPaths` for you and the deploy log says so. Without
367it the database sits inside a release directory and the next deploy starts the
368app on an empty one — silently, with the data still in a release that is about
369to be pruned.
370
371Two cases it does not cover:
372
373- **`DB_DATABASE` unset.** An app can default its own path internally, which the
374 deploy never sees. Guessing the filename would report the data as safe while
375 sharing a path the app may not use, so the deploy warns instead — set
376 `DB_DATABASE`, or list the file in `sharedPaths` yourself.
377- **An absolute path.** A database outside the release tree already survives; a
378 deploy replaces the release, not the filesystem around it.
379
380Turning existing on-box state into shared state does not throw it away: the
381first deploy to share a path copies the live release's copy into `shared/`
382(SQLite's `-wal`/`-shm` sidecars included), and a site's first deploy seeds a
383still-empty shared file from the copy the artifact shipped.
384
385Several sites of one project can share ONE file — an app and its API on one
386SQLite database — with the object form, which names an absolute `target`:
387
388```typescript
389sharedPaths: [{ path: 'database/app.sqlite', target: '/var/www/acme-app/shared/database/app.sqlite', seed: false }]
390```
391
392Each site installs under its own base, so a plain string would give each of them
393a database of its own. `seed: false` marks the sites that do not own the file.
394
347395### CDN / caching
348396
349397The `cache` hint applies to either origin:
packages/core/src/types.tsmodified+5-0
Changes to packages/core/src/types.ts
@@ -1243,6 +1243,11 @@ export interface SiteConfig {
12431243 * A release is a fresh directory, so anything the app WRITES and must keep
12441244 * has to be listed here or the next deploy silently starts it from empty.
12451245 *
1246 * A SQLite database is the one exception, added for you: when the site's
1247 * resolved env says `DB_CONNECTION=sqlite` and `DB_DATABASE` names a path
1248 * inside the release, the deploy shares that file without being asked. An
1249 * env that says SQLite but not WHERE is warned about rather than guessed at.
1250 *
12461251 * An entry may instead be a {@link SharedPathSpec} naming an absolute
12471252 * `target`, which is how SEVERAL sites of one project point at ONE file —
12481253 * an app and its API sharing a single SQLite database, say. Each site
packages/ts-cloud/src/drivers/shared/compute-deploy.tsmodified+9-0
Changes to packages/ts-cloud/src/drivers/shared/compute-deploy.ts
@@ -17,6 +17,7 @@ import { resolveNotifications, sendNotifications } from './notifications'
1717import { buildPhpFpmPoolScript, phpFpmPoolListen } from './php-fpm-pool'
1818import { buildDeployHistoryHeader, buildSiteOwnerGuard } from './releases'
1919import { buildRpxConfig, buildRpxFragmentRefreshScript, buildRpxLbConfig, buildRpxProvisionScript, certDomainsForConfig, rpxCertRenewServiceName, usesRpxProxy } from './rpx-gateway'
20import { inferSqliteSharedPath } from './sqlite-shared-path'
2021
2122export interface ComputeDeployLogger {
2223 info(message: string): void
@@ -95,6 +96,14 @@ export async function deploySiteRelease(
9596 ...productionEnv,
9697 }
9798
99 // A SQLite database inside the release directory is thrown away by the next
100 // deploy. The script builders share it automatically when the env says where
101 // it lives; when the env says SQLite but not where, only the operator can
102 // resolve it — so say so here rather than deploying quietly over it.
103 const sqlite = inferSqliteSharedPath(envWithServices, site.sharedPaths ?? [])
104 if (sqlite.warning) logger.warn(`Site '${siteName}': ${sqlite.warning}`)
105 else if (sqlite.path) logger.info(`Site '${siteName}': sharing '${sqlite.path}' so the SQLite database survives deploys.`)
106
98107 // PHP/Laravel sites deploy via git clone + atomic releases on the box (no
99108 // tarball upload). The box clones the repo, runs the deploy script inside the
100109 // new release, flips `current`, then nginx is (re)pointed at it.
packages/ts-cloud/src/drivers/shared/deploy-script.tsmodified+10-1
Changes to packages/ts-cloud/src/drivers/shared/deploy-script.ts
@@ -13,6 +13,7 @@
1313import type { SharedPathEntry } from '@ts-cloud/core'
1414import { formatEnvFile } from './env-file'
1515import { buildActivateRelease, buildDeployLock, buildEnsureReleaseLayout, buildLinkSharedPaths, buildPromoteStagedRelease, buildPruneReleases, buildResetReleaseDir, buildStrandedReleaseTrap, dedupeSharedPaths, DEFAULT_KEEP_RELEASES, releasePaths } from './releases'
16import { sqliteSharedPaths } from './sqlite-shared-path'
1617
1718/**
1819 * Translate a `start` command (e.g. "bun run server.ts") into an absolute
@@ -184,7 +185,15 @@ export function buildSiteDeployScript(options: BuildSiteDeployScriptOptions): st
184185 const serviceName = `${unitBase}.service`
185186 const tarball = releaseTarballTmpPath(slug, siteName, releaseId)
186187 // `.env` is always shared; a site adds anything else it writes and must keep.
187 const sharedPaths = dedupeSharedPaths(['.env', ...(options.sharedPaths ?? [])])
188 // A SQLite database is added for it: the deploy already knows the connection
189 // and the file path from the env it is about to write, and an undeclared
190 // SQLite file inside the release is discarded by the NEXT deploy.
191 const declaredSharedPaths = options.sharedPaths ?? []
192 const sharedPaths = dedupeSharedPaths([
193 '.env',
194 ...declaredSharedPaths,
195 ...sqliteSharedPaths(envEntries, declaredSharedPaths),
196 ])
188197
189198 const envFile = formatEnvFile(envEntries)
190199
packages/ts-cloud/src/drivers/shared/laravel-deploy.tsmodified+6-1
Changes to packages/ts-cloud/src/drivers/shared/laravel-deploy.ts
@@ -19,6 +19,7 @@ import { formatEnvFile } from './env-file'
1919import { buildGitCheckoutScript } from './git-deploy'
2020import { PANTRY_PROJECT_DIR, pantryEnvActivation } from './package-manager'
2121import { buildActivateRelease, buildDeployHistoryHeader, buildEnsureReleaseLayout, buildLinkSharedPaths, buildPruneReleases, DEFAULT_KEEP_RELEASES, DEFAULT_SHARED_PATHS, releasePaths } from './releases'
22import { sqliteSharedPaths } from './sqlite-shared-path'
2223
2324export const MACRO_CREATE_RELEASE = '$CREATE_RELEASE'
2425export const MACRO_ACTIVATE_RELEASE = '$ACTIVATE_RELEASE'
@@ -142,7 +143,11 @@ export function buildLaravelDeployScript(options: LaravelDeployOptions): string[
142143 // pinned at install time (php.net@<version>), not in the deploy command.
143144 const phpBin = 'php'
144145 const paths = releasePaths(base, releaseId)
145 const sharedPaths = site.sharedPaths ?? DEFAULT_SHARED_PATHS
146 const declaredSharedPaths = site.sharedPaths ?? DEFAULT_SHARED_PATHS
147 // Share the app's SQLite database automatically when the env names one inside
148 // the release — see `./sqlite-shared-path`. A `database/*.sqlite` left out of
149 // `sharedPaths` is thrown away by the next deploy.
150 const sharedPaths = [...declaredSharedPaths, ...sqliteSharedPaths(site.env, declaredSharedPaths)]
146151 const keepReleases = site.keepReleases ?? DEFAULT_KEEP_RELEASES
147152 const template = site.deployScript?.length ? site.deployScript : defaultDeployScriptFor(site.type ?? 'laravel')
148153
packages/ts-cloud/src/drivers/shared/releases.tsmodified+28-0
Changes to packages/ts-cloud/src/drivers/shared/releases.ts
@@ -339,6 +339,31 @@ export function buildPromoteStagedRelease(paths: ReleasePaths): string[] {
339339 ]
340340}
341341
342/**
343 * Seed an as-yet-empty shared FILE from the copy the incoming release shipped.
344 *
345 * {@link buildAdoptSharedPathFn} rescues state from the release that is
346 * currently live, which covers a path that becomes shared on an existing site.
347 * It cannot cover a site's FIRST deploy: there is no live release to adopt
348 * from, so the layout step leaves a zero-byte placeholder and the link below
349 * would replace the artifact's real file with it — an app shipping a seeded
350 * SQLite database would come up empty on the very deploy that created it.
351 *
352 * Narrow on purpose: only a regular, non-empty file in the release, and only
353 * when the shared target is still zero bytes (what a placeholder looks like,
354 * and what no real SQLite database ever is — a database with any schema in it
355 * is at least one page). It can therefore only ever put content where there
356 * was none.
357 */
358function buildSeedSharedFromRelease(link: string, target: string): string[] {
359 return [
360 `if [ -f ${link} ] && [ ! -L ${link} ] && [ -s ${link} ] && [ ! -s ${target} ]; then`,
361 ` cp -a ${link} ${target}`,
362 ` echo "[ts-cloud] seeded shared/ from the release's own copy of the file"`,
363 'fi',
364 ]
365}
366
342367/**
343368 * Symlink every shared path from `shared/` into the freshly checked-out release,
344369 * replacing whatever the checkout shipped (e.g. the repo's empty `storage`).
@@ -355,6 +380,9 @@ export function buildLinkSharedPaths(
355380
356381 // A site that owns the target always links: the layout step just created it.
357382 if (seed) {
383 // `.env` is excluded: the deploy writes the shared one itself, and the
384 // release's own env files are deleted before this runs.
385 if (p !== '.env' && isFileSharedPath(p)) lines.push(...buildSeedSharedFromRelease(link, target))
358386 lines.push(...relink)
359387 continue
360388 }
packages/ts-cloud/src/drivers/shared/sqlite-shared-path.test.tsadded+96-0
Changes to packages/ts-cloud/src/drivers/shared/sqlite-shared-path.test.ts
@@ -0,0 +1,96 @@
1import { describe, expect, it } from 'bun:test'
2import { buildSiteDeployScript } from './deploy-script'
3import { inferSqliteSharedPath, sqliteSharedPaths, usesSqlite } from './sqlite-shared-path'
4
5describe('inferSqliteSharedPath', () => {
6 it('shares a release-relative sqlite file', () => {
7 expect(inferSqliteSharedPath({ DB_CONNECTION: 'sqlite', DB_DATABASE: 'database/stacks.sqlite' }))
8 .toEqual({ path: 'database/stacks.sqlite' })
9 })
10
11 it('accepts the sqlite3 spelling and a leading ./', () => {
12 expect(inferSqliteSharedPath({ DB_CONNECTION: 'SQLite3', DB_DATABASE: './database/app.sqlite' }))
13 .toEqual({ path: 'database/app.sqlite' })
14 })
15
16 it('leaves a non-sqlite app alone', () => {
17 expect(inferSqliteSharedPath({ DB_CONNECTION: 'pgsql', DB_DATABASE: 'loghq' })).toEqual({})
18 expect(usesSqlite({ DB_CONNECTION: 'mysql' })).toBe(false)
19 })
20
21 /**
22 * A path outside the release is not replaced by a deploy — it needs no
23 * shared/ entry, and inventing one would symlink a path the app does not use.
24 */
25 it('leaves an absolute path alone', () => {
26 expect(inferSqliteSharedPath({ DB_CONNECTION: 'sqlite', DB_DATABASE: '/var/data/app.sqlite' })).toEqual({})
27 })
28
29 it('refuses a path escaping the release', () => {
30 expect(inferSqliteSharedPath({ DB_CONNECTION: 'sqlite', DB_DATABASE: '../shared/app.sqlite' })).toEqual({})
31 })
32
33 it('leaves an in-memory database alone', () => {
34 expect(inferSqliteSharedPath({ DB_CONNECTION: 'sqlite', DB_DATABASE: ':memory:' })).toEqual({})
35 })
36
37 /**
38 * Guessing the filename would report the data as safe while sharing a path
39 * the app may never write. Say what is unknown instead.
40 */
41 it('warns when the app is on sqlite but names no file', () => {
42 const { path, warning } = inferSqliteSharedPath({ DB_CONNECTION: 'sqlite' })
43 expect(path).toBeUndefined()
44 expect(warning).toContain('DB_DATABASE')
45 })
46
47 it('does not duplicate a path the site already declares', () => {
48 const env = { DB_CONNECTION: 'sqlite', DB_DATABASE: 'database/app.sqlite' }
49 expect(inferSqliteSharedPath(env, ['database/app.sqlite'])).toEqual({})
50 // Including the spec form, which is how sibling sites share one database.
51 expect(inferSqliteSharedPath(env, [{ path: 'database/app.sqlite', target: '/var/www/api/shared/db.sqlite' }]))
52 .toEqual({})
53 expect(sqliteSharedPaths(env, ['database/app.sqlite'])).toEqual([])
54 })
55})
56
57describe('server-app deploy script', () => {
58 const script = (env: Record<string, string>): string =>
59 buildSiteDeployScript({
60 siteName: 'app',
61 slug: 'acme',
62 artifactFetch: [],
63 releaseId: 'rel1',
64 execStart: '/usr/local/bin/bun run server.ts',
65 envEntries: env,
66 port: 3000,
67 }).join('\n')
68
69 /**
70 * The failure this guards against: a SQLite file written inside the release
71 * directory, which the NEXT deploy leaves behind in a pruned release.
72 */
73 it('shares the sqlite database without being told to', () => {
74 const out = script({ DB_CONNECTION: 'sqlite', DB_DATABASE: 'database/stacks.sqlite' })
75 expect(out).toContain('/var/www/app/shared/database/stacks.sqlite')
76 expect(out).toContain('ln -sfn /var/www/app/shared/database/stacks.sqlite /var/www/app/releases/rel1/database/stacks.sqlite')
77 })
78
79 it('records it in the shared-paths manifest, so a rollback relinks it', () => {
80 const out = script({ DB_CONNECTION: 'sqlite', DB_DATABASE: 'database/stacks.sqlite' })
81 expect(out).toContain('database/stacks.sqlite\t/var/www/app/shared/database/stacks.sqlite')
82 })
83
84 it('adopts the live copy before placeholding it, so existing data is not lost', () => {
85 const out = script({ DB_CONNECTION: 'sqlite', DB_DATABASE: 'database/stacks.sqlite' })
86 const adopt = out.indexOf("ts_cloud_adopt_shared 'database/stacks.sqlite'")
87 const touch = out.indexOf('touch /var/www/app/shared/database/stacks.sqlite')
88 expect(adopt).toBeGreaterThan(-1)
89 expect(touch).toBeGreaterThan(adopt)
90 })
91
92 it('adds nothing for a postgres app', () => {
93 const out = script({ DB_CONNECTION: 'pgsql', DB_DATABASE: 'loghq' })
94 expect(out).not.toContain('shared/loghq')
95 })
96})
packages/ts-cloud/src/drivers/shared/sqlite-shared-path.tsadded+123-0
Changes to packages/ts-cloud/src/drivers/shared/sqlite-shared-path.ts
@@ -0,0 +1,123 @@
1/**
2 * Infer the shared path for an app whose database is SQLite.
3 *
4 * A release directory is disposable: the next deploy is a NEW directory and the
5 * old one is pruned. Anything the app writes and must keep therefore has to live
6 * in `shared/` and be symlinked in (see {@link import('./releases')}). `.env` is
7 * shared implicitly; everything else has to be declared in `site.sharedPaths`.
8 *
9 * A SQLite database is the one case ts-cloud can work out on its own, because
10 * the connection and the file path are already in the environment it writes to
11 * the box. Left undeclared, the file lands inside the release, and the deploy
12 * after it starts the app on an empty database with no warning — the whole
13 * dataset is one deploy from being orphaned inside a pruned release.
14 *
15 * So: read `DB_CONNECTION`/`DB_DATABASE` out of the site's resolved env, and
16 * when they describe a release-relative SQLite file, share it automatically.
17 * When they say SQLite but do not say where, say so loudly instead of guessing
18 * a filename — a wrong guess would share a path the app never writes and leave
19 * the real database exactly as exposed, while reporting that it is safe.
20 */
21import type { SharedPathEntry } from '@ts-cloud/core'
22import { sharedPathOf } from './releases'
23
24/**
25 * `DB_CONNECTION` values that mean "a SQLite file on this box". Stacks and
26 * Laravel both spell it `sqlite`; `sqlite3` shows up in hand-written configs
27 * and PDO DSNs.
28 */
29const SQLITE_CONNECTIONS = new Set(['sqlite', 'sqlite3'])
30
31/** In-memory databases have no file to keep — nothing to share, nothing to warn about. */
32const IN_MEMORY = new Set([':memory:', 'memory'])
33
34/** Is this site's resolved environment pointing at a SQLite database? */
35export function usesSqlite(env: Record<string, string | undefined> | undefined): boolean {
36 const connection = env?.DB_CONNECTION?.trim().toLowerCase()
37 return connection != null && SQLITE_CONNECTIONS.has(connection)
38}
39
40/**
41 * Normalize the configured database path to a release-relative one.
42 *
43 * Returns `undefined` when the path is not release-relative — absolute (it
44 * already lives outside the release and survives on its own) or escaping the
45 * release via `..` (which `shared/` cannot express, and which a symlink would
46 * point somewhere surprising).
47 */
48function releaseRelativePath(value: string): string | undefined {
49 const trimmed = value.trim().replace(/^\.\//, '')
50 if (trimmed === '' || trimmed.startsWith('/') || trimmed.startsWith('~')) return undefined
51 if (trimmed.split('/').includes('..')) return undefined
52 return trimmed.replace(/\/+$/, '')
53}
54
55export interface SqliteSharedPathInference {
56 /**
57 * Release-relative path to add to the site's shared paths. Absent when the
58 * app is not on SQLite, the file already survives deploys, or the path could
59 * not be determined.
60 */
61 path?: string
62 /**
63 * Why nothing could be inferred even though the app IS on SQLite — an
64 * operator-facing sentence naming what to declare. Absent when there is
65 * nothing to worry about.
66 */
67 warning?: string
68}
69
70/**
71 * Work out whether a site's SQLite database needs to be added to its shared
72 * paths, given the environment the deploy writes to the box and whatever the
73 * site already declares.
74 */
75export function inferSqliteSharedPath(
76 env: Record<string, string | undefined> | undefined,
77 declared: readonly SharedPathEntry[] = [],
78): SqliteSharedPathInference {
79 if (!usesSqlite(env)) return {}
80
81 const configured = env?.DB_DATABASE?.trim()
82 // An in-memory database has no file to keep — nothing to share, and nothing
83 // an operator could do about it if there were.
84 if (configured && IN_MEMORY.has(configured.toLowerCase())) return {}
85
86 if (!configured) {
87 // An app can default its own database path internally (Stacks writes
88 // `database/stacks.sqlite`), which the deploy never sees. Guessing that
89 // filename would share a path the app may not use and report the data as
90 // safe when it is not, so name the problem instead.
91 return {
92 warning:
93 'DB_CONNECTION is sqlite but DB_DATABASE names no file, so ts-cloud cannot tell where the database lives. '
94 + 'If it is written inside the release directory, the next deploy discards it. '
95 + "Set DB_DATABASE, or list the file in the site's `sharedPaths`.",
96 }
97 }
98
99 const relative = releaseRelativePath(configured)
100 // Absolute (or `..`-escaping) paths are outside the release tree already —
101 // a deploy replaces the release, not the filesystem around it.
102 if (!relative) return {}
103
104 // Already declared — the operator's own entry wins, including a
105 // `SharedPathSpec` pointing at a database shared with a sibling site.
106 if (declared.some(entry => sharedPathOf(entry) === relative)) return {}
107
108 return { path: relative }
109}
110
111/**
112 * The shared-path entries to append for a site's SQLite database — `[]` when
113 * there is nothing to add. Kept separate from {@link inferSqliteSharedPath} so
114 * script builders can splice it in without having to care about the warning,
115 * which only the deploy driver can surface.
116 */
117export function sqliteSharedPaths(
118 env: Record<string, string | undefined> | undefined,
119 declared: readonly SharedPathEntry[] = [],
120): SharedPathEntry[] {
121 const { path } = inferSqliteSharedPath(env, declared)
122 return path ? [path] : []
123}
packages/ts-cloud/test/drivers/shared-paths.test.tsmodified+43-0
Changes to packages/ts-cloud/test/drivers/shared-paths.test.ts
@@ -154,3 +154,46 @@ describe('shared paths pointed at a project-level target', () => {
154154 expect(buildLinkSharedPaths(paths, [spec]).join('\n')).not.toContain('if [ -e ')
155155 })
156156})
157
158/**
159 * Adoption rescues state from the LIVE release, which covers a path that
160 * becomes shared on an existing site. A site's FIRST deploy has no live release
161 * to adopt from, so without this the empty placeholder would replace whatever
162 * the artifact shipped — an app shipping a seeded SQLite database would come up
163 * empty on the very deploy that created it.
164 */
165describe('seeding a shared file from the incoming release', () => {
166 const link = (shared: SharedPathEntry[]): string => buildLinkSharedPaths(paths, shared).join('\n')
167
168 it('copies the release copy in when the shared file is still a placeholder', () => {
169 const out = link(['database/app.sqlite'])
170 expect(out).toContain('if [ -f /var/www/site/releases/rel1/database/app.sqlite ]')
171 expect(out).toContain('[ ! -s /var/www/site/shared/database/app.sqlite ]')
172 expect(out).toContain('cp -a /var/www/site/releases/rel1/database/app.sqlite /var/www/site/shared/database/app.sqlite')
173 })
174
175 it('still links the shared file afterwards', () => {
176 const out = link(['database/app.sqlite'])
177 const seed = out.indexOf('cp -a /var/www/site/releases/rel1/database/app.sqlite')
178 const ln = out.indexOf('ln -sfn /var/www/site/shared/database/app.sqlite')
179 expect(seed).toBeGreaterThan(-1)
180 expect(ln).toBeGreaterThan(seed)
181 })
182
183 it('leaves .env alone — the deploy writes the shared one itself', () => {
184 expect(link(['.env'])).not.toContain('cp -a')
185 })
186
187 it('leaves directories alone', () => {
188 expect(link(['storage'])).not.toContain('cp -a')
189 })
190
191 /**
192 * A site that does not own the target must not seed it: the owner's own
193 * deploy adopts or writes it, and a copy from here would make that a no-op.
194 */
195 it('leaves a target this site does not own alone', () => {
196 expect(link([{ path: 'database/app.sqlite', target: '/var/www/api/shared/app.sqlite', seed: false }]))
197 .not.toContain('cp -a')
198 })
199})