Skip to content

Commit e6bc13d

Browse files
committed
refactor(state): share the driver state and honour the configured state dir
Driver state hardcoded storage/cloud/state and never consulted stateDir or TS_CLOUD_STATE_DIR, so configuring either moved every other kind of state and left this one behind. Two more places rebuilt the same path by hand, and the dashboard's copy assembled it from the slug and environment, which silently ignored an explicit project.stackName and read a file that was never written. The path now comes from one function that follows the configured state dir when there is one and keeps the old location when there is not, so nothing moves for a project that never set it. The Hetzner module stays as a re-export so its callers are untouched.
1 parent ba6c2f2 commit e6bc13d

6 files changed

Lines changed: 226 additions & 30 deletions

File tree

packages/core/src/state-dir.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,18 @@ export function setStateDir(dir?: string | null): void {
4646
configuredStateDir = trimmed || null
4747
}
4848

49+
/**
50+
* Whether anything configured the state directory (config or environment).
51+
*
52+
* The driver state files predate the configurable directory and have their
53+
* own legacy home (`storage/cloud/state`, meant to be committed). They only
54+
* move under the state directory when a project actually chose one, so a
55+
* standalone project keeps its committed state where it always was.
56+
*/
57+
export function isStateDirConfigured(): boolean {
58+
return Boolean(process.env[STATE_DIR_ENV_VAR]?.trim() || configuredStateDir)
59+
}
60+
4961
/**
5062
* The configured state directory, as written — relative or absolute.
5163
*/

packages/ts-cloud/src/deploy/dashboard-data-server.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@
77
import type { CloudConfig, EnvironmentType } from '@ts-cloud/core'
88
import { existsSync, readFileSync } from 'node:fs'
99
import { join } from 'node:path'
10+
import { resolveProjectStackName } from '@ts-cloud/core'
1011
import { createCloudDriver } from '../drivers'
1112
import { resolveHetznerLocation } from '../drivers/hetzner/config'
13+
import { driverStatePath } from '../drivers/shared/driver-state'
1214
import { buildServerTopology } from './dashboard-topology'
1315
import { resolveSiteKind, siteInstallBase } from './site-target'
1416
import { describeSshKeys } from './ssh-config-editor'
@@ -378,7 +380,11 @@ function configuredBandwidthBudgetBytes(config: CloudConfig): number {
378380
}
379381

