mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-30 12:11:56 +00:00
Merge branch 'next/minor' of github.com:Start9Labs/start-os into next/major
This commit is contained in:
5
sdk/package/.gitignore
vendored
Normal file
5
sdk/package/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
.vscode
|
||||
dist/
|
||||
node_modules/
|
||||
lib/coverage
|
||||
lib/test/output.ts
|
||||
1
sdk/package/.npmignore
Normal file
1
sdk/package/.npmignore
Normal file
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
1
sdk/package/.prettierignore
Normal file
1
sdk/package/.prettierignore
Normal file
@@ -0,0 +1 @@
|
||||
/lib/exver/exver.ts
|
||||
21
sdk/package/LICENSE
Normal file
21
sdk/package/LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 Start9 Labs
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
18
sdk/package/README.md
Normal file
18
sdk/package/README.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# Start SDK
|
||||
|
||||
## Config Conversion
|
||||
|
||||
- Copy the old config json (from the getConfig.ts)
|
||||
- Install the start-sdk with `npm i`
|
||||
- paste the config into makeOutput.ts::oldSpecToBuilder (second param)
|
||||
- Make the third param
|
||||
|
||||
```ts
|
||||
{
|
||||
StartSdk: "start-sdk/lib",
|
||||
}
|
||||
```
|
||||
|
||||
- run the script `npm run buildOutput` to make the output.ts
|
||||
- Copy this whole file into startos/procedures/config/spec.ts
|
||||
- Fix all the TODO
|
||||
8
sdk/package/jest.config.js
Normal file
8
sdk/package/jest.config.js
Normal file
@@ -0,0 +1,8 @@
|
||||
/** @type {import('ts-jest').JestConfigWithTsJest} */
|
||||
module.exports = {
|
||||
preset: "ts-jest",
|
||||
automock: false,
|
||||
testEnvironment: "node",
|
||||
rootDir: "./lib/",
|
||||
modulePathIgnorePatterns: ["./dist/"],
|
||||
}
|
||||
1369
sdk/package/lib/StartSdk.ts
Normal file
1369
sdk/package/lib/StartSdk.ts
Normal file
File diff suppressed because it is too large
Load Diff
208
sdk/package/lib/backup/Backups.ts
Normal file
208
sdk/package/lib/backup/Backups.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import * as T from "../../../base/lib/types"
|
||||
import * as child_process from "child_process"
|
||||
import { asError } from "../util"
|
||||
|
||||
export const DEFAULT_OPTIONS: T.SyncOptions = {
|
||||
delete: true,
|
||||
exclude: [],
|
||||
}
|
||||
export type BackupSync<Volumes extends string> = {
|
||||
dataPath: `/media/startos/volumes/${Volumes}/${string}`
|
||||
backupPath: `/media/startos/backup/${string}`
|
||||
options?: Partial<T.SyncOptions>
|
||||
backupOptions?: Partial<T.SyncOptions>
|
||||
restoreOptions?: Partial<T.SyncOptions>
|
||||
}
|
||||
/**
|
||||
* This utility simplifies the volume backup process.
|
||||
* ```ts
|
||||
* export const { createBackup, restoreBackup } = Backups.volumes("main").build();
|
||||
* ```
|
||||
*
|
||||
* Changing the options of the rsync, (ie excludes) use either
|
||||
* ```ts
|
||||
* Backups.volumes("main").set_options({exclude: ['bigdata/']}).volumes('excludedVolume').build()
|
||||
* // or
|
||||
* Backups.with_options({exclude: ['bigdata/']}).volumes('excludedVolume').build()
|
||||
* ```
|
||||
*
|
||||
* Using the more fine control, using the addSets for more control
|
||||
* ```ts
|
||||
* Backups.addSets({
|
||||
* srcVolume: 'main', srcPath:'smallData/', dstPath: 'main/smallData/', dstVolume: : Backups.BACKUP
|
||||
* }, {
|
||||
* srcVolume: 'main', srcPath:'bigData/', dstPath: 'main/bigData/', dstVolume: : Backups.BACKUP, options: {exclude:['bigData/excludeThis']}}
|
||||
* ).build()q
|
||||
* ```
|
||||
*/
|
||||
export class Backups<M extends T.SDKManifest> {
|
||||
private constructor(
|
||||
private options = DEFAULT_OPTIONS,
|
||||
private restoreOptions: Partial<T.SyncOptions> = {},
|
||||
private backupOptions: Partial<T.SyncOptions> = {},
|
||||
private backupSet = [] as BackupSync<M["volumes"][number]>[],
|
||||
) {}
|
||||
|
||||
static withVolumes<M extends T.SDKManifest = never>(
|
||||
...volumeNames: Array<M["volumes"][number]>
|
||||
): Backups<M> {
|
||||
return Backups.withSyncs(
|
||||
...volumeNames.map((srcVolume) => ({
|
||||
dataPath: `/media/startos/volumes/${srcVolume}/` as const,
|
||||
backupPath: `/media/startos/backup/${srcVolume}/` as const,
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
static withSyncs<M extends T.SDKManifest = never>(
|
||||
...syncs: BackupSync<M["volumes"][number]>[]
|
||||
) {
|
||||
return syncs.reduce((acc, x) => acc.addSync(x), new Backups<M>())
|
||||
}
|
||||
|
||||
static withOptions<M extends T.SDKManifest = never>(
|
||||
options?: Partial<T.SyncOptions>,
|
||||
) {
|
||||
return new Backups<M>({ ...DEFAULT_OPTIONS, ...options })
|
||||
}
|
||||
|
||||
setOptions(options?: Partial<T.SyncOptions>) {
|
||||
this.options = {
|
||||
...this.options,
|
||||
...options,
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
setBackupOptions(options?: Partial<T.SyncOptions>) {
|
||||
this.backupOptions = {
|
||||
...this.backupOptions,
|
||||
...options,
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
setRestoreOptions(options?: Partial<T.SyncOptions>) {
|
||||
this.restoreOptions = {
|
||||
...this.restoreOptions,
|
||||
...options,
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
addVolume(
|
||||
volume: M["volumes"][number],
|
||||
options?: Partial<{
|
||||
options: T.SyncOptions
|
||||
backupOptions: T.SyncOptions
|
||||
restoreOptions: T.SyncOptions
|
||||
}>,
|
||||
) {
|
||||
return this.addSync({
|
||||
dataPath: `/media/startos/volumes/${volume}/` as const,
|
||||
backupPath: `/media/startos/backup/${volume}/` as const,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
addSync(sync: BackupSync<M["volumes"][0]>) {
|
||||
this.backupSet.push({
|
||||
...sync,
|
||||
options: { ...this.options, ...sync.options },
|
||||
})
|
||||
return this
|
||||
}
|
||||
|
||||
async createBackup() {
|
||||
for (const item of this.backupSet) {
|
||||
const rsyncResults = await runRsync({
|
||||
srcPath: item.dataPath,
|
||||
dstPath: item.backupPath,
|
||||
options: {
|
||||
...this.options,
|
||||
...this.backupOptions,
|
||||
...item.options,
|
||||
...item.backupOptions,
|
||||
},
|
||||
})
|
||||
await rsyncResults.wait()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
async restoreBackup() {
|
||||
for (const item of this.backupSet) {
|
||||
const rsyncResults = await runRsync({
|
||||
srcPath: item.backupPath,
|
||||
dstPath: item.dataPath,
|
||||
options: {
|
||||
...this.options,
|
||||
...this.backupOptions,
|
||||
...item.options,
|
||||
...item.backupOptions,
|
||||
},
|
||||
})
|
||||
await rsyncResults.wait()
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
async function runRsync(rsyncOptions: {
|
||||
srcPath: string
|
||||
dstPath: string
|
||||
options: T.SyncOptions
|
||||
}): Promise<{
|
||||
id: () => Promise<string>
|
||||
wait: () => Promise<null>
|
||||
progress: () => Promise<number>
|
||||
}> {
|
||||
const { srcPath, dstPath, options } = rsyncOptions
|
||||
|
||||
const command = "rsync"
|
||||
const args: string[] = []
|
||||
if (options.delete) {
|
||||
args.push("--delete")
|
||||
}
|
||||
for (const exclude of options.exclude) {
|
||||
args.push(`--exclude=${exclude}`)
|
||||
}
|
||||
args.push("-actAXH")
|
||||
args.push("--info=progress2")
|
||||
args.push("--no-inc-recursive")
|
||||
args.push(srcPath)
|
||||
args.push(dstPath)
|
||||
const spawned = child_process.spawn(command, args, { detached: true })
|
||||
let percentage = 0.0
|
||||
spawned.stdout.on("data", (data: unknown) => {
|
||||
const lines = String(data).replace("\r", "\n").split("\n")
|
||||
for (const line of lines) {
|
||||
const parsed = /$([0-9.]+)%/.exec(line)?.[1]
|
||||
if (!parsed) continue
|
||||
percentage = Number.parseFloat(parsed)
|
||||
}
|
||||
})
|
||||
|
||||
spawned.stderr.on("data", (data: unknown) => {
|
||||
console.error(`Backups.runAsync`, asError(data))
|
||||
})
|
||||
|
||||
const id = async () => {
|
||||
const pid = spawned.pid
|
||||
if (pid === undefined) {
|
||||
throw new Error("rsync process has no pid")
|
||||
}
|
||||
return String(pid)
|
||||
}
|
||||
const waitPromise = new Promise<null>((resolve, reject) => {
|
||||
spawned.on("exit", (code: any) => {
|
||||
if (code === 0) {
|
||||
resolve(null)
|
||||
} else {
|
||||
reject(new Error(`rsync exited with code ${code}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
const wait = () => waitPromise
|
||||
const progress = () => Promise.resolve(percentage)
|
||||
return { id, wait, progress }
|
||||
}
|
||||
2
sdk/package/lib/backup/index.ts
Normal file
2
sdk/package/lib/backup/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import "./Backups"
|
||||
import "./setupBackups"
|
||||
39
sdk/package/lib/backup/setupBackups.ts
Normal file
39
sdk/package/lib/backup/setupBackups.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Backups } from "./Backups"
|
||||
import * as T from "../../../base/lib/types"
|
||||
import { _ } from "../util"
|
||||
|
||||
export type SetupBackupsParams<M extends T.SDKManifest> =
|
||||
| M["volumes"][number][]
|
||||
| ((_: { effects: T.Effects }) => Promise<Backups<M>>)
|
||||
|
||||
type SetupBackupsRes = {
|
||||
createBackup: T.ExpectedExports.createBackup
|
||||
restoreBackup: T.ExpectedExports.restoreBackup
|
||||
}
|
||||
|
||||
export function setupBackups<M extends T.SDKManifest>(
|
||||
options: SetupBackupsParams<M>,
|
||||
) {
|
||||
let backupsFactory: (_: { effects: T.Effects }) => Promise<Backups<M>>
|
||||
if (options instanceof Function) {
|
||||
backupsFactory = options
|
||||
} else {
|
||||
backupsFactory = async () => Backups.withVolumes(...options)
|
||||
}
|
||||
const answer: {
|
||||
createBackup: T.ExpectedExports.createBackup
|
||||
restoreBackup: T.ExpectedExports.restoreBackup
|
||||
} = {
|
||||
get createBackup() {
|
||||
return (async (options) => {
|
||||
return (await backupsFactory(options)).createBackup()
|
||||
}) as T.ExpectedExports.createBackup
|
||||
},
|
||||
get restoreBackup() {
|
||||
return (async (options) => {
|
||||
return (await backupsFactory(options)).restoreBackup()
|
||||
}) as T.ExpectedExports.restoreBackup
|
||||
},
|
||||
}
|
||||
return answer
|
||||
}
|
||||
64
sdk/package/lib/health/HealthCheck.ts
Normal file
64
sdk/package/lib/health/HealthCheck.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Effects, HealthReceipt } from "../../../base/lib/types"
|
||||
import { HealthCheckResult } from "./checkFns/HealthCheckResult"
|
||||
import { Trigger } from "../trigger"
|
||||
import { TriggerInput } from "../trigger/TriggerInput"
|
||||
import { defaultTrigger } from "../trigger/defaultTrigger"
|
||||
import { once, asError } from "../util"
|
||||
import { object, unknown } from "ts-matches"
|
||||
|
||||
export type HealthCheckParams = {
|
||||
effects: Effects
|
||||
name: string
|
||||
trigger?: Trigger
|
||||
fn(): Promise<HealthCheckResult> | HealthCheckResult
|
||||
onFirstSuccess?: () => unknown | Promise<unknown>
|
||||
}
|
||||
|
||||
export function healthCheck(o: HealthCheckParams) {
|
||||
new Promise(async () => {
|
||||
let currentValue: TriggerInput = {}
|
||||
const getCurrentValue = () => currentValue
|
||||
const trigger = (o.trigger ?? defaultTrigger)(getCurrentValue)
|
||||
const triggerFirstSuccess = once(() =>
|
||||
Promise.resolve(
|
||||
"onFirstSuccess" in o && o.onFirstSuccess
|
||||
? o.onFirstSuccess()
|
||||
: undefined,
|
||||
),
|
||||
)
|
||||
for (
|
||||
let res = await trigger.next();
|
||||
!res.done;
|
||||
res = await trigger.next()
|
||||
) {
|
||||
try {
|
||||
const { result, message } = await o.fn()
|
||||
await o.effects.setHealth({
|
||||
name: o.name,
|
||||
id: o.name,
|
||||
result,
|
||||
message: message || "",
|
||||
})
|
||||
currentValue.lastResult = result
|
||||
await triggerFirstSuccess().catch((err) => {
|
||||
console.error(asError(err))
|
||||
})
|
||||
} catch (e) {
|
||||
await o.effects.setHealth({
|
||||
name: o.name,
|
||||
id: o.name,
|
||||
result: "failure",
|
||||
message: asMessage(e) || "",
|
||||
})
|
||||
currentValue.lastResult = "failure"
|
||||
}
|
||||
}
|
||||
})
|
||||
return {} as HealthReceipt
|
||||
}
|
||||
function asMessage(e: unknown) {
|
||||
if (object({ message: unknown }).test(e)) return String(e.message)
|
||||
const value = String(e)
|
||||
if (value.length == null) return null
|
||||
return value
|
||||
}
|
||||
3
sdk/package/lib/health/checkFns/HealthCheckResult.ts
Normal file
3
sdk/package/lib/health/checkFns/HealthCheckResult.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { T } from "../../../../base/lib"
|
||||
|
||||
export type HealthCheckResult = Omit<T.NamedHealthCheckResult, "name">
|
||||
67
sdk/package/lib/health/checkFns/checkPortListening.ts
Normal file
67
sdk/package/lib/health/checkFns/checkPortListening.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { Effects } from "../../../../base/lib/types"
|
||||
import { stringFromStdErrOut } from "../../util"
|
||||
import { HealthCheckResult } from "./HealthCheckResult"
|
||||
|
||||
import { promisify } from "node:util"
|
||||
import * as CP from "node:child_process"
|
||||
|
||||
const cpExec = promisify(CP.exec)
|
||||
const cpExecFile = promisify(CP.execFile)
|
||||
export function containsAddress(x: string, port: number) {
|
||||
const readPorts = x
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.splice(1)
|
||||
.map((x) => x.split(" ").filter(Boolean)[1]?.split(":")?.[1])
|
||||
.filter(Boolean)
|
||||
.map((x) => Number.parseInt(x, 16))
|
||||
.filter(Number.isFinite)
|
||||
return readPorts.indexOf(port) >= 0
|
||||
}
|
||||
|
||||
/**
|
||||
* This is used to check if a port is listening on the system.
|
||||
* Used during the health check fn or the check main fn.
|
||||
*/
|
||||
export async function checkPortListening(
|
||||
effects: Effects,
|
||||
port: number,
|
||||
options: {
|
||||
errorMessage: string
|
||||
successMessage: string
|
||||
timeoutMessage?: string
|
||||
timeout?: number
|
||||
},
|
||||
): Promise<HealthCheckResult> {
|
||||
return Promise.race<HealthCheckResult>([
|
||||
Promise.resolve().then(async () => {
|
||||
const hasAddress =
|
||||
containsAddress(
|
||||
await cpExec(`cat /proc/net/tcp`, {}).then(stringFromStdErrOut),
|
||||
port,
|
||||
) ||
|
||||
containsAddress(
|
||||
await cpExec("cat /proc/net/udp", {}).then(stringFromStdErrOut),
|
||||
port,
|
||||
)
|
||||
if (hasAddress) {
|
||||
return { result: "success", message: options.successMessage }
|
||||
}
|
||||
return {
|
||||
result: "failure",
|
||||
message: options.errorMessage,
|
||||
}
|
||||
}),
|
||||
new Promise((resolve) => {
|
||||
setTimeout(
|
||||
() =>
|
||||
resolve({
|
||||
result: "failure",
|
||||
message:
|
||||
options.timeoutMessage || `Timeout trying to check port ${port}`,
|
||||
}),
|
||||
options.timeout ?? 1_000,
|
||||
)
|
||||
}),
|
||||
])
|
||||
}
|
||||
36
sdk/package/lib/health/checkFns/checkWebUrl.ts
Normal file
36
sdk/package/lib/health/checkFns/checkWebUrl.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Effects } from "../../../../base/lib/types"
|
||||
import { asError } from "../../util"
|
||||
import { HealthCheckResult } from "./HealthCheckResult"
|
||||
import { timeoutPromise } from "./index"
|
||||
import "isomorphic-fetch"
|
||||
|
||||
/**
|
||||
* This is a helper function to check if a web url is reachable.
|
||||
* @param url
|
||||
* @param createSuccess
|
||||
* @returns
|
||||
*/
|
||||
export const checkWebUrl = async (
|
||||
effects: Effects,
|
||||
url: string,
|
||||
{
|
||||
timeout = 1000,
|
||||
successMessage = `Reached ${url}`,
|
||||
errorMessage = `Error while fetching URL: ${url}`,
|
||||
} = {},
|
||||
): Promise<HealthCheckResult> => {
|
||||
return Promise.race([fetch(url), timeoutPromise(timeout)])
|
||||
.then(
|
||||
(x) =>
|
||||
({
|
||||
result: "success",
|
||||
message: successMessage,
|
||||
}) as const,
|
||||
)
|
||||
.catch((e) => {
|
||||
console.warn(`Error while fetching URL: ${url}`)
|
||||
console.error(JSON.stringify(e))
|
||||
console.error(asError(e))
|
||||
return { result: "failure" as const, message: errorMessage }
|
||||
})
|
||||
}
|
||||
11
sdk/package/lib/health/checkFns/index.ts
Normal file
11
sdk/package/lib/health/checkFns/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { runHealthScript } from "./runHealthScript"
|
||||
export { checkPortListening } from "./checkPortListening"
|
||||
export { HealthCheckResult } from "./HealthCheckResult"
|
||||
export { checkWebUrl } from "./checkWebUrl"
|
||||
|
||||
export function timeoutPromise(ms: number, { message = "Timed out" } = {}) {
|
||||
return new Promise<never>((resolve, reject) =>
|
||||
setTimeout(() => reject(new Error(message)), ms),
|
||||
)
|
||||
}
|
||||
export { runHealthScript }
|
||||
35
sdk/package/lib/health/checkFns/runHealthScript.ts
Normal file
35
sdk/package/lib/health/checkFns/runHealthScript.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { HealthCheckResult } from "./HealthCheckResult"
|
||||
import { timeoutPromise } from "./index"
|
||||
import { SubContainer } from "../../util/SubContainer"
|
||||
|
||||
/**
|
||||
* Running a health script, is used when we want to have a simple
|
||||
* script in bash or something like that. It should return something that is useful
|
||||
* in {result: string} else it is considered an error
|
||||
* @param param0
|
||||
* @returns
|
||||
*/
|
||||
export const runHealthScript = async (
|
||||
runCommand: string[],
|
||||
subcontainer: SubContainer,
|
||||
{
|
||||
timeout = 30000,
|
||||
errorMessage = `Error while running command: ${runCommand}`,
|
||||
message = (res: string) =>
|
||||
`Have ran script ${runCommand} and the result: ${res}`,
|
||||
} = {},
|
||||
): Promise<HealthCheckResult> => {
|
||||
const res = await Promise.race([
|
||||
subcontainer.exec(runCommand),
|
||||
timeoutPromise(timeout),
|
||||
]).catch((e) => {
|
||||
console.warn(errorMessage)
|
||||
console.warn(JSON.stringify(e))
|
||||
console.warn(e.toString())
|
||||
throw { result: "failure", message: errorMessage } as HealthCheckResult
|
||||
})
|
||||
return {
|
||||
result: "success",
|
||||
message: message(res.stdout.toString()),
|
||||
} as HealthCheckResult
|
||||
}
|
||||
1
sdk/package/lib/health/index.ts
Normal file
1
sdk/package/lib/health/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
import "./checkFns"
|
||||
48
sdk/package/lib/index.ts
Normal file
48
sdk/package/lib/index.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
S9pk,
|
||||
Version,
|
||||
VersionRange,
|
||||
ExtendedVersion,
|
||||
inputSpec,
|
||||
ISB,
|
||||
IST,
|
||||
types,
|
||||
T,
|
||||
matches,
|
||||
utils,
|
||||
} from "../../base/lib"
|
||||
|
||||
export {
|
||||
S9pk,
|
||||
Version,
|
||||
VersionRange,
|
||||
ExtendedVersion,
|
||||
inputSpec,
|
||||
ISB,
|
||||
IST,
|
||||
types,
|
||||
T,
|
||||
matches,
|
||||
utils,
|
||||
}
|
||||
export { Daemons } from "./mainFn/Daemons"
|
||||
export { SubContainer } from "./util/SubContainer"
|
||||
export { StartSdk } from "./StartSdk"
|
||||
export { setupManifest, buildManifest } from "./manifest/setupManifest"
|
||||
export { FileHelper } from "./util/fileHelper"
|
||||
export { setupExposeStore } from "./store/setupExposeStore"
|
||||
export { pathBuilder } from "../../base/lib/util/PathBuilder"
|
||||
|
||||
export * as actions from "../../base/lib/actions"
|
||||
export * as backup from "./backup"
|
||||
export * as daemons from "./mainFn/Daemons"
|
||||
export * as health from "./health"
|
||||
export * as healthFns from "./health/checkFns"
|
||||
export * as inits from "./inits"
|
||||
export * as mainFn from "./mainFn"
|
||||
export * as toml from "@iarna/toml"
|
||||
export * as yaml from "yaml"
|
||||
export * as startSdk from "./StartSdk"
|
||||
export * as YAML from "yaml"
|
||||
export * as TOML from "@iarna/toml"
|
||||
export * from "./version"
|
||||
3
sdk/package/lib/inits/index.ts
Normal file
3
sdk/package/lib/inits/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import "./setupInit"
|
||||
import "./setupUninstall"
|
||||
import "./setupInstall"
|
||||
64
sdk/package/lib/inits/setupInit.ts
Normal file
64
sdk/package/lib/inits/setupInit.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Actions } from "../../../base/lib/actions/setupActions"
|
||||
import { ExtendedVersion } from "../../../base/lib/exver"
|
||||
import { UpdateServiceInterfaces } from "../../../base/lib/interfaces/setupInterfaces"
|
||||
import { ExposedStorePaths } from "../../../base/lib/types"
|
||||
import * as T from "../../../base/lib/types"
|
||||
import { VersionGraph } from "../version/VersionGraph"
|
||||
import { Install } from "./setupInstall"
|
||||
import { Uninstall } from "./setupUninstall"
|
||||
|
||||
export function setupInit<Manifest extends T.SDKManifest, Store>(
|
||||
versions: VersionGraph<string>,
|
||||
install: Install<Manifest, Store>,
|
||||
uninstall: Uninstall<Manifest, Store>,
|
||||
setServiceInterfaces: UpdateServiceInterfaces<any>,
|
||||
setDependencies: (options: {
|
||||
effects: T.Effects
|
||||
}) => Promise<null | void | undefined>,
|
||||
actions: Actions<Store, any>,
|
||||
exposedStore: ExposedStorePaths,
|
||||
): {
|
||||
packageInit: T.ExpectedExports.packageInit
|
||||
packageUninit: T.ExpectedExports.packageUninit
|
||||
containerInit: T.ExpectedExports.containerInit
|
||||
} {
|
||||
return {
|
||||
packageInit: async (opts) => {
|
||||
const prev = await opts.effects.getDataVersion()
|
||||
if (prev) {
|
||||
await versions.migrate({
|
||||
effects: opts.effects,
|
||||
from: ExtendedVersion.parse(prev),
|
||||
to: versions.currentVersion(),
|
||||
})
|
||||
} else {
|
||||
await install.install(opts)
|
||||
await opts.effects.setDataVersion({
|
||||
version: versions.current.options.version,
|
||||
})
|
||||
}
|
||||
},
|
||||
packageUninit: async (opts) => {
|
||||
if (opts.nextVersion) {
|
||||
const prev = await opts.effects.getDataVersion()
|
||||
if (prev) {
|
||||
await versions.migrate({
|
||||
effects: opts.effects,
|
||||
from: ExtendedVersion.parse(prev),
|
||||
to: ExtendedVersion.parse(opts.nextVersion),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
await uninstall.uninstall(opts)
|
||||
}
|
||||
},
|
||||
containerInit: async (opts) => {
|
||||
await setServiceInterfaces({
|
||||
...opts,
|
||||
})
|
||||
await actions.update({ effects: opts.effects })
|
||||
await opts.effects.exposeForDependents({ paths: exposedStore })
|
||||
await setDependencies({ effects: opts.effects })
|
||||
},
|
||||
}
|
||||
}
|
||||
25
sdk/package/lib/inits/setupInstall.ts
Normal file
25
sdk/package/lib/inits/setupInstall.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import * as T from "../../../base/lib/types"
|
||||
|
||||
export type InstallFn<Manifest extends T.SDKManifest, Store> = (opts: {
|
||||
effects: T.Effects
|
||||
}) => Promise<null | void | undefined>
|
||||
export class Install<Manifest extends T.SDKManifest, Store> {
|
||||
private constructor(readonly fn: InstallFn<Manifest, Store>) {}
|
||||
static of<Manifest extends T.SDKManifest, Store>(
|
||||
fn: InstallFn<Manifest, Store>,
|
||||
) {
|
||||
return new Install(fn)
|
||||
}
|
||||
|
||||
async install({ effects }: Parameters<T.ExpectedExports.packageInit>[0]) {
|
||||
await this.fn({
|
||||
effects,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function setupInstall<Manifest extends T.SDKManifest, Store>(
|
||||
fn: InstallFn<Manifest, Store>,
|
||||
) {
|
||||
return Install.of(fn)
|
||||
}
|
||||
29
sdk/package/lib/inits/setupUninstall.ts
Normal file
29
sdk/package/lib/inits/setupUninstall.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import * as T from "../../../base/lib/types"
|
||||
|
||||
export type UninstallFn<Manifest extends T.SDKManifest, Store> = (opts: {
|
||||
effects: T.Effects
|
||||
}) => Promise<null | void | undefined>
|
||||
export class Uninstall<Manifest extends T.SDKManifest, Store> {
|
||||
private constructor(readonly fn: UninstallFn<Manifest, Store>) {}
|
||||
static of<Manifest extends T.SDKManifest, Store>(
|
||||
fn: UninstallFn<Manifest, Store>,
|
||||
) {
|
||||
return new Uninstall(fn)
|
||||
}
|
||||
|
||||
async uninstall({
|
||||
effects,
|
||||
nextVersion,
|
||||
}: Parameters<T.ExpectedExports.packageUninit>[0]) {
|
||||
if (!nextVersion)
|
||||
await this.fn({
|
||||
effects,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function setupUninstall<Manifest extends T.SDKManifest, Store>(
|
||||
fn: UninstallFn<Manifest, Store>,
|
||||
) {
|
||||
return Uninstall.of(fn)
|
||||
}
|
||||
149
sdk/package/lib/mainFn/CommandController.ts
Normal file
149
sdk/package/lib/mainFn/CommandController.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { DEFAULT_SIGTERM_TIMEOUT } from "."
|
||||
import { NO_TIMEOUT, SIGKILL, SIGTERM } from "../../../base/lib/types"
|
||||
|
||||
import * as T from "../../../base/lib/types"
|
||||
import { asError } from "../../../base/lib/util/asError"
|
||||
import {
|
||||
ExecSpawnable,
|
||||
MountOptions,
|
||||
SubContainerHandle,
|
||||
SubContainer,
|
||||
} from "../util/SubContainer"
|
||||
import { splitCommand } from "../util"
|
||||
import * as cp from "child_process"
|
||||
|
||||
export class CommandController {
|
||||
private constructor(
|
||||
readonly runningAnswer: Promise<unknown>,
|
||||
private state: { exited: boolean },
|
||||
private readonly subcontainer: SubContainer,
|
||||
private process: cp.ChildProcess,
|
||||
readonly sigtermTimeout: number = DEFAULT_SIGTERM_TIMEOUT,
|
||||
) {}
|
||||
static of<Manifest extends T.SDKManifest>() {
|
||||
return async <A extends string>(
|
||||
effects: T.Effects,
|
||||
subcontainer:
|
||||
| {
|
||||
id: keyof Manifest["images"] & T.ImageId
|
||||
sharedRun?: boolean
|
||||
}
|
||||
| SubContainer,
|
||||
command: T.CommandType,
|
||||
options: {
|
||||
subcontainerName?: string
|
||||
// Defaults to the DEFAULT_SIGTERM_TIMEOUT = 30_000ms
|
||||
sigtermTimeout?: number
|
||||
mounts?: { path: string; options: MountOptions }[]
|
||||
runAsInit?: boolean
|
||||
env?:
|
||||
| {
|
||||
[variable: string]: string
|
||||
}
|
||||
| undefined
|
||||
cwd?: string | undefined
|
||||
user?: string | undefined
|
||||
onStdout?: (chunk: Buffer | string | any) => void
|
||||
onStderr?: (chunk: Buffer | string | any) => void
|
||||
},
|
||||
) => {
|
||||
const commands = splitCommand(command)
|
||||
const subc =
|
||||
subcontainer instanceof SubContainer
|
||||
? subcontainer
|
||||
: await (async () => {
|
||||
const subc = await SubContainer.of(
|
||||
effects,
|
||||
subcontainer,
|
||||
options?.subcontainerName || commands.join(" "),
|
||||
)
|
||||
for (let mount of options.mounts || []) {
|
||||
await subc.mount(mount.options, mount.path)
|
||||
}
|
||||
return subc
|
||||
})()
|
||||
let childProcess: cp.ChildProcess
|
||||
if (options.runAsInit) {
|
||||
childProcess = await subc.launch(commands, {
|
||||
env: options.env,
|
||||
})
|
||||
} else {
|
||||
childProcess = await subc.spawn(commands, {
|
||||
env: options.env,
|
||||
stdio: options.onStdout || options.onStderr ? "pipe" : "inherit",
|
||||
})
|
||||
}
|
||||
|
||||
if (options.onStdout) childProcess.stdout?.on("data", options.onStdout)
|
||||
if (options.onStderr) childProcess.stderr?.on("data", options.onStderr)
|
||||
|
||||
const state = { exited: false }
|
||||
const answer = new Promise<null>((resolve, reject) => {
|
||||
childProcess.on("exit", (code) => {
|
||||
state.exited = true
|
||||
if (
|
||||
code === 0 ||
|
||||
code === 143 ||
|
||||
(code === null && childProcess.signalCode == "SIGTERM")
|
||||
) {
|
||||
return resolve(null)
|
||||
}
|
||||
if (code) {
|
||||
return reject(new Error(`${commands[0]} exited with code ${code}`))
|
||||
} else {
|
||||
return reject(
|
||||
new Error(
|
||||
`${commands[0]} exited with signal ${childProcess.signalCode}`,
|
||||
),
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return new CommandController(
|
||||
answer,
|
||||
state,
|
||||
subc,
|
||||
childProcess,
|
||||
options.sigtermTimeout,
|
||||
)
|
||||
}
|
||||
}
|
||||
get subContainerHandle() {
|
||||
return new SubContainerHandle(this.subcontainer)
|
||||
}
|
||||
async wait({ timeout = NO_TIMEOUT } = {}) {
|
||||
if (timeout > 0)
|
||||
setTimeout(() => {
|
||||
this.term()
|
||||
}, timeout)
|
||||
try {
|
||||
return await this.runningAnswer
|
||||
} finally {
|
||||
if (!this.state.exited) {
|
||||
this.process.kill("SIGKILL")
|
||||
}
|
||||
await this.subcontainer.destroy?.().catch((_) => {})
|
||||
}
|
||||
}
|
||||
async term({ signal = SIGTERM, timeout = this.sigtermTimeout } = {}) {
|
||||
try {
|
||||
if (!this.state.exited) {
|
||||
if (signal !== "SIGKILL") {
|
||||
setTimeout(() => {
|
||||
if (!this.state.exited) this.process.kill("SIGKILL")
|
||||
}, timeout)
|
||||
}
|
||||
if (!this.process.kill(signal)) {
|
||||
console.error(
|
||||
`failed to send signal ${signal} to pid ${this.process.pid}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
await this.runningAnswer
|
||||
} finally {
|
||||
await this.subcontainer.destroy?.()
|
||||
}
|
||||
}
|
||||
}
|
||||
89
sdk/package/lib/mainFn/Daemon.ts
Normal file
89
sdk/package/lib/mainFn/Daemon.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import * as T from "../../../base/lib/types"
|
||||
import { asError } from "../../../base/lib/util/asError"
|
||||
import { ExecSpawnable, MountOptions, SubContainer } from "../util/SubContainer"
|
||||
import { CommandController } from "./CommandController"
|
||||
|
||||
const TIMEOUT_INCREMENT_MS = 1000
|
||||
const MAX_TIMEOUT_MS = 30000
|
||||
/**
|
||||
* This is a wrapper around CommandController that has a state of off, where the command shouldn't be running
|
||||
* and the others state of running, where it will keep a living running command
|
||||
*/
|
||||
|
||||
export class Daemon {
|
||||
private commandController: CommandController | null = null
|
||||
private shouldBeRunning = false
|
||||
constructor(private startCommand: () => Promise<CommandController>) {}
|
||||
get subContainerHandle(): undefined | ExecSpawnable {
|
||||
return this.commandController?.subContainerHandle
|
||||
}
|
||||
static of<Manifest extends T.SDKManifest>() {
|
||||
return async <A extends string>(
|
||||
effects: T.Effects,
|
||||
subcontainer:
|
||||
| {
|
||||
id: keyof Manifest["images"] & T.ImageId
|
||||
sharedRun?: boolean
|
||||
}
|
||||
| SubContainer,
|
||||
command: T.CommandType,
|
||||
options: {
|
||||
subcontainerName?: string
|
||||
mounts?: { path: string; options: MountOptions }[]
|
||||
env?:
|
||||
| {
|
||||
[variable: string]: string
|
||||
}
|
||||
| undefined
|
||||
cwd?: string | undefined
|
||||
user?: string | undefined
|
||||
onStdout?: (chunk: Buffer | string | any) => void
|
||||
onStderr?: (chunk: Buffer | string | any) => void
|
||||
sigtermTimeout?: number
|
||||
},
|
||||
) => {
|
||||
const startCommand = () =>
|
||||
CommandController.of<Manifest>()(
|
||||
effects,
|
||||
subcontainer,
|
||||
command,
|
||||
options,
|
||||
)
|
||||
return new Daemon(startCommand)
|
||||
}
|
||||
}
|
||||
async start() {
|
||||
if (this.commandController) {
|
||||
return
|
||||
}
|
||||
this.shouldBeRunning = true
|
||||
let timeoutCounter = 0
|
||||
new Promise(async () => {
|
||||
while (this.shouldBeRunning) {
|
||||
this.commandController = await this.startCommand()
|
||||
await this.commandController.wait().catch((err) => console.error(err))
|
||||
await new Promise((resolve) => setTimeout(resolve, timeoutCounter))
|
||||
timeoutCounter += TIMEOUT_INCREMENT_MS
|
||||
timeoutCounter = Math.max(MAX_TIMEOUT_MS, timeoutCounter)
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.error(asError(err))
|
||||
})
|
||||
}
|
||||
async term(termOptions?: {
|
||||
signal?: NodeJS.Signals | undefined
|
||||
timeout?: number | undefined
|
||||
}) {
|
||||
return this.stop(termOptions)
|
||||
}
|
||||
async stop(termOptions?: {
|
||||
signal?: NodeJS.Signals | undefined
|
||||
timeout?: number | undefined
|
||||
}) {
|
||||
this.shouldBeRunning = false
|
||||
await this.commandController
|
||||
?.term({ ...termOptions })
|
||||
.catch((e) => console.error(asError(e)))
|
||||
this.commandController = null
|
||||
}
|
||||
}
|
||||
174
sdk/package/lib/mainFn/Daemons.ts
Normal file
174
sdk/package/lib/mainFn/Daemons.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { HealthReceipt, Signals } from "../../../base/lib/types"
|
||||
|
||||
import { HealthCheckResult } from "../health/checkFns"
|
||||
|
||||
import { Trigger } from "../trigger"
|
||||
import * as T from "../../../base/lib/types"
|
||||
import { Mounts } from "./Mounts"
|
||||
import { ExecSpawnable, MountOptions } from "../util/SubContainer"
|
||||
|
||||
import { promisify } from "node:util"
|
||||
import * as CP from "node:child_process"
|
||||
|
||||
export { Daemon } from "./Daemon"
|
||||
export { CommandController } from "./CommandController"
|
||||
import { HealthDaemon } from "./HealthDaemon"
|
||||
import { Daemon } from "./Daemon"
|
||||
import { CommandController } from "./CommandController"
|
||||
|
||||
export const cpExec = promisify(CP.exec)
|
||||
export const cpExecFile = promisify(CP.execFile)
|
||||
export type Ready = {
|
||||
display: string | null
|
||||
fn: (
|
||||
spawnable: ExecSpawnable,
|
||||
) => Promise<HealthCheckResult> | HealthCheckResult
|
||||
trigger?: Trigger
|
||||
}
|
||||
|
||||
type DaemonsParams<
|
||||
Manifest extends T.SDKManifest,
|
||||
Ids extends string,
|
||||
Command extends string,
|
||||
Id extends string,
|
||||
> = {
|
||||
command: T.CommandType
|
||||
image: { id: keyof Manifest["images"] & T.ImageId; sharedRun?: boolean }
|
||||
mounts: Mounts<Manifest>
|
||||
env?: Record<string, string>
|
||||
ready: Ready
|
||||
requires: Exclude<Ids, Id>[]
|
||||
sigtermTimeout?: number
|
||||
onStdout?: (chunk: Buffer | string | any) => void
|
||||
onStderr?: (chunk: Buffer | string | any) => void
|
||||
}
|
||||
|
||||
type ErrorDuplicateId<Id extends string> = `The id '${Id}' is already used`
|
||||
|
||||
export const runCommand = <Manifest extends T.SDKManifest>() =>
|
||||
CommandController.of<Manifest>()
|
||||
|
||||
/**
|
||||
* A class for defining and controlling the service daemons
|
||||
```ts
|
||||
Daemons.of({
|
||||
effects,
|
||||
started,
|
||||
interfaceReceipt, // Provide the interfaceReceipt to prove it was completed
|
||||
healthReceipts, // Provide the healthReceipts or [] to prove they were at least considered
|
||||
}).addDaemon('webui', {
|
||||
command: 'hello-world', // The command to start the daemon
|
||||
ready: {
|
||||
display: 'Web Interface',
|
||||
// The function to run to determine the health status of the daemon
|
||||
fn: () =>
|
||||
checkPortListening(effects, 80, {
|
||||
successMessage: 'The web interface is ready',
|
||||
errorMessage: 'The web interface is not ready',
|
||||
}),
|
||||
},
|
||||
requires: [],
|
||||
})
|
||||
```
|
||||
*/
|
||||
export class Daemons<Manifest extends T.SDKManifest, Ids extends string>
|
||||
implements T.DaemonBuildable
|
||||
{
|
||||
private constructor(
|
||||
readonly effects: T.Effects,
|
||||
readonly started: (onTerm: () => PromiseLike<void>) => PromiseLike<null>,
|
||||
readonly daemons: Promise<Daemon>[],
|
||||
readonly ids: Ids[],
|
||||
readonly healthDaemons: HealthDaemon[],
|
||||
) {}
|
||||
/**
|
||||
* Returns an empty new Daemons class with the provided inputSpec.
|
||||
*
|
||||
* Call .addDaemon() on the returned class to add a daemon.
|
||||
*
|
||||
* Daemons run in the order they are defined, with latter daemons being capable of
|
||||
* depending on prior daemons
|
||||
* @param options
|
||||
* @returns
|
||||
*/
|
||||
static of<Manifest extends T.SDKManifest>(options: {
|
||||
effects: T.Effects
|
||||
started: (onTerm: () => PromiseLike<void>) => PromiseLike<null>
|
||||
healthReceipts: HealthReceipt[]
|
||||
}) {
|
||||
return new Daemons<Manifest, never>(
|
||||
options.effects,
|
||||
options.started,
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
)
|
||||
}
|
||||
/**
|
||||
* Returns the complete list of daemons, including the one defined here
|
||||
* @param id
|
||||
* @param newDaemon
|
||||
* @returns
|
||||
*/
|
||||
addDaemon<Id extends string, Command extends string>(
|
||||
// prettier-ignore
|
||||
id:
|
||||
"" extends Id ? never :
|
||||
ErrorDuplicateId<Id> extends Id ? never :
|
||||
Id extends Ids ? ErrorDuplicateId<Id> :
|
||||
Id,
|
||||
options: DaemonsParams<Manifest, Ids, Command, Id>,
|
||||
) {
|
||||
const daemonIndex = this.daemons.length
|
||||
const daemon = Daemon.of()(this.effects, options.image, options.command, {
|
||||
...options,
|
||||
mounts: options.mounts.build(),
|
||||
subcontainerName: id,
|
||||
})
|
||||
const healthDaemon = new HealthDaemon(
|
||||
daemon,
|
||||
daemonIndex,
|
||||
options.requires
|
||||
.map((x) => this.ids.indexOf(id as any))
|
||||
.filter((x) => x >= 0)
|
||||
.map((id) => this.healthDaemons[id]),
|
||||
id,
|
||||
this.ids,
|
||||
options.ready,
|
||||
this.effects,
|
||||
options.sigtermTimeout,
|
||||
)
|
||||
const daemons = this.daemons.concat(daemon)
|
||||
const ids = [...this.ids, id] as (Ids | Id)[]
|
||||
const healthDaemons = [...this.healthDaemons, healthDaemon]
|
||||
return new Daemons<Manifest, Ids | Id>(
|
||||
this.effects,
|
||||
this.started,
|
||||
daemons,
|
||||
ids,
|
||||
healthDaemons,
|
||||
)
|
||||
}
|
||||
|
||||
async build() {
|
||||
this.updateMainHealth()
|
||||
this.healthDaemons.forEach((x) =>
|
||||
x.addWatcher(() => this.updateMainHealth()),
|
||||
)
|
||||
const built = {
|
||||
term: async (options?: { signal?: Signals; timeout?: number }) => {
|
||||
try {
|
||||
await Promise.all(this.healthDaemons.map((x) => x.term(options)))
|
||||
} finally {
|
||||
this.effects.setMainStatus({ status: "stopped" })
|
||||
}
|
||||
},
|
||||
}
|
||||
this.started(() => built.term())
|
||||
return built
|
||||
}
|
||||
|
||||
private updateMainHealth() {
|
||||
this.effects.setMainStatus({ status: "running" })
|
||||
}
|
||||
}
|
||||
151
sdk/package/lib/mainFn/HealthDaemon.ts
Normal file
151
sdk/package/lib/mainFn/HealthDaemon.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { HealthCheckResult } from "../health/checkFns"
|
||||
import { defaultTrigger } from "../trigger/defaultTrigger"
|
||||
import { Ready } from "./Daemons"
|
||||
import { Daemon } from "./Daemon"
|
||||
import { SetHealth, Effects } from "../../../base/lib/types"
|
||||
import { DEFAULT_SIGTERM_TIMEOUT } from "."
|
||||
import { asError } from "../../../base/lib/util/asError"
|
||||
|
||||
const oncePromise = <T>() => {
|
||||
let resolve: (value: T) => void
|
||||
const promise = new Promise<T>((res) => {
|
||||
resolve = res
|
||||
})
|
||||
return { resolve: resolve!, promise }
|
||||
}
|
||||
|
||||
/**
|
||||
* Wanted a structure that deals with controlling daemons by their health status
|
||||
* States:
|
||||
* -- Waiting for dependencies to be success
|
||||
* -- Running: Daemon is running and the status is in the health
|
||||
*
|
||||
*/
|
||||
export class HealthDaemon {
|
||||
private _health: HealthCheckResult = { result: "starting", message: null }
|
||||
private healthWatchers: Array<() => unknown> = []
|
||||
private running = false
|
||||
constructor(
|
||||
private readonly daemon: Promise<Daemon>,
|
||||
readonly daemonIndex: number,
|
||||
private readonly dependencies: HealthDaemon[],
|
||||
readonly id: string,
|
||||
readonly ids: string[],
|
||||
readonly ready: Ready,
|
||||
readonly effects: Effects,
|
||||
readonly sigtermTimeout: number = DEFAULT_SIGTERM_TIMEOUT,
|
||||
) {
|
||||
this.updateStatus()
|
||||
this.dependencies.forEach((d) => d.addWatcher(() => this.updateStatus()))
|
||||
}
|
||||
|
||||
/** Run after we want to do cleanup */
|
||||
async term(termOptions?: {
|
||||
signal?: NodeJS.Signals | undefined
|
||||
timeout?: number | undefined
|
||||
}) {
|
||||
this.healthWatchers = []
|
||||
this.running = false
|
||||
this.healthCheckCleanup?.()
|
||||
|
||||
await this.daemon.then((d) =>
|
||||
d.term({
|
||||
timeout: this.sigtermTimeout,
|
||||
...termOptions,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/** Want to add another notifier that the health might have changed */
|
||||
addWatcher(watcher: () => unknown) {
|
||||
this.healthWatchers.push(watcher)
|
||||
}
|
||||
|
||||
get health() {
|
||||
return Object.freeze(this._health)
|
||||
}
|
||||
|
||||
private async changeRunning(newStatus: boolean) {
|
||||
if (this.running === newStatus) return
|
||||
|
||||
this.running = newStatus
|
||||
|
||||
if (newStatus) {
|
||||
;(await this.daemon).start()
|
||||
this.setupHealthCheck()
|
||||
} else {
|
||||
;(await this.daemon).stop()
|
||||
this.turnOffHealthCheck()
|
||||
|
||||
this.setHealth({ result: "starting", message: null })
|
||||
}
|
||||
}
|
||||
|
||||
private healthCheckCleanup: (() => null) | null = null
|
||||
private turnOffHealthCheck() {
|
||||
this.healthCheckCleanup?.()
|
||||
}
|
||||
private async setupHealthCheck() {
|
||||
if (this.healthCheckCleanup) return
|
||||
const trigger = (this.ready.trigger ?? defaultTrigger)(() => ({
|
||||
lastResult: this._health.result,
|
||||
}))
|
||||
|
||||
const { promise: status, resolve: setStatus } = oncePromise<{
|
||||
done: true
|
||||
}>()
|
||||
new Promise(async () => {
|
||||
for (
|
||||
let res = await Promise.race([status, trigger.next()]);
|
||||
!res.done;
|
||||
res = await Promise.race([status, trigger.next()])
|
||||
) {
|
||||
const handle = (await this.daemon).subContainerHandle
|
||||
|
||||
if (handle) {
|
||||
const response: HealthCheckResult = await Promise.resolve(
|
||||
this.ready.fn(handle),
|
||||
).catch((err) => {
|
||||
console.error(asError(err))
|
||||
return {
|
||||
result: "failure",
|
||||
message: "message" in err ? err.message : String(err),
|
||||
}
|
||||
})
|
||||
await this.setHealth(response)
|
||||
} else {
|
||||
await this.setHealth({
|
||||
result: "failure",
|
||||
message: "Daemon not running",
|
||||
})
|
||||
}
|
||||
}
|
||||
}).catch((err) => console.error(`Daemon ${this.id} failed: ${err}`))
|
||||
|
||||
this.healthCheckCleanup = () => {
|
||||
setStatus({ done: true })
|
||||
this.healthCheckCleanup = null
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private async setHealth(health: HealthCheckResult) {
|
||||
this._health = health
|
||||
this.healthWatchers.forEach((watcher) => watcher())
|
||||
const display = this.ready.display
|
||||
const result = health.result
|
||||
if (!display) {
|
||||
return
|
||||
}
|
||||
await this.effects.setHealth({
|
||||
...health,
|
||||
id: this.id,
|
||||
name: display,
|
||||
} as SetHealth)
|
||||
}
|
||||
|
||||
private async updateStatus() {
|
||||
const healths = this.dependencies.map((d) => d._health)
|
||||
this.changeRunning(healths.every((x) => x.result === "success"))
|
||||
}
|
||||
}
|
||||
125
sdk/package/lib/mainFn/Mounts.ts
Normal file
125
sdk/package/lib/mainFn/Mounts.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import * as T from "../../../base/lib/types"
|
||||
import { MountOptions } from "../util/SubContainer"
|
||||
|
||||
type MountArray = { path: string; options: MountOptions }[]
|
||||
|
||||
export class Mounts<Manifest extends T.SDKManifest> {
|
||||
private constructor(
|
||||
readonly volumes: {
|
||||
id: Manifest["volumes"][number]
|
||||
subpath: string | null
|
||||
mountpoint: string
|
||||
readonly: boolean
|
||||
}[],
|
||||
readonly assets: {
|
||||
id: Manifest["assets"][number]
|
||||
subpath: string | null
|
||||
mountpoint: string
|
||||
}[],
|
||||
readonly dependencies: {
|
||||
dependencyId: string
|
||||
volumeId: string
|
||||
subpath: string | null
|
||||
mountpoint: string
|
||||
readonly: boolean
|
||||
}[],
|
||||
) {}
|
||||
|
||||
static of<Manifest extends T.SDKManifest>() {
|
||||
return new Mounts<Manifest>([], [], [])
|
||||
}
|
||||
|
||||
addVolume(
|
||||
id: Manifest["volumes"][number],
|
||||
subpath: string | null,
|
||||
mountpoint: string,
|
||||
readonly: boolean,
|
||||
) {
|
||||
this.volumes.push({
|
||||
id,
|
||||
subpath,
|
||||
mountpoint,
|
||||
readonly,
|
||||
})
|
||||
return this
|
||||
}
|
||||
|
||||
addAssets(
|
||||
id: Manifest["assets"][number],
|
||||
subpath: string | null,
|
||||
mountpoint: string,
|
||||
) {
|
||||
this.assets.push({
|
||||
id,
|
||||
subpath,
|
||||
mountpoint,
|
||||
})
|
||||
return this
|
||||
}
|
||||
|
||||
addDependency<DependencyManifest extends T.SDKManifest>(
|
||||
dependencyId: keyof Manifest["dependencies"] & string,
|
||||
volumeId: DependencyManifest["volumes"][number],
|
||||
subpath: string | null,
|
||||
mountpoint: string,
|
||||
readonly: boolean,
|
||||
) {
|
||||
this.dependencies.push({
|
||||
dependencyId,
|
||||
volumeId,
|
||||
subpath,
|
||||
mountpoint,
|
||||
readonly,
|
||||
})
|
||||
return this
|
||||
}
|
||||
|
||||
build(): MountArray {
|
||||
const mountpoints = new Set()
|
||||
for (let mountpoint of this.volumes
|
||||
.map((v) => v.mountpoint)
|
||||
.concat(this.assets.map((a) => a.mountpoint))
|
||||
.concat(this.dependencies.map((d) => d.mountpoint))) {
|
||||
if (mountpoints.has(mountpoint)) {
|
||||
throw new Error(
|
||||
`cannot mount more than once to mountpoint ${mountpoint}`,
|
||||
)
|
||||
}
|
||||
mountpoints.add(mountpoint)
|
||||
}
|
||||
return ([] as MountArray)
|
||||
.concat(
|
||||
this.volumes.map((v) => ({
|
||||
path: v.mountpoint,
|
||||
options: {
|
||||
type: "volume",
|
||||
id: v.id,
|
||||
subpath: v.subpath,
|
||||
readonly: v.readonly,
|
||||
},
|
||||
})),
|
||||
)
|
||||
.concat(
|
||||
this.assets.map((a) => ({
|
||||
path: a.mountpoint,
|
||||
options: {
|
||||
type: "assets",
|
||||
id: a.id,
|
||||
subpath: a.subpath,
|
||||
},
|
||||
})),
|
||||
)
|
||||
.concat(
|
||||
this.dependencies.map((d) => ({
|
||||
path: d.mountpoint,
|
||||
options: {
|
||||
type: "pointer",
|
||||
packageId: d.dependencyId,
|
||||
volumeId: d.volumeId,
|
||||
subpath: d.subpath,
|
||||
readonly: d.readonly,
|
||||
},
|
||||
})),
|
||||
)
|
||||
}
|
||||
}
|
||||
27
sdk/package/lib/mainFn/index.ts
Normal file
27
sdk/package/lib/mainFn/index.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import * as T from "../../../base/lib/types"
|
||||
import { Daemons } from "./Daemons"
|
||||
import "../../../base/lib/interfaces/ServiceInterfaceBuilder"
|
||||
import "../../../base/lib/interfaces/Origin"
|
||||
|
||||
export const DEFAULT_SIGTERM_TIMEOUT = 30_000
|
||||
/**
|
||||
* Used to ensure that the main function is running with the valid proofs.
|
||||
* We first do the folowing order of things
|
||||
* 1. We get the interfaces
|
||||
* 2. We setup all the commands to setup the system
|
||||
* 3. We create the health checks
|
||||
* 4. We setup the daemons init system
|
||||
* @param fn
|
||||
* @returns
|
||||
*/
|
||||
export const setupMain = <Manifest extends T.SDKManifest, Store>(
|
||||
fn: (o: {
|
||||
effects: T.Effects
|
||||
started(onTerm: () => PromiseLike<void>): PromiseLike<null>
|
||||
}) => Promise<Daemons<Manifest, any>>,
|
||||
): T.ExpectedExports.main => {
|
||||
return async (options) => {
|
||||
const result = await fn(options)
|
||||
return result
|
||||
}
|
||||
}
|
||||
108
sdk/package/lib/manifest/setupManifest.ts
Normal file
108
sdk/package/lib/manifest/setupManifest.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import * as T from "../../../base/lib/types"
|
||||
import { ImageConfig, ImageId, VolumeId } from "../../../base/lib/types"
|
||||
import {
|
||||
SDKManifest,
|
||||
SDKImageInputSpec,
|
||||
} from "../../../base/lib/types/ManifestTypes"
|
||||
import { SDKVersion } from "../StartSdk"
|
||||
import { VersionGraph } from "../version/VersionGraph"
|
||||
import { execSync } from "child_process"
|
||||
|
||||
/**
|
||||
* @description Use this function to define critical information about your package
|
||||
*
|
||||
* @param versions Every version of the package, imported from ./versions
|
||||
* @param manifest Static properties of the package
|
||||
*/
|
||||
export function setupManifest<
|
||||
Id extends string,
|
||||
VolumesTypes extends VolumeId,
|
||||
AssetTypes extends VolumeId,
|
||||
Manifest extends {
|
||||
id: Id
|
||||
assets: AssetTypes[]
|
||||
volumes: VolumesTypes[]
|
||||
} & SDKManifest,
|
||||
>(manifest: Manifest): Manifest {
|
||||
return manifest
|
||||
}
|
||||
|
||||
function gitHash(): string {
|
||||
const hash = execSync("git rev-parse HEAD").toString().trim()
|
||||
try {
|
||||
execSync("git diff-index --quiet HEAD --")
|
||||
return hash
|
||||
} catch (e) {
|
||||
return hash + "-modified"
|
||||
}
|
||||
}
|
||||
|
||||
export function buildManifest<
|
||||
Id extends string,
|
||||
Version extends string,
|
||||
Dependencies extends Record<string, unknown>,
|
||||
VolumesTypes extends VolumeId,
|
||||
AssetTypes extends VolumeId,
|
||||
ImagesTypes extends ImageId,
|
||||
Manifest extends {
|
||||
dependencies: Dependencies
|
||||
id: Id
|
||||
assets: AssetTypes[]
|
||||
images: Record<ImagesTypes, SDKImageInputSpec>
|
||||
volumes: VolumesTypes[]
|
||||
},
|
||||
>(
|
||||
versions: VersionGraph<Version>,
|
||||
manifest: SDKManifest & Manifest,
|
||||
): Manifest & T.Manifest {
|
||||
const images = Object.entries(manifest.images).reduce(
|
||||
(images, [k, v]) => {
|
||||
v.arch = v.arch || ["aarch64", "x86_64"]
|
||||
if (v.emulateMissingAs === undefined)
|
||||
v.emulateMissingAs = (v.arch as string[]).includes("aarch64")
|
||||
? "aarch64"
|
||||
: v.arch[0] || null
|
||||
images[k] = v as ImageConfig
|
||||
return images
|
||||
},
|
||||
{} as { [k: string]: ImageConfig },
|
||||
)
|
||||
return {
|
||||
...manifest,
|
||||
gitHash: gitHash(),
|
||||
osVersion: SDKVersion,
|
||||
version: versions.current.options.version,
|
||||
releaseNotes: versions.current.options.releaseNotes,
|
||||
satisfies: versions.current.options.satisfies || [],
|
||||
canMigrateTo: versions.canMigrateTo().toString(),
|
||||
canMigrateFrom: versions.canMigrateFrom().toString(),
|
||||
images,
|
||||
alerts: {
|
||||
install: manifest.alerts?.install || null,
|
||||
update: manifest.alerts?.update || null,
|
||||
uninstall: manifest.alerts?.uninstall || null,
|
||||
restore: manifest.alerts?.restore || null,
|
||||
start: manifest.alerts?.start || null,
|
||||
stop: manifest.alerts?.stop || null,
|
||||
},
|
||||
hardwareRequirements: {
|
||||
device: manifest.hardwareRequirements?.device || [],
|
||||
ram: manifest.hardwareRequirements?.ram || null,
|
||||
arch:
|
||||
manifest.hardwareRequirements?.arch === undefined
|
||||
? Object.values(images).reduce(
|
||||
(arch, inputSpec) => {
|
||||
if (inputSpec.emulateMissingAs) {
|
||||
return arch
|
||||
}
|
||||
if (arch === null) {
|
||||
return inputSpec.arch
|
||||
}
|
||||
return arch.filter((a) => inputSpec.arch.includes(a))
|
||||
},
|
||||
null as string[] | null,
|
||||
)
|
||||
: manifest.hardwareRequirements?.arch,
|
||||
},
|
||||
}
|
||||
}
|
||||
61
sdk/package/lib/store/getStore.ts
Normal file
61
sdk/package/lib/store/getStore.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Effects } from "../../../base/lib/Effects"
|
||||
import { PathBuilder, extractJsonPath } from "../util"
|
||||
|
||||
export class GetStore<Store, StoreValue> {
|
||||
constructor(
|
||||
readonly effects: Effects,
|
||||
readonly path: PathBuilder<Store, StoreValue>,
|
||||
readonly options: {
|
||||
/** Defaults to what ever the package currently in */
|
||||
packageId?: string | undefined
|
||||
} = {},
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Returns the value of Store at the provided path. Restart the service if the value changes
|
||||
*/
|
||||
const() {
|
||||
return this.effects.store.get<Store, StoreValue>({
|
||||
...this.options,
|
||||
path: extractJsonPath(this.path),
|
||||
callback: () => this.effects.constRetry(),
|
||||
})
|
||||
}
|
||||
/**
|
||||
* Returns the value of Store at the provided path. Does nothing if the value changes
|
||||
*/
|
||||
once() {
|
||||
return this.effects.store.get<Store, StoreValue>({
|
||||
...this.options,
|
||||
path: extractJsonPath(this.path),
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Watches the value of Store at the provided path. Takes a custom callback function to run whenever the value changes
|
||||
*/
|
||||
async *watch() {
|
||||
while (true) {
|
||||
let callback: () => void
|
||||
const waitForNext = new Promise<void>((resolve) => {
|
||||
callback = resolve
|
||||
})
|
||||
yield await this.effects.store.get<Store, StoreValue>({
|
||||
...this.options,
|
||||
path: extractJsonPath(this.path),
|
||||
callback: () => callback(),
|
||||
})
|
||||
await waitForNext
|
||||
}
|
||||
}
|
||||
}
|
||||
export function getStore<Store, StoreValue>(
|
||||
effects: Effects,
|
||||
path: PathBuilder<Store, StoreValue>,
|
||||
options: {
|
||||
/** Defaults to what ever the package currently in */
|
||||
packageId?: string | undefined
|
||||
} = {},
|
||||
) {
|
||||
return new GetStore<Store, StoreValue>(effects, path, options)
|
||||
}
|
||||
28
sdk/package/lib/store/setupExposeStore.ts
Normal file
28
sdk/package/lib/store/setupExposeStore.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { ExposedStorePaths } from "../../../base/lib/types"
|
||||
import { Affine, _ } from "../util"
|
||||
import {
|
||||
PathBuilder,
|
||||
extractJsonPath,
|
||||
pathBuilder,
|
||||
} from "../../../base/lib/util/PathBuilder"
|
||||
|
||||
/**
|
||||
* @description Use this function to determine which Store values to expose and make available to other services running on StartOS. Store values not exposed here will be kept private. Use the type safe pathBuilder to traverse your Store's structure.
|
||||
* @example
|
||||
* In this example, we expose the hypothetical Store values "adminPassword" and "nameLastUpdatedAt".
|
||||
*
|
||||
* ```
|
||||
export const exposedStore = setupExposeStore<Store>((pathBuilder) => [
|
||||
pathBuilder.adminPassword
|
||||
pathBuilder.nameLastUpdatedAt,
|
||||
])
|
||||
* ```
|
||||
*/
|
||||
export const setupExposeStore = <Store extends Record<string, any>>(
|
||||
fn: (pathBuilder: PathBuilder<Store>) => PathBuilder<Store, any>[],
|
||||
) => {
|
||||
return fn(pathBuilder<Store>()).map(
|
||||
(x) => extractJsonPath(x) as string,
|
||||
) as ExposedStorePaths
|
||||
}
|
||||
export { ExposedStorePaths }
|
||||
17
sdk/package/lib/test/health.readyCheck.test.ts
Normal file
17
sdk/package/lib/test/health.readyCheck.test.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { containsAddress } from "../health/checkFns/checkPortListening"
|
||||
|
||||
describe("Health ready check", () => {
|
||||
it("Should be able to parse an example information", () => {
|
||||
let input = `
|
||||
|
||||
sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
|
||||
0: 00000000:1F90 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 21634478 1 0000000000000000 100 0 0 10 0
|
||||
1: 00000000:0050 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 21634477 1 0000000000000000 100 0 0 10 0
|
||||
2: 0B00007F:9671 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 21635458 1 0000000000000000 100 0 0 10 0
|
||||
3: 00000000:0D73 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 21634479 1 0000000000000000 100 0 0 10 0
|
||||
`
|
||||
|
||||
expect(containsAddress(input, 80)).toBe(true)
|
||||
expect(containsAddress(input, 1234)).toBe(false)
|
||||
})
|
||||
})
|
||||
30
sdk/package/lib/test/host.test.ts
Normal file
30
sdk/package/lib/test/host.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { ServiceInterfaceBuilder } from "../../../base/lib/interfaces/ServiceInterfaceBuilder"
|
||||
import { Effects } from "../../../base/lib/Effects"
|
||||
import { sdk } from "../test/output.sdk"
|
||||
|
||||
describe("host", () => {
|
||||
test("Testing that the types work", () => {
|
||||
async function test(effects: Effects) {
|
||||
const foo = sdk.host.multi(effects, "foo")
|
||||
const fooOrigin = await foo.bindPort(80, {
|
||||
protocol: "http" as const,
|
||||
preferredExternalPort: 80,
|
||||
})
|
||||
const fooInterface = new ServiceInterfaceBuilder({
|
||||
effects,
|
||||
name: "Foo",
|
||||
id: "foo",
|
||||
description: "A Foo",
|
||||
hasPrimary: false,
|
||||
type: "ui",
|
||||
username: "bar",
|
||||
path: "/baz",
|
||||
search: { qux: "yes" },
|
||||
schemeOverride: null,
|
||||
masked: false,
|
||||
})
|
||||
|
||||
await fooOrigin.export([fooInterface])
|
||||
}
|
||||
})
|
||||
})
|
||||
892
sdk/package/lib/test/inputSpecBuilder.test.ts
Normal file
892
sdk/package/lib/test/inputSpecBuilder.test.ts
Normal file
@@ -0,0 +1,892 @@
|
||||
import { testOutput } from "./output.test"
|
||||
import { InputSpec } from "../../../base/lib/actions/input/builder/inputSpec"
|
||||
import { List } from "../../../base/lib/actions/input/builder/list"
|
||||
import { Value } from "../../../base/lib/actions/input/builder/value"
|
||||
import { Variants } from "../../../base/lib/actions/input/builder/variants"
|
||||
import { ValueSpec } from "../../../base/lib/actions/input/inputSpecTypes"
|
||||
import { setupManifest } from "../manifest/setupManifest"
|
||||
import { StartSdk } from "../StartSdk"
|
||||
import { VersionGraph } from "../version/VersionGraph"
|
||||
import { VersionInfo } from "../version/VersionInfo"
|
||||
|
||||
describe("builder tests", () => {
|
||||
test("text", async () => {
|
||||
const bitcoinPropertiesBuilt: {
|
||||
"peer-tor-address": ValueSpec
|
||||
} = await InputSpec.of({
|
||||
"peer-tor-address": Value.text({
|
||||
name: "Peer tor address",
|
||||
description: "The Tor address of the peer interface",
|
||||
required: true,
|
||||
default: null,
|
||||
}),
|
||||
}).build({} as any)
|
||||
expect(bitcoinPropertiesBuilt).toMatchObject({
|
||||
"peer-tor-address": {
|
||||
type: "text",
|
||||
description: "The Tor address of the peer interface",
|
||||
warning: null,
|
||||
masked: false,
|
||||
placeholder: null,
|
||||
minLength: null,
|
||||
maxLength: null,
|
||||
patterns: [],
|
||||
disabled: false,
|
||||
inputmode: "text",
|
||||
name: "Peer tor address",
|
||||
required: true,
|
||||
default: null,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("values", () => {
|
||||
test("toggle", async () => {
|
||||
const value = Value.toggle({
|
||||
name: "Testing",
|
||||
description: null,
|
||||
warning: null,
|
||||
default: false,
|
||||
})
|
||||
const validator = value.validator
|
||||
validator.unsafeCast(false)
|
||||
testOutput<typeof validator._TYPE, boolean>()(null)
|
||||
})
|
||||
test("text", async () => {
|
||||
const value = Value.text({
|
||||
name: "Testing",
|
||||
required: true,
|
||||
default: null,
|
||||
})
|
||||
const validator = value.validator
|
||||
const rawIs = await value.build({} as any)
|
||||
validator.unsafeCast("test text")
|
||||
expect(() => validator.unsafeCast(null)).toThrowError()
|
||||
testOutput<typeof validator._TYPE, string>()(null)
|
||||
})
|
||||
test("text with default", async () => {
|
||||
const value = Value.text({
|
||||
name: "Testing",
|
||||
required: true,
|
||||
default: "this is a default value",
|
||||
})
|
||||
const validator = value.validator
|
||||
const rawIs = await value.build({} as any)
|
||||
validator.unsafeCast("test text")
|
||||
expect(() => validator.unsafeCast(null)).toThrowError()
|
||||
testOutput<typeof validator._TYPE, string>()(null)
|
||||
})
|
||||
test("optional text", async () => {
|
||||
const value = Value.text({
|
||||
name: "Testing",
|
||||
required: false,
|
||||
default: null,
|
||||
})
|
||||
const validator = value.validator
|
||||
const rawIs = await value.build({} as any)
|
||||
validator.unsafeCast("test text")
|
||||
validator.unsafeCast(null)
|
||||
testOutput<typeof validator._TYPE, string | null>()(null)
|
||||
})
|
||||
test("color", async () => {
|
||||
const value = Value.color({
|
||||
name: "Testing",
|
||||
required: false,
|
||||
default: null,
|
||||
description: null,
|
||||
warning: null,
|
||||
})
|
||||
const validator = value.validator
|
||||
validator.unsafeCast("#000000")
|
||||
testOutput<typeof validator._TYPE, string | null>()(null)
|
||||
})
|
||||
test("datetime", async () => {
|
||||
const value = Value.datetime({
|
||||
name: "Testing",
|
||||
required: true,
|
||||
default: null,
|
||||
description: null,
|
||||
warning: null,
|
||||
inputmode: "date",
|
||||
min: null,
|
||||
max: null,
|
||||
})
|
||||
const validator = value.validator
|
||||
validator.unsafeCast("2021-01-01")
|
||||
testOutput<typeof validator._TYPE, string>()(null)
|
||||
})
|
||||
test("optional datetime", async () => {
|
||||
const value = Value.datetime({
|
||||
name: "Testing",
|
||||
required: false,
|
||||
default: null,
|
||||
description: null,
|
||||
warning: null,
|
||||
inputmode: "date",
|
||||
min: null,
|
||||
max: null,
|
||||
})
|
||||
const validator = value.validator
|
||||
validator.unsafeCast("2021-01-01")
|
||||
testOutput<typeof validator._TYPE, string | null>()(null)
|
||||
})
|
||||
test("textarea", async () => {
|
||||
const value = Value.textarea({
|
||||
name: "Testing",
|
||||
required: false,
|
||||
default: null,
|
||||
description: null,
|
||||
warning: null,
|
||||
minLength: null,
|
||||
maxLength: null,
|
||||
placeholder: null,
|
||||
})
|
||||
const validator = value.validator
|
||||
validator.unsafeCast("test text")
|
||||
testOutput<typeof validator._TYPE, string | null>()(null)
|
||||
})
|
||||
test("number", async () => {
|
||||
const value = Value.number({
|
||||
name: "Testing",
|
||||
required: true,
|
||||
default: null,
|
||||
integer: false,
|
||||
description: null,
|
||||
warning: null,
|
||||
min: null,
|
||||
max: null,
|
||||
step: null,
|
||||
units: null,
|
||||
placeholder: null,
|
||||
})
|
||||
const validator = value.validator
|
||||
validator.unsafeCast(2)
|
||||
testOutput<typeof validator._TYPE, number>()(null)
|
||||
})
|
||||
test("optional number", async () => {
|
||||
const value = Value.number({
|
||||
name: "Testing",
|
||||
required: false,
|
||||
default: null,
|
||||
integer: false,
|
||||
description: null,
|
||||
warning: null,
|
||||
min: null,
|
||||
max: null,
|
||||
step: null,
|
||||
units: null,
|
||||
placeholder: null,
|
||||
})
|
||||
const validator = value.validator
|
||||
validator.unsafeCast(2)
|
||||
testOutput<typeof validator._TYPE, number | null>()(null)
|
||||
})
|
||||
test("select", async () => {
|
||||
const value = Value.select({
|
||||
name: "Testing",
|
||||
default: "a",
|
||||
values: {
|
||||
a: "A",
|
||||
b: "B",
|
||||
},
|
||||
description: null,
|
||||
warning: null,
|
||||
})
|
||||
const validator = value.validator
|
||||
validator.unsafeCast("a")
|
||||
validator.unsafeCast("b")
|
||||
expect(() => validator.unsafeCast("c")).toThrowError()
|
||||
testOutput<typeof validator._TYPE, "a" | "b">()(null)
|
||||
})
|
||||
test("nullable select", async () => {
|
||||
const value = Value.select({
|
||||
name: "Testing",
|
||||
default: "a",
|
||||
values: {
|
||||
a: "A",
|
||||
b: "B",
|
||||
},
|
||||
description: null,
|
||||
warning: null,
|
||||
})
|
||||
const validator = value.validator
|
||||
validator.unsafeCast("a")
|
||||
validator.unsafeCast("b")
|
||||
testOutput<typeof validator._TYPE, "a" | "b">()(null)
|
||||
})
|
||||
test("multiselect", async () => {
|
||||
const value = Value.multiselect({
|
||||
name: "Testing",
|
||||
values: {
|
||||
a: "A",
|
||||
b: "B",
|
||||
},
|
||||
default: [],
|
||||
description: null,
|
||||
warning: null,
|
||||
minLength: null,
|
||||
maxLength: null,
|
||||
})
|
||||
const validator = value.validator
|
||||
validator.unsafeCast([])
|
||||
validator.unsafeCast(["a", "b"])
|
||||
|
||||
expect(() => validator.unsafeCast(["e"])).toThrowError()
|
||||
expect(() => validator.unsafeCast([4])).toThrowError()
|
||||
testOutput<typeof validator._TYPE, Array<"a" | "b">>()(null)
|
||||
})
|
||||
test("object", async () => {
|
||||
const value = Value.object(
|
||||
{
|
||||
name: "Testing",
|
||||
description: null,
|
||||
},
|
||||
InputSpec.of({
|
||||
a: Value.toggle({
|
||||
name: "test",
|
||||
description: null,
|
||||
warning: null,
|
||||
default: false,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
const validator = value.validator
|
||||
validator.unsafeCast({ a: true })
|
||||
testOutput<typeof validator._TYPE, { a: boolean }>()(null)
|
||||
})
|
||||
test("union", async () => {
|
||||
const value = Value.union(
|
||||
{
|
||||
name: "Testing",
|
||||
default: "a",
|
||||
description: null,
|
||||
warning: null,
|
||||
},
|
||||
Variants.of({
|
||||
a: {
|
||||
name: "a",
|
||||
spec: InputSpec.of({
|
||||
b: Value.toggle({
|
||||
name: "b",
|
||||
description: null,
|
||||
warning: null,
|
||||
default: false,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
)
|
||||
const validator = value.validator
|
||||
validator.unsafeCast({ selection: "a", value: { b: false } })
|
||||
type Test = typeof validator._TYPE
|
||||
testOutput<
|
||||
Test,
|
||||
{
|
||||
selection: "a"
|
||||
value: {
|
||||
b: boolean
|
||||
}
|
||||
other?: {}
|
||||
}
|
||||
>()(null)
|
||||
})
|
||||
|
||||
describe("dynamic", () => {
|
||||
const fakeOptions = {
|
||||
inputSpec: "inputSpec",
|
||||
effects: "effects",
|
||||
utils: "utils",
|
||||
} as any
|
||||
test("toggle", async () => {
|
||||
const value = Value.dynamicToggle(async () => ({
|
||||
name: "Testing",
|
||||
description: null,
|
||||
warning: null,
|
||||
default: false,
|
||||
}))
|
||||
const validator = value.validator
|
||||
validator.unsafeCast(false)
|
||||
expect(() => validator.unsafeCast(null)).toThrowError()
|
||||
testOutput<typeof validator._TYPE, boolean>()(null)
|
||||
expect(await value.build(fakeOptions)).toMatchObject({
|
||||
name: "Testing",
|
||||
description: null,
|
||||
warning: null,
|
||||
default: false,
|
||||
})
|
||||
})
|
||||
test("text", async () => {
|
||||
const value = Value.dynamicText(async () => ({
|
||||
name: "Testing",
|
||||
required: false,
|
||||
default: null,
|
||||
}))
|
||||
const validator = value.validator
|
||||
const rawIs = await value.build({} as any)
|
||||
validator.unsafeCast("test text")
|
||||
validator.unsafeCast(null)
|
||||
testOutput<typeof validator._TYPE, string | null>()(null)
|
||||
expect(await value.build(fakeOptions)).toMatchObject({
|
||||
name: "Testing",
|
||||
required: false,
|
||||
default: null,
|
||||
})
|
||||
})
|
||||
test("text with default", async () => {
|
||||
const value = Value.dynamicText(async () => ({
|
||||
name: "Testing",
|
||||
required: false,
|
||||
default: "this is a default value",
|
||||
}))
|
||||
const validator = value.validator
|
||||
validator.unsafeCast("test text")
|
||||
validator.unsafeCast(null)
|
||||
testOutput<typeof validator._TYPE, string | null>()(null)
|
||||
expect(await value.build(fakeOptions)).toMatchObject({
|
||||
name: "Testing",
|
||||
required: false,
|
||||
default: "this is a default value",
|
||||
})
|
||||
})
|
||||
test("optional text", async () => {
|
||||
const value = Value.dynamicText(async () => ({
|
||||
name: "Testing",
|
||||
required: false,
|
||||
default: null,
|
||||
}))
|
||||
const validator = value.validator
|
||||
const rawIs = await value.build({} as any)
|
||||
validator.unsafeCast("test text")
|
||||
validator.unsafeCast(null)
|
||||
testOutput<typeof validator._TYPE, string | null>()(null)
|
||||
expect(await value.build(fakeOptions)).toMatchObject({
|
||||
name: "Testing",
|
||||
required: false,
|
||||
default: null,
|
||||
})
|
||||
})
|
||||
test("color", async () => {
|
||||
const value = Value.dynamicColor(async () => ({
|
||||
name: "Testing",
|
||||
required: false,
|
||||
default: null,
|
||||
description: null,
|
||||
warning: null,
|
||||
}))
|
||||
const validator = value.validator
|
||||
validator.unsafeCast("#000000")
|
||||
validator.unsafeCast(null)
|
||||
testOutput<typeof validator._TYPE, string | null>()(null)
|
||||
expect(await value.build(fakeOptions)).toMatchObject({
|
||||
name: "Testing",
|
||||
required: false,
|
||||
default: null,
|
||||
description: null,
|
||||
warning: null,
|
||||
})
|
||||
})
|
||||
test("datetime", async () => {
|
||||
const sdk = StartSdk.of()
|
||||
.withManifest(
|
||||
setupManifest({
|
||||
id: "testOutput",
|
||||
title: "",
|
||||
license: "",
|
||||
wrapperRepo: "",
|
||||
upstreamRepo: "",
|
||||
supportSite: "",
|
||||
marketingSite: "",
|
||||
donationUrl: null,
|
||||
description: {
|
||||
short: "",
|
||||
long: "",
|
||||
},
|
||||
containers: {},
|
||||
images: {},
|
||||
volumes: [],
|
||||
assets: [],
|
||||
alerts: {
|
||||
install: null,
|
||||
update: null,
|
||||
uninstall: null,
|
||||
restore: null,
|
||||
start: null,
|
||||
stop: null,
|
||||
},
|
||||
dependencies: {
|
||||
"remote-test": {
|
||||
description: "",
|
||||
optional: true,
|
||||
s9pk: "https://example.com/remote-test.s9pk",
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
.withStore<{ test: "a" }>()
|
||||
.build(true)
|
||||
|
||||
const value = Value.dynamicDatetime<{ test: "a" }>(
|
||||
async ({ effects }) => {
|
||||
;async () => {
|
||||
;(await sdk.store
|
||||
.getOwn(effects, sdk.StorePath.test)
|
||||
.once()) satisfies "a"
|
||||
}
|
||||
|
||||
return {
|
||||
name: "Testing",
|
||||
required: true,
|
||||
default: null,
|
||||
inputmode: "date",
|
||||
}
|
||||
},
|
||||
)
|
||||
const validator = value.validator
|
||||
validator.unsafeCast("2021-01-01")
|
||||
validator.unsafeCast(null)
|
||||
testOutput<typeof validator._TYPE, string | null>()(null)
|
||||
expect(await value.build(fakeOptions)).toMatchObject({
|
||||
name: "Testing",
|
||||
required: true,
|
||||
default: null,
|
||||
description: null,
|
||||
warning: null,
|
||||
inputmode: "date",
|
||||
})
|
||||
})
|
||||
test("textarea", async () => {
|
||||
const value = Value.dynamicTextarea(async () => ({
|
||||
name: "Testing",
|
||||
required: false,
|
||||
default: null,
|
||||
description: null,
|
||||
warning: null,
|
||||
minLength: null,
|
||||
maxLength: null,
|
||||
placeholder: null,
|
||||
}))
|
||||
const validator = value.validator
|
||||
validator.unsafeCast("test text")
|
||||
testOutput<typeof validator._TYPE, string | null>()(null)
|
||||
expect(await value.build(fakeOptions)).toMatchObject({
|
||||
name: "Testing",
|
||||
required: false,
|
||||
})
|
||||
})
|
||||
test("number", async () => {
|
||||
const value = Value.dynamicNumber(() => ({
|
||||
name: "Testing",
|
||||
required: true,
|
||||
default: null,
|
||||
integer: false,
|
||||
description: null,
|
||||
warning: null,
|
||||
min: null,
|
||||
max: null,
|
||||
step: null,
|
||||
units: null,
|
||||
placeholder: null,
|
||||
}))
|
||||
const validator = value.validator
|
||||
validator.unsafeCast(2)
|
||||
validator.unsafeCast(null)
|
||||
expect(() => validator.unsafeCast("null")).toThrowError()
|
||||
testOutput<typeof validator._TYPE, number | null>()(null)
|
||||
expect(await value.build(fakeOptions)).toMatchObject({
|
||||
name: "Testing",
|
||||
required: true,
|
||||
})
|
||||
})
|
||||
test("select", async () => {
|
||||
const value = Value.dynamicSelect(() => ({
|
||||
name: "Testing",
|
||||
default: "a",
|
||||
values: {
|
||||
a: "A",
|
||||
b: "B",
|
||||
},
|
||||
description: null,
|
||||
warning: null,
|
||||
}))
|
||||
const validator = value.validator
|
||||
validator.unsafeCast("a")
|
||||
validator.unsafeCast("b")
|
||||
validator.unsafeCast("c")
|
||||
testOutput<typeof validator._TYPE, string>()(null)
|
||||
expect(await value.build(fakeOptions)).toMatchObject({
|
||||
name: "Testing",
|
||||
})
|
||||
})
|
||||
test("multiselect", async () => {
|
||||
const value = Value.dynamicMultiselect(() => ({
|
||||
name: "Testing",
|
||||
values: {
|
||||
a: "A",
|
||||
b: "B",
|
||||
},
|
||||
default: [],
|
||||
description: null,
|
||||
warning: null,
|
||||
minLength: null,
|
||||
maxLength: null,
|
||||
}))
|
||||
const validator = value.validator
|
||||
validator.unsafeCast([])
|
||||
validator.unsafeCast(["a", "b"])
|
||||
validator.unsafeCast(["c"])
|
||||
|
||||
expect(() => validator.unsafeCast([4])).toThrowError()
|
||||
expect(() => validator.unsafeCast(null)).toThrowError()
|
||||
testOutput<typeof validator._TYPE, Array<string>>()(null)
|
||||
expect(await value.build(fakeOptions)).toMatchObject({
|
||||
name: "Testing",
|
||||
default: [],
|
||||
})
|
||||
})
|
||||
})
|
||||
describe("filtering", () => {
|
||||
test("union", async () => {
|
||||
const value = Value.filteredUnion(
|
||||
() => ["a", "c"],
|
||||
{
|
||||
name: "Testing",
|
||||
default: "a",
|
||||
description: null,
|
||||
warning: null,
|
||||
},
|
||||
Variants.of({
|
||||
a: {
|
||||
name: "a",
|
||||
spec: InputSpec.of({
|
||||
b: Value.toggle({
|
||||
name: "b",
|
||||
description: null,
|
||||
warning: null,
|
||||
default: false,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
b: {
|
||||
name: "b",
|
||||
spec: InputSpec.of({
|
||||
b: Value.toggle({
|
||||
name: "b",
|
||||
description: null,
|
||||
warning: null,
|
||||
default: false,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
)
|
||||
const validator = value.validator
|
||||
validator.unsafeCast({ selection: "a", value: { b: false } })
|
||||
type Test = typeof validator._TYPE
|
||||
testOutput<
|
||||
Test,
|
||||
| {
|
||||
selection: "a"
|
||||
value: {
|
||||
b: boolean
|
||||
}
|
||||
other?: {
|
||||
b?: {
|
||||
b?: boolean
|
||||
}
|
||||
}
|
||||
}
|
||||
| {
|
||||
selection: "b"
|
||||
value: {
|
||||
b: boolean
|
||||
}
|
||||
other?: {
|
||||
a?: {
|
||||
b?: boolean
|
||||
}
|
||||
}
|
||||
}
|
||||
>()(null)
|
||||
|
||||
const built = await value.build({} as any)
|
||||
expect(built).toMatchObject({
|
||||
name: "Testing",
|
||||
variants: {
|
||||
b: {},
|
||||
},
|
||||
})
|
||||
expect(built).toMatchObject({
|
||||
name: "Testing",
|
||||
variants: {
|
||||
a: {},
|
||||
b: {},
|
||||
},
|
||||
})
|
||||
expect(built).toMatchObject({
|
||||
name: "Testing",
|
||||
variants: {
|
||||
a: {},
|
||||
b: {},
|
||||
},
|
||||
disabled: ["a", "c"],
|
||||
})
|
||||
})
|
||||
})
|
||||
test("dynamic union", async () => {
|
||||
const value = Value.dynamicUnion(
|
||||
() => ({
|
||||
disabled: ["a", "c"],
|
||||
name: "Testing",
|
||||
default: "b",
|
||||
description: null,
|
||||
warning: null,
|
||||
}),
|
||||
Variants.of({
|
||||
a: {
|
||||
name: "a",
|
||||
spec: InputSpec.of({
|
||||
b: Value.toggle({
|
||||
name: "b",
|
||||
description: null,
|
||||
warning: null,
|
||||
default: false,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
b: {
|
||||
name: "b",
|
||||
spec: InputSpec.of({
|
||||
b: Value.toggle({
|
||||
name: "b",
|
||||
description: null,
|
||||
warning: null,
|
||||
default: false,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}),
|
||||
)
|
||||
const validator = value.validator
|
||||
validator.unsafeCast({ selection: "a", value: { b: false } })
|
||||
type Test = typeof validator._TYPE
|
||||
testOutput<
|
||||
Test,
|
||||
| {
|
||||
selection: "a"
|
||||
value: {
|
||||
b: boolean
|
||||
}
|
||||
other?: {
|
||||
b?: {
|
||||
b?: boolean
|
||||
}
|
||||
}
|
||||
}
|
||||
| {
|
||||
selection: "b"
|
||||
value: {
|
||||
b: boolean
|
||||
}
|
||||
other?: {
|
||||
a?: {
|
||||
b?: boolean
|
||||
}
|
||||
}
|
||||
}
|
||||
>()(null)
|
||||
|
||||
const built = await value.build({} as any)
|
||||
expect(built).toMatchObject({
|
||||
name: "Testing",
|
||||
variants: {
|
||||
b: {},
|
||||
},
|
||||
})
|
||||
expect(built).toMatchObject({
|
||||
name: "Testing",
|
||||
variants: {
|
||||
a: {},
|
||||
b: {},
|
||||
},
|
||||
})
|
||||
expect(built).toMatchObject({
|
||||
name: "Testing",
|
||||
variants: {
|
||||
a: {},
|
||||
b: {},
|
||||
},
|
||||
disabled: ["a", "c"],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("Builder List", () => {
|
||||
test("obj", async () => {
|
||||
const value = Value.list(
|
||||
List.obj(
|
||||
{
|
||||
name: "test",
|
||||
},
|
||||
{
|
||||
spec: InputSpec.of({
|
||||
test: Value.toggle({
|
||||
name: "test",
|
||||
description: null,
|
||||
warning: null,
|
||||
default: false,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
const validator = value.validator
|
||||
validator.unsafeCast([{ test: true }])
|
||||
testOutput<typeof validator._TYPE, { test: boolean }[]>()(null)
|
||||
})
|
||||
test("text", async () => {
|
||||
const value = Value.list(
|
||||
List.text(
|
||||
{
|
||||
name: "test",
|
||||
},
|
||||
{
|
||||
patterns: [],
|
||||
},
|
||||
),
|
||||
)
|
||||
const validator = value.validator
|
||||
validator.unsafeCast(["test", "text"])
|
||||
testOutput<typeof validator._TYPE, string[]>()(null)
|
||||
})
|
||||
describe("dynamic", () => {
|
||||
test("text", async () => {
|
||||
const value = Value.list(
|
||||
List.dynamicText(() => ({
|
||||
name: "test",
|
||||
spec: { patterns: [] },
|
||||
})),
|
||||
)
|
||||
const validator = value.validator
|
||||
validator.unsafeCast(["test", "text"])
|
||||
expect(() => validator.unsafeCast([3, 4])).toThrowError()
|
||||
expect(() => validator.unsafeCast(null)).toThrowError()
|
||||
testOutput<typeof validator._TYPE, string[]>()(null)
|
||||
expect(await value.build({} as any)).toMatchObject({
|
||||
name: "test",
|
||||
spec: { patterns: [] },
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("Nested nullable values", () => {
|
||||
test("Testing text", async () => {
|
||||
const value = InputSpec.of({
|
||||
a: Value.text({
|
||||
name: "Temp Name",
|
||||
description:
|
||||
"If no name is provided, the name from inputSpec will be used",
|
||||
required: false,
|
||||
default: null,
|
||||
}),
|
||||
})
|
||||
const validator = value.validator
|
||||
validator.unsafeCast({ a: null })
|
||||
validator.unsafeCast({ a: "test" })
|
||||
expect(() => validator.unsafeCast({ a: 4 })).toThrowError()
|
||||
testOutput<typeof validator._TYPE, { a: string | null }>()(null)
|
||||
})
|
||||
test("Testing number", async () => {
|
||||
const value = InputSpec.of({
|
||||
a: Value.number({
|
||||
name: "Temp Name",
|
||||
description:
|
||||
"If no name is provided, the name from inputSpec will be used",
|
||||
required: false,
|
||||
default: null,
|
||||
warning: null,
|
||||
placeholder: null,
|
||||
integer: false,
|
||||
min: null,
|
||||
max: null,
|
||||
step: null,
|
||||
units: null,
|
||||
}),
|
||||
})
|
||||
const validator = value.validator
|
||||
validator.unsafeCast({ a: null })
|
||||
validator.unsafeCast({ a: 5 })
|
||||
expect(() => validator.unsafeCast({ a: "4" })).toThrowError()
|
||||
testOutput<typeof validator._TYPE, { a: number | null }>()(null)
|
||||
})
|
||||
test("Testing color", async () => {
|
||||
const value = InputSpec.of({
|
||||
a: Value.color({
|
||||
name: "Temp Name",
|
||||
description:
|
||||
"If no name is provided, the name from inputSpec will be used",
|
||||
required: false,
|
||||
default: null,
|
||||
warning: null,
|
||||
}),
|
||||
})
|
||||
const validator = value.validator
|
||||
validator.unsafeCast({ a: null })
|
||||
validator.unsafeCast({ a: "5" })
|
||||
expect(() => validator.unsafeCast({ a: 4 })).toThrowError()
|
||||
testOutput<typeof validator._TYPE, { a: string | null }>()(null)
|
||||
})
|
||||
test("Testing select", async () => {
|
||||
const value = InputSpec.of({
|
||||
a: Value.select({
|
||||
name: "Temp Name",
|
||||
description:
|
||||
"If no name is provided, the name from inputSpec will be used",
|
||||
default: "a",
|
||||
warning: null,
|
||||
values: {
|
||||
a: "A",
|
||||
},
|
||||
}),
|
||||
})
|
||||
const higher = await Value.select({
|
||||
name: "Temp Name",
|
||||
description:
|
||||
"If no name is provided, the name from inputSpec will be used",
|
||||
default: "a",
|
||||
warning: null,
|
||||
values: {
|
||||
a: "A",
|
||||
},
|
||||
}).build({} as any)
|
||||
|
||||
const validator = value.validator
|
||||
validator.unsafeCast({ a: "a" })
|
||||
expect(() => validator.unsafeCast({ a: "4" })).toThrowError()
|
||||
testOutput<typeof validator._TYPE, { a: "a" }>()(null)
|
||||
})
|
||||
test("Testing multiselect", async () => {
|
||||
const value = InputSpec.of({
|
||||
a: Value.multiselect({
|
||||
name: "Temp Name",
|
||||
description:
|
||||
"If no name is provided, the name from inputSpec will be used",
|
||||
|
||||
warning: null,
|
||||
default: [],
|
||||
values: {
|
||||
a: "A",
|
||||
},
|
||||
minLength: null,
|
||||
maxLength: null,
|
||||
}),
|
||||
})
|
||||
const validator = value.validator
|
||||
validator.unsafeCast({ a: [] })
|
||||
validator.unsafeCast({ a: ["a"] })
|
||||
expect(() => validator.unsafeCast({ a: ["4"] })).toThrowError()
|
||||
expect(() => validator.unsafeCast({ a: "4" })).toThrowError()
|
||||
testOutput<typeof validator._TYPE, { a: "a"[] }>()(null)
|
||||
})
|
||||
})
|
||||
428
sdk/package/lib/test/makeOutput.ts
Normal file
428
sdk/package/lib/test/makeOutput.ts
Normal file
@@ -0,0 +1,428 @@
|
||||
import { oldSpecToBuilder } from "../../scripts/oldSpecToBuilder"
|
||||
|
||||
oldSpecToBuilder(
|
||||
// Make the location
|
||||
"./lib/test/output.ts",
|
||||
// Put the inputSpec here
|
||||
{
|
||||
mediasources: {
|
||||
type: "list",
|
||||
subtype: "enum",
|
||||
name: "Media Sources",
|
||||
description: "List of Media Sources to use with Jellyfin",
|
||||
range: "[1,*)",
|
||||
default: ["nextcloud"],
|
||||
spec: {
|
||||
values: ["nextcloud", "filebrowser"],
|
||||
"value-names": {
|
||||
nextcloud: "NextCloud",
|
||||
filebrowser: "File Browser",
|
||||
},
|
||||
},
|
||||
},
|
||||
testListUnion: {
|
||||
type: "list",
|
||||
subtype: "union",
|
||||
name: "Lightning Nodes",
|
||||
description: "List of Lightning Network node instances to manage",
|
||||
range: "[1,*)",
|
||||
default: ["lnd"],
|
||||
spec: {
|
||||
type: "string",
|
||||
"display-as": "{{name}}",
|
||||
"unique-by": "name",
|
||||
name: "Node Implementation",
|
||||
tag: {
|
||||
id: "type",
|
||||
name: "Type",
|
||||
description:
|
||||
"- LND: Lightning Network Daemon from Lightning Labs\n- CLN: Core Lightning from Blockstream\n",
|
||||
"variant-names": {
|
||||
lnd: "Lightning Network Daemon (LND)",
|
||||
"c-lightning": "Core Lightning (CLN)",
|
||||
},
|
||||
},
|
||||
default: "lnd",
|
||||
variants: {
|
||||
lnd: {
|
||||
name: {
|
||||
type: "string",
|
||||
name: "Node Name",
|
||||
description: "Name of this node in the list",
|
||||
default: "LND Wrapper",
|
||||
nullable: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
rpc: {
|
||||
type: "object",
|
||||
name: "RPC Settings",
|
||||
description: "RPC configuration options.",
|
||||
spec: {
|
||||
enable: {
|
||||
type: "boolean",
|
||||
name: "Enable",
|
||||
description: "Allow remote RPC requests.",
|
||||
default: true,
|
||||
},
|
||||
username: {
|
||||
type: "string",
|
||||
nullable: false,
|
||||
name: "Username",
|
||||
description: "The username for connecting to Bitcoin over RPC.",
|
||||
default: "bitcoin",
|
||||
masked: true,
|
||||
pattern: "^[a-zA-Z0-9_]+$",
|
||||
"pattern-description":
|
||||
"Must be alphanumeric (can contain underscore).",
|
||||
},
|
||||
password: {
|
||||
type: "string",
|
||||
nullable: false,
|
||||
name: "RPC Password",
|
||||
description: "The password for connecting to Bitcoin over RPC.",
|
||||
default: {
|
||||
charset: "a-z,2-7",
|
||||
len: 20,
|
||||
},
|
||||
pattern: '^[^\\n"]*$',
|
||||
"pattern-description":
|
||||
"Must not contain newline or quote characters.",
|
||||
copyable: true,
|
||||
masked: true,
|
||||
},
|
||||
bio: {
|
||||
type: "string",
|
||||
nullable: false,
|
||||
name: "Username",
|
||||
description: "The username for connecting to Bitcoin over RPC.",
|
||||
default: "bitcoin",
|
||||
masked: true,
|
||||
pattern: "^[a-zA-Z0-9_]+$",
|
||||
"pattern-description":
|
||||
"Must be alphanumeric (can contain underscore).",
|
||||
textarea: true,
|
||||
},
|
||||
advanced: {
|
||||
type: "object",
|
||||
name: "Advanced",
|
||||
description: "Advanced RPC Settings",
|
||||
spec: {
|
||||
auth: {
|
||||
name: "Authorization",
|
||||
description:
|
||||
"Username and hashed password for JSON-RPC connections. RPC clients connect using the usual http basic authentication.",
|
||||
type: "list",
|
||||
subtype: "string",
|
||||
default: [],
|
||||
spec: {
|
||||
pattern:
|
||||
"^[a-zA-Z0-9_-]+:([0-9a-fA-F]{2})+\\$([0-9a-fA-F]{2})+$",
|
||||
"pattern-description":
|
||||
'Each item must be of the form "<USERNAME>:<SALT>$<HASH>".',
|
||||
masked: false,
|
||||
},
|
||||
range: "[0,*)",
|
||||
},
|
||||
serialversion: {
|
||||
name: "Serialization Version",
|
||||
description:
|
||||
"Return raw transaction or block hex with Segwit or non-SegWit serialization.",
|
||||
type: "enum",
|
||||
values: ["non-segwit", "segwit"],
|
||||
"value-names": {},
|
||||
default: "segwit",
|
||||
},
|
||||
servertimeout: {
|
||||
name: "Rpc Server Timeout",
|
||||
description:
|
||||
"Number of seconds after which an uncompleted RPC call will time out.",
|
||||
type: "number",
|
||||
nullable: false,
|
||||
range: "[5,300]",
|
||||
integral: true,
|
||||
units: "seconds",
|
||||
default: 30,
|
||||
},
|
||||
threads: {
|
||||
name: "Threads",
|
||||
description:
|
||||
"Set the number of threads for handling RPC calls. You may wish to increase this if you are making lots of calls via an integration.",
|
||||
type: "number",
|
||||
nullable: false,
|
||||
default: 16,
|
||||
range: "[1,64]",
|
||||
integral: true,
|
||||
},
|
||||
workqueue: {
|
||||
name: "Work Queue",
|
||||
description:
|
||||
"Set the depth of the work queue to service RPC calls. Determines how long the backlog of RPC requests can get before it just rejects new ones.",
|
||||
type: "number",
|
||||
nullable: false,
|
||||
default: 128,
|
||||
range: "[8,256]",
|
||||
integral: true,
|
||||
units: "requests",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"zmq-enabled": {
|
||||
type: "boolean",
|
||||
name: "ZeroMQ Enabled",
|
||||
description: "Enable the ZeroMQ interface",
|
||||
default: true,
|
||||
},
|
||||
txindex: {
|
||||
type: "boolean",
|
||||
name: "Transaction Index",
|
||||
description: "Enable the Transaction Index (txindex)",
|
||||
default: true,
|
||||
},
|
||||
wallet: {
|
||||
type: "object",
|
||||
name: "Wallet",
|
||||
description: "Wallet Settings",
|
||||
spec: {
|
||||
enable: {
|
||||
name: "Enable Wallet",
|
||||
description: "Load the wallet and enable wallet RPC calls.",
|
||||
type: "boolean",
|
||||
default: true,
|
||||
},
|
||||
avoidpartialspends: {
|
||||
name: "Avoid Partial Spends",
|
||||
description:
|
||||
"Group outputs by address, selecting all or none, instead of selecting on a per-output basis. This improves privacy at the expense of higher transaction fees.",
|
||||
type: "boolean",
|
||||
default: true,
|
||||
},
|
||||
discardfee: {
|
||||
name: "Discard Change Tolerance",
|
||||
description:
|
||||
"The fee rate (in BTC/kB) that indicates your tolerance for discarding change by adding it to the fee.",
|
||||
type: "number",
|
||||
nullable: false,
|
||||
default: 0.0001,
|
||||
range: "[0,.01]",
|
||||
integral: false,
|
||||
units: "BTC/kB",
|
||||
},
|
||||
},
|
||||
},
|
||||
advanced: {
|
||||
type: "object",
|
||||
name: "Advanced",
|
||||
description: "Advanced Settings",
|
||||
spec: {
|
||||
mempool: {
|
||||
type: "object",
|
||||
name: "Mempool",
|
||||
description: "Mempool Settings",
|
||||
spec: {
|
||||
mempoolfullrbf: {
|
||||
name: "Enable Full RBF",
|
||||
description:
|
||||
"Policy for your node to use for relaying and mining unconfirmed transactions. For details, see https://github.com/bitcoin/bitcoin/blob/master/doc/release-notes/release-notes-24.0.md#notice-of-new-option-for-transaction-replacement-policies",
|
||||
type: "boolean",
|
||||
default: false,
|
||||
},
|
||||
persistmempool: {
|
||||
type: "boolean",
|
||||
name: "Persist Mempool",
|
||||
description: "Save the mempool on shutdown and load on restart.",
|
||||
default: true,
|
||||
},
|
||||
maxmempool: {
|
||||
type: "number",
|
||||
nullable: false,
|
||||
name: "Max Mempool Size",
|
||||
description:
|
||||
"Keep the transaction memory pool below <n> megabytes.",
|
||||
range: "[1,*)",
|
||||
integral: true,
|
||||
units: "MiB",
|
||||
default: 300,
|
||||
},
|
||||
mempoolexpiry: {
|
||||
type: "number",
|
||||
nullable: false,
|
||||
name: "Mempool Expiration",
|
||||
description:
|
||||
"Do not keep transactions in the mempool longer than <n> hours.",
|
||||
range: "[1,*)",
|
||||
integral: true,
|
||||
units: "Hr",
|
||||
default: 336,
|
||||
},
|
||||
},
|
||||
},
|
||||
peers: {
|
||||
type: "object",
|
||||
name: "Peers",
|
||||
description: "Peer Connection Settings",
|
||||
spec: {
|
||||
listen: {
|
||||
type: "boolean",
|
||||
name: "Make Public",
|
||||
description:
|
||||
"Allow other nodes to find your server on the network.",
|
||||
default: true,
|
||||
},
|
||||
onlyconnect: {
|
||||
type: "boolean",
|
||||
name: "Disable Peer Discovery",
|
||||
description: "Only connect to specified peers.",
|
||||
default: false,
|
||||
},
|
||||
onlyonion: {
|
||||
type: "boolean",
|
||||
name: "Disable Clearnet",
|
||||
description: "Only connect to peers over Tor.",
|
||||
default: false,
|
||||
},
|
||||
addnode: {
|
||||
name: "Add Nodes",
|
||||
description: "Add addresses of nodes to connect to.",
|
||||
type: "list",
|
||||
subtype: "object",
|
||||
range: "[0,*)",
|
||||
default: [],
|
||||
spec: {
|
||||
"unique-by": null,
|
||||
spec: {
|
||||
hostname: {
|
||||
type: "string",
|
||||
nullable: true,
|
||||
name: "Hostname",
|
||||
description: "Domain or IP address of bitcoin peer",
|
||||
pattern:
|
||||
"(^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$)|((^(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))$)|(^[a-z2-7]{16}\\.onion$)|(^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$))",
|
||||
"pattern-description":
|
||||
"Must be either a domain name, or an IPv4 or IPv6 address. Do not include protocol scheme (eg 'http://') or port.",
|
||||
masked: false,
|
||||
},
|
||||
port: {
|
||||
type: "number",
|
||||
nullable: true,
|
||||
name: "Port",
|
||||
description:
|
||||
"Port that peer is listening on for inbound p2p connections",
|
||||
range: "[0,65535]",
|
||||
integral: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
dbcache: {
|
||||
type: "number",
|
||||
nullable: true,
|
||||
name: "Database Cache",
|
||||
description:
|
||||
"How much RAM to allocate for caching the TXO set. Higher values improve syncing performance, but increase your chance of using up all your system's memory or corrupting your database in the event of an ungraceful shutdown. Set this high but comfortably below your system's total RAM during IBD, then turn down to 450 (or leave blank) once the sync completes.",
|
||||
warning:
|
||||
"WARNING: Increasing this value results in a higher chance of ungraceful shutdowns, which can leave your node unusable if it happens during the initial block download. Use this setting with caution. Be sure to set this back to the default (450 or leave blank) once your node is synced. DO NOT press the STOP button if your dbcache is large. Instead, set this number back to the default, hit save, and wait for bitcoind to restart on its own.",
|
||||
range: "(0,*)",
|
||||
integral: true,
|
||||
units: "MiB",
|
||||
},
|
||||
pruning: {
|
||||
type: "union",
|
||||
name: "Pruning Settings",
|
||||
description:
|
||||
"Blockchain Pruning Options\nReduce the blockchain size on disk\n",
|
||||
warning:
|
||||
"If you set pruning to Manual and your disk is smaller than the total size of the blockchain, you MUST have something running that prunes these blocks or you may overfill your disk!\nDisabling pruning will convert your node into a full archival node. This requires a resync of the entire blockchain, a process that may take several days. Make sure you have enough free disk space or you may fill up your disk.\n",
|
||||
tag: {
|
||||
id: "mode",
|
||||
name: "Pruning Mode",
|
||||
description:
|
||||
'- Disabled: Disable pruning\n- Automatic: Limit blockchain size on disk to a certain number of megabytes\n- Manual: Prune blockchain with the "pruneblockchain" RPC\n',
|
||||
"variant-names": {
|
||||
disabled: "Disabled",
|
||||
automatic: "Automatic",
|
||||
manual: "Manual",
|
||||
},
|
||||
},
|
||||
variants: {
|
||||
disabled: {},
|
||||
automatic: {
|
||||
size: {
|
||||
type: "number",
|
||||
nullable: false,
|
||||
name: "Max Chain Size",
|
||||
description: "Limit of blockchain size on disk.",
|
||||
warning:
|
||||
"Increasing this value will require re-syncing your node.",
|
||||
default: 550,
|
||||
range: "[550,1000000)",
|
||||
integral: true,
|
||||
units: "MiB",
|
||||
},
|
||||
},
|
||||
manual: {
|
||||
size: {
|
||||
type: "number",
|
||||
nullable: false,
|
||||
name: "Failsafe Chain Size",
|
||||
description: "Prune blockchain if size expands beyond this.",
|
||||
default: 65536,
|
||||
range: "[550,1000000)",
|
||||
integral: true,
|
||||
units: "MiB",
|
||||
},
|
||||
},
|
||||
},
|
||||
default: "disabled",
|
||||
},
|
||||
blockfilters: {
|
||||
type: "object",
|
||||
name: "Block Filters",
|
||||
description: "Settings for storing and serving compact block filters",
|
||||
spec: {
|
||||
blockfilterindex: {
|
||||
type: "boolean",
|
||||
name: "Compute Compact Block Filters (BIP158)",
|
||||
description:
|
||||
"Generate Compact Block Filters during initial sync (IBD) to enable 'getblockfilter' RPC. This is useful if dependent services need block filters to efficiently scan for addresses/transactions etc.",
|
||||
default: true,
|
||||
},
|
||||
peerblockfilters: {
|
||||
type: "boolean",
|
||||
name: "Serve Compact Block Filters to Peers (BIP157)",
|
||||
description:
|
||||
"Serve Compact Block Filters as a peer service to other nodes on the network. This is useful if you wish to connect an SPV client to your node to make it efficient to scan transactions without having to download all block data. 'Compute Compact Block Filters (BIP158)' is required.",
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
bloomfilters: {
|
||||
type: "object",
|
||||
name: "Bloom Filters (BIP37)",
|
||||
description: "Setting for serving Bloom Filters",
|
||||
spec: {
|
||||
peerbloomfilters: {
|
||||
type: "boolean",
|
||||
name: "Serve Bloom Filters to Peers",
|
||||
description:
|
||||
"Peers have the option of setting filters on each connection they make after the version handshake has completed. Bloom filters are for clients implementing SPV (Simplified Payment Verification) that want to check that block headers connect together correctly, without needing to verify the full blockchain. The client must trust that the transactions in the chain are in fact valid. It is highly recommended AGAINST using for anything except Bisq integration.",
|
||||
warning:
|
||||
"This is ONLY for use with Bisq integration, please use Block Filters for all other applications.",
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
// convert this to `start-sdk/lib` for conversions
|
||||
StartSdk: "./output.sdk",
|
||||
},
|
||||
)
|
||||
53
sdk/package/lib/test/output.sdk.ts
Normal file
53
sdk/package/lib/test/output.sdk.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { CurrentDependenciesResult } from "../../../base/lib/dependencies/setupDependencies"
|
||||
import { StartSdk } from "../StartSdk"
|
||||
import { setupManifest } from "../manifest/setupManifest"
|
||||
import { VersionGraph } from "../version/VersionGraph"
|
||||
|
||||
export type Manifest = any
|
||||
export const sdk = StartSdk.of()
|
||||
.withManifest(
|
||||
setupManifest({
|
||||
id: "testOutput",
|
||||
title: "",
|
||||
license: "",
|
||||
replaces: [],
|
||||
wrapperRepo: "",
|
||||
upstreamRepo: "",
|
||||
supportSite: "",
|
||||
marketingSite: "",
|
||||
donationUrl: null,
|
||||
description: {
|
||||
short: "",
|
||||
long: "",
|
||||
},
|
||||
containers: {},
|
||||
images: {
|
||||
main: {
|
||||
source: {
|
||||
dockerTag: "start9/hello-world",
|
||||
},
|
||||
arch: ["aarch64", "x86_64"],
|
||||
emulateMissingAs: "aarch64",
|
||||
},
|
||||
},
|
||||
volumes: [],
|
||||
assets: [],
|
||||
alerts: {
|
||||
install: null,
|
||||
update: null,
|
||||
uninstall: null,
|
||||
restore: null,
|
||||
start: null,
|
||||
stop: null,
|
||||
},
|
||||
dependencies: {
|
||||
"remote-test": {
|
||||
description: "",
|
||||
optional: false,
|
||||
s9pk: "https://example.com/remote-test.s9pk",
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
.withStore<{ storeRoot: { storeLeaf: "value" } }>()
|
||||
.build(true)
|
||||
146
sdk/package/lib/test/output.test.ts
Normal file
146
sdk/package/lib/test/output.test.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { InputSpecSpec, matchInputSpecSpec } from "./output"
|
||||
import * as _I from "../index"
|
||||
import { camelCase } from "../../scripts/oldSpecToBuilder"
|
||||
import { deepMerge } from "../../../base/lib/util"
|
||||
|
||||
export type IfEquals<T, U, Y = unknown, N = never> =
|
||||
(<G>() => G extends T ? 1 : 2) extends <G>() => G extends U ? 1 : 2 ? Y : N
|
||||
export function testOutput<A, B>(): (c: IfEquals<A, B>) => null {
|
||||
return () => null
|
||||
}
|
||||
|
||||
/// Testing the types of the input spec
|
||||
testOutput<InputSpecSpec["rpc"]["enable"], boolean>()(null)
|
||||
testOutput<InputSpecSpec["rpc"]["username"], string>()(null)
|
||||
testOutput<InputSpecSpec["rpc"]["username"], string>()(null)
|
||||
|
||||
testOutput<InputSpecSpec["rpc"]["advanced"]["auth"], string[]>()(null)
|
||||
testOutput<
|
||||
InputSpecSpec["rpc"]["advanced"]["serialversion"],
|
||||
"segwit" | "non-segwit"
|
||||
>()(null)
|
||||
testOutput<InputSpecSpec["rpc"]["advanced"]["servertimeout"], number>()(null)
|
||||
testOutput<
|
||||
InputSpecSpec["advanced"]["peers"]["addnode"][0]["hostname"],
|
||||
string | null
|
||||
>()(null)
|
||||
testOutput<
|
||||
InputSpecSpec["testListUnion"][0]["union"]["value"]["name"],
|
||||
string
|
||||
>()(null)
|
||||
testOutput<InputSpecSpec["testListUnion"][0]["union"]["selection"], "lnd">()(
|
||||
null,
|
||||
)
|
||||
testOutput<InputSpecSpec["mediasources"], Array<"filebrowser" | "nextcloud">>()(
|
||||
null,
|
||||
)
|
||||
|
||||
// @ts-expect-error Because enable should be a boolean
|
||||
testOutput<InputSpecSpec["rpc"]["enable"], string>()(null)
|
||||
// prettier-ignore
|
||||
// @ts-expect-error Expect that the string is the one above
|
||||
testOutput<InputSpecSpec["testListUnion"][0]['selection']['selection'], "selection">()(null);
|
||||
|
||||
/// Here we test the output of the matchInputSpecSpec function
|
||||
describe("Inputs", () => {
|
||||
const validInput: InputSpecSpec = {
|
||||
mediasources: ["filebrowser"],
|
||||
testListUnion: [
|
||||
{
|
||||
union: { selection: "lnd", value: { name: "string" } },
|
||||
},
|
||||
],
|
||||
rpc: {
|
||||
enable: true,
|
||||
bio: "This is a bio",
|
||||
username: "test",
|
||||
password: "test",
|
||||
advanced: {
|
||||
auth: ["test"],
|
||||
serialversion: "segwit",
|
||||
servertimeout: 6,
|
||||
threads: 3,
|
||||
workqueue: 9,
|
||||
},
|
||||
},
|
||||
"zmq-enabled": false,
|
||||
txindex: false,
|
||||
wallet: { enable: false, avoidpartialspends: false, discardfee: 0.0001 },
|
||||
advanced: {
|
||||
mempool: {
|
||||
maxmempool: 1,
|
||||
persistmempool: true,
|
||||
mempoolexpiry: 23,
|
||||
mempoolfullrbf: true,
|
||||
},
|
||||
peers: {
|
||||
listen: true,
|
||||
onlyconnect: true,
|
||||
onlyonion: true,
|
||||
addnode: [
|
||||
{
|
||||
hostname: "test",
|
||||
port: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
dbcache: 5,
|
||||
pruning: {
|
||||
selection: "disabled",
|
||||
value: { disabled: {} },
|
||||
},
|
||||
blockfilters: {
|
||||
blockfilterindex: false,
|
||||
peerblockfilters: false,
|
||||
},
|
||||
bloomfilters: { peerbloomfilters: false },
|
||||
},
|
||||
}
|
||||
|
||||
test("test valid input", () => {
|
||||
const output = matchInputSpecSpec.unsafeCast(validInput)
|
||||
expect(output).toEqual(validInput)
|
||||
})
|
||||
test("test no longer care about the conversion of min/max and validating", () => {
|
||||
matchInputSpecSpec.unsafeCast(
|
||||
deepMerge({}, validInput, { rpc: { advanced: { threads: 0 } } }),
|
||||
)
|
||||
})
|
||||
test("test errors should throw for number in string", () => {
|
||||
expect(() =>
|
||||
matchInputSpecSpec.unsafeCast(
|
||||
deepMerge({}, validInput, { rpc: { enable: 2 } }),
|
||||
),
|
||||
).toThrowError()
|
||||
})
|
||||
test("Test that we set serialversion to something not segwit or non-segwit", () => {
|
||||
expect(() =>
|
||||
matchInputSpecSpec.unsafeCast(
|
||||
deepMerge({}, validInput, {
|
||||
rpc: { advanced: { serialversion: "testing" } },
|
||||
}),
|
||||
),
|
||||
).toThrowError()
|
||||
})
|
||||
})
|
||||
|
||||
describe("camelCase", () => {
|
||||
test("'EquipmentClass name'", () => {
|
||||
expect(camelCase("EquipmentClass name")).toEqual("equipmentClassName")
|
||||
})
|
||||
test("'Equipment className'", () => {
|
||||
expect(camelCase("Equipment className")).toEqual("equipmentClassName")
|
||||
})
|
||||
test("'equipment class name'", () => {
|
||||
expect(camelCase("equipment class name")).toEqual("equipmentClassName")
|
||||
})
|
||||
test("'Equipment Class Name'", () => {
|
||||
expect(camelCase("Equipment Class Name")).toEqual("equipmentClassName")
|
||||
})
|
||||
test("'hyphen-name-format'", () => {
|
||||
expect(camelCase("hyphen-name-format")).toEqual("hyphenNameFormat")
|
||||
})
|
||||
test("'underscore_name_format'", () => {
|
||||
expect(camelCase("underscore_name_format")).toEqual("underscoreNameFormat")
|
||||
})
|
||||
})
|
||||
111
sdk/package/lib/test/store.test.ts
Normal file
111
sdk/package/lib/test/store.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { Effects } from "../../../base/lib/types"
|
||||
import { extractJsonPath } from "../../../base/lib/util/PathBuilder"
|
||||
import { StartSdk } from "../StartSdk"
|
||||
|
||||
type Store = {
|
||||
inputSpec: {
|
||||
someValue: "a" | "b"
|
||||
}
|
||||
}
|
||||
type Manifest = any
|
||||
const todo = <A>(): A => {
|
||||
throw new Error("not implemented")
|
||||
}
|
||||
const noop = () => {}
|
||||
|
||||
const sdk = StartSdk.of()
|
||||
.withManifest({} as Manifest)
|
||||
.withStore<Store>()
|
||||
.build(true)
|
||||
|
||||
const storePath = sdk.StorePath
|
||||
|
||||
describe("Store", () => {
|
||||
test("types", async () => {
|
||||
;async () => {
|
||||
sdk.store.setOwn(todo<Effects>(), storePath.inputSpec, {
|
||||
someValue: "a",
|
||||
})
|
||||
sdk.store.setOwn(todo<Effects>(), storePath.inputSpec.someValue, "b")
|
||||
sdk.store.setOwn(todo<Effects>(), storePath, {
|
||||
inputSpec: { someValue: "b" },
|
||||
})
|
||||
sdk.store.setOwn(
|
||||
todo<Effects>(),
|
||||
storePath.inputSpec.someValue,
|
||||
|
||||
// @ts-expect-error Type is wrong for the setting value
|
||||
5,
|
||||
)
|
||||
sdk.store.setOwn(
|
||||
todo<Effects>(),
|
||||
// @ts-expect-error Path is wrong
|
||||
"/inputSpec/someVae3lue",
|
||||
"someValue",
|
||||
)
|
||||
|
||||
todo<Effects>().store.set<Store>({
|
||||
path: extractJsonPath(storePath.inputSpec.someValue),
|
||||
value: "b",
|
||||
})
|
||||
todo<Effects>().store.set<Store, "/inputSpec/some2Value">({
|
||||
path: extractJsonPath(storePath.inputSpec.someValue),
|
||||
//@ts-expect-error Path is wrong
|
||||
value: "someValueIn",
|
||||
})
|
||||
;(await sdk.store
|
||||
.getOwn(todo<Effects>(), storePath.inputSpec.someValue)
|
||||
.const()) satisfies string
|
||||
;(await sdk.store
|
||||
.getOwn(todo<Effects>(), storePath.inputSpec)
|
||||
.const()) satisfies Store["inputSpec"]
|
||||
await sdk.store // @ts-expect-error Path is wrong
|
||||
.getOwn(todo<Effects>(), "/inputSpec/somdsfeValue")
|
||||
.const()
|
||||
/// ----------------- ERRORS -----------------
|
||||
|
||||
sdk.store.setOwn(todo<Effects>(), storePath, {
|
||||
// @ts-expect-error Type is wrong for the setting value
|
||||
inputSpec: { someValue: "notInAOrB" },
|
||||
})
|
||||
sdk.store.setOwn(
|
||||
todo<Effects>(),
|
||||
sdk.StorePath.inputSpec.someValue,
|
||||
// @ts-expect-error Type is wrong for the setting value
|
||||
"notInAOrB",
|
||||
)
|
||||
;(await sdk.store
|
||||
.getOwn(todo<Effects>(), storePath.inputSpec.someValue)
|
||||
.const()) satisfies string
|
||||
;(await sdk.store
|
||||
.getOwn(todo<Effects>(), storePath.inputSpec)
|
||||
.const()) satisfies Store["inputSpec"]
|
||||
await sdk.store // @ts-expect-error Path is wrong
|
||||
.getOwn("/inputSpec/somdsfeValue")
|
||||
.const()
|
||||
|
||||
///
|
||||
;(await sdk.store
|
||||
.getOwn(todo<Effects>(), storePath.inputSpec.someValue)
|
||||
// @ts-expect-error satisfies type is wrong
|
||||
.const()) satisfies number
|
||||
await sdk.store // @ts-expect-error Path is wrong
|
||||
.getOwn(todo<Effects>(), extractJsonPath(storePath.inputSpec))
|
||||
.const()
|
||||
;(await todo<Effects>().store.get({
|
||||
path: extractJsonPath(storePath.inputSpec.someValue),
|
||||
callback: noop,
|
||||
})) satisfies string
|
||||
await todo<Effects>().store.get<Store, "/inputSpec/someValue">({
|
||||
// @ts-expect-error Path is wrong as in it doesn't match above
|
||||
path: "/inputSpec/someV2alue",
|
||||
callback: noop,
|
||||
})
|
||||
await todo<Effects>().store.get<Store, "/inputSpec/someV2alue">({
|
||||
// @ts-expect-error Path is wrong as in it doesn't exists in wrapper type
|
||||
path: "/inputSpec/someV2alue",
|
||||
callback: noop,
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
5
sdk/package/lib/trigger/TriggerInput.ts
Normal file
5
sdk/package/lib/trigger/TriggerInput.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { HealthStatus } from "../../../base/lib/types"
|
||||
|
||||
export type TriggerInput = {
|
||||
lastResult?: HealthStatus
|
||||
}
|
||||
32
sdk/package/lib/trigger/changeOnFirstSuccess.ts
Normal file
32
sdk/package/lib/trigger/changeOnFirstSuccess.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Trigger } from "./index"
|
||||
|
||||
export function changeOnFirstSuccess(o: {
|
||||
beforeFirstSuccess: Trigger
|
||||
afterFirstSuccess: Trigger
|
||||
}): Trigger {
|
||||
return async function* (getInput) {
|
||||
let currentValue = getInput()
|
||||
while (!currentValue.lastResult) {
|
||||
yield
|
||||
currentValue = getInput()
|
||||
}
|
||||
const beforeFirstSuccess = o.beforeFirstSuccess(getInput)
|
||||
for (
|
||||
let res = await beforeFirstSuccess.next();
|
||||
currentValue?.lastResult !== "success" && !res.done;
|
||||
res = await beforeFirstSuccess.next()
|
||||
) {
|
||||
yield
|
||||
currentValue = getInput()
|
||||
}
|
||||
const afterFirstSuccess = o.afterFirstSuccess(getInput)
|
||||
for (
|
||||
let res = await afterFirstSuccess.next();
|
||||
!res.done;
|
||||
res = await afterFirstSuccess.next()
|
||||
) {
|
||||
yield
|
||||
currentValue = getInput()
|
||||
}
|
||||
}
|
||||
}
|
||||
8
sdk/package/lib/trigger/cooldownTrigger.ts
Normal file
8
sdk/package/lib/trigger/cooldownTrigger.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export function cooldownTrigger(timeMs: number) {
|
||||
return async function* () {
|
||||
while (true) {
|
||||
await new Promise((resolve) => setTimeout(resolve, timeMs))
|
||||
yield
|
||||
}
|
||||
}
|
||||
}
|
||||
8
sdk/package/lib/trigger/defaultTrigger.ts
Normal file
8
sdk/package/lib/trigger/defaultTrigger.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { cooldownTrigger } from "./cooldownTrigger"
|
||||
import { changeOnFirstSuccess } from "./changeOnFirstSuccess"
|
||||
import { successFailure } from "./successFailure"
|
||||
|
||||
export const defaultTrigger = changeOnFirstSuccess({
|
||||
beforeFirstSuccess: cooldownTrigger(1000),
|
||||
afterFirstSuccess: cooldownTrigger(30000),
|
||||
})
|
||||
7
sdk/package/lib/trigger/index.ts
Normal file
7
sdk/package/lib/trigger/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { TriggerInput } from "./TriggerInput"
|
||||
export { changeOnFirstSuccess } from "./changeOnFirstSuccess"
|
||||
export { cooldownTrigger } from "./cooldownTrigger"
|
||||
|
||||
export type Trigger = (
|
||||
getInput: () => TriggerInput,
|
||||
) => AsyncIterator<unknown, unknown, never>
|
||||
33
sdk/package/lib/trigger/lastStatus.ts
Normal file
33
sdk/package/lib/trigger/lastStatus.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Trigger } from "."
|
||||
import { HealthStatus } from "../../../base/lib/types"
|
||||
|
||||
export type LastStatusTriggerParams = { [k in HealthStatus]?: Trigger } & {
|
||||
default: Trigger
|
||||
}
|
||||
|
||||
export function lastStatus(o: LastStatusTriggerParams): Trigger {
|
||||
return async function* (getInput) {
|
||||
let trigger = o.default(getInput)
|
||||
const triggers: {
|
||||
[k in HealthStatus]?: AsyncIterator<unknown, unknown, never>
|
||||
} & { default: AsyncIterator<unknown, unknown, never> } = {
|
||||
default: trigger,
|
||||
}
|
||||
while (true) {
|
||||
let currentValue = getInput()
|
||||
let prev: HealthStatus | "default" | undefined = currentValue.lastResult
|
||||
if (!prev) {
|
||||
yield
|
||||
continue
|
||||
}
|
||||
if (!(prev in o)) {
|
||||
prev = "default"
|
||||
}
|
||||
if (!triggers[prev]) {
|
||||
triggers[prev] = o[prev]!(getInput)
|
||||
}
|
||||
await triggers[prev]?.next()
|
||||
yield
|
||||
}
|
||||
}
|
||||
}
|
||||
7
sdk/package/lib/trigger/successFailure.ts
Normal file
7
sdk/package/lib/trigger/successFailure.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Trigger } from "."
|
||||
import { lastStatus } from "./lastStatus"
|
||||
|
||||
export const successFailure = (o: {
|
||||
duringSuccess: Trigger
|
||||
duringError: Trigger
|
||||
}) => lastStatus({ success: o.duringSuccess, default: o.duringError })
|
||||
47
sdk/package/lib/util/GetSslCertificate.ts
Normal file
47
sdk/package/lib/util/GetSslCertificate.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { T } from ".."
|
||||
import { Effects } from "../../../base/lib/Effects"
|
||||
|
||||
export class GetSslCertificate {
|
||||
constructor(
|
||||
readonly effects: Effects,
|
||||
readonly hostnames: string[],
|
||||
readonly algorithm?: T.Algorithm,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Returns the system SMTP credentials. Restarts the service if the credentials change
|
||||
*/
|
||||
const() {
|
||||
return this.effects.getSslCertificate({
|
||||
hostnames: this.hostnames,
|
||||
algorithm: this.algorithm,
|
||||
callback: () => this.effects.constRetry(),
|
||||
})
|
||||
}
|
||||
/**
|
||||
* Returns the system SMTP credentials. Does nothing if the credentials change
|
||||
*/
|
||||
once() {
|
||||
return this.effects.getSslCertificate({
|
||||
hostnames: this.hostnames,
|
||||
algorithm: this.algorithm,
|
||||
})
|
||||
}
|
||||
/**
|
||||
* Watches the system SMTP credentials. Takes a custom callback function to run whenever the credentials change
|
||||
*/
|
||||
async *watch() {
|
||||
while (true) {
|
||||
let callback: () => void
|
||||
const waitForNext = new Promise<void>((resolve) => {
|
||||
callback = resolve
|
||||
})
|
||||
yield await this.effects.getSslCertificate({
|
||||
hostnames: this.hostnames,
|
||||
algorithm: this.algorithm,
|
||||
callback: () => callback(),
|
||||
})
|
||||
await waitForNext
|
||||
}
|
||||
}
|
||||
}
|
||||
439
sdk/package/lib/util/SubContainer.ts
Normal file
439
sdk/package/lib/util/SubContainer.ts
Normal file
@@ -0,0 +1,439 @@
|
||||
import * as fs from "fs/promises"
|
||||
import * as T from "../../../base/lib/types"
|
||||
import * as cp from "child_process"
|
||||
import { promisify } from "util"
|
||||
import { Buffer } from "node:buffer"
|
||||
import { once } from "../../../base/lib/util/once"
|
||||
export const execFile = promisify(cp.execFile)
|
||||
const WORKDIR = (imageId: string) => `/media/startos/images/${imageId}/`
|
||||
const False = () => false
|
||||
type ExecResults = {
|
||||
exitCode: number | null
|
||||
exitSignal: NodeJS.Signals | null
|
||||
stdout: string | Buffer
|
||||
stderr: string | Buffer
|
||||
}
|
||||
|
||||
export type ExecOptions = {
|
||||
input?: string | Buffer
|
||||
}
|
||||
|
||||
const TIMES_TO_WAIT_FOR_PROC = 100
|
||||
|
||||
/**
|
||||
* This is the type that is going to describe what an subcontainer could do. The main point of the
|
||||
* subcontainer is to have commands that run in a chrooted environment. This is useful for running
|
||||
* commands in a containerized environment. But, I wanted the destroy to sometimes be doable, for example the
|
||||
* case where the subcontainer isn't owned by the process, the subcontainer shouldn't be destroyed.
|
||||
*/
|
||||
export interface ExecSpawnable {
|
||||
get destroy(): undefined | (() => Promise<null>)
|
||||
exec(
|
||||
command: string[],
|
||||
options?: CommandOptions & ExecOptions,
|
||||
timeoutMs?: number | null,
|
||||
): Promise<ExecResults>
|
||||
spawn(
|
||||
command: string[],
|
||||
options?: CommandOptions & StdioOptions,
|
||||
): Promise<cp.ChildProcess>
|
||||
}
|
||||
/**
|
||||
* Want to limit what we can do in a container, so we want to launch a container with a specific image and the mounts.
|
||||
*
|
||||
* Implements:
|
||||
* @see {@link ExecSpawnable}
|
||||
*/
|
||||
export class SubContainer implements ExecSpawnable {
|
||||
private leader: cp.ChildProcess
|
||||
private leaderExited: boolean = false
|
||||
private waitProc: () => Promise<null>
|
||||
private constructor(
|
||||
readonly effects: T.Effects,
|
||||
readonly imageId: T.ImageId,
|
||||
readonly rootfs: string,
|
||||
readonly guid: T.Guid,
|
||||
) {
|
||||
this.leaderExited = false
|
||||
this.leader = cp.spawn("start-cli", ["subcontainer", "launch", rootfs], {
|
||||
killSignal: "SIGKILL",
|
||||
stdio: "inherit",
|
||||
})
|
||||
this.leader.on("exit", () => {
|
||||
this.leaderExited = true
|
||||
})
|
||||
this.waitProc = once(
|
||||
() =>
|
||||
new Promise(async (resolve, reject) => {
|
||||
let count = 0
|
||||
while (
|
||||
!(await fs.stat(`${this.rootfs}/proc/1`).then((x) => !!x, False))
|
||||
) {
|
||||
if (count++ > TIMES_TO_WAIT_FOR_PROC) {
|
||||
console.debug("Failed to start subcontainer", {
|
||||
guid: this.guid,
|
||||
imageId: this.imageId,
|
||||
rootfs: this.rootfs,
|
||||
})
|
||||
reject(new Error(`Failed to start subcontainer ${this.imageId}`))
|
||||
}
|
||||
await wait(1)
|
||||
}
|
||||
resolve(null)
|
||||
}),
|
||||
)
|
||||
}
|
||||
static async of(
|
||||
effects: T.Effects,
|
||||
image: { id: T.ImageId; sharedRun?: boolean },
|
||||
name: string,
|
||||
) {
|
||||
const { id, sharedRun } = image
|
||||
const [rootfs, guid] = await effects.subcontainer.createFs({
|
||||
imageId: id as string,
|
||||
name,
|
||||
})
|
||||
|
||||
const shared = ["dev", "sys"]
|
||||
if (!!sharedRun) {
|
||||
shared.push("run")
|
||||
}
|
||||
|
||||
await fs.mkdir(`${rootfs}/etc`, { recursive: true })
|
||||
await fs.copyFile("/etc/resolv.conf", `${rootfs}/etc/resolv.conf`)
|
||||
|
||||
for (const dirPart of shared) {
|
||||
const from = `/${dirPart}`
|
||||
const to = `${rootfs}/${dirPart}`
|
||||
await fs.mkdir(from, { recursive: true })
|
||||
await fs.mkdir(to, { recursive: true })
|
||||
await execFile("mount", ["--rbind", from, to])
|
||||
}
|
||||
|
||||
return new SubContainer(effects, id, rootfs, guid)
|
||||
}
|
||||
|
||||
static async with<T>(
|
||||
effects: T.Effects,
|
||||
image: { id: T.ImageId; sharedRun?: boolean },
|
||||
mounts: { options: MountOptions; path: string }[],
|
||||
name: string,
|
||||
fn: (subContainer: SubContainer) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const subContainer = await SubContainer.of(effects, image, name)
|
||||
try {
|
||||
for (let mount of mounts) {
|
||||
await subContainer.mount(mount.options, mount.path)
|
||||
}
|
||||
return await fn(subContainer)
|
||||
} finally {
|
||||
await subContainer.destroy()
|
||||
}
|
||||
}
|
||||
|
||||
async mount(options: MountOptions, path: string): Promise<SubContainer> {
|
||||
path = path.startsWith("/")
|
||||
? `${this.rootfs}${path}`
|
||||
: `${this.rootfs}/${path}`
|
||||
if (options.type === "volume") {
|
||||
const subpath = options.subpath
|
||||
? options.subpath.startsWith("/")
|
||||
? options.subpath
|
||||
: `/${options.subpath}`
|
||||
: "/"
|
||||
const from = `/media/startos/volumes/${options.id}${subpath}`
|
||||
|
||||
await fs.mkdir(from, { recursive: true })
|
||||
await fs.mkdir(path, { recursive: true })
|
||||
await execFile("mount", ["--bind", from, path])
|
||||
} else if (options.type === "assets") {
|
||||
const subpath = options.subpath
|
||||
? options.subpath.startsWith("/")
|
||||
? options.subpath
|
||||
: `/${options.subpath}`
|
||||
: "/"
|
||||
const from = `/media/startos/assets/${options.id}${subpath}`
|
||||
|
||||
await fs.mkdir(from, { recursive: true })
|
||||
await fs.mkdir(path, { recursive: true })
|
||||
await execFile("mount", ["--bind", from, path])
|
||||
} else if (options.type === "pointer") {
|
||||
await this.effects.mount({ location: path, target: options })
|
||||
} else if (options.type === "backup") {
|
||||
const subpath = options.subpath
|
||||
? options.subpath.startsWith("/")
|
||||
? options.subpath
|
||||
: `/${options.subpath}`
|
||||
: "/"
|
||||
const from = `/media/startos/backup${subpath}`
|
||||
|
||||
await fs.mkdir(from, { recursive: true })
|
||||
await fs.mkdir(path, { recursive: true })
|
||||
await execFile("mount", ["--bind", from, path])
|
||||
} else {
|
||||
throw new Error(`unknown type ${(options as any).type}`)
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
private async killLeader() {
|
||||
if (this.leaderExited) {
|
||||
return
|
||||
}
|
||||
return new Promise<null>((resolve, reject) => {
|
||||
try {
|
||||
let timeout = setTimeout(() => this.leader.kill("SIGKILL"), 30000)
|
||||
this.leader.on("exit", () => {
|
||||
clearTimeout(timeout)
|
||||
resolve(null)
|
||||
})
|
||||
if (!this.leader.kill("SIGTERM")) {
|
||||
reject(new Error("kill(2) failed"))
|
||||
}
|
||||
} catch (e) {
|
||||
reject(e)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
get destroy() {
|
||||
return async () => {
|
||||
const guid = this.guid
|
||||
await this.killLeader()
|
||||
await this.effects.subcontainer.destroyFs({ guid })
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async exec(
|
||||
command: string[],
|
||||
options?: CommandOptions & ExecOptions,
|
||||
timeoutMs: number | null = 30000,
|
||||
): Promise<{
|
||||
exitCode: number | null
|
||||
exitSignal: NodeJS.Signals | null
|
||||
stdout: string | Buffer
|
||||
stderr: string | Buffer
|
||||
}> {
|
||||
await this.waitProc()
|
||||
const imageMeta: T.ImageMetadata = await fs
|
||||
.readFile(`/media/startos/images/${this.imageId}.json`, {
|
||||
encoding: "utf8",
|
||||
})
|
||||
.catch(() => "{}")
|
||||
.then(JSON.parse)
|
||||
let extra: string[] = []
|
||||
if (options?.user) {
|
||||
extra.push(`--user=${options.user}`)
|
||||
delete options.user
|
||||
}
|
||||
let workdir = imageMeta.workdir || "/"
|
||||
if (options?.cwd) {
|
||||
workdir = options.cwd
|
||||
delete options.cwd
|
||||
}
|
||||
const child = cp.spawn(
|
||||
"start-cli",
|
||||
[
|
||||
"subcontainer",
|
||||
"exec",
|
||||
`--env=/media/startos/images/${this.imageId}.env`,
|
||||
`--workdir=${workdir}`,
|
||||
...extra,
|
||||
this.rootfs,
|
||||
...command,
|
||||
],
|
||||
options || {},
|
||||
)
|
||||
if (options?.input) {
|
||||
await new Promise<null>((resolve, reject) =>
|
||||
child.stdin.write(options.input, (e) => {
|
||||
if (e) {
|
||||
reject(e)
|
||||
} else {
|
||||
resolve(null)
|
||||
}
|
||||
}),
|
||||
)
|
||||
await new Promise<null>((resolve) => child.stdin.end(resolve))
|
||||
}
|
||||
const pid = child.pid
|
||||
const stdout = { data: "" as string }
|
||||
const stderr = { data: "" as string }
|
||||
const appendData =
|
||||
(appendTo: { data: string }) => (chunk: string | Buffer | any) => {
|
||||
if (typeof chunk === "string" || chunk instanceof Buffer) {
|
||||
appendTo.data += chunk.toString()
|
||||
} else {
|
||||
console.error("received unexpected chunk", chunk)
|
||||
}
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
child.on("error", reject)
|
||||
let killTimeout: NodeJS.Timeout | undefined
|
||||
if (timeoutMs !== null && child.pid) {
|
||||
killTimeout = setTimeout(() => child.kill("SIGKILL"), timeoutMs)
|
||||
}
|
||||
child.stdout.on("data", appendData(stdout))
|
||||
child.stderr.on("data", appendData(stderr))
|
||||
child.on("exit", (code, signal) => {
|
||||
clearTimeout(killTimeout)
|
||||
resolve({
|
||||
exitCode: code,
|
||||
exitSignal: signal,
|
||||
stdout: stdout.data,
|
||||
stderr: stderr.data,
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async launch(
|
||||
command: string[],
|
||||
options?: CommandOptions,
|
||||
): Promise<cp.ChildProcessWithoutNullStreams> {
|
||||
await this.waitProc()
|
||||
const imageMeta: any = await fs
|
||||
.readFile(`/media/startos/images/${this.imageId}.json`, {
|
||||
encoding: "utf8",
|
||||
})
|
||||
.catch(() => "{}")
|
||||
.then(JSON.parse)
|
||||
let extra: string[] = []
|
||||
if (options?.user) {
|
||||
extra.push(`--user=${options.user}`)
|
||||
delete options.user
|
||||
}
|
||||
let workdir = imageMeta.workdir || "/"
|
||||
if (options?.cwd) {
|
||||
workdir = options.cwd
|
||||
delete options.cwd
|
||||
}
|
||||
await this.killLeader()
|
||||
this.leaderExited = false
|
||||
this.leader = cp.spawn(
|
||||
"start-cli",
|
||||
[
|
||||
"subcontainer",
|
||||
"launch",
|
||||
`--env=/media/startos/images/${this.imageId}.env`,
|
||||
`--workdir=${workdir}`,
|
||||
...extra,
|
||||
this.rootfs,
|
||||
...command,
|
||||
],
|
||||
{ ...options, stdio: "inherit" },
|
||||
)
|
||||
this.leader.on("exit", () => {
|
||||
this.leaderExited = true
|
||||
})
|
||||
return this.leader as cp.ChildProcessWithoutNullStreams
|
||||
}
|
||||
|
||||
async spawn(
|
||||
command: string[],
|
||||
options: CommandOptions & StdioOptions = { stdio: "inherit" },
|
||||
): Promise<cp.ChildProcess> {
|
||||
await this.waitProc()
|
||||
const imageMeta: any = await fs
|
||||
.readFile(`/media/startos/images/${this.imageId}.json`, {
|
||||
encoding: "utf8",
|
||||
})
|
||||
.catch(() => "{}")
|
||||
.then(JSON.parse)
|
||||
let extra: string[] = []
|
||||
if (options.user) {
|
||||
extra.push(`--user=${options.user}`)
|
||||
delete options.user
|
||||
}
|
||||
let workdir = imageMeta.workdir || "/"
|
||||
if (options.cwd) {
|
||||
workdir = options.cwd
|
||||
delete options.cwd
|
||||
}
|
||||
return cp.spawn(
|
||||
"start-cli",
|
||||
[
|
||||
"subcontainer",
|
||||
"exec",
|
||||
`--env=/media/startos/images/${this.imageId}.env`,
|
||||
`--workdir=${workdir}`,
|
||||
...extra,
|
||||
this.rootfs,
|
||||
...command,
|
||||
],
|
||||
options,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Take an subcontainer but remove the ability to add the mounts and the destroy function.
|
||||
* Lets other functions, like health checks, to not destroy the parents.
|
||||
*
|
||||
*/
|
||||
export class SubContainerHandle implements ExecSpawnable {
|
||||
constructor(private subContainer: ExecSpawnable) {}
|
||||
get destroy() {
|
||||
return undefined
|
||||
}
|
||||
|
||||
exec(
|
||||
command: string[],
|
||||
options?: CommandOptions,
|
||||
timeoutMs?: number | null,
|
||||
): Promise<ExecResults> {
|
||||
return this.subContainer.exec(command, options, timeoutMs)
|
||||
}
|
||||
spawn(
|
||||
command: string[],
|
||||
options: CommandOptions & StdioOptions = { stdio: "inherit" },
|
||||
): Promise<cp.ChildProcess> {
|
||||
return this.subContainer.spawn(command, options)
|
||||
}
|
||||
}
|
||||
|
||||
export type CommandOptions = {
|
||||
env?: { [variable: string]: string }
|
||||
cwd?: string
|
||||
user?: string
|
||||
}
|
||||
|
||||
export type StdioOptions = {
|
||||
stdio?: cp.IOType
|
||||
}
|
||||
|
||||
export type MountOptions =
|
||||
| MountOptionsVolume
|
||||
| MountOptionsAssets
|
||||
| MountOptionsPointer
|
||||
| MountOptionsBackup
|
||||
|
||||
export type MountOptionsVolume = {
|
||||
type: "volume"
|
||||
id: string
|
||||
subpath: string | null
|
||||
readonly: boolean
|
||||
}
|
||||
|
||||
export type MountOptionsAssets = {
|
||||
type: "assets"
|
||||
id: string
|
||||
subpath: string | null
|
||||
}
|
||||
|
||||
export type MountOptionsPointer = {
|
||||
type: "pointer"
|
||||
packageId: string
|
||||
volumeId: string
|
||||
subpath: string | null
|
||||
readonly: boolean
|
||||
}
|
||||
|
||||
export type MountOptionsBackup = {
|
||||
type: "backup"
|
||||
subpath: string | null
|
||||
}
|
||||
function wait(time: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, time))
|
||||
}
|
||||
246
sdk/package/lib/util/fileHelper.ts
Normal file
246
sdk/package/lib/util/fileHelper.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
import * as matches from "ts-matches"
|
||||
import * as YAML from "yaml"
|
||||
import * as TOML from "@iarna/toml"
|
||||
import merge from "lodash.merge"
|
||||
import * as T from "../../../base/lib/types"
|
||||
import * as fs from "node:fs/promises"
|
||||
import { asError } from "../../../base/lib/util"
|
||||
|
||||
const previousPath = /(.+?)\/([^/]*)$/
|
||||
|
||||
const exists = (path: string) =>
|
||||
fs.access(path).then(
|
||||
() => true,
|
||||
() => false,
|
||||
)
|
||||
|
||||
async function onCreated(path: string) {
|
||||
if (path === "/") return
|
||||
if (!path.startsWith("/")) path = `${process.cwd()}/${path}`
|
||||
if (await exists(path)) {
|
||||
return
|
||||
}
|
||||
const split = path.split("/")
|
||||
const filename = split.pop()
|
||||
const parent = split.join("/")
|
||||
await onCreated(parent)
|
||||
const ctrl = new AbortController()
|
||||
const watch = fs.watch(parent, { persistent: false, signal: ctrl.signal })
|
||||
if (
|
||||
await fs.access(path).then(
|
||||
() => true,
|
||||
() => false,
|
||||
)
|
||||
) {
|
||||
ctrl.abort("finished")
|
||||
return
|
||||
}
|
||||
for await (let event of watch) {
|
||||
if (event.filename === filename) {
|
||||
ctrl.abort("finished")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description Use this class to read/write an underlying configuration file belonging to the upstream service.
|
||||
*
|
||||
* Using the static functions, choose between officially supported file formats (json, yaml, toml), or a custom format (raw).
|
||||
* @example
|
||||
* Below are a few examples
|
||||
*
|
||||
* ```
|
||||
* import { matches, FileHelper } from '@start9labs/start-sdk'
|
||||
* const { arrayOf, boolean, literal, literals, object, oneOf, natural, string } = matches
|
||||
*
|
||||
* export const jsonFile = FileHelper.json('./inputSpec.json', object({
|
||||
* passwords: arrayOf(string)
|
||||
* type: oneOf(literals('private', 'public'))
|
||||
* }))
|
||||
*
|
||||
* export const tomlFile = FileHelper.toml('./inputSpec.toml', object({
|
||||
* url: literal('https://start9.com')
|
||||
* public: boolean
|
||||
* }))
|
||||
*
|
||||
* export const yamlFile = FileHelper.yaml('./inputSpec.yml', object({
|
||||
* name: string
|
||||
* age: natural
|
||||
* }))
|
||||
*
|
||||
* export const bitcoinConfFile = FileHelper.raw(
|
||||
* './service.conf',
|
||||
* (obj: CustomType) => customConvertObjToFormattedString(obj),
|
||||
* (str) => customParseStringToTypedObj(str),
|
||||
* )
|
||||
* ```
|
||||
*/
|
||||
export class FileHelper<A> {
|
||||
protected constructor(
|
||||
readonly path: string,
|
||||
readonly writeData: (dataIn: A) => string,
|
||||
readonly readData: (stringValue: string) => unknown,
|
||||
readonly validate: (value: unknown) => A,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Accepts structured data and overwrites the existing file on disk.
|
||||
*/
|
||||
private async writeFile(data: A): Promise<null> {
|
||||
const parent = previousPath.exec(this.path)
|
||||
if (parent) {
|
||||
await fs.mkdir(parent[1], { recursive: true })
|
||||
}
|
||||
|
||||
await fs.writeFile(this.path, this.writeData(data))
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private async readFile(): Promise<unknown> {
|
||||
if (!(await exists(this.path))) {
|
||||
return null
|
||||
}
|
||||
return this.readData(
|
||||
await fs.readFile(this.path).then((data) => data.toString("utf-8")),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the file from disk and converts it to structured data.
|
||||
*/
|
||||
private async readOnce(): Promise<A | null> {
|
||||
const data = await this.readFile()
|
||||
if (!data) return null
|
||||
return this.validate(data)
|
||||
}
|
||||
|
||||
private async readConst(effects: T.Effects): Promise<A | null> {
|
||||
const watch = this.readWatch()
|
||||
const res = await watch.next()
|
||||
watch.next().then(effects.constRetry)
|
||||
return res.value
|
||||
}
|
||||
|
||||
private async *readWatch() {
|
||||
let res
|
||||
while (true) {
|
||||
if (await exists(this.path)) {
|
||||
const ctrl = new AbortController()
|
||||
const watch = fs.watch(this.path, {
|
||||
persistent: false,
|
||||
signal: ctrl.signal,
|
||||
})
|
||||
res = await this.readOnce()
|
||||
const listen = Promise.resolve()
|
||||
.then(async () => {
|
||||
for await (const _ of watch) {
|
||||
ctrl.abort("finished")
|
||||
return null
|
||||
}
|
||||
})
|
||||
.catch((e) => console.error(asError(e)))
|
||||
yield res
|
||||
await listen
|
||||
} else {
|
||||
yield null
|
||||
await onCreated(this.path).catch((e) => console.error(asError(e)))
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
get read() {
|
||||
return {
|
||||
once: () => this.readOnce(),
|
||||
const: (effects: T.Effects) => this.readConst(effects),
|
||||
watch: () => this.readWatch(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Accepts full structured data and performs a merge with the existing file on disk if it exists.
|
||||
*/
|
||||
async write(data: A) {
|
||||
const fileData = (await this.readFile()) || {}
|
||||
const mergeData = merge({}, fileData, data)
|
||||
return await this.writeFile(this.validate(mergeData))
|
||||
}
|
||||
|
||||
/**
|
||||
* Accepts partial structured data and performs a merge with the existing file on disk.
|
||||
*/
|
||||
async merge(data: T.DeepPartial<A>) {
|
||||
const fileData =
|
||||
(await this.readFile()) ||
|
||||
(() => {
|
||||
throw new Error(`${this.path}: does not exist`)
|
||||
})()
|
||||
const mergeData = merge({}, fileData, data)
|
||||
return await this.writeFile(this.validate(mergeData))
|
||||
}
|
||||
|
||||
/**
|
||||
* We wanted to be able to have a fileHelper, and just modify the path later in time.
|
||||
* Like one behaviour of another dependency or something similar.
|
||||
*/
|
||||
withPath(path: string) {
|
||||
return new FileHelper<A>(path, this.writeData, this.readData, this.validate)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a File Helper for an arbitrary file type.
|
||||
*
|
||||
* Provide custom functions for translating data to/from the file format.
|
||||
*/
|
||||
static raw<A>(
|
||||
path: string,
|
||||
toFile: (dataIn: A) => string,
|
||||
fromFile: (rawData: string) => unknown,
|
||||
validate: (data: unknown) => A,
|
||||
) {
|
||||
return new FileHelper<A>(path, toFile, fromFile, validate)
|
||||
}
|
||||
/**
|
||||
* Create a File Helper for a .json file.
|
||||
*/
|
||||
static json<A>(path: string, shape: matches.Validator<unknown, A>) {
|
||||
return new FileHelper<A>(
|
||||
path,
|
||||
(inData) => JSON.stringify(inData, null, 2),
|
||||
(inString) => JSON.parse(inString),
|
||||
(data) => shape.unsafeCast(data),
|
||||
)
|
||||
}
|
||||
/**
|
||||
* Create a File Helper for a .toml file
|
||||
*/
|
||||
static toml<A extends Record<string, unknown>>(
|
||||
path: string,
|
||||
shape: matches.Validator<unknown, A>,
|
||||
) {
|
||||
return new FileHelper<A>(
|
||||
path,
|
||||
(inData) => TOML.stringify(inData as any),
|
||||
(inString) => TOML.parse(inString),
|
||||
(data) => shape.unsafeCast(data),
|
||||
)
|
||||
}
|
||||
/**
|
||||
* Create a File Helper for a .yaml file
|
||||
*/
|
||||
static yaml<A extends Record<string, unknown>>(
|
||||
path: string,
|
||||
shape: matches.Validator<unknown, A>,
|
||||
) {
|
||||
return new FileHelper<A>(
|
||||
path,
|
||||
(inData) => YAML.stringify(inData, null, 2),
|
||||
(inString) => YAML.parse(inString),
|
||||
(data) => shape.unsafeCast(data),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default FileHelper
|
||||
4
sdk/package/lib/util/index.ts
Normal file
4
sdk/package/lib/util/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from "../../../base/lib/util"
|
||||
export { GetSslCertificate } from "./GetSslCertificate"
|
||||
|
||||
export { hostnameInfoToAddress } from "../../../base/lib/util/Hostname"
|
||||
203
sdk/package/lib/version/VersionGraph.ts
Normal file
203
sdk/package/lib/version/VersionGraph.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { ExtendedVersion, VersionRange } from "../../../base/lib/exver"
|
||||
import * as T from "../../../base/lib/types"
|
||||
import { Graph, Vertex, once } from "../util"
|
||||
import { IMPOSSIBLE, VersionInfo } from "./VersionInfo"
|
||||
|
||||
export class VersionGraph<CurrentVersion extends string> {
|
||||
private readonly graph: () => Graph<
|
||||
ExtendedVersion | VersionRange,
|
||||
((opts: { effects: T.Effects }) => Promise<void>) | undefined
|
||||
>
|
||||
private constructor(
|
||||
readonly current: VersionInfo<CurrentVersion>,
|
||||
versions: Array<VersionInfo<any>>,
|
||||
) {
|
||||
this.graph = once(() => {
|
||||
const graph = new Graph<
|
||||
ExtendedVersion | VersionRange,
|
||||
((opts: { effects: T.Effects }) => Promise<void>) | undefined
|
||||
>()
|
||||
const flavorMap: Record<
|
||||
string,
|
||||
[
|
||||
ExtendedVersion,
|
||||
VersionInfo<any>,
|
||||
Vertex<
|
||||
ExtendedVersion | VersionRange,
|
||||
((opts: { effects: T.Effects }) => Promise<void>) | undefined
|
||||
>,
|
||||
][]
|
||||
> = {}
|
||||
for (let version of [current, ...versions]) {
|
||||
const v = ExtendedVersion.parse(version.options.version)
|
||||
const vertex = graph.addVertex(v, [], [])
|
||||
const flavor = v.flavor || ""
|
||||
if (!flavorMap[flavor]) {
|
||||
flavorMap[flavor] = []
|
||||
}
|
||||
flavorMap[flavor].push([v, version, vertex])
|
||||
}
|
||||
for (let flavor in flavorMap) {
|
||||
flavorMap[flavor].sort((a, b) => a[0].compareForSort(b[0]))
|
||||
let prev:
|
||||
| [
|
||||
ExtendedVersion,
|
||||
VersionInfo<any>,
|
||||
Vertex<
|
||||
ExtendedVersion | VersionRange,
|
||||
(opts: { effects: T.Effects }) => Promise<void>
|
||||
>,
|
||||
]
|
||||
| undefined = undefined
|
||||
for (let [v, version, vertex] of flavorMap[flavor]) {
|
||||
if (version.options.migrations.up !== IMPOSSIBLE) {
|
||||
let range
|
||||
if (prev) {
|
||||
graph.addEdge(version.options.migrations.up, prev[2], vertex)
|
||||
range = VersionRange.anchor(">=", prev[0]).and(
|
||||
VersionRange.anchor("<", v),
|
||||
)
|
||||
} else {
|
||||
range = VersionRange.anchor("<", v)
|
||||
}
|
||||
const vRange = graph.addVertex(range, [], [])
|
||||
graph.addEdge(version.options.migrations.up, vRange, vertex)
|
||||
}
|
||||
|
||||
if (version.options.migrations.down !== IMPOSSIBLE) {
|
||||
let range
|
||||
if (prev) {
|
||||
graph.addEdge(version.options.migrations.down, vertex, prev[2])
|
||||
range = VersionRange.anchor(">=", prev[0]).and(
|
||||
VersionRange.anchor("<", v),
|
||||
)
|
||||
} else {
|
||||
range = VersionRange.anchor("<", v)
|
||||
}
|
||||
const vRange = graph.addVertex(range, [], [])
|
||||
graph.addEdge(version.options.migrations.down, vertex, vRange)
|
||||
}
|
||||
|
||||
if (version.options.migrations.other) {
|
||||
for (let rangeStr in version.options.migrations.other) {
|
||||
const range = VersionRange.parse(rangeStr)
|
||||
const vRange = graph.addVertex(range, [], [])
|
||||
graph.addEdge(
|
||||
version.options.migrations.other[rangeStr],
|
||||
vRange,
|
||||
vertex,
|
||||
)
|
||||
for (let matching of graph.findVertex(
|
||||
(v) =>
|
||||
v.metadata instanceof ExtendedVersion &&
|
||||
v.metadata.satisfies(range),
|
||||
)) {
|
||||
graph.addEdge(
|
||||
version.options.migrations.other[rangeStr],
|
||||
matching,
|
||||
vertex,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return graph
|
||||
})
|
||||
}
|
||||
currentVersion = once(() =>
|
||||
ExtendedVersion.parse(this.current.options.version),
|
||||
)
|
||||
/**
|
||||
* Each exported `VersionInfo.of()` should be imported and provided as an argument to this function.
|
||||
*
|
||||
* ** The current version must be the FIRST argument. **
|
||||
*/
|
||||
static of<
|
||||
CurrentVersion extends string,
|
||||
OtherVersions extends Array<VersionInfo<any>>,
|
||||
>(
|
||||
currentVersion: VersionInfo<CurrentVersion>,
|
||||
...other: EnsureUniqueId<OtherVersions, OtherVersions, CurrentVersion>
|
||||
) {
|
||||
return new VersionGraph(currentVersion, other as Array<VersionInfo<any>>)
|
||||
}
|
||||
async migrate({
|
||||
effects,
|
||||
from,
|
||||
to,
|
||||
}: {
|
||||
effects: T.Effects
|
||||
from: ExtendedVersion
|
||||
to: ExtendedVersion
|
||||
}) {
|
||||
const graph = this.graph()
|
||||
if (from && to) {
|
||||
const path = graph.shortestPath(
|
||||
(v) =>
|
||||
(v.metadata instanceof VersionRange &&
|
||||
v.metadata.satisfiedBy(from)) ||
|
||||
(v.metadata instanceof ExtendedVersion && v.metadata.equals(from)),
|
||||
(v) =>
|
||||
(v.metadata instanceof VersionRange && v.metadata.satisfiedBy(to)) ||
|
||||
(v.metadata instanceof ExtendedVersion && v.metadata.equals(to)),
|
||||
)
|
||||
if (path) {
|
||||
for (let edge of path) {
|
||||
if (edge.metadata) {
|
||||
await edge.metadata({ effects })
|
||||
}
|
||||
await effects.setDataVersion({ version: edge.to.metadata.toString() })
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
throw new Error()
|
||||
}
|
||||
canMigrateFrom = once(() =>
|
||||
Array.from(
|
||||
this.graph().reverseBreadthFirstSearch(
|
||||
(v) =>
|
||||
(v.metadata instanceof VersionRange &&
|
||||
v.metadata.satisfiedBy(this.currentVersion())) ||
|
||||
(v.metadata instanceof ExtendedVersion &&
|
||||
v.metadata.equals(this.currentVersion())),
|
||||
),
|
||||
).reduce(
|
||||
(acc, x) =>
|
||||
acc.or(
|
||||
x.metadata instanceof VersionRange
|
||||
? x.metadata
|
||||
: VersionRange.anchor("=", x.metadata),
|
||||
),
|
||||
VersionRange.none(),
|
||||
),
|
||||
)
|
||||
canMigrateTo = once(() =>
|
||||
Array.from(
|
||||
this.graph().breadthFirstSearch(
|
||||
(v) =>
|
||||
(v.metadata instanceof VersionRange &&
|
||||
v.metadata.satisfiedBy(this.currentVersion())) ||
|
||||
(v.metadata instanceof ExtendedVersion &&
|
||||
v.metadata.equals(this.currentVersion())),
|
||||
),
|
||||
).reduce(
|
||||
(acc, x) =>
|
||||
acc.or(
|
||||
x.metadata instanceof VersionRange
|
||||
? x.metadata
|
||||
: VersionRange.anchor("=", x.metadata),
|
||||
),
|
||||
VersionRange.none(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
// prettier-ignore
|
||||
export type EnsureUniqueId<A, B = A, OtherVersions = never> =
|
||||
B extends [] ? A :
|
||||
B extends [VersionInfo<infer Version>, ...infer Rest] ? (
|
||||
Version extends OtherVersions ? "One or more versions are not unique"[] :
|
||||
EnsureUniqueId<A, Rest, Version | OtherVersions>
|
||||
) : "There exists a migration that is not a Migration"[]
|
||||
83
sdk/package/lib/version/VersionInfo.ts
Normal file
83
sdk/package/lib/version/VersionInfo.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { ValidateExVer } from "../../../base/lib/exver"
|
||||
import * as T from "../../../base/lib/types"
|
||||
|
||||
export const IMPOSSIBLE = Symbol("IMPOSSIBLE")
|
||||
|
||||
export type VersionOptions<Version extends string> = {
|
||||
/** The exver-compliant version number */
|
||||
version: Version & ValidateExVer<Version>
|
||||
/** The release notes for this version */
|
||||
releaseNotes: string
|
||||
/** Data migrations for this version */
|
||||
migrations: {
|
||||
/**
|
||||
* A migration from the previous version. Leave empty to indicate no migration is necessary.
|
||||
* Set to `IMPOSSIBLE` to indicate migrating from the previous version is not possible.
|
||||
*/
|
||||
up?: ((opts: { effects: T.Effects }) => Promise<void>) | typeof IMPOSSIBLE
|
||||
/**
|
||||
* A migration to the previous version. Leave blank to indicate no migration is necessary.
|
||||
* Set to `IMPOSSIBLE` to indicate downgrades are prohibited
|
||||
*/
|
||||
down?: ((opts: { effects: T.Effects }) => Promise<void>) | typeof IMPOSSIBLE
|
||||
/**
|
||||
* Additional migrations, such as fast-forward migrations, or migrations from other flavors.
|
||||
*/
|
||||
other?: Record<string, (opts: { effects: T.Effects }) => Promise<void>>
|
||||
}
|
||||
}
|
||||
|
||||
export class VersionInfo<Version extends string> {
|
||||
private _version: null | Version = null
|
||||
private constructor(
|
||||
readonly options: VersionOptions<Version> & { satisfies: string[] },
|
||||
) {}
|
||||
/**
|
||||
* @description Use this function to define a new version of the service. By convention, each version should receive its own file.
|
||||
* @property {string} version
|
||||
* @property {string} releaseNotes
|
||||
* @property {object} migrations
|
||||
* @returns A VersionInfo class instance that is exported, then imported into versions/index.ts.
|
||||
*/
|
||||
static of<Version extends string>(options: VersionOptions<Version>) {
|
||||
return new VersionInfo<Version>({ ...options, satisfies: [] })
|
||||
}
|
||||
/** Specify a version that this version is 100% backwards compatible to */
|
||||
satisfies<V extends string>(
|
||||
version: V & ValidateExVer<V>,
|
||||
): VersionInfo<Version> {
|
||||
return new VersionInfo({
|
||||
...this.options,
|
||||
satisfies: [...this.options.satisfies, version],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function __type_tests() {
|
||||
const version: VersionInfo<"1.0.0:0"> = VersionInfo.of({
|
||||
version: "1.0.0:0",
|
||||
releaseNotes: "",
|
||||
migrations: {},
|
||||
})
|
||||
.satisfies("#other:1.0.0:0")
|
||||
.satisfies("#other:2.0.0:0")
|
||||
// @ts-expect-error
|
||||
.satisfies("#other:2.f.0:0")
|
||||
|
||||
let a: VersionInfo<"1.0.0:0"> = version
|
||||
// @ts-expect-error
|
||||
let b: VersionInfo<"1.0.0:3"> = version
|
||||
|
||||
VersionInfo.of({
|
||||
// @ts-expect-error
|
||||
version: "test",
|
||||
releaseNotes: "",
|
||||
migrations: {},
|
||||
})
|
||||
VersionInfo.of({
|
||||
// @ts-expect-error
|
||||
version: "test" as string,
|
||||
releaseNotes: "",
|
||||
migrations: {},
|
||||
})
|
||||
}
|
||||
2
sdk/package/lib/version/index.ts
Normal file
2
sdk/package/lib/version/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./VersionGraph"
|
||||
export * from "./VersionInfo"
|
||||
4714
sdk/package/package-lock.json
generated
Normal file
4714
sdk/package/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
64
sdk/package/package.json
Normal file
64
sdk/package/package.json
Normal file
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"name": "@start9labs/start-sdk",
|
||||
"version": "0.3.6-alpha.21",
|
||||
"description": "Software development kit to facilitate packaging services for StartOS",
|
||||
"main": "./package/lib/index.js",
|
||||
"types": "./package/lib/index.d.ts",
|
||||
"sideEffects": true,
|
||||
"typesVersion": {
|
||||
">=3.1": {
|
||||
"*": [
|
||||
"package/lib/*",
|
||||
"base/lib/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc && tsc --project tsconfig-cjs.json && copyfiles package.json dist",
|
||||
"test": "jest -c ./jest.config.js --coverage",
|
||||
"buildOutput": "ts-node ./lib/test/makeOutput.ts && npx prettier --write \"**/*.ts\"",
|
||||
"check": "tsc --noEmit",
|
||||
"tsc": "tsc"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/Start9Labs/start-sdk.git"
|
||||
},
|
||||
"author": "Start9 Labs",
|
||||
"license": "MIT",
|
||||
"bugs": {
|
||||
"url": "https://github.com/Start9Labs/start-sdk/issues"
|
||||
},
|
||||
"homepage": "https://github.com/Start9Labs/start-sdk#readme",
|
||||
"dependencies": {
|
||||
"isomorphic-fetch": "^3.0.0",
|
||||
"lodash.merge": "^4.6.2",
|
||||
"mime-types": "^2.1.35",
|
||||
"ts-matches": "^6.1.0",
|
||||
"yaml": "^2.2.2",
|
||||
"@iarna/toml": "^2.2.5",
|
||||
"@noble/curves": "^1.4.0",
|
||||
"@noble/hashes": "^1.4.0"
|
||||
},
|
||||
"prettier": {
|
||||
"trailingComma": "all",
|
||||
"tabWidth": 2,
|
||||
"semi": false,
|
||||
"singleQuote": false
|
||||
},
|
||||
"devDependencies": {
|
||||
"@iarna/toml": "^2.2.5",
|
||||
"@types/jest": "^29.4.0",
|
||||
"@types/lodash.merge": "^4.6.2",
|
||||
"copyfiles": "^2.4.1",
|
||||
"jest": "^29.4.3",
|
||||
"peggy": "^3.0.2",
|
||||
"prettier": "^3.2.5",
|
||||
"ts-jest": "^29.0.5",
|
||||
"ts-node": "^10.9.1",
|
||||
"ts-pegjs": "^4.2.1",
|
||||
"tsx": "^4.7.1",
|
||||
"typescript": "^5.0.4",
|
||||
"yaml": "^2.2.2"
|
||||
}
|
||||
}
|
||||
386
sdk/package/scripts/oldSpecToBuilder.ts
Normal file
386
sdk/package/scripts/oldSpecToBuilder.ts
Normal file
@@ -0,0 +1,386 @@
|
||||
import * as fs from "fs"
|
||||
|
||||
// https://stackoverflow.com/questions/2970525/converting-any-string-into-camel-case
|
||||
export function camelCase(value: string) {
|
||||
return value
|
||||
.replace(/([\(\)\[\]])/g, "")
|
||||
.replace(/^([A-Z])|[\s-_](\w)/g, function (match, p1, p2, offset) {
|
||||
if (p2) return p2.toUpperCase()
|
||||
return p1.toLowerCase()
|
||||
})
|
||||
}
|
||||
|
||||
export async function oldSpecToBuilder(
|
||||
file: string,
|
||||
inputData: Promise<any> | any,
|
||||
options?: Parameters<typeof makeFileContentFromOld>[1],
|
||||
) {
|
||||
await fs.writeFile(
|
||||
file,
|
||||
await makeFileContentFromOld(inputData, options),
|
||||
(err) => console.error(err),
|
||||
)
|
||||
}
|
||||
|
||||
function isString(x: unknown): x is string {
|
||||
return typeof x === "string"
|
||||
}
|
||||
|
||||
export default async function makeFileContentFromOld(
|
||||
inputData: Promise<any> | any,
|
||||
{ StartSdk = "start-sdk", nested = true } = {},
|
||||
) {
|
||||
const outputLines: string[] = []
|
||||
outputLines.push(`
|
||||
import { sdk } from "${StartSdk}"
|
||||
const {InputSpec, List, Value, Variants} = sdk
|
||||
`)
|
||||
const data = await inputData
|
||||
|
||||
const namedConsts = new Set(["InputSpec", "Value", "List"])
|
||||
const inputSpecName = newConst("inputSpecSpec", convertInputSpec(data))
|
||||
const inputSpecMatcherName = newConst(
|
||||
"matchInputSpecSpec",
|
||||
`${inputSpecName}.validator`,
|
||||
)
|
||||
outputLines.push(
|
||||
`export type InputSpecSpec = typeof ${inputSpecMatcherName}._TYPE;`,
|
||||
)
|
||||
|
||||
return outputLines.join("\n")
|
||||
|
||||
function newConst(key: string, data: string, type?: string) {
|
||||
const variableName = getNextConstName(camelCase(key))
|
||||
outputLines.push(
|
||||
`export const ${variableName}${!type ? "" : `: ${type}`} = ${data};`,
|
||||
)
|
||||
return variableName
|
||||
}
|
||||
function maybeNewConst(key: string, data: string) {
|
||||
if (nested) return data
|
||||
return newConst(key, data)
|
||||
}
|
||||
function convertInputSpecInner(data: any) {
|
||||
let answer = "{"
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
const variableName = maybeNewConst(key, convertValueSpec(value))
|
||||
|
||||
answer += `${JSON.stringify(key)}: ${variableName},`
|
||||
}
|
||||
return `${answer}}`
|
||||
}
|
||||
|
||||
function convertInputSpec(data: any) {
|
||||
return `InputSpec.of(${convertInputSpecInner(data)})`
|
||||
}
|
||||
function convertValueSpec(value: any): string {
|
||||
switch (value.type) {
|
||||
case "string": {
|
||||
if (value.textarea) {
|
||||
return `${rangeToTodoComment(
|
||||
value?.range,
|
||||
)}Value.textarea(${JSON.stringify(
|
||||
{
|
||||
name: value.name || null,
|
||||
description: value.description || null,
|
||||
warning: value.warning || null,
|
||||
required: !(value.nullable || false),
|
||||
default: value.default,
|
||||
placeholder: value.placeholder || null,
|
||||
maxLength: null,
|
||||
minLength: null,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)})`
|
||||
}
|
||||
return `${rangeToTodoComment(value?.range)}Value.text(${JSON.stringify(
|
||||
{
|
||||
name: value.name || null,
|
||||
default: value.default || null,
|
||||
required: !value.nullable,
|
||||
description: value.description || null,
|
||||
warning: value.warning || null,
|
||||
masked: value.masked || false,
|
||||
placeholder: value.placeholder || null,
|
||||
inputmode: "text",
|
||||
patterns: value.pattern
|
||||
? [
|
||||
{
|
||||
regex: value.pattern,
|
||||
description: value["pattern-description"],
|
||||
},
|
||||
]
|
||||
: [],
|
||||
minLength: null,
|
||||
maxLength: null,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)})`
|
||||
}
|
||||
case "number": {
|
||||
return `${rangeToTodoComment(
|
||||
value?.range,
|
||||
)}Value.number(${JSON.stringify(
|
||||
{
|
||||
name: value.name || null,
|
||||
description: value.description || null,
|
||||
warning: value.warning || null,
|
||||
default: value.default || null,
|
||||
required: !value.nullable,
|
||||
min: null,
|
||||
max: null,
|
||||
step: null,
|
||||
integer: value.integral || false,
|
||||
units: value.units || null,
|
||||
placeholder: value.placeholder || null,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)})`
|
||||
}
|
||||
case "boolean": {
|
||||
return `Value.toggle(${JSON.stringify(
|
||||
{
|
||||
name: value.name || null,
|
||||
default: value.default || false,
|
||||
description: value.description || null,
|
||||
warning: value.warning || null,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)})`
|
||||
}
|
||||
case "enum": {
|
||||
const allValueNames = new Set([
|
||||
...(value?.["values"] || []),
|
||||
...Object.keys(value?.["value-names"] || {}),
|
||||
])
|
||||
const values = Object.fromEntries(
|
||||
Array.from(allValueNames)
|
||||
.filter(isString)
|
||||
.map((key) => [key, value?.spec?.["value-names"]?.[key] || key]),
|
||||
)
|
||||
return `Value.select(${JSON.stringify(
|
||||
{
|
||||
name: value.name || null,
|
||||
description: value.description || null,
|
||||
warning: value.warning || null,
|
||||
default: value.default,
|
||||
values,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)} as const)`
|
||||
}
|
||||
case "object": {
|
||||
const specName = maybeNewConst(
|
||||
value.name + "_spec",
|
||||
convertInputSpec(value.spec),
|
||||
)
|
||||
return `Value.object({
|
||||
name: ${JSON.stringify(value.name || null)},
|
||||
description: ${JSON.stringify(value.description || null)},
|
||||
}, ${specName})`
|
||||
}
|
||||
case "union": {
|
||||
const variants = maybeNewConst(
|
||||
value.name + "_variants",
|
||||
convertVariants(value.variants, value.tag["variant-names"] || {}),
|
||||
)
|
||||
|
||||
return `Value.union({
|
||||
name: ${JSON.stringify(value.name || null)},
|
||||
description: ${JSON.stringify(value.tag.description || null)},
|
||||
warning: ${JSON.stringify(value.tag.warning || null)},
|
||||
default: ${JSON.stringify(value.default)},
|
||||
}, ${variants})`
|
||||
}
|
||||
case "list": {
|
||||
if (value.subtype === "enum") {
|
||||
const allValueNames = new Set([
|
||||
...(value?.spec?.["values"] || []),
|
||||
...Object.keys(value?.spec?.["value-names"] || {}),
|
||||
])
|
||||
const values = Object.fromEntries(
|
||||
Array.from(allValueNames)
|
||||
.filter(isString)
|
||||
.map((key: string) => [
|
||||
key,
|
||||
value?.spec?.["value-names"]?.[key] ?? key,
|
||||
]),
|
||||
)
|
||||
return `Value.multiselect(${JSON.stringify(
|
||||
{
|
||||
name: value.name || null,
|
||||
minLength: null,
|
||||
maxLength: null,
|
||||
default: value.default ?? null,
|
||||
description: value.description || null,
|
||||
warning: value.warning || null,
|
||||
values,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)})`
|
||||
}
|
||||
const list = maybeNewConst(value.name + "_list", convertList(value))
|
||||
return `Value.list(${list})`
|
||||
}
|
||||
case "pointer": {
|
||||
return `/* TODO deal with point removed point "${value.name}" */null as any`
|
||||
}
|
||||
}
|
||||
throw Error(`Unknown type "${value.type}"`)
|
||||
}
|
||||
|
||||
function convertList(value: any) {
|
||||
switch (value.subtype) {
|
||||
case "string": {
|
||||
return `${rangeToTodoComment(value?.range)}List.text(${JSON.stringify(
|
||||
{
|
||||
name: value.name || null,
|
||||
minLength: null,
|
||||
maxLength: null,
|
||||
default: value.default || null,
|
||||
description: value.description || null,
|
||||
warning: value.warning || null,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}, ${JSON.stringify({
|
||||
masked: value?.spec?.masked || false,
|
||||
placeholder: value?.spec?.placeholder || null,
|
||||
patterns: value?.spec?.pattern
|
||||
? [
|
||||
{
|
||||
regex: value.spec.pattern,
|
||||
description: value?.spec?.["pattern-description"],
|
||||
},
|
||||
]
|
||||
: [],
|
||||
minLength: null,
|
||||
maxLength: null,
|
||||
})})`
|
||||
}
|
||||
// case "number": {
|
||||
// return `${rangeToTodoComment(value?.range)}List.number(${JSON.stringify(
|
||||
// {
|
||||
// name: value.name || null,
|
||||
// minLength: null,
|
||||
// maxLength: null,
|
||||
// default: value.default || null,
|
||||
// description: value.description || null,
|
||||
// warning: value.warning || null,
|
||||
// },
|
||||
// null,
|
||||
// 2,
|
||||
// )}, ${JSON.stringify({
|
||||
// integer: value?.spec?.integral || false,
|
||||
// min: null,
|
||||
// max: null,
|
||||
// units: value?.spec?.units || null,
|
||||
// placeholder: value?.spec?.placeholder || null,
|
||||
// })})`
|
||||
// }
|
||||
case "enum": {
|
||||
return "/* error!! list.enum */"
|
||||
}
|
||||
case "object": {
|
||||
const specName = maybeNewConst(
|
||||
value.name + "_spec",
|
||||
convertInputSpec(value.spec.spec),
|
||||
)
|
||||
return `${rangeToTodoComment(value?.range)}List.obj({
|
||||
name: ${JSON.stringify(value.name || null)},
|
||||
minLength: ${JSON.stringify(null)},
|
||||
maxLength: ${JSON.stringify(null)},
|
||||
default: ${JSON.stringify(value.default || null)},
|
||||
description: ${JSON.stringify(value.description || null)},
|
||||
}, {
|
||||
spec: ${specName},
|
||||
displayAs: ${JSON.stringify(value?.spec?.["display-as"] || null)},
|
||||
uniqueBy: ${JSON.stringify(value?.spec?.["unique-by"] || null)},
|
||||
})`
|
||||
}
|
||||
case "union": {
|
||||
const variants = maybeNewConst(
|
||||
value.name + "_variants",
|
||||
convertVariants(
|
||||
value.spec.variants,
|
||||
value.spec["variant-names"] || {},
|
||||
),
|
||||
)
|
||||
const unionValueName = maybeNewConst(
|
||||
value.name + "_union",
|
||||
`${rangeToTodoComment(value?.range)}
|
||||
Value.union({
|
||||
name: ${JSON.stringify(value?.spec?.tag?.name || null)},
|
||||
description: ${JSON.stringify(
|
||||
value?.spec?.tag?.description || null,
|
||||
)},
|
||||
warning: ${JSON.stringify(value?.spec?.tag?.warning || null)},
|
||||
default: ${JSON.stringify(value?.spec?.default || null)},
|
||||
}, ${variants})
|
||||
`,
|
||||
)
|
||||
const listInputSpec = maybeNewConst(
|
||||
value.name + "_list_inputSpec",
|
||||
`
|
||||
InputSpec.of({
|
||||
"union": ${unionValueName}
|
||||
})
|
||||
`,
|
||||
)
|
||||
return `${rangeToTodoComment(value?.range)}List.obj({
|
||||
name:${JSON.stringify(value.name || null)},
|
||||
minLength:${JSON.stringify(null)},
|
||||
maxLength:${JSON.stringify(null)},
|
||||
default: [],
|
||||
description: ${JSON.stringify(value.description || null)},
|
||||
warning: ${JSON.stringify(value.warning || null)},
|
||||
}, {
|
||||
spec: ${listInputSpec},
|
||||
displayAs: ${JSON.stringify(value?.spec?.["display-as"] || null)},
|
||||
uniqueBy: ${JSON.stringify(value?.spec?.["unique-by"] || null)},
|
||||
})`
|
||||
}
|
||||
}
|
||||
throw new Error(`Unknown subtype "${value.subtype}"`)
|
||||
}
|
||||
|
||||
function convertVariants(
|
||||
variants: Record<string, unknown>,
|
||||
variantNames: Record<string, string>,
|
||||
): string {
|
||||
let answer = "Variants.of({"
|
||||
for (const [key, value] of Object.entries(variants)) {
|
||||
const variantSpec = maybeNewConst(key, convertInputSpec(value))
|
||||
answer += `"${key}": {name: "${
|
||||
variantNames[key] || key
|
||||
}", spec: ${variantSpec}},`
|
||||
}
|
||||
return `${answer}})`
|
||||
}
|
||||
|
||||
function getNextConstName(name: string, i = 0): string {
|
||||
const newName = !i ? name : name + i
|
||||
if (namedConsts.has(newName)) {
|
||||
return getNextConstName(name, i + 1)
|
||||
}
|
||||
namedConsts.add(newName)
|
||||
return newName
|
||||
}
|
||||
}
|
||||
|
||||
function rangeToTodoComment(range: string | undefined) {
|
||||
if (!range) return ""
|
||||
return `/* TODO: Convert range for this value (${range})*/`
|
||||
}
|
||||
|
||||
// oldSpecToBuilder(
|
||||
// "./inputSpec.ts",
|
||||
// // Put inputSpec here
|
||||
// {},
|
||||
// )
|
||||
19
sdk/package/tsconfig.json
Normal file
19
sdk/package/tsconfig.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"preserveConstEnums": true,
|
||||
"sourceMap": true,
|
||||
"pretty": true,
|
||||
"declaration": true,
|
||||
"noImplicitAny": true,
|
||||
"esModuleInterop": true,
|
||||
"types": ["node", "jest"],
|
||||
"moduleResolution": "node",
|
||||
"skipLibCheck": true,
|
||||
"module": "commonjs",
|
||||
"outDir": "../dist",
|
||||
"target": "es2018"
|
||||
},
|
||||
"include": ["lib/**/*", "../base/lib/util/Hostname.ts"],
|
||||
"exclude": ["lib/**/*.spec.ts", "lib/**/*.gen.ts", "list", "node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user