@needle-tools/worker-launcher 0.0.0-stage → 1.0.0-alpha.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -3
- package/index.js +3 -0
- package/package.json +32 -6
- package/public/app.css +44 -0
- package/public/app.js +124 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.svg +10 -0
- package/public/index.html +79 -0
- package/public/needle-logo.svg +59 -0
- package/public/workspace-controls.css +159 -0
- package/public/workspace-tokens.css +113 -0
- package/scripts/prepare-ui.mjs +9 -0
- package/src/autostart.js +52 -0
- package/src/cli.js +29 -0
- package/src/credentials.js +60 -0
- package/src/docker-client.js +16 -0
- package/src/docker-readiness.js +40 -0
- package/src/fleet.js +28 -0
- package/src/manager.js +112 -0
- package/src/registry-access.js +80 -0
- package/src/registry-metadata.js +14 -0
- package/src/registry-pull.js +29 -0
- package/src/server.js +47 -0
- package/src/supervisor.js +21 -0
package/src/manager.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import {mkdir,readFile,writeFile,rename} from 'node:fs/promises';
|
|
2
|
+
import {randomUUID} from 'node:crypto';
|
|
3
|
+
import {join} from 'node:path';
|
|
4
|
+
import {executeDocker} from './docker-client.js';
|
|
5
|
+
import {validWorkerTag} from './registry-access.js';
|
|
6
|
+
|
|
7
|
+
const environments={staging:'https://cloud-staging.needle.tools/api',production:'https://cloud.needle.tools/api'};
|
|
8
|
+
const ownerLabel='tools.needle.worker-launcher';
|
|
9
|
+
const defaults={environment:'staging',workerId:'',count:1,cpus:2,memory:4};
|
|
10
|
+
|
|
11
|
+
function validate(value,allowEmpty=false){
|
|
12
|
+
if(!environments[value.environment])throw new Error('Choose staging or production.');
|
|
13
|
+
if(typeof value.workerId!=='string'||!(allowEmpty&&value.workerId==='')&&!/^worker-[A-Za-z0-9_-]{1,120}$/.test(value.workerId))throw new Error('Enter the worker ID created in Cloud admin.');
|
|
14
|
+
for(const [field,min,max] of [['count',1,32],['cpus',1,128],['memory',1,512]])if(!Number.isInteger(value[field])||value[field]<min||value[field]>max)throw new Error(`${field} must be an integer from ${min} to ${max}.`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function createManager({directory,docker=executeDocker,imageOverride=process.env.NEEDLE_WORKER_IMAGE,credentials,registry}={}){
|
|
18
|
+
await mkdir(directory,{recursive:true,mode:0o700});
|
|
19
|
+
const file=join(directory,'settings.json');
|
|
20
|
+
let settings={...defaults},managerId=randomUUID(),workerKey='',enabled=false,pinnedImage,credentialError,imageTag='';
|
|
21
|
+
let queue=Promise.resolve(),pending=0;
|
|
22
|
+
try{
|
|
23
|
+
const stored=JSON.parse(await readFile(file,'utf8'));
|
|
24
|
+
settings=Object.fromEntries(Object.keys(defaults).map(key=>[key,stored.settings?.[key]??defaults[key]]));
|
|
25
|
+
managerId=stored.managerId;enabled=stored.enabled===true;
|
|
26
|
+
if(!/^[a-f0-9-]{36}$/.test(managerId))throw new Error('Invalid launcher identity');
|
|
27
|
+
validate(settings,true);
|
|
28
|
+
if(stored.imageTag){if(!validWorkerTag(stored.imageTag))throw new Error('Invalid saved worker image version.');imageTag=stored.imageTag;}
|
|
29
|
+
if(stored.pinnedImage?.reference===image()&&/^sha256:[a-zA-Z0-9-]+$/.test(stored.pinnedImage.id))pinnedImage=stored.pinnedImage;
|
|
30
|
+
}catch(error){if(error.code!=='ENOENT')throw error;}
|
|
31
|
+
function identity(value=settings){return `${managerId}:${value.environment}:${value.workerId}`;}
|
|
32
|
+
function image(){return imageOverride||`container.needle.tools/needle-cloud/worker:${imageTag||settings.environment}`;}
|
|
33
|
+
async function pull(reference){if(registry?.status().configured)await registry.pull(reference);else await docker(['pull',reference],{timeout:180000});}
|
|
34
|
+
if(credentials&&settings.workerId){
|
|
35
|
+
try{workerKey=await credentials.get(identity());if(workerKey&&!/^ncw_[a-f0-9]{64}$/.test(workerKey))throw new Error('Stored worker key is invalid. Enter a new key from Cloud admin.');}
|
|
36
|
+
catch(error){workerKey='';credentialError=error.message;console.error('[worker-launcher] Protected key unavailable',{reason:credentialError});}
|
|
37
|
+
}
|
|
38
|
+
async function persist(){await writeFile(file+'.tmp',JSON.stringify({managerId,settings,imageTag,enabled,pinnedImage},null,2),{mode:0o600});await rename(file+'.tmp',file);}
|
|
39
|
+
await persist();
|
|
40
|
+
async function owned(){
|
|
41
|
+
const output=await docker(['ps','-a','--filter',`label=${ownerLabel}=${managerId}`,'--format','{{json .}}']);
|
|
42
|
+
return output?output.split(/\r?\n/).map(line=>{const value=JSON.parse(line);return {id:value.ID,name:value.Names,state:value.State,status:value.Status,image:value.Image};}):[];
|
|
43
|
+
}
|
|
44
|
+
async function exclusive(action){pending++;const operation=queue.then(action);queue=operation.catch(()=>{});try{return await operation;}finally{pending--;}}
|
|
45
|
+
async function reconcile(){return exclusive(async()=>{
|
|
46
|
+
if(!enabled)return;
|
|
47
|
+
const info=JSON.parse(await docker(['info','--format','{{json .}}']));
|
|
48
|
+
if(info.OSType!=='linux')throw new Error('Switch Docker to Linux containers.');
|
|
49
|
+
if(!['x86_64','amd64'].includes(info.Architecture))throw new Error('This launcher currently supports Linux amd64 workers. Apple Silicon support needs validation.');
|
|
50
|
+
const containers=await owned(),prefix=`needle-worker-${managerId.slice(0,8)}-`;
|
|
51
|
+
const slots=new Map();
|
|
52
|
+
for(const container of containers){
|
|
53
|
+
const slot=container.name?.startsWith(prefix)?Number(container.name.slice(prefix.length)):NaN;
|
|
54
|
+
if(!Number.isInteger(slot)||slot<1||slot>settings.count||slots.has(slot))throw new Error('An owned container does not match this configuration. Stop and remove the old containers before starting again.');
|
|
55
|
+
slots.set(slot,container);
|
|
56
|
+
}
|
|
57
|
+
const missing=Array.from({length:settings.count},(_,index)=>index+1).filter(slot=>!slots.has(slot));
|
|
58
|
+
if(missing.length){
|
|
59
|
+
if(!workerKey)throw new Error(credentialError||'Enter the worker key for this session or unlock the protected key store before creating containers.');
|
|
60
|
+
if(pinnedImage){try{await docker(['image','inspect','--format','{{.Id}}',pinnedImage.id]);}catch{if(pinnedImage.digest)await pull(pinnedImage.digest);else throw new Error('The pinned worker image is missing. Restore it or reset the worker configuration; automatic upgrades are disabled.');}}
|
|
61
|
+
else{
|
|
62
|
+
if(!imageOverride)await pull(image());
|
|
63
|
+
const id=await docker(['image','inspect','--format','{{.Id}}',image()]);
|
|
64
|
+
if(!/^sha256:[a-zA-Z0-9-]+$/.test(id))throw new Error('Docker did not return a valid image identity.');
|
|
65
|
+
let digest;
|
|
66
|
+
if(!imageOverride){const digests=await docker(['image','inspect','--format','{{range .RepoDigests}}{{println .}}{{end}}',id]);const repository=image().replace(/:[^/:]+$/,'');digest=digests.split(/\r?\n/).find(value=>value.startsWith(repository+'@sha256:')&&/^[^\s]+@sha256:[a-f0-9]{64}$/.test(value));}
|
|
67
|
+
pinnedImage={reference:image(),id,digest};await persist();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const environment={NEEDLE_SHARE_ENDPOINT:environments[settings.environment],NEEDLE_WORKER_ID:settings.workerId,NEEDLE_SHARE_API_KEY:workerKey,ENABLE_OPTIMIZATION:'true',ENABLE_HDRI_COMPRESSION:'true',ENABLE_AI_MATERIAL:'false',ENABLE_AI_3D_ASSET:'false',ENABLE_SPLAT_COMPRESSION:'false'};
|
|
71
|
+
// @todo 2026-11-03T12:53:12Z Add worker drain before replacing healthy containers for image upgrades; see plans/worker-orchestrator.md. Recovery only starts stopped or missing replicas.
|
|
72
|
+
for(let slot=1;slot<=settings.count&&enabled;slot++){
|
|
73
|
+
const existing=slots.get(slot);
|
|
74
|
+
if(existing){if(['running','restarting'].includes(existing.state))continue;if(existing.state==='paused')await docker(['unpause',existing.id]);else await docker(['start',existing.id]);continue;}
|
|
75
|
+
const args=['run','--detach','--init','--restart','unless-stopped','--label',`${ownerLabel}=${managerId}`,'--label',`tools.needle.worker-slot=${slot}`,'--name',prefix+slot,'--cpus',String(settings.cpus),'--memory',`${settings.memory}g`];
|
|
76
|
+
for(const key of Object.keys(environment))args.push('--env',key);
|
|
77
|
+
args.push(pinnedImage.id);await docker(args,{environment,timeout:30000});
|
|
78
|
+
}
|
|
79
|
+
});}
|
|
80
|
+
const manager={
|
|
81
|
+
shouldRun:()=>enabled,
|
|
82
|
+
async status(){
|
|
83
|
+
let containers=[],dockerStatus;
|
|
84
|
+
try{const info=JSON.parse(await docker(['info','--format','{{json .}}']));if(info.OSType!=='linux')throw new Error('Switch Docker to Linux containers.');dockerStatus={available:true,version:info.ServerVersion,architecture:info.Architecture};containers=await owned();}
|
|
85
|
+
catch(error){dockerStatus={available:false,error:error.message};}
|
|
86
|
+
return {settings:{...settings},imageTag,configured:!!workerKey,enabled,busy:pending>0,image:image(),endpoint:environments[settings.environment],docker:dockerStatus,containers,keyProtection:credentials?.kind||'Session only',credentialError};
|
|
87
|
+
},
|
|
88
|
+
async configure(value){return exclusive(async()=>{
|
|
89
|
+
validate(value);
|
|
90
|
+
if(value.imageTag!==undefined&&value.imageTag!==''&&!validWorkerTag(value.imageTag))throw new Error('Choose a valid worker image version.');
|
|
91
|
+
if(value.workerKey!==undefined&&value.workerKey!==''&&(typeof value.workerKey!=='string'||!/^ncw_[a-f0-9]{64}$/.test(value.workerKey)))throw new Error('Enter a valid enrolled worker key.');
|
|
92
|
+
if(enabled)throw new Error('Stop the worker before changing its configuration.');
|
|
93
|
+
if(settings.workerId&&(await owned()).length)throw new Error('Stop and remove this launcher?s containers before changing their configuration.');
|
|
94
|
+
const next=Object.fromEntries(Object.keys(defaults).map(key=>[key,value[key]]));
|
|
95
|
+
const changed=settings.environment!==next.environment||settings.workerId!==next.workerId,previousIdentity=identity();
|
|
96
|
+
const nextKey=value.workerKey||(changed?'':workerKey);
|
|
97
|
+
if(credentials&&value.workerKey)await credentials.set(identity(next),value.workerKey);
|
|
98
|
+
settings=next;imageTag=value.imageTag||'';workerKey=nextKey;credentialError=undefined;pinnedImage=undefined;await persist();
|
|
99
|
+
if(credentials&&changed)await credentials.remove(previousIdentity);
|
|
100
|
+
});},
|
|
101
|
+
async forget(){return exclusive(async()=>{if(enabled||(await owned()).length)throw Error('Stop and remove this worker’s containers before removing its configuration.');if(credentials)await credentials.remove(identity());workerKey='';settings={...defaults};imageTag='';pinnedImage=undefined;credentialError=undefined;await persist();});},
|
|
102
|
+
async enable(){return exclusive(async()=>{if(!workerKey)throw new Error(credentialError||'Enter the worker key for this session or unlock the protected key store before starting containers.');enabled=true;await persist();});},
|
|
103
|
+
async start(){await manager.enable();await reconcile();},
|
|
104
|
+
reconcile,
|
|
105
|
+
async stop(confirmed){
|
|
106
|
+
if(confirmed!==true)throw new Error('Confirm that stopping may interrupt active jobs.');
|
|
107
|
+
enabled=false;
|
|
108
|
+
return exclusive(async()=>{enabled=false;await persist();const containers=await owned();for(const container of containers)await docker(['rm','--force',container.id],{timeout:30000});});
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
return manager;
|
|
112
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import {readFile,writeFile,rename,mkdir} from 'node:fs/promises';
|
|
3
|
+
import {join} from 'node:path';
|
|
4
|
+
import {readWorkerMetadata} from './registry-metadata.js';
|
|
5
|
+
import {pullRegistryImage} from './registry-pull.js';
|
|
6
|
+
|
|
7
|
+
export const registryHost='container.needle.tools';
|
|
8
|
+
export const workerRepository='needle-cloud/worker';
|
|
9
|
+
/** @param {unknown} tag @returns {tag is string} */
|
|
10
|
+
export function validWorkerTag(tag){return typeof tag==='string'&&/^[\w][\w.-]{0,127}$/.test(tag);}
|
|
11
|
+
/** @typedef {{kind:string,get:(id:string)=>Promise<string>,set:(id:string,value:string)=>Promise<unknown>,remove:(id:string)=>Promise<unknown>}} Vault */
|
|
12
|
+
/** @typedef {{username:string,password:string,serveraddress:string}} RegistryAuth */
|
|
13
|
+
/** @param {{directory:string,credentials:Vault,request?:typeof fetch,pull?:(reference:string,auth:RegistryAuth)=>Promise<unknown>}} options */
|
|
14
|
+
export async function createRegistryAccess({directory,credentials,request=fetch,pull=pullRegistryImage}){
|
|
15
|
+
const file=join(directory,'registry.json'),identity=`registry:${registryHost}`;
|
|
16
|
+
let userId='',busy=false;
|
|
17
|
+
try{const value=JSON.parse(await readFile(file,'utf8'));if(typeof value.userId==='string'&&/^[A-Za-z0-9_-]{1,256}$/.test(value.userId))userId=value.userId;}
|
|
18
|
+
catch(error){if(!(error instanceof Error&&'code' in error&&error.code==='ENOENT'))throw new Error('Could not read registry settings.');}
|
|
19
|
+
/** @param {unknown} id @param {unknown} key @returns {RegistryAuth} */
|
|
20
|
+
function validate(id,key){
|
|
21
|
+
if(typeof id!=='string'||!/^[A-Za-z0-9_-]{1,256}$/.test(id))throw new Error('Enter your Logto user ID from the registry account menu.');
|
|
22
|
+
if(typeof key!=='string'||!key.length||key.length>4096||/[\s\x00-\x1f\x7f]/.test(key))throw new Error('Enter a valid Zot API key.');
|
|
23
|
+
return {username:id,password:key,serveraddress:registryHost};
|
|
24
|
+
}
|
|
25
|
+
async function auth(){
|
|
26
|
+
if(!userId)throw new Error('Connect your registry account in Launcher settings first.');
|
|
27
|
+
try{
|
|
28
|
+
const protectedValue=await credentials.get(identity);
|
|
29
|
+
if(!protectedValue.startsWith('ncr_'))throw new Error();
|
|
30
|
+
const value=JSON.parse(Buffer.from(protectedValue.slice(4),'base64url').toString('utf8'));
|
|
31
|
+
if(value.username!==userId)throw new Error();
|
|
32
|
+
return validate(value.username,value.password);
|
|
33
|
+
}catch{throw new Error('Registry credentials are unavailable. Unlock the OS credential store or reconnect in Settings.');}
|
|
34
|
+
}
|
|
35
|
+
/** @param {RegistryAuth} credential */
|
|
36
|
+
async function tags(credential){
|
|
37
|
+
const endpoint=`https://${registryHost}/v2/${workerRepository}/tags/list`;
|
|
38
|
+
let next=endpoint+'?n=100';const found=new Set();
|
|
39
|
+
for(let page=0;next&&page<20;page++){
|
|
40
|
+
let response;
|
|
41
|
+
try{response=await request(next,{headers:{Authorization:'Basic '+Buffer.from(credential.username+':'+credential.password).toString('base64'),Accept:'application/json'},redirect:'error',signal:AbortSignal.timeout(15000)});}
|
|
42
|
+
catch{throw new Error('Could not reach the registry securely. Check your connection and try again.');}
|
|
43
|
+
if(!response.ok){await response.body?.cancel();throw new Error(response.status===401?'Registry login failed. Check your user ID and Zot API key.':response.status===403||response.status===404?'Read access to the worker repository was denied or it has no published versions.':'Registry is unavailable. Please try again.');}
|
|
44
|
+
let raw='';
|
|
45
|
+
if(!response.body)throw new Error('Registry returned an empty response.');
|
|
46
|
+
const reader=response.body.getReader();let size=0;
|
|
47
|
+
try{const decoder=new TextDecoder();for(;;){const {done,value}=await reader.read();if(done)break;size+=value.byteLength;if(size>1024*1024)throw new Error('Registry response is too large.');raw+=decoder.decode(value,{stream:true});}raw+=decoder.decode();}
|
|
48
|
+
finally{await reader.cancel().catch(()=>{});}
|
|
49
|
+
let value;try{value=JSON.parse(raw);}catch{throw new Error('Registry returned invalid version data.');}
|
|
50
|
+
if(value?.name!==workerRepository||value.tags!==null&&(!Array.isArray(value.tags)||!value.tags.every(validWorkerTag)))throw new Error('Registry returned invalid version data.');
|
|
51
|
+
for(const tag of value.tags??[])found.add(tag);
|
|
52
|
+
const link=response.headers.get('link');
|
|
53
|
+
if(!link){next='';continue;}
|
|
54
|
+
const match=link.match(/^<([^>]+)>;\s*rel="?next"?$/);
|
|
55
|
+
if(!match)throw new Error('Registry returned unsupported pagination.');
|
|
56
|
+
const target=new URL(match[1],endpoint);
|
|
57
|
+
if(target.origin!==`https://${registryHost}`||target.pathname!==new URL(endpoint).pathname||target.username||target.password||target.hash)throw new Error('Registry returned unsafe pagination.');
|
|
58
|
+
next=target.href;
|
|
59
|
+
}
|
|
60
|
+
if(next)throw new Error('Too many worker versions to list.');
|
|
61
|
+
return {repository:workerRepository,tags:[...found].sort()};
|
|
62
|
+
}
|
|
63
|
+
/** @param {()=>Promise<unknown>} action */
|
|
64
|
+
async function exclusive(action){if(busy)throw new Error('A registry operation is already running.');busy=true;try{return await action();}finally{busy=false;}}
|
|
65
|
+
/** @param {string} id */
|
|
66
|
+
async function persist(id){await mkdir(directory,{recursive:true,mode:0o700});await writeFile(file+'.tmp',JSON.stringify({userId:id}),{mode:0o600});await rename(file+'.tmp',file);userId=id;}
|
|
67
|
+
return {
|
|
68
|
+
status:()=>({host:registryHost,repository:workerRepository,userId,configured:!!userId,protection:credentials.kind,busy}),
|
|
69
|
+
/** @param {{userId?:unknown,apiKey?:unknown}} value */
|
|
70
|
+
connect:async value=>exclusive(async()=>{const credential=validate(value.userId,value.apiKey);await tags(credential);try{await credentials.set(identity,'ncr_'+Buffer.from(JSON.stringify(credential)).toString('base64url'));}catch{throw new Error('Could not save registry credentials in the OS vault. Unlock it and try again.');}await persist(credential.username);}),
|
|
71
|
+
forget:async()=>exclusive(async()=>{await credentials.remove(identity);await persist('');}),
|
|
72
|
+
list:async()=>tags(await auth()),
|
|
73
|
+
/** @param {unknown} tag */
|
|
74
|
+
details:async tag=>{if(!validWorkerTag(tag))throw Error('Choose a valid worker version.');return readWorkerMetadata(tag,await auth(),request);},
|
|
75
|
+
/** @param {unknown} tag */
|
|
76
|
+
install:async tag=>exclusive(async()=>{if(!validWorkerTag(tag))throw new Error('Choose a valid worker version.');const credential=await auth();if(!(await tags(credential)).tags.includes(tag))throw new Error('This worker version is unavailable for your account.');await pull(`${registryHost}/${workerRepository}:${tag}`,credential);}),
|
|
77
|
+
/** @param {string} reference */
|
|
78
|
+
pull:async reference=>{if(!reference.startsWith(`${registryHost}/${workerRepository}:`)&&!new RegExp(`^${registryHost.replaceAll('.','\\.')}/${workerRepository}@sha256:[a-f0-9]{64}$`).test(reference))throw new Error('Only the configured worker repository can be pulled.');await pull(reference,await auth());}
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Metadata is read only from the fixed worker repository; redirects never receive credentials.
|
|
2
|
+
export async function readWorkerMetadata(tag,credential,request=fetch){
|
|
3
|
+
const base='https://container.needle.tools/v2/needle-cloud/worker';
|
|
4
|
+
const headers={Authorization:'Basic '+Buffer.from(credential.username+':'+credential.password).toString('base64'),Accept:'application/vnd.oci.image.index.v1+json, application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.docker.distribution.manifest.v2+json'};
|
|
5
|
+
async function json(path){const response=await request(base+path,{headers,redirect:'error',signal:AbortSignal.timeout(15000)});if(!response.ok){await response.body?.cancel();throw Error('Could not read image metadata (HTTP '+response.status+').');}if(!response.body)throw Error('Image metadata is empty.');const reader=response.body.getReader();let size=0,raw='';try{const decoder=new TextDecoder();for(;;){const {done,value}=await reader.read();if(done)break;size+=value.byteLength;if(size>4*1024*1024)throw Error('Image metadata is too large.');raw+=decoder.decode(value,{stream:true});}raw+=decoder.decode();return JSON.parse(raw);}finally{await reader.cancel().catch(()=>{});}}
|
|
6
|
+
const digest=value=>typeof value==='string'&&/^sha256:[a-f0-9]{64}$/.test(value);
|
|
7
|
+
let manifest=await json('/manifests/'+encodeURIComponent(tag));
|
|
8
|
+
const annotations={...manifest.annotations};
|
|
9
|
+
if(Array.isArray(manifest.manifests)){const child=manifest.manifests.find(item=>item.platform?.os==='linux'&&item.platform?.architecture==='amd64');if(!child||!digest(child.digest))throw Error('No Linux amd64 runtime metadata is available.');manifest=await json('/manifests/'+child.digest);}
|
|
10
|
+
if(!digest(manifest.config?.digest))throw Error('Image config metadata is unavailable.');
|
|
11
|
+
const config=await json('/blobs/'+manifest.config.digest),labels={...config.config?.Labels,...annotations,...manifest.annotations};
|
|
12
|
+
const text=value=>typeof value==='string'?value.replace(/[\x00-\x1f\x7f]/g,'').slice(0,256):'';
|
|
13
|
+
return {title:text(labels['org.opencontainers.image.title']),version:text(labels['org.opencontainers.image.version']),revision:text(labels['org.opencontainers.image.revision']),created:text(labels['org.opencontainers.image.created']||config.created),platform:[text(config.os),text(config.architecture)].filter(Boolean).join('/')};
|
|
14
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import {request} from 'node:http';
|
|
3
|
+
import {executeDocker} from './docker-client.js';
|
|
4
|
+
|
|
5
|
+
/** Pull through the local Engine API; no registry secret enters argv, files or container environments.
|
|
6
|
+
* @param {string} reference
|
|
7
|
+
* @param {import('./registry-access.js').RegistryAuth} auth
|
|
8
|
+
* @param {{execute?:(args:string[])=>Promise<string>,environment?:NodeJS.ProcessEnv}} [options]
|
|
9
|
+
* @returns {Promise<void>}
|
|
10
|
+
*/
|
|
11
|
+
export async function pullRegistryImage(reference,auth,{execute=executeDocker,environment=process.env}={}){
|
|
12
|
+
const endpoint=environment.DOCKER_CONTEXT?JSON.parse(await execute(['context','inspect','--format','{{json .Endpoints.docker}}']))?.Host:environment.DOCKER_HOST||JSON.parse(await execute(['context','inspect','--format','{{json .Endpoints.docker}}']))?.Host;
|
|
13
|
+
if(typeof endpoint!=='string'||!endpoint.startsWith('unix://')&&!endpoint.startsWith('npipe:////./pipe/'))throw new Error('Registry credentials require a local Docker socket. Remote Docker contexts are not supported.');
|
|
14
|
+
const socketPath=endpoint.startsWith('unix://')?endpoint.slice(7):'\\\\.\\pipe\\'+endpoint.slice('npipe:////./pipe/'.length);
|
|
15
|
+
const version=(await execute(['version','--format','{{.Server.APIVersion}}'])).trim();
|
|
16
|
+
if(!/^1\.\d+$/.test(version))throw new Error('Could not determine the Docker Engine API version.');
|
|
17
|
+
await new Promise((resolve,reject)=>{
|
|
18
|
+
let complete=false,buffer='';
|
|
19
|
+
const fail=()=>{if(!complete){complete=true;req.destroy();reject(new Error('Worker image download failed. Check Docker, registry access and available disk space.'));}};
|
|
20
|
+
const req=request({socketPath,path:`/v${version}/images/create?${new URLSearchParams({fromImage:reference,platform:'linux/amd64'})}`,method:'POST',headers:{'X-Registry-Auth':Buffer.from(JSON.stringify(auth)).toString('base64url'),'Content-Length':0}},res=>{
|
|
21
|
+
if(res.statusCode!==200){res.resume();fail();return;}
|
|
22
|
+
res.setEncoding('utf8');
|
|
23
|
+
res.on('data',chunk=>{buffer+=chunk;if(buffer.length>1024*1024){fail();return;}let end;while((end=buffer.indexOf('\n'))>=0){const line=buffer.slice(0,end);buffer=buffer.slice(end+1);if(line.trim())try{if(JSON.parse(line).error)fail();}catch{fail();}}});
|
|
24
|
+
res.on('error',fail);res.on('aborted',fail);
|
|
25
|
+
res.on('end',()=>{if(buffer.trim())try{if(JSON.parse(buffer).error)fail();}catch{fail();}if(!complete){complete=true;resolve(undefined);}});
|
|
26
|
+
});
|
|
27
|
+
const timer=setTimeout(fail,180000);req.on('close',()=>clearTimeout(timer));req.on('error',fail);req.end();
|
|
28
|
+
});
|
|
29
|
+
}
|
package/src/server.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import {createServer} from 'node:http';
|
|
2
|
+
import {randomBytes,timingSafeEqual} from 'node:crypto';
|
|
3
|
+
import {readFile} from 'node:fs/promises';
|
|
4
|
+
|
|
5
|
+
const assets=new Map([['/',['index.html','text/html']],['/setup',['index.html','text/html']],['/settings',['index.html','text/html']],['/app.js',['app.js','text/javascript']],['/app.css',['app.css','text/css']],['/needle-logo.svg',['needle-logo.svg','image/svg+xml']],['/favicon.ico',['favicon.ico','image/x-icon']],['/favicon.svg',['favicon.svg','image/svg+xml']],...['workspace-tokens.css','workspace-controls.css'].map(name=>['/'+name,[name,'text/css']])]);
|
|
6
|
+
export function createLauncherServer(manager,{autostart,supervisor,registry}={}){
|
|
7
|
+
const session=randomBytes(32).toString('hex');
|
|
8
|
+
return createServer(async(req,res)=>{
|
|
9
|
+
res.setHeader('Cache-Control','no-store');res.setHeader('X-Content-Type-Options','nosniff');res.setHeader('Referrer-Policy','no-referrer');
|
|
10
|
+
res.setHeader('Content-Security-Policy',"default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'");
|
|
11
|
+
const port=req.socket.localPort;
|
|
12
|
+
if(![`127.0.0.1:${port}`,`localhost:${port}`].includes(req.headers.host)){res.writeHead(403).end('Invalid localhost host');return;}
|
|
13
|
+
const origin=`http://${req.headers.host}`;
|
|
14
|
+
const send=(status,value)=>{res.writeHead(status,{'Content-Type':'application/json'}).end(JSON.stringify(value));};
|
|
15
|
+
try{
|
|
16
|
+
const route=new URL(req.url,origin).pathname;
|
|
17
|
+
if(req.method==='GET'&&assets.has(route)){
|
|
18
|
+
const [file,type]=assets.get(route);const body=await readFile(new URL('../public/'+file,import.meta.url));
|
|
19
|
+
if(['/', '/setup', '/settings'].includes(route))res.setHeader('Set-Cookie',`needle_launcher=${session}; HttpOnly; SameSite=Strict; Path=/`);
|
|
20
|
+
res.writeHead(200,{'Content-Type':type+'; charset=utf-8'}).end(body);return;
|
|
21
|
+
}
|
|
22
|
+
if(req.method==='GET'&&route==='/api/session'&&req.headers['x-launcher-session']==='refresh'&&req.headers['sec-fetch-site']==='same-origin'){res.setHeader('Set-Cookie',`needle_launcher=${session}; HttpOnly; SameSite=Strict; Path=/`);send(200,{ok:true});return;}
|
|
23
|
+
const cookie=req.headers.cookie?.split(';').map(value=>value.trim()).find(value=>value.startsWith('needle_launcher='))?.slice('needle_launcher='.length)||'';
|
|
24
|
+
if(cookie.length!==session.length||!timingSafeEqual(Buffer.from(cookie),Buffer.from(session))){send(401,{error:'Open the launcher page to start a local session.'});return;}
|
|
25
|
+
if(req.method==='GET'&&route==='/api/status'){const status=await manager.status();if(supervisor)status.supervision=supervisor.status();send(200,status);return;}
|
|
26
|
+
if(req.method==='GET'&&route==='/api/autostart'&&autostart){send(200,await autostart.status());return;}
|
|
27
|
+
if(req.method==='GET'&&route==='/api/registry'&®istry){send(200,registry.status());return;}
|
|
28
|
+
if(req.method!=='POST'||req.headers.origin!==origin){send(403,{error:'Only same-origin launcher requests are allowed.'});return;}
|
|
29
|
+
if(!req.headers['content-type']?.startsWith('application/json')){send(415,{error:'Use JSON requests.'});return;}
|
|
30
|
+
let data='';for await(const chunk of req){data+=chunk;if(Buffer.byteLength(data)>16384){send(413,{error:'Request too large.'});return;}}
|
|
31
|
+
let body;try{body=JSON.parse(data);}catch{send(400,{error:'Invalid JSON.'});return;}
|
|
32
|
+
if(!body||typeof body!=='object'||Array.isArray(body)){send(400,{error:'Expected settings object.'});return;}
|
|
33
|
+
if(route==='/api/settings')await manager.configure(body);
|
|
34
|
+
else if(route==='/api/registry/connect'&®istry)await registry.connect(body);
|
|
35
|
+
else if(route==='/api/registry/forget'&®istry)await registry.forget();
|
|
36
|
+
else if(route==='/api/registry/list'&®istry){send(200,await registry.list());return;}
|
|
37
|
+
else if(route==='/api/registry/details'&®istry){send(200,await registry.details(body.tag));return;}
|
|
38
|
+
else if(route==='/api/registry/install'&®istry)await registry.install(body.tag);
|
|
39
|
+
else if(route==='/api/autostart'&&autostart){send(200,await autostart.set(body.enabled));return;}
|
|
40
|
+
else if(route==='/api/remove')await manager.remove(body.profileId,body.confirmed);
|
|
41
|
+
else if(route==='/api/start')await (supervisor||manager).start(body.profileId);
|
|
42
|
+
else if(route==='/api/stop')await (supervisor||manager).stop(body.confirmed,body.profileId);
|
|
43
|
+
else{send(404,{error:'Unknown launcher action.'});return;}
|
|
44
|
+
send(200,{ok:true});
|
|
45
|
+
}catch(error){console.error('[worker-launcher] Request failed',{path:req.url?.split('?')[0],reason:error.code||error.name});send(400,{error:error.message});}
|
|
46
|
+
});
|
|
47
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export function createSupervisor({manager,readiness,now=Date.now,interval=10000}){
|
|
2
|
+
let state={phase:'paused',error:undefined,retryAt:0},failures=0,active=false,timer,closed=false,generation=0;
|
|
3
|
+
function update(next){const changed=state.phase!==next.phase||state.error!==next.error;state=next;if(changed)console.log('[worker-launcher] Supervisor state',{phase:state.phase,...(state.error?{reason:state.error}:{})});}
|
|
4
|
+
const api={
|
|
5
|
+
status:()=>({...state,docker:readiness.status?.()}),
|
|
6
|
+
async tick(){
|
|
7
|
+
if(active||closed)return;
|
|
8
|
+
if(!manager.shouldRun()){update({phase:'paused',retryAt:0});return;}
|
|
9
|
+
if(now()<state.retryAt)return;
|
|
10
|
+
active=true;const run=generation;
|
|
11
|
+
try{update({phase:'checking',retryAt:0});await readiness.ensure();if(run!==generation||!manager.shouldRun())return;update({phase:'recovering',retryAt:0});await manager.reconcile();if(run===generation&&manager.shouldRun()){failures=0;update({phase:'running',retryAt:0});}}
|
|
12
|
+
catch(error){if(run===generation){failures++;update({phase:'error',error:error.message,retryAt:now()+Math.min(300000,10000*2**Math.min(failures-1,5))});}}
|
|
13
|
+
finally{active=false;}
|
|
14
|
+
},
|
|
15
|
+
async start(){await manager.enable();generation++;state.retryAt=0;void api.tick();},
|
|
16
|
+
async stop(confirmed){if(confirmed!==true)throw new Error('Confirm that stopping may interrupt active jobs.');generation++;await manager.stop(true);failures=0;update({phase:'paused',retryAt:0});},
|
|
17
|
+
startLoop(){closed=false;void api.tick();timer=setInterval(()=>void api.tick(),interval);timer.unref();},
|
|
18
|
+
close(){closed=true;generation++;clearInterval(timer);},
|
|
19
|
+
};
|
|
20
|
+
return api;
|
|
21
|
+
}
|