380382
function loadLocalState(config: CloudConfig, environment: EnvironmentType): LocalState | null {
381-
const statePath = join(process.cwd(), 'storage', 'cloud', 'state', `${config.project.slug}-${environment}.json`)
383+
// The same path the driver wrote to: `project.stackName` overrides the
384+
// `<slug>-<environment>` convention, and a configured state directory moves
385+
// the file. Spelling either by hand here is how the dashboard reported a
386+
// box as missing while the deploy that created it had just succeeded.
387+
const statePath = driverStatePath(resolveProjectStackName(config, environment))
382388
if (!existsSync(statePath)) return null
383389

384390
try {

packages/ts-cloud/src/drivers/hetzner/resize-state.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { HetznerResizePhase } from './resize'
22
import type { HetznerResizeManifest } from './resize-remote'
33
import { mkdir, readFile, rename, rmdir, stat, unlink, writeFile } from 'node:fs/promises'
44
import { join } from 'node:path'
5-
import { STATE_DIR } from './state'
5+
import { driverStateDir } from '../shared/driver-state'
66

77
export interface HetznerResizeCheckpoint {
88
schemaVersion: 1
@@ -23,11 +23,11 @@ export interface HetznerResizeCheckpoint {
2323
}
2424

2525
export function resizeCheckpointPath(stackName: string): string {
26-
return join(process.cwd(), STATE_DIR, `${stackName}-resize.json`)
26+
return join(driverStateDir(), `${stackName}-resize.json`)
2727
}
2828

2929
export function resizeLockPath(stackName: string): string {
30-
return join(process.cwd(), STATE_DIR, `${stackName}-resize.lock`)
30+
return join(driverStateDir(), `${stackName}-resize.lock`)
3131
}
3232

3333
export async function readResizeCheckpoint(stackName: string): Promise<HetznerResizeCheckpoint | null> {
@@ -40,7 +40,7 @@ export async function readResizeCheckpoint(stackName: string): Promise<HetznerRe
4040

4141
export async function writeResizeCheckpoint(checkpoint: HetznerResizeCheckpoint): Promise<void> {
4242
const path = resizeCheckpointPath(checkpoint.stackName)
43-
await mkdir(join(process.cwd(), STATE_DIR), { recursive: true })
43+
await mkdir(driverStateDir(), { recursive: true })
4444
const tempPath = `${path}.${process.pid}.tmp`
4545
await writeFile(tempPath, `${JSON.stringify(checkpoint, null, 2)}\n`, 'utf8')
4646
await rename(tempPath, path)
@@ -51,7 +51,7 @@ export async function acquireResizeLock(
5151
staleAfterMs: number = 30 * 60_000,
5252
): Promise<() => Promise<void>> {
5353
const path = resizeLockPath(stackName)
54-
await mkdir(join(process.cwd(), STATE_DIR), { recursive: true })
54+
await mkdir(driverStateDir(), { recursive: true })
5555
try {
5656
await mkdir(path)
5757
} catch (error) {

packages/ts-cloud/src/drivers/hetzner/state.ts

Lines changed: 22 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
1-
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
2-
import { join } from 'node:path'
1+
/**
2+
* Hetzner driver state.
3+
*
4+
* The file handling itself lives in `../shared/driver-state` (one reader and
5+
* writer for every SSH-style driver); this module keeps the Hetzner-typed
6+
* view of it so the driver, the dashboard and the CLI read `serverId` and
7+
* friends without a cast at every call site.
8+
*/
9+
import type { DriverState } from '../shared/driver-state'
10+
import { driverStatePath as sharedDriverStatePath, readDriverState as sharedReadDriverState, writeDriverState as sharedWriteDriverState } from '../shared/driver-state'
11+
12+
export { LEGACY_DRIVER_STATE_DIR } from '../shared/driver-state'
313

414
export interface HetznerDriverState {
515
provider: 'hetzner'
@@ -34,33 +44,21 @@ export interface HetznerDriverState {
3444
appServerIds?: number[]
3545
}
3646

37-
// Deploy state lives under the project's `storage/` tree (the Stacks storage
38-
// convention) rather than a hidden `.ts-cloud/` folder — and, unlike the
39-
// gitignored `storage/framework/`, `storage/cloud/` is meant to be COMMITTED so
40-
// CI (which never has a local .ts-cloud) can resolve the existing box by its
41-
// recorded serverId instead of trying to provision a new one.
42-
export const STATE_DIR = 'storage/cloud/state'
47+
/**
48+
* The legacy, project-relative driver-state directory. Kept for callers that
49+
* spell the path by hand; new code should ask `driverStateDir()` instead,
50+
* which also honours a configured state directory.
51+
*/
52+
export const STATE_DIR: string = 'storage/cloud/state'
4353

4454
export function driverStatePath(stackName: string): string {
45-
return join(process.cwd(), STATE_DIR, `${stackName}.json`)
55+
return sharedDriverStatePath(stackName)
4656
}
4757

4858
export async function readDriverState(stackName: string): Promise<HetznerDriverState | null> {
49-
try {
50-
const raw = await readFile(driverStatePath(stackName), 'utf8')
51-
return JSON.parse(raw) as HetznerDriverState
52-
} catch {
53-
return null
54-
}
59+
return sharedReadDriverState<HetznerDriverState>(stackName)
5560
}
5661

57-
export async function writeDriverState(stackName: string, state: HetznerDriverState): Promise<void> {
58-
const path = driverStatePath(stackName)
59-
await mkdir(join(process.cwd(), STATE_DIR), { recursive: true })
60-
// Atomic write: a crash mid-write would corrupt the JSON, and the reader's
61-
// catch-all would then lose the pinned serverId (silently re-provisioning a
62-
// duplicate box). Temp file + rename on the same filesystem is atomic.
63-
const tmp = `${path}.${process.pid}.tmp`
64-
await writeFile(tmp, `${JSON.stringify(state, null, 2)}\n`, 'utf8')
65-
await rename(tmp, path)
62+
export async function writeDriverState(stackName: string, state: DriverState): Promise<void> {
63+
await sharedWriteDriverState(stackName, state)
6664
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/**
2+
* The per-stack state a compute driver records on the machine running deploys.
3+
*
4+
* Every SSH-style driver has to remember which box a stack lives on: Hetzner
5+
* pins a server id so CI can find the existing box instead of provisioning a
6+
* duplicate, and the ssh driver pins the host it adopted plus what it did to
7+
* it. One file per stack, one reader and one writer, so a driver, the
8+
* dashboard and the CLI cannot disagree about where that file is.
9+
*
10+
* ## Where the file lives
11+
*
12+
* Driver state predates the configurable state directory and has a legacy
13+
* home of its own, `storage/cloud/state/` (the Stacks storage convention),
14+
* which is meant to be COMMITTED: unlike dashboard credentials it holds
15+
* nothing secret, and a checkout without it re-provisions. A project that
16+
* configured `stateDir` (or set `TS_CLOUD_STATE_DIR`) keeps driver state
17+
* under it instead, in `<stateDir>/state/`. A Stacks application sets
18+
* `stateDir: 'storage/cloud'`, so for it the two spellings name the same
19+
* directory and nothing moves.
20+
*/
21+
import type { HetznerDriverState } from '../hetzner/state'
22+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
23+
import { join } from 'node:path'
24+
import { isStateDirConfigured, resolveStatePath } from '@ts-cloud/core'
25+
26+
/** The committed driver-state directory used when no state directory is configured. */
27+
export const LEGACY_DRIVER_STATE_DIR = 'storage/cloud/state'
28+
29+
/** What the ssh driver remembers about a host it adopted. */
30+
export interface SshDriverState {
31+
provider: 'ssh'
32+
stackName: string
33+
/** The hostname or address deploys connect to, exactly as configured. */
34+
host: string
35+
sshUser: string
36+
sshPort: number
37+
/** `SHA256:...` of the pinned host key, when the host-key policy pins. */
38+
hostKeyFingerprint?: string
39+
/** The address DNS should point at, when known (configured, or detected). */
40+
publicIp?: string
41+
/** The host's first LAN address, as reported by the preflight. */
42+
lanIp?: string
43+
deployStoragePath?: string
44+
profile?: 'raspberry-pi' | 'generic'
45+
/** The bootstrap recipe version last applied to the host. */
46+
bootstrapVersion?: number
47+
/** When that bootstrap ran, ISO 8601. */
48+
bootstrappedAt?: string
49+
}
50+
51+
export type DriverState = HetznerDriverState | SshDriverState
52+
53+
/**
54+
* The directory driver state files live in, absolute.
55+
*
56+
* `<stateDir>/state` when a state directory is configured, the legacy
57+
* `storage/cloud/state` otherwise. See the module comment for why the legacy
58+
* path is not simply `.ts-cloud/state`.
59+
*/
60+
export function driverStateDir(cwd: string = process.cwd()): string {
61+
return isStateDirConfigured() ? resolveStatePath(cwd, 'state') : join(cwd, LEGACY_DRIVER_STATE_DIR)
62+
}
63+
64+
/** Absolute path of the state file for `stackName`. */
65+
export function driverStatePath(stackName: string, cwd: string = process.cwd()): string {
66+
return join(driverStateDir(cwd), `${stackName}.json`)
67+
}
68+
69+
/**
70+
* Read the state file for `stackName`, or null when there is none (or it is
71+
* unreadable: a corrupt file is treated as absent, and the atomic write below
72+
* is what keeps that from ever losing a pin).
73+
*/
74+
export async function readDriverState<T extends DriverState = DriverState>(
75+
stackName: string,
76+
cwd: string = process.cwd(),
77+
): Promise<T | null> {
78+
try {
79+
const raw = await readFile(driverStatePath(stackName, cwd), 'utf8')
80+
return JSON.parse(raw) as T
81+
} catch {
82+
return null
83+
}
84+
}
85+
86+
/**
87+
* Replace the state file for `stackName` atomically: a crash mid-write would
88+
* corrupt the JSON, and the reader's catch-all would then lose the pinned box
89+
* (silently re-provisioning a duplicate). Temp file + rename on the same
90+
* filesystem is atomic.
91+
*/
92+
export async function writeDriverState(stackName: string, state: DriverState, cwd: string = process.cwd()): Promise<void> {
93+
const path = driverStatePath(stackName, cwd)
94+
await mkdir(driverStateDir(cwd), { recursive: true })
95+
const tmp = `${path}.${process.pid}.tmp`
96+
await writeFile(tmp, `${JSON.stringify(state, null, 2)}\n`, 'utf8')
97+
await rename(tmp, path)
98+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
2+
import { mkdtempSync, rmSync } from 'node:fs'
3+
import { tmpdir } from 'node:os'
4+
import { join } from 'node:path'
5+
import { setStateDir } from '@ts-cloud/core'
6+
import { driverStatePath as hetznerDriverStatePath } from '../../src/drivers/hetzner/state'
7+
import { resizeCheckpointPath } from '../../src/drivers/hetzner/resize-state'
8+
import { driverStateDir, driverStatePath, readDriverState, writeDriverState } from '../../src/drivers/shared/driver-state'
9+
10+
/**
11+
* Driver state has two homes: the legacy, committed `storage/cloud/state/`
12+
* and `<stateDir>/state/` once a project configures a state directory. A
13+
* Stacks application configures `storage/cloud`, so for it both spellings
14+
* must name the same file; the dashboard used to derive the path by hand
15+
* and could not know that.
16+
*/
17+
function reset(): void {
18+
setStateDir(null)
19+
delete process.env.TS_CLOUD_STATE_DIR
20+
}
21+
22+
beforeEach(reset)
23+
afterEach(reset)
24+
25+
describe('driver state location', () => {
26+
it('stays in the committed storage/cloud/state when nothing configures a state directory', () => {
27+
expect(driverStateDir('/srv/app')).toBe('/srv/app/storage/cloud/state')
28+
expect(driverStatePath('acme-production', '/srv/app')).toBe('/srv/app/storage/cloud/state/acme-production.json')
29+
})
30+
31+
it('follows a configured state directory', () => {
32+
setStateDir('.ts-cloud')
33+
expect(driverStatePath('acme-production', '/srv/app')).toBe('/srv/app/.ts-cloud/state/acme-production.json')
34+
})
35+
36+
it('resolves to the identical path for a Stacks app (stateDir: storage/cloud)', () => {
37+
const legacy = driverStatePath('acme-production', '/srv/app')
38+
setStateDir('storage/cloud')
39+
expect(driverStatePath('acme-production', '/srv/app')).toBe(legacy)
40+
})
41+
42+
it('lets the environment override the config', () => {
43+
setStateDir('storage/cloud')
44+
process.env.TS_CLOUD_STATE_DIR = 'var/ts-cloud'
45+
expect(driverStatePath('acme-production', '/srv/app')).toBe('/srv/app/var/ts-cloud/state/acme-production.json')
46+
})
47+
48+
it('pins state to an absolute directory regardless of cwd', () => {
49+
setStateDir('/var/lib/ts-cloud')
50+
expect(driverStatePath('acme-production', '/srv/app')).toBe('/var/lib/ts-cloud/state/acme-production.json')
51+
})
52+
53+
it('is the path the Hetzner shim and the resize checkpoint use', () => {
54+
setStateDir('.ts-cloud')
55+
expect(hetznerDriverStatePath('acme-production')).toBe(driverStatePath('acme-production'))
56+
expect(resizeCheckpointPath('acme-production')).toBe(join(driverStateDir(), 'acme-production-resize.json'))
57+
})
58+
})
59+
60+
describe('driver state file', () => {
61+
let dir: string
62+
beforeEach(() => {
63+
dir = mkdtempSync(join(tmpdir(), 'ts-cloud-driver-state-'))
64+
})
65+
afterEach(() => {
66+
rmSync(dir, { recursive: true, force: true })
67+
})
68+
69+
it('round-trips an ssh state record and reads back null when absent', async () => {
70+
expect(await readDriverState('pi-production', dir)).toBeNull()
71+
await writeDriverState('pi-production', { provider: 'ssh', stackName: 'pi-production', host: 'pi.local', sshUser: 'pi', sshPort: 22 }, dir)
72+
const state = await readDriverState('pi-production', dir)
73+
expect(state?.provider).toBe('ssh')
74+
expect(state && 'host' in state ? state.host : undefined).toBe('pi.local')
75+
})
76+
77+
it('leaves no temp file behind', async () => {
78+
await writeDriverState('pi-production', { provider: 'ssh', stackName: 'pi-production', host: 'pi.local', sshUser: 'pi', sshPort: 22 }, dir)
79+
const files = [...new Bun.Glob('*').scanSync({ cwd: driverStateDir(dir) })]
80+
expect(files).toEqual(['pi-production.json'])
81+
})
82+
})

0 commit comments

Comments
 (0)