Add StartOS 0.4 package scaffold (manifest, main, interfaces, 2 actions)
- package/Makefile + s9pk.mk + package.json + tsconfig.json - startos/manifest: dockerBuild source pointing at ../image/Dockerfile - startos/main: reads /data/config.yaml reactively, passes env vars to container - startos/interfaces: binds port 9999 as HTTP UI - startos/actions: showPublicKey (read /data/ssh/id_ed25519.pub), configureSparks - TS + JS bundle compile clean (tsc --noEmit, ncc build)
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
import { sdk } from '../sdk'
|
||||
import { sparkConfigYaml } from '../fileModels/sparkConfig.yaml'
|
||||
|
||||
const { InputSpec, Value } = sdk
|
||||
|
||||
const inputSpec = InputSpec.of({
|
||||
spark1_host: Value.text({
|
||||
name: 'Spark 1 hostname or IP',
|
||||
description: 'Head node. Example: <spark-1-ip>',
|
||||
required: true,
|
||||
default: null,
|
||||
placeholder: '<spark-1-ip>',
|
||||
masked: false,
|
||||
}),
|
||||
spark1_user: Value.text({
|
||||
name: 'Spark 1 SSH user',
|
||||
description: 'Usually "<spark-user>".',
|
||||
required: true,
|
||||
default: '<spark-user>',
|
||||
placeholder: '<spark-user>',
|
||||
masked: false,
|
||||
}),
|
||||
spark2_host: Value.text({
|
||||
name: 'Spark 2 hostname or IP',
|
||||
description: 'Worker node. Example: <spark-2-ip>',
|
||||
required: true,
|
||||
default: null,
|
||||
placeholder: '<spark-2-ip>',
|
||||
masked: false,
|
||||
}),
|
||||
spark2_user: Value.text({
|
||||
name: 'Spark 2 SSH user',
|
||||
description: 'Usually "<spark-user>".',
|
||||
required: true,
|
||||
default: '<spark-user>',
|
||||
placeholder: '<spark-user>',
|
||||
masked: false,
|
||||
}),
|
||||
})
|
||||
|
||||
export const configureSparks = sdk.Action.withInput(
|
||||
'configure-sparks',
|
||||
async () => ({
|
||||
name: 'Configure Sparks',
|
||||
description: 'Set the hostnames and SSH users for your two Spark nodes.',
|
||||
warning: null,
|
||||
visibility: 'enabled',
|
||||
allowedStatuses: 'any',
|
||||
group: null,
|
||||
}),
|
||||
async () => inputSpec,
|
||||
async ({ effects }) => {
|
||||
const cfg = await sparkConfigYaml.read().once()
|
||||
return cfg ?? null
|
||||
},
|
||||
async ({ effects, input }) => {
|
||||
await sparkConfigYaml.merge(effects, input)
|
||||
return null
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,7 @@
|
||||
import { sdk } from '../sdk'
|
||||
import { configureSparks } from './configureSparks'
|
||||
import { showPublicKey } from './showPublicKey'
|
||||
|
||||
export const actions = sdk.Actions.of()
|
||||
.addAction(showPublicKey)
|
||||
.addAction(configureSparks)
|
||||
@@ -0,0 +1,54 @@
|
||||
import { sdk } from '../sdk'
|
||||
import { promises as fs } from 'fs'
|
||||
import * as path from 'path'
|
||||
|
||||
export const showPublicKey = sdk.Action.withoutInput(
|
||||
'show-public-key',
|
||||
async () => ({
|
||||
name: 'Show Public Key',
|
||||
description:
|
||||
'Display the SSH public key. Paste it into ~/.ssh/authorized_keys on each Spark to grant access.',
|
||||
warning: null,
|
||||
visibility: 'enabled',
|
||||
allowedStatuses: 'any',
|
||||
group: null,
|
||||
}),
|
||||
async () => {
|
||||
// The container generates the key under /data/ssh/id_ed25519.pub on first boot.
|
||||
// The volume "main" is mounted at the host path that StartOS exposes via `sdk.volumes.main`.
|
||||
// For an Action running in the host, we read the file directly through the volume path.
|
||||
const pubKeyPath = path.join(
|
||||
sdk.volumes.main.path,
|
||||
'ssh',
|
||||
'id_ed25519.pub',
|
||||
)
|
||||
let key: string
|
||||
try {
|
||||
key = (await fs.readFile(pubKeyPath, 'utf8')).trim()
|
||||
} catch (e) {
|
||||
return {
|
||||
version: '1' as const,
|
||||
title: 'Public Key Not Found',
|
||||
message:
|
||||
'The container has not yet generated its SSH keypair. Start the service, wait a few seconds, and try again.',
|
||||
result: null,
|
||||
}
|
||||
}
|
||||
return {
|
||||
version: '1' as const,
|
||||
title: 'SSH Public Key',
|
||||
message:
|
||||
'Append this single line to ~/.ssh/authorized_keys on EACH Spark (<spark-user> user):\n\n' +
|
||||
key,
|
||||
result: {
|
||||
type: 'single' as const,
|
||||
name: 'Public Key',
|
||||
description: 'Copy this line to each Spark.',
|
||||
value: key,
|
||||
masked: false,
|
||||
copyable: true,
|
||||
qr: false,
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
import { sdk } from './sdk'
|
||||
|
||||
export const { createBackup, restoreInit } = sdk.setupBackups(
|
||||
async ({ effects }) => sdk.Backups.ofVolumes('main'),
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
import { sdk } from './sdk'
|
||||
|
||||
export const setDependencies = sdk.setupDependencies(
|
||||
async ({ effects }) => ({}),
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
import { FileHelper } from '@start9labs/start-sdk'
|
||||
import { z } from 'zod'
|
||||
import { sdk } from '../sdk'
|
||||
|
||||
export const sparkConfigSchema = z.object({
|
||||
spark1_host: z.string().catch(''),
|
||||
spark1_user: z.string().catch('<spark-user>'),
|
||||
spark2_host: z.string().catch(''),
|
||||
spark2_user: z.string().catch('<spark-user>'),
|
||||
})
|
||||
|
||||
export type SparkConfig = z.infer<typeof sparkConfigSchema>
|
||||
|
||||
export const sparkConfigYaml = FileHelper.yaml(
|
||||
{ base: sdk.volumes.main, subpath: 'config.yaml' },
|
||||
sparkConfigSchema,
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
export const DEFAULT_LANG = 'en_US'
|
||||
|
||||
const dict = {
|
||||
// main.ts
|
||||
'Starting Spark Control…': 0,
|
||||
'Web Interface': 1,
|
||||
'The web interface is ready': 2,
|
||||
'The web interface is not ready': 3,
|
||||
|
||||
// interfaces.ts
|
||||
'Web UI': 4,
|
||||
'The Spark Control web interface': 5,
|
||||
|
||||
// actions
|
||||
'Show Public Key': 6,
|
||||
'Configure Sparks': 7,
|
||||
} as const
|
||||
|
||||
/**
|
||||
* Plumbing. DO NOT EDIT.
|
||||
*/
|
||||
export type I18nKey = keyof typeof dict
|
||||
export type LangDict = Record<(typeof dict)[I18nKey], string>
|
||||
export default dict
|
||||
@@ -0,0 +1,3 @@
|
||||
import { LangDict } from './default'
|
||||
|
||||
export default {} satisfies Record<string, LangDict>
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Plumbing. DO NOT EDIT this file.
|
||||
*/
|
||||
import { setupI18n } from '@start9labs/start-sdk'
|
||||
import defaultDict, { DEFAULT_LANG } from './dictionaries/default'
|
||||
import translations from './dictionaries/translations'
|
||||
|
||||
export const i18n = setupI18n(defaultDict, translations, DEFAULT_LANG)
|
||||
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Plumbing. DO NOT EDIT.
|
||||
*/
|
||||
export { createBackup } from './backups'
|
||||
export { main } from './main'
|
||||
export { init, uninit } from './init'
|
||||
export { actions } from './actions'
|
||||
import { buildManifest } from '@start9labs/start-sdk'
|
||||
import { manifest as sdkManifest } from './manifest'
|
||||
import { versionGraph } from './versions'
|
||||
export const manifest = buildManifest(versionGraph, sdkManifest)
|
||||
@@ -0,0 +1,16 @@
|
||||
import { sdk } from '../sdk'
|
||||
import { setDependencies } from '../dependencies'
|
||||
import { setInterfaces } from '../interfaces'
|
||||
import { versionGraph } from '../versions'
|
||||
import { actions } from '../actions'
|
||||
import { restoreInit } from '../backups'
|
||||
|
||||
export const init = sdk.setupInit(
|
||||
restoreInit,
|
||||
versionGraph,
|
||||
setInterfaces,
|
||||
setDependencies,
|
||||
actions,
|
||||
)
|
||||
|
||||
export const uninit = sdk.setupUninit(versionGraph)
|
||||
@@ -0,0 +1,25 @@
|
||||
import { i18n } from './i18n'
|
||||
import { sdk } from './sdk'
|
||||
import { uiPort } from './utils'
|
||||
|
||||
export const setInterfaces = sdk.setupInterfaces(async ({ effects }) => {
|
||||
const uiMulti = sdk.MultiHost.of(effects, 'ui-multi')
|
||||
const uiMultiOrigin = await uiMulti.bindPort(uiPort, {
|
||||
protocol: 'http',
|
||||
})
|
||||
const ui = sdk.createInterface(effects, {
|
||||
name: i18n('Web UI'),
|
||||
id: 'ui',
|
||||
description: i18n('The Spark Control web interface'),
|
||||
type: 'ui',
|
||||
masked: false,
|
||||
schemeOverride: null,
|
||||
username: null,
|
||||
path: '',
|
||||
query: {},
|
||||
})
|
||||
|
||||
const uiReceipt = await uiMultiOrigin.export([ui])
|
||||
|
||||
return [uiReceipt]
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
import { i18n } from './i18n'
|
||||
import { sdk } from './sdk'
|
||||
import { uiPort } from './utils'
|
||||
import { sparkConfigYaml } from './fileModels/sparkConfig.yaml'
|
||||
|
||||
export const main = sdk.setupMain(async ({ effects }) => {
|
||||
console.info(i18n('Starting Spark Control…'))
|
||||
|
||||
// Reactively read SSH targets from the user-configured yaml file.
|
||||
// Changing this file via the "Configure Sparks" action restarts the daemon.
|
||||
const cfg = (await sparkConfigYaml.read().const(effects)) ?? {
|
||||
spark1_host: '',
|
||||
spark1_user: '<spark-user>',
|
||||
spark2_host: '',
|
||||
spark2_user: '<spark-user>',
|
||||
}
|
||||
|
||||
return sdk.Daemons.of(effects).addDaemon('primary', {
|
||||
subcontainer: await sdk.SubContainer.of(
|
||||
effects,
|
||||
{ imageId: 'spark-control' },
|
||||
sdk.Mounts.of().mountVolume({
|
||||
volumeId: 'main',
|
||||
subpath: null,
|
||||
mountpoint: '/data',
|
||||
readonly: false,
|
||||
}),
|
||||
'spark-control-sub',
|
||||
),
|
||||
exec: {
|
||||
command: ['/app/entrypoint.sh'],
|
||||
env: {
|
||||
SPARK1_HOST: cfg.spark1_host,
|
||||
SPARK1_USER: cfg.spark1_user,
|
||||
SPARK2_HOST: cfg.spark2_host,
|
||||
SPARK2_USER: cfg.spark2_user,
|
||||
BIND_PORT: String(uiPort),
|
||||
},
|
||||
},
|
||||
ready: {
|
||||
display: i18n('Web Interface'),
|
||||
fn: () =>
|
||||
sdk.healthCheck.checkPortListening(effects, uiPort, {
|
||||
successMessage: i18n('The web interface is ready'),
|
||||
errorMessage: i18n('The web interface is not ready'),
|
||||
}),
|
||||
},
|
||||
requires: [],
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
export const short = {
|
||||
en_US: 'Control panel for a DGX Spark vLLM cluster',
|
||||
}
|
||||
|
||||
export const long = {
|
||||
en_US:
|
||||
'Browser-based control panel for a dual-DGX-Spark vLLM cluster on the LAN. See which model is loaded, swap models with one click, and watch streaming logs until the new model is ready. SSHes into the Spark to run launch-cluster.sh.',
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { setupManifest } from '@start9labs/start-sdk'
|
||||
import { long, short } from './i18n'
|
||||
|
||||
export const manifest = setupManifest({
|
||||
id: 'spark-control',
|
||||
title: 'Spark Control',
|
||||
license: 'MIT',
|
||||
packageRepo: 'https://github.com/grant/spark-control',
|
||||
upstreamRepo: 'https://github.com/grant/spark-control',
|
||||
marketingUrl: 'https://github.com/grant/spark-control',
|
||||
donationUrl: 'https://github.com/grant/spark-control',
|
||||
docsUrls: [],
|
||||
description: { short, long },
|
||||
volumes: ['main'],
|
||||
images: {
|
||||
'spark-control': {
|
||||
source: {
|
||||
dockerBuild: {
|
||||
dockerfile: '../image/Dockerfile',
|
||||
workdir: '..',
|
||||
},
|
||||
},
|
||||
arch: ['x86_64', 'aarch64'],
|
||||
},
|
||||
},
|
||||
alerts: {
|
||||
install: null,
|
||||
update: null,
|
||||
uninstall: null,
|
||||
restore: null,
|
||||
start: null,
|
||||
stop: null,
|
||||
},
|
||||
dependencies: {},
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
import { StartSdk } from '@start9labs/start-sdk'
|
||||
import { manifest } from './manifest'
|
||||
|
||||
/**
|
||||
* Plumbing. DO NOT EDIT.
|
||||
*/
|
||||
export const sdk = StartSdk.of().withManifest(manifest).build(true)
|
||||
@@ -0,0 +1,2 @@
|
||||
// Shared constants for the spark-control StartOS package.
|
||||
export const uiPort = 9999
|
||||
@@ -0,0 +1,7 @@
|
||||
import { VersionGraph } from '@start9labs/start-sdk'
|
||||
import { v0_1_0 } from './v0_1_0'
|
||||
|
||||
export const versionGraph = VersionGraph.of({
|
||||
current: v0_1_0,
|
||||
other: [],
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
import { VersionInfo, IMPOSSIBLE } from '@start9labs/start-sdk'
|
||||
|
||||
export const v0_1_0 = VersionInfo.of({
|
||||
version: '0.1.0:0',
|
||||
releaseNotes: {
|
||||
en_US: 'Initial release: swap UI, status, health for Parakeet/Magpie.',
|
||||
},
|
||||
migrations: {
|
||||
up: async ({ effects }) => {},
|
||||
down: IMPOSSIBLE,
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user