dsh 0.2.0-rc.2: in-container update (experimental channel), path-agnostic compose, upstream patches adapted to 0.1.x/0.2.x
This commit is contained in:
commit
e6dcef9bf5
302 files changed
+75178
No files matched your search
@@ -0,0 +1,195 @@
|
||||
import { createHash, randomBytes, randomUUID } from 'node:crypto'
|
||||
import { chmod, mkdir, open, readFile, readdir, rename, rm, stat } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { canonicalJson } from '../../../../platform/lib/canonical-json.mjs'
|
||||
import { durableReplace } from '../../../../platform/lib/atomic.mjs'
|
||||
import { defaultProxyConfiguration, validateProxyConfiguration } from './contracts.mjs'
|
||||
import { ProxyConfigurationError } from './errors.mjs'
|
||||
|
||||
function hash(bytes) {
|
||||
return createHash('sha256').update(bytes).digest('hex')
|
||||
}
|
||||
|
||||
async function syncDirectory(path) {
|
||||
const handle = await open(path, 'r')
|
||||
try { await handle.sync() } finally { await handle.close() }
|
||||
}
|
||||
|
||||
async function writeSynced(path, bytes, mode) {
|
||||
const handle = await open(path, 'wx', mode)
|
||||
try {
|
||||
await handle.writeFile(bytes)
|
||||
await handle.sync()
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
}
|
||||
|
||||
function revision() {
|
||||
return randomBytes(32).toString('base64url')
|
||||
}
|
||||
|
||||
function parse(bytes, label) {
|
||||
try { return JSON.parse(bytes.toString('utf8')) } catch { throw new ProxyConfigurationError(`${label} is corrupt`, { code: 'PROXY_STATE_CORRUPT', stage: 'load' }) }
|
||||
}
|
||||
|
||||
export class ProxyConfigurationStore {
|
||||
constructor(root, { now = () => new Date() } = {}) {
|
||||
this.root = root
|
||||
this.revisionsRoot = join(root, 'revisions')
|
||||
this.currentPath = join(root, 'current.json')
|
||||
this.now = now
|
||||
this.queue = Promise.resolve()
|
||||
}
|
||||
|
||||
exclusive(operation) {
|
||||
const result = this.queue.then(operation, operation)
|
||||
this.queue = result.then(() => undefined, () => undefined)
|
||||
return result
|
||||
}
|
||||
|
||||
async prepare() {
|
||||
await mkdir(this.revisionsRoot, { recursive: true, mode: 0o700 })
|
||||
await chmod(this.root, 0o700)
|
||||
await chmod(this.revisionsRoot, 0o700)
|
||||
}
|
||||
|
||||
async readPointer() {
|
||||
try {
|
||||
const pointer = parse(await readFile(this.currentPath), 'proxy current pointer')
|
||||
if (pointer?.schema !== 1 || typeof pointer.revision !== 'string' || !/^[A-Za-z0-9_-]{43}$/.test(pointer.revision)) {
|
||||
throw new ProxyConfigurationError('proxy current pointer is corrupt', { code: 'PROXY_STATE_CORRUPT', stage: 'load' })
|
||||
}
|
||||
return pointer.revision
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') return null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async readRevision(id) {
|
||||
const root = join(this.revisionsRoot, id)
|
||||
const [configBytes, credentialsBytes, manifestBytes] = await Promise.all([
|
||||
readFile(join(root, 'config.json')),
|
||||
readFile(join(root, 'credentials.json')),
|
||||
readFile(join(root, 'manifest.json')),
|
||||
])
|
||||
const manifest = parse(manifestBytes, 'proxy revision manifest')
|
||||
if (
|
||||
manifest?.schema !== 1 || manifest.revision !== id
|
||||
|| manifest.files?.config !== hash(configBytes)
|
||||
|| manifest.files?.credentials !== hash(credentialsBytes)
|
||||
) throw new ProxyConfigurationError('proxy revision manifest does not match its files', { code: 'PROXY_STATE_CORRUPT', stage: 'load' })
|
||||
const configuration = parse(configBytes, 'proxy configuration')
|
||||
const credentials = parse(credentialsBytes, 'proxy credentials')
|
||||
if (credentials?.schema !== 1 || credentials.username !== configuration?.proxy?.username || !('password' in credentials)) {
|
||||
throw new ProxyConfigurationError('proxy credentials do not match configuration', { code: 'PROXY_STATE_CORRUPT', stage: 'load' })
|
||||
}
|
||||
const validated = validateProxyConfiguration({
|
||||
...configuration,
|
||||
proxy: { ...configuration.proxy, password: credentials.password },
|
||||
noProxy: { user: configuration.noProxy.user },
|
||||
})
|
||||
if (validated.configuration.proxy.passwordConfigured !== configuration.proxy.passwordConfigured) {
|
||||
throw new ProxyConfigurationError('proxy password state is inconsistent', { code: 'PROXY_STATE_CORRUPT', stage: 'load' })
|
||||
}
|
||||
return Object.freeze({ revision: id, configuration: validated.configuration, credentials: validated.credentials, createdAt: manifest.createdAt })
|
||||
}
|
||||
|
||||
async recoveryCandidates() {
|
||||
const entries = await readdir(this.revisionsRoot, { withFileTypes: true })
|
||||
const candidates = []
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || entry.name.startsWith('.')) continue
|
||||
try {
|
||||
const loaded = await this.readRevision(entry.name)
|
||||
candidates.push(loaded)
|
||||
} catch {}
|
||||
}
|
||||
return candidates.sort((left, right) => right.createdAt.localeCompare(left.createdAt))
|
||||
}
|
||||
|
||||
async load() {
|
||||
await this.prepare()
|
||||
let current = null
|
||||
let corruptPointer = false
|
||||
try {
|
||||
current = await this.readPointer()
|
||||
} catch (error) {
|
||||
if (error?.code !== 'PROXY_STATE_CORRUPT') throw error
|
||||
corruptPointer = true
|
||||
}
|
||||
if (current !== null) {
|
||||
try { return Object.freeze({ ...(await this.readRevision(current)), recovery: 'none' }) } catch (error) {
|
||||
if (error?.code !== 'PROXY_STATE_CORRUPT' && error?.code !== 'ENOENT') throw error
|
||||
}
|
||||
}
|
||||
const recovered = (await this.recoveryCandidates())[0]
|
||||
if (recovered !== undefined) {
|
||||
await durableReplace(this.currentPath, canonicalJson({ schema: 1, revision: recovered.revision }), 0o640)
|
||||
return Object.freeze({ ...recovered, recovery: 'previous' })
|
||||
}
|
||||
const entries = await readdir(this.revisionsRoot, { withFileTypes: true })
|
||||
const hadPublishedState = corruptPointer || entries.some(entry => entry.isDirectory() && !entry.name.startsWith('.'))
|
||||
if (corruptPointer) {
|
||||
await rename(this.currentPath, join(this.root, `current.corrupt.${randomUUID()}.json`))
|
||||
await syncDirectory(this.root)
|
||||
}
|
||||
const initialized = await this.commit({ baseRevision: null, value: defaultProxyConfiguration() })
|
||||
return Object.freeze({ ...initialized, recovery: hadPublishedState ? 'reset-disabled' : 'none' })
|
||||
}
|
||||
|
||||
commit(request) {
|
||||
return this.exclusive(() => this.commitUnlocked(request))
|
||||
}
|
||||
|
||||
async commitUnlocked({ baseRevision, value }) {
|
||||
await this.prepare()
|
||||
let current = null
|
||||
try { current = await this.loadCurrentWithoutRecovery() } catch (error) {
|
||||
if (error?.code !== 'ENOENT') throw error
|
||||
}
|
||||
if ((current?.revision ?? null) !== baseRevision) {
|
||||
throw new ProxyConfigurationError('proxy configuration changed', {
|
||||
code: 'REVISION_CONFLICT', statusCode: 409, stage: 'activate',
|
||||
})
|
||||
}
|
||||
const validated = validateProxyConfiguration(value, { existingPassword: current?.credentials.password ?? null })
|
||||
const id = revision()
|
||||
const staging = join(this.revisionsRoot, `.${id}.${randomUUID()}.tmp`)
|
||||
const published = join(this.revisionsRoot, id)
|
||||
await mkdir(staging, { mode: 0o700 })
|
||||
try {
|
||||
const configBytes = canonicalJson(validated.configuration)
|
||||
const credentialsBytes = canonicalJson({ schema: 1, ...validated.credentials })
|
||||
const manifestBytes = canonicalJson({
|
||||
schema: 1,
|
||||
revision: id,
|
||||
createdAt: this.now().toISOString(),
|
||||
files: { config: hash(configBytes), credentials: hash(credentialsBytes) },
|
||||
})
|
||||
await writeSynced(join(staging, 'config.json'), configBytes, 0o640)
|
||||
await writeSynced(join(staging, 'credentials.json'), credentialsBytes, 0o600)
|
||||
await writeSynced(join(staging, 'manifest.json'), manifestBytes, 0o640)
|
||||
await syncDirectory(staging)
|
||||
await rename(staging, published)
|
||||
await syncDirectory(this.revisionsRoot)
|
||||
const candidate = await this.readRevision(id)
|
||||
await durableReplace(this.currentPath, canonicalJson({ schema: 1, revision: id }), 0o640)
|
||||
return candidate
|
||||
} catch (error) {
|
||||
await rm(staging, { recursive: true, force: true })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async loadCurrentWithoutRecovery() {
|
||||
const current = await this.readPointer()
|
||||
if (current === null) {
|
||||
const error = new Error('proxy configuration is not initialized')
|
||||
error.code = 'ENOENT'
|
||||
throw error
|
||||
}
|
||||
return this.readRevision(current)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user