@deeep-network/riptide 0.2.3 → 0.2.5
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/dist/cli.js +15 -35
- package/dist/index.js +14 -34
- package/package.json +5 -1
package/dist/cli.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
"use strict";var
|
|
2
|
+
"use strict";var N=Object.create;var x=Object.defineProperty;var _=Object.getOwnPropertyDescriptor;var F=Object.getOwnPropertyNames;var A=Object.getPrototypeOf,L=Object.prototype.hasOwnProperty;var n=(o,e)=>x(o,"name",{value:e,configurable:!0});var Y=(o,e,t,r)=>{if(e&&typeof e=="object"||typeof e=="function")for(let i of F(e))!L.call(o,i)&&i!==t&&x(o,i,{get:()=>e[i],enumerable:!(r=_(e,i))||r.enumerable});return o};var g=(o,e,t)=>(t=o!=null?N(A(o)):{},Y(e||!o||!o.__esModule?x(t,"default",{value:o,enumerable:!0}):t,o));var D=g(require("pino"));function y(o){return o instanceof Error&&"exitCode"in o&&typeof o.exitCode=="number"}n(y,"isRiptideError");var k=g(require("pino"));function P(o){let{level:e="info",format:t="pretty",serviceName:r}=o,i={level:e,timestamp:k.default.stdTimeFunctions.isoTime,formatters:{log:n(s=>({service:r,...s}),"log"),bindings:n(s=>{let{pid:a,hostname:c,...d}=s;return d},"bindings")}};return t==="pretty"&&!process.env.NODE_ENV?.includes("prod")?(0,k.default)({...i,transport:{target:"pino-pretty",options:{colorize:!0,translateTime:"SYS:standard",ignore:"pid,hostname"}}}):(0,k.default)(i)}n(P,"createLogger");var O=require("child_process"),f=require("fs"),V=g(require("http")),W=g(require("https")),b=g(require("path")),R=require("util");var q=(0,R.promisify)(O.exec);function H(){return{sleep:n(o=>new Promise(e=>setTimeout(e,o)),"sleep"),retry:n(async(o,e={})=>{let{maxAttempts:t=3,delay:r=1e3,backoffMultiplier:i=2,maxDelay:s=3e4}=e,a,c=r;for(let d=1;d<=t;d++)try{return await o()}catch(p){if(a=p instanceof Error?p:new Error(String(p)),d===t)throw a;await new Promise(m=>setTimeout(m,Math.min(c,s))),c*=i}throw a},"retry"),execCommand:n(async(o,e={})=>{let{timeout:t=3e4,cwd:r=process.cwd(),env:i=process.env}=e;try{let{stdout:s,stderr:a}=await q(o,{timeout:t,cwd:r,env:{...process.env,...i}});return{stdout:s.trim(),stderr:a.trim(),exitCode:0}}catch(s){if(s.killed&&s.signal==="SIGTERM"){let c=`Command timed out after ${t/1e3}s. Consider increasing timeout if command needs more time to complete: utils.execCommand('${o}', { timeout: ${t*2} })`,d=s.stderr?.trim()||"",p=d?`${d}
|
|
3
3
|
|
|
4
4
|
${c}`:c,m=[];s.stdout?.trim()&&m.push(`STDOUT: ${s.stdout.trim()}`),p&&m.push(`STDERR: ${p}`);let M=[`Command execution timed out: ${o}`,...m].join(`
|
|
5
5
|
|
|
6
|
-
`),E=new Error(M);throw E.name="CommandTimeoutError",E}return{stdout:s.stdout?.trim()||"",stderr:s.stderr?.trim()||s.message,exitCode:s.code||1}}},"execCommand"),writeFile:n(async(o,e,t={})=>{let{mode:r="0644",encoding:i="utf8"}=t;await f.promises.mkdir(b.dirname(o),{recursive:!0}),await f.promises.writeFile(o,e,{encoding:i}),await f.promises.chmod(o,r)},"writeFile"),readFile:n(async o=>await f.promises.readFile(o,"utf8"),"readFile"),fileExists:n(async o=>{try{return await f.promises.access(o),!0}catch{return!1}},"fileExists"),downloadFile:n(async function o(e,t){return new Promise((r,i)=>{let s=b.dirname(t);f.promises.mkdir(s,{recursive:!0}).then(()=>{let a=(0,f.createWriteStream)(t);(e.startsWith("https:")?V:U).get(e,p=>{if(p.statusCode===200)p.pipe(a),a.on("finish",()=>{a.close(),r(t)});else if(p.statusCode===301||p.statusCode===302){let m=p.headers.location;m?r(o(m,t)):i(new Error(`Redirect without location header: ${p.statusCode}`))}else i(new Error(`Failed to download: ${p.statusCode} ${p.statusMessage}`))}).on("error",p=>{f.promises.unlink(t).catch(()=>{}),i(p)}),a.on("error",p=>{f.promises.unlink(t).catch(()=>{}),i(p)})}).catch(i)})},"downloadFile")}}n(I,"createUtilityContext");var H=require("http");var w=class{constructor(e,t,r,i,s){this.getStatus=r;this.executeHealthCheck=i;this.getMetrics=s;this.port=e,this.logger=t.child({component:"web-server"})}static{n(this,"WebServer")}server=null;port;logger;async start(){if(this.server){this.logger.warn("Web server already running");return}return this.server=(0,H.createServer)(async(e,t)=>{if(e.method!=="GET"){t.writeHead(405,{"Content-Type":"application/json"}),t.end(JSON.stringify({error:"Method not allowed"}));return}if(e.url==="/health")try{let r=await this.executeHealthCheck(),i=this.getStatus(),s={healthy:r,status:i.status,uptime:i.uptime,message:i.message};r?t.writeHead(200,{"Content-Type":"application/json"}):t.writeHead(503,{"Content-Type":"application/json"}),t.end(JSON.stringify(s))}catch(r){this.logger.error({error:r},"Health check endpoint error"),t.writeHead(500,{"Content-Type":"application/json"}),t.end(JSON.stringify({healthy:!1,error:r instanceof Error?r.message:"Internal server error"}))}else if(e.url==="/metrics")try{let r=await this.getMetrics();t.writeHead(200,{"Content-Type":"text/plain; version=0.0.4"}),t.end(r)}catch(r){this.logger.error({error:r},"Metrics endpoint error"),t.writeHead(500,{"Content-Type":"application/json"}),t.end(JSON.stringify({error:r instanceof Error?r.message:"Internal server error"}))}else t.writeHead(404,{"Content-Type":"application/json"}),t.end(JSON.stringify({error:"Not found"}))}),new Promise((e,t)=>{this.server.once("error",r=>{this.logger.error({error:r,port:this.port},"Failed to start web server"),t(r)}),this.server.listen(this.port,()=>{e()})})}async stop(){if(this.server)return new Promise(e=>{this.server.close(()=>{this.logger.info("Web server stopped"),this.server=null,e()})})}};var S=class{static{n(this,"RiptideEntrypoint")}logger;hooks;config;isShuttingDown=!1;status={status:"starting"};startTime;heartbeatInterval;webServer;constructor(e,t,r={}){this.hooks=e,this.config=t,this.startTime=Date.now(),this.logger=P({serviceName:t.service.name,level:r.logLevel||t.logging?.level||"info",format:process.env.NODE_ENV==="production"?"json":t.logging?.format||"pretty"}),this.setupGlobalErrorHandlers(),this.setupSignalHandlers()}async start(){try{let e=this.config.service.version||"unknown",t=process.env.NODE_ENV||"production";this.logger.info({version:e,environment:t},`Starting ${this.config.service.name}`),await this.processSecrets(),await this.executeHook("start"),await this.startWebServer(this.config.health?.port||3e3),this.startHeartbeat(),this.status={status:"healthy",uptime:Date.now()-this.startTime,message:"Service started successfully"},this.logger.info({service:this.config.service.name},`${this.config.service.name} service is ready`),await this.waitForShutdown()}catch(e){this.logger.error({error:e instanceof Error?e.message:String(e),stack:e instanceof Error?e.stack:void 0},`Failed to start ${this.config.service.name} service`),this.status={status:"unhealthy",message:e instanceof Error?e.message:String(e)},v(e)?(this.logger.error(`Exiting with code ${e.exitCode} (${e.name})`),process.exit(e.exitCode)):process.exit(1)}}async processSecrets(){if(!this.hooks.installSecrets){this.logger.info("No installSecrets hook, continuing...");return}this.logger.info("Installing secrets...");try{await this.executeHook("installSecrets")}catch(e){throw this.logger.error({error:e instanceof Error?e.message:String(e),stack:e instanceof Error?e.stack:void 0,hookName:"installSecrets"},"Failed to install secrets"),e}}async executeHook(e){let t=this.hooks[e];if(typeof t!="function")throw new Error(`Required hook '${e}' not found`);let r=this.createHookContext(),i=Date.now();try{let s=await t(r),a=Date.now()-i;return this.logger.debug(`Hook ${e} completed in ${a}ms`),s}catch(s){let a=Date.now()-i;throw this.logger.error({hookName:e,duration:a,error:s instanceof Error?s.message:String(s),stack:s instanceof Error?s.stack:void 0},"Hook threw an exception, will not continue"),s}}async executeHookSafely(e){if(typeof this.hooks[e]!="function")return this.logger.debug(`Optional hook '${e}' not found, skipping`),null;try{return await this.executeHook(e)}catch(r){return this.logger.warn({hookName:e,error:r instanceof Error?r.message:String(r)},"Optional hook execution failed but continuing"),null}}createHookContext(){return{config:this.config,logger:this.logger,env:process.env,utils:I()}}setupGlobalErrorHandlers(){process.on("uncaughtException",e=>{this.logger.error({error:e.message,stack:e.stack},"Uncaught exception"),v(e)?(this.logger.info(`Exiting with code ${e.exitCode} (${e.name}) from unhandled exception`),process.exit(e.exitCode)):(this.logger.error("Exiting with code 1 due to unhandled exception"),process.exit(1))}),process.on("unhandledRejection",(e,t)=>{this.logger.error({reason:e,promise:t},"Unhandled promise rejection"),v(e)?(this.logger.info(`Exiting with code ${e.exitCode} (${e.name}) from unhandled rejection`),process.exit(e.exitCode)):(this.logger.error("Exiting with code 1 due to unhandled promise rejection"),process.exit(1))})}setupSignalHandlers(){let e=n(async t=>{this.isShuttingDown&&(this.logger.warn("Force shutdown signal received"),process.exit(1)),this.isShuttingDown=!0,this.status={status:"stopping",message:`Received ${t} signal`},this.logger.info(`${t} signal received, starting graceful shutdown...`);try{this.heartbeatInterval&&clearInterval(this.heartbeatInterval),this.webServer&&await this.webServer.stop(),await this.executeHookSafely("stop"),this.status={status:"stopped",message:"Graceful shutdown completed"},this.logger.info("Graceful shutdown completed"),process.exit(0)}catch(r){this.logger.error({error:r instanceof Error?r.message:String(r)},"Error during graceful shutdown"),process.exit(1)}},"gracefulShutdown");process.on("SIGTERM",()=>e("SIGTERM")),process.on("SIGINT",()=>e("SIGINT"))}startHeartbeat(){if(!this.hooks.heartbeat){this.logger.info("No heartbeat hook defined, skipping heartbeat");return}if(!this.config.heartbeat?.enabled){this.logger.info("Heartbeat disabled in config, skipping...");return}let e=this.config.heartbeat?.interval||6e4;this.logger.info({interval:e},"Starting heartbeat");let t=!1;this.heartbeatInterval=setInterval(async()=>{if(t){this.logger.info("Heartbeat still executing, skipping this interval");return}t=!0;try{let r=await this.executeHookSafely("heartbeat");if(r===null){this.logger.info("Heartbeat hook returned null, skipping heartbeat");return}await this.pingSonar(r)}catch(r){this.logger.warn({error:r instanceof Error?r.message:String(r)},"Heartbeat execution failed")}finally{t=!1}},e)}async pingSonar(e){let t=process.env.SONAR_API_URL,r=process.env.SONAR_API_KEY,i=process.env.NOMAD_JOB_NAME;if(!t||!r||!i){this.logger.info({hasUrl:!!t,hasKey:!!r,hasJobId:!!i},"Sonar API configuration incomplete, skipping heartbeat send");return}let s=Math.floor(Date.now()/1e3),a={entity_id:i,client_timestamp:s,metadata:e};try{this.logger.info({payload:a,sonarUrl:t,sonarApiKey:r,nomadJobId:i},"Sending heartbeat to Sonar API");let c=await fetch(`${t}/api/v1/heartbeat`,{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${r}`},body:JSON.stringify(a)});if(!c.ok){let d=await c.text();this.logger.warn({status:c.status,error:d,entity_id:i},"Sonar API heartbeat failed")}}catch(c){this.logger.error({error:c instanceof Error?c.message:String(c),errorType:c?.constructor?.name,errorCause:c instanceof Error&&"cause"in c?c.cause:void 0,stack:c instanceof Error?c.stack:void 0,url:`${t}/api/v1/heartbeat`,entity_id:i},"Failed to send heartbeat to Sonar API")}}async startWebServer(e){this.webServer=new w(e,this.logger,()=>({...this.status,uptime:Date.now()-this.startTime}),async()=>await this.executeHookSafely("health")===!0,async()=>await this.getMetrics());try{await this.webServer.start()}catch(t){this.logger.error({error:t,port:e},"Failed to start web server")}}async waitForShutdown(){return new Promise(e=>{let t=n(()=>{this.isShuttingDown?e():setTimeout(t,100)},"checkShutdown");t()})}async getMetrics(){if(!this.hooks.metrics)return{uptime:Date.now()-this.startTime,status:this.status.status};try{return await this.executeHookSafely("metrics")}catch{return{uptime:Date.now()-this.startTime,status:this.status.status}}}};var h=g(require("fs/promises")),l=g(require("path"));var C=class{constructor(e){this.logger=e}static{n(this,"ServiceScaffolder")}async scaffold(e){let{serviceName:t,targetPath:r=".",template:i="basic",description:s}=e,a=l.join(r,t);this.logger.info(`Creating new Coral Reef service: ${t}`),this.logger.info(`Template: ${i}`),this.logger.info(`Location: ${a}`),await this.createDirectoryStructure(a),await this.createPackageJson(a,t,s),await this.createRiptideConfig(a,t,s),await this.createTsConfig(a),await this.createTsupConfig(a),await this.createHooks(a,i),await this.createDockerfile(a,t),this.logger.info("\u2705 Service scaffolding complete!"),this.showNextSteps(t,a)}async createDirectoryStructure(e){await h.mkdir(l.join(e,"src"),{recursive:!0})}async createPackageJson(e,t,r){let i=await this.checkIfInWorkspace(e),s=i?"workspace:*":await this.getRiptideVersion(),a=i?`cd ../.. && docker build --platform \${DOCKER_PLATFORM:-linux/amd64} --progress=plain -t reef-${t} -f reef/${t}/Dockerfile .`:`docker build --platform \${DOCKER_PLATFORM:-linux/amd64} --progress=plain -t reef-${t} .`,c={name:`reef-${t}`,version:"1.0.0",description:r||`${t} service for Coral Reef`,main:"dist/hooks.js",scripts:{build:"tsc --noEmit && tsup","build:docker":a,clean:"rm -rf dist",start:"npx @deeep-network/riptide start --hooks dist/hooks.js",validate:"pnpm run build && npx @deeep-network/riptide validate --hooks dist/hooks.js","type-check":"tsc --noEmit"},dependencies:{"@deeep-network/riptide":s},devDependencies:{typescript:"^5.8.3",tsup:"^8.5.0","@types/node":"^20.0.0"},engines:{node:">=22.0.0"}};await h.writeFile(l.join(e,"package.json"),JSON.stringify(c,null,2))}async createRiptideConfig(e,t,r){let i={service:{name:t,version:"1.0.0",description:r||`${t} service`},logging:{level:"info"}};await h.writeFile(l.join(e,"riptide.config.json"),JSON.stringify(i,null,2))}async createTsConfig(e){let r=await this.checkIfInWorkspace(e)?{extends:"../../tsconfig.json",compilerOptions:{outDir:"./dist",rootDir:"./src",declaration:!0,declarationMap:!0,sourceMap:!0},include:["src/**/*"],exclude:["dist","node_modules"]}:{compilerOptions:{target:"ES2022",module:"commonjs",lib:["ES2022"],outDir:"./dist",rootDir:"./src",strict:!0,esModuleInterop:!0,skipLibCheck:!0,forceConsistentCasingInFileNames:!0,declaration:!0,declarationMap:!0,sourceMap:!0,moduleResolution:"node"},include:["src/**/*"],exclude:["dist","node_modules"]};await h.writeFile(l.join(e,"tsconfig.json"),JSON.stringify(r,null,2))}async createTsupConfig(e){await h.writeFile(l.join(e,"tsup.config.ts"),`import { defineConfig } from 'tsup'
|
|
6
|
+
`),E=new Error(M);throw E.name="CommandTimeoutError",E}return{stdout:s.stdout?.trim()||"",stderr:s.stderr?.trim()||s.message,exitCode:s.code||1}}},"execCommand"),writeFile:n(async(o,e,t={})=>{let{mode:r="0644",encoding:i="utf8"}=t;await f.promises.mkdir(b.dirname(o),{recursive:!0}),await f.promises.writeFile(o,e,{encoding:i}),await f.promises.chmod(o,r)},"writeFile"),readFile:n(async o=>await f.promises.readFile(o,"utf8"),"readFile"),fileExists:n(async o=>{try{return await f.promises.access(o),!0}catch{return!1}},"fileExists"),downloadFile:n(async function o(e,t){return new Promise((r,i)=>{let s=b.dirname(t);f.promises.mkdir(s,{recursive:!0}).then(()=>{let a=(0,f.createWriteStream)(t);(e.startsWith("https:")?W:V).get(e,p=>{if(p.statusCode===200)p.pipe(a),a.on("finish",()=>{a.close(),r(t)});else if(p.statusCode===301||p.statusCode===302){let m=p.headers.location;m?r(o(m,t)):i(new Error(`Redirect without location header: ${p.statusCode}`))}else i(new Error(`Failed to download: ${p.statusCode} ${p.statusMessage}`))}).on("error",p=>{f.promises.unlink(t).catch(()=>{}),i(p)}),a.on("error",p=>{f.promises.unlink(t).catch(()=>{}),i(p)})}).catch(i)})},"downloadFile")}}n(H,"createUtilityContext");var I=require("http");var w=class{constructor(e,t,r,i,s){this.getStatus=r;this.executeHealthCheck=i;this.getMetrics=s;this.port=e,this.logger=t.child({component:"web-server"})}static{n(this,"WebServer")}server=null;port;logger;async start(){if(this.server){this.logger.warn("Web server already running");return}return this.server=(0,I.createServer)(async(e,t)=>{if(e.method!=="GET"){t.writeHead(405,{"Content-Type":"application/json"}),t.end(JSON.stringify({error:"Method not allowed"}));return}if(e.url==="/health")try{let r=await this.executeHealthCheck(),i=this.getStatus(),s={healthy:r,status:i.status,uptime:i.uptime,message:i.message};r?t.writeHead(200,{"Content-Type":"application/json"}):t.writeHead(503,{"Content-Type":"application/json"}),t.end(JSON.stringify(s))}catch(r){this.logger.error({error:r},"Health check endpoint error"),t.writeHead(500,{"Content-Type":"application/json"}),t.end(JSON.stringify({healthy:!1,error:r instanceof Error?r.message:"Internal server error"}))}else if(e.url==="/metrics")try{let r=await this.getMetrics();t.writeHead(200,{"Content-Type":"text/plain; version=0.0.4"}),t.end(r)}catch(r){this.logger.error({error:r},"Metrics endpoint error"),t.writeHead(500,{"Content-Type":"application/json"}),t.end(JSON.stringify({error:r instanceof Error?r.message:"Internal server error"}))}else t.writeHead(404,{"Content-Type":"application/json"}),t.end(JSON.stringify({error:"Not found"}))}),new Promise((e,t)=>{this.server.once("error",r=>{this.logger.error({error:r,port:this.port},"Failed to start web server"),t(r)}),this.server.listen(this.port,()=>{e()})})}async stop(){if(this.server)return new Promise(e=>{this.server.close(()=>{this.logger.info("Web server stopped"),this.server=null,e()})})}};var S=class{static{n(this,"RiptideEntrypoint")}logger;hooks;config;isShuttingDown=!1;status={status:"starting"};startTime;heartbeatInterval;webServer;constructor(e,t,r={}){this.hooks=e,this.config=t,this.startTime=Date.now(),this.logger=P({serviceName:t.service.name,level:r.logLevel||t.logging?.level||"info",format:process.env.NODE_ENV==="production"?"json":t.logging?.format||"pretty"}),this.setupGlobalErrorHandlers(),this.setupSignalHandlers()}async start(){try{let e=this.config.service.version||"unknown",t=process.env.NODE_ENV||"production";this.logger.info({version:e,environment:t},`Starting ${this.config.service.name}`),await this.processSecrets(),await this.executeHook("start"),await this.startWebServer(this.config.health?.port||3e3),this.startHeartbeat(),this.status={status:"healthy",uptime:Date.now()-this.startTime,message:"Service started successfully"},this.logger.info({service:this.config.service.name},`${this.config.service.name} service is ready`),await this.waitForShutdown()}catch(e){this.logger.error({error:e instanceof Error?e.message:String(e),stack:e instanceof Error?e.stack:void 0},`Failed to start ${this.config.service.name} service`),this.status={status:"unhealthy",message:e instanceof Error?e.message:String(e)},y(e)?(this.logger.error(`Exiting with code ${e.exitCode} (${e.name})`),process.exit(e.exitCode)):process.exit(1)}}async processSecrets(){if(!this.hooks.installSecrets){this.logger.info("No installSecrets hook, continuing...");return}this.logger.info("Processing secrets...");try{await this.executeHook("installSecrets")}catch(e){throw this.logger.error({error:e instanceof Error?e.message:String(e),stack:e instanceof Error?e.stack:void 0,hookName:"installSecrets"},"Failed to install secrets"),e}}async executeHook(e){let t=this.hooks[e];if(typeof t!="function")throw new Error(`Required hook '${e}' not found`);let r=this.createHookContext(),i=Date.now();try{let s=await t(r),a=Date.now()-i;return this.logger.debug(`Hook ${e} completed in ${a}ms`),s}catch(s){let a=Date.now()-i;throw this.logger.error({hookName:e,duration:a,error:s instanceof Error?s.message:String(s),stack:s instanceof Error?s.stack:void 0},"Hook threw an exception, will not continue"),s}}async executeHookSafely(e){if(typeof this.hooks[e]!="function")return this.logger.debug(`Optional hook '${e}' not found, skipping`),null;try{return await this.executeHook(e)}catch(r){return this.logger.warn({hookName:e,error:r instanceof Error?r.message:String(r)},"Optional hook execution failed but continuing"),null}}createHookContext(){return{config:this.config,logger:this.logger,env:process.env,utils:H()}}setupGlobalErrorHandlers(){process.on("uncaughtException",e=>{this.logger.error({error:e.message,stack:e.stack},"Uncaught exception"),y(e)?(this.logger.info(`Exiting with code ${e.exitCode} (${e.name}) from unhandled exception`),process.exit(e.exitCode)):(this.logger.error("Exiting with code 1 due to unhandled exception"),process.exit(1))}),process.on("unhandledRejection",(e,t)=>{this.logger.error({reason:e,promise:t},"Unhandled promise rejection"),y(e)?(this.logger.info(`Exiting with code ${e.exitCode} (${e.name}) from unhandled rejection`),process.exit(e.exitCode)):(this.logger.error("Exiting with code 1 due to unhandled promise rejection"),process.exit(1))})}setupSignalHandlers(){let e=n(async t=>{this.isShuttingDown&&(this.logger.warn("Force shutdown signal received"),process.exit(1)),this.isShuttingDown=!0,this.status={status:"stopping",message:`Received ${t} signal`},this.logger.info(`${t} signal received, starting graceful shutdown...`);try{this.heartbeatInterval&&clearInterval(this.heartbeatInterval),this.webServer&&await this.webServer.stop(),await this.executeHookSafely("stop"),this.status={status:"stopped",message:"Graceful shutdown completed"},this.logger.info("Graceful shutdown completed"),process.exit(0)}catch(r){this.logger.error({error:r instanceof Error?r.message:String(r)},"Error during graceful shutdown"),process.exit(1)}},"gracefulShutdown");process.on("SIGTERM",()=>e("SIGTERM")),process.on("SIGINT",()=>e("SIGINT"))}startHeartbeat(){if(!this.hooks.heartbeat){this.logger.info("No heartbeat hook defined, skipping heartbeat");return}if(!this.config.heartbeat?.enabled){this.logger.info("Heartbeat disabled in config, skipping...");return}let e=this.config.heartbeat?.interval||6e4;this.logger.info({interval:e},"Starting heartbeat");let t=!1;this.heartbeatInterval=setInterval(async()=>{if(t){this.logger.info("Heartbeat still executing, skipping this interval");return}t=!0;try{let r=await this.executeHookSafely("heartbeat");if(r===null){this.logger.info("Heartbeat hook returned null, skipping heartbeat");return}await this.pingSonar(r)}catch(r){this.logger.warn({error:r instanceof Error?r.message:String(r)},"Heartbeat execution failed")}finally{t=!1}},e)}async pingSonar(e){let t=process.env.SONAR_API_URL,r=process.env.SONAR_API_KEY,i=process.env.NOMAD_JOB_NAME;if(!t||!r||!i){this.logger.info({hasUrl:!!t,hasKey:!!r,hasJobId:!!i},"Sonar API configuration incomplete, skipping heartbeat send");return}let s=Math.floor(Date.now()/1e3),a={entity_id:i,client_timestamp:s,metadata:e};try{this.logger.info({payload:a,sonarUrl:t,sonarApiKey:r,nomadJobId:i},"Sending heartbeat to Sonar API");let c=await fetch(`${t}/api/v1/heartbeat`,{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${r}`},body:JSON.stringify(a)});if(!c.ok){let d=await c.text();this.logger.warn({status:c.status,error:d,entity_id:i},"Sonar API heartbeat failed")}}catch(c){this.logger.error({error:c instanceof Error?c.message:String(c),errorType:c?.constructor?.name,errorCause:c instanceof Error&&"cause"in c?c.cause:void 0,stack:c instanceof Error?c.stack:void 0,url:`${t}/api/v1/heartbeat`,entity_id:i},"Failed to send heartbeat to Sonar API")}}async startWebServer(e){this.webServer=new w(e,this.logger,()=>({...this.status,uptime:Date.now()-this.startTime}),async()=>await this.executeHookSafely("health")===!0,async()=>await this.getMetrics());try{await this.webServer.start()}catch(t){this.logger.error({error:t,port:e},"Failed to start web server")}}async waitForShutdown(){return new Promise(e=>{let t=n(()=>{this.isShuttingDown?e():setTimeout(t,100)},"checkShutdown");t()})}async getMetrics(){if(!this.hooks.metrics)return{uptime:Date.now()-this.startTime,status:this.status.status};try{return await this.executeHookSafely("metrics")}catch{return{uptime:Date.now()-this.startTime,status:this.status.status}}}};var h=g(require("fs/promises")),l=g(require("path"));var C=class{constructor(e){this.logger=e}static{n(this,"ServiceScaffolder")}async scaffold(e){let{serviceName:t,targetPath:r=".",template:i="basic",description:s}=e,a=l.join(r,t);this.logger.info(`Creating new Coral Reef service: ${t}`),this.logger.info(`Template: ${i}`),this.logger.info(`Location: ${a}`),await this.createDirectoryStructure(a),await this.createPackageJson(a,t,s),await this.createRiptideConfig(a,t,s),await this.createTsConfig(a),await this.createTsupConfig(a),await this.createHooks(a,i),await this.createDockerfile(a,t),this.logger.info("\u2705 Service scaffolding complete!"),this.showNextSteps(t,a)}async createDirectoryStructure(e){await h.mkdir(l.join(e,"src"),{recursive:!0})}async createPackageJson(e,t,r){let i=await this.checkIfInWorkspace(e),s=i?"workspace:*":await this.getRiptideVersion(),a=i?`cd ../.. && docker build --platform \${DOCKER_PLATFORM:-linux/amd64} --progress=plain -t reef-${t} -f services/${t}/Dockerfile .`:`docker build --platform \${DOCKER_PLATFORM:-linux/amd64} --progress=plain -t reef-${t} .`,c={name:`reef-${t}`,version:"1.0.0",description:r||`${t} service for Coral Reef`,main:"dist/hooks.js",scripts:{build:"tsc --noEmit && tsup","build:docker":a,clean:"rm -rf dist",start:"npx @deeep-network/riptide start --hooks dist/hooks.js",validate:"pnpm run build && npx @deeep-network/riptide validate --hooks dist/hooks.js","type-check":"tsc --noEmit"},dependencies:{"@deeep-network/riptide":s},devDependencies:{typescript:"^5.8.3",tsup:"^8.5.0","@types/node":"^20.0.0"},engines:{node:">=22.0.0"}};await h.writeFile(l.join(e,"package.json"),JSON.stringify(c,null,2))}async createRiptideConfig(e,t,r){let i={service:{name:t,version:"1.0.0",description:r||`${t} service`},logging:{level:"info"}};await h.writeFile(l.join(e,"riptide.config.json"),JSON.stringify(i,null,2))}async createTsConfig(e){let r=await this.checkIfInWorkspace(e)?{extends:"../../tsconfig.json",compilerOptions:{outDir:"./dist",rootDir:"./src",declaration:!0,declarationMap:!0,sourceMap:!0},include:["src/**/*"],exclude:["dist","node_modules"]}:{compilerOptions:{target:"ES2022",module:"commonjs",lib:["ES2022"],outDir:"./dist",rootDir:"./src",strict:!0,esModuleInterop:!0,skipLibCheck:!0,forceConsistentCasingInFileNames:!0,declaration:!0,declarationMap:!0,sourceMap:!0,moduleResolution:"node"},include:["src/**/*"],exclude:["dist","node_modules"]};await h.writeFile(l.join(e,"tsconfig.json"),JSON.stringify(r,null,2))}async createTsupConfig(e){await h.writeFile(l.join(e,"tsup.config.ts"),`import { defineConfig } from 'tsup'
|
|
7
7
|
|
|
8
8
|
export default defineConfig({
|
|
9
9
|
entry: ['src/hooks.ts'],
|
|
@@ -246,7 +246,7 @@ module.exports = {
|
|
|
246
246
|
logger.info('Service stopping')
|
|
247
247
|
}
|
|
248
248
|
}
|
|
249
|
-
`}async createDockerfile(e,t){let i=await this.checkIfInWorkspace(e)?`# ${t}
|
|
249
|
+
`}async createDockerfile(e,t){let i=await this.checkIfInWorkspace(e)?`# DeEEP Network Service for ${t}
|
|
250
250
|
|
|
251
251
|
# ----------------------------------------
|
|
252
252
|
# Base
|
|
@@ -278,7 +278,7 @@ RUN pnpm turbo run build --filter=reef-${t}...
|
|
|
278
278
|
RUN pnpm deploy --prod --filter reef-${t} deploy
|
|
279
279
|
|
|
280
280
|
# ----------------------------------------
|
|
281
|
-
# Third-party Base
|
|
281
|
+
# Third-party Base
|
|
282
282
|
# ----------------------------------------
|
|
283
283
|
FROM node:22-alpine AS third-party-base
|
|
284
284
|
|
|
@@ -290,40 +290,30 @@ RUN apk add --no-cache \\
|
|
|
290
290
|
ca-certificates
|
|
291
291
|
|
|
292
292
|
# ----------------------------------------
|
|
293
|
-
# Final Layer:
|
|
293
|
+
# Final Layer: Riptide Runtime
|
|
294
294
|
# ----------------------------------------
|
|
295
295
|
FROM third-party-base
|
|
296
|
-
|
|
297
|
-
# \u{1F512} SECURITY LAYER: Inject controlled Riptide runtime
|
|
298
|
-
# This layer comes from your signed, verified base image
|
|
299
296
|
COPY --from=quay.io/nerdnode/riptide:latest /usr/local/bin/riptide /usr/local/bin/riptide
|
|
300
297
|
COPY --from=quay.io/nerdnode/riptide:latest /riptide-runtime/ /riptide-runtime/
|
|
301
298
|
|
|
302
|
-
# Verify Riptide binary permissions and ownership
|
|
303
299
|
RUN chmod +x /usr/local/bin/riptide && \\
|
|
304
300
|
chown root:root /usr/local/bin/riptide
|
|
305
|
-
|
|
306
|
-
# Set up environment
|
|
307
301
|
ENV NODE_ENV=production
|
|
308
302
|
ENV PATH="/usr/local/bin:$PATH"
|
|
309
303
|
|
|
310
304
|
# Copy the built application (your hooks that manage the service)
|
|
311
|
-
WORKDIR /riptide/
|
|
305
|
+
WORKDIR /riptide/services/${t}
|
|
312
306
|
COPY --from=builder /app/deploy/dist ./dist
|
|
313
307
|
COPY --from=builder /app/deploy/node_modules ./node_modules
|
|
314
308
|
COPY --from=builder /app/deploy/package.json ./package.json
|
|
309
|
+
COPY services/${t}/riptide.config.json ./riptide.config.json
|
|
315
310
|
|
|
316
|
-
#
|
|
317
|
-
COPY reef/${t}/riptide.config.json ./riptide.config.json
|
|
318
|
-
|
|
319
|
-
# Expose health check port
|
|
311
|
+
# Expose riptide health check port
|
|
320
312
|
EXPOSE 3000
|
|
321
313
|
|
|
322
|
-
# \u{1F6E1}\uFE0F SECURITY: Use controlled Riptide binary directly (bypasses npm)
|
|
323
|
-
# This ensures we use YOUR verified Riptide, not workspace dependencies
|
|
324
314
|
ENTRYPOINT ["/usr/local/bin/riptide"]
|
|
325
|
-
CMD ["start", "--config", "/riptide/
|
|
326
|
-
`:`# ${t}
|
|
315
|
+
CMD ["start", "--config", "/riptide/services/${t}/riptide.config.json", "--hooks", "/riptide/services/${t}/dist/hooks.js"]
|
|
316
|
+
`:`# DeEEP Network Service for ${t}
|
|
327
317
|
|
|
328
318
|
# ----------------------------------------
|
|
329
319
|
# Builder Stage
|
|
@@ -353,7 +343,7 @@ RUN pnpm run build
|
|
|
353
343
|
RUN pnpm prune --prod
|
|
354
344
|
|
|
355
345
|
# ----------------------------------------
|
|
356
|
-
# Third-party Base
|
|
346
|
+
# Third-party Base
|
|
357
347
|
# ----------------------------------------
|
|
358
348
|
FROM node:22-alpine AS third-party-base
|
|
359
349
|
|
|
@@ -365,20 +355,14 @@ RUN apk add --no-cache \\
|
|
|
365
355
|
ca-certificates
|
|
366
356
|
|
|
367
357
|
# ----------------------------------------
|
|
368
|
-
# Final Layer:
|
|
358
|
+
# Final Layer: Riptide Runtime
|
|
369
359
|
# ----------------------------------------
|
|
370
360
|
FROM third-party-base
|
|
371
|
-
|
|
372
|
-
# \u{1F512} SECURITY LAYER: Inject controlled Riptide runtime
|
|
373
|
-
# This layer comes from your signed, verified base image
|
|
374
361
|
COPY --from=quay.io/nerdnode/riptide:latest /usr/local/bin/riptide /usr/local/bin/riptide
|
|
375
362
|
COPY --from=quay.io/nerdnode/riptide:latest /riptide-runtime/ /riptide-runtime/
|
|
376
363
|
|
|
377
|
-
# Verify Riptide binary permissions and ownership
|
|
378
364
|
RUN chmod +x /usr/local/bin/riptide && \\
|
|
379
365
|
chown root:root /usr/local/bin/riptide
|
|
380
|
-
|
|
381
|
-
# Set up environment
|
|
382
366
|
ENV NODE_ENV=production
|
|
383
367
|
ENV PATH="/usr/local/bin:$PATH"
|
|
384
368
|
|
|
@@ -387,15 +371,11 @@ WORKDIR /app
|
|
|
387
371
|
COPY --from=builder /app/dist ./dist
|
|
388
372
|
COPY --from=builder /app/node_modules ./node_modules
|
|
389
373
|
COPY --from=builder /app/package.json ./package.json
|
|
390
|
-
|
|
391
|
-
# Copy Riptide configuration directly from source
|
|
392
374
|
COPY riptide.config.json ./riptide.config.json
|
|
393
375
|
|
|
394
|
-
# Expose health check port
|
|
376
|
+
# Expose riptide health check port
|
|
395
377
|
EXPOSE 3000
|
|
396
378
|
|
|
397
|
-
# \u{1F6E1}\uFE0F SECURITY: Use controlled Riptide binary directly (bypasses npm)
|
|
398
|
-
# This ensures we use YOUR verified Riptide, not workspace dependencies
|
|
399
379
|
ENTRYPOINT ["/usr/local/bin/riptide"]
|
|
400
380
|
CMD ["start", "--config", "/app/riptide.config.json", "--hooks", "/app/dist/hooks.js"]
|
|
401
381
|
`;await h.writeFile(l.join(e,"Dockerfile"),i)}showNextSteps(e,t){console.log(`
|
|
@@ -429,7 +409,7 @@ Next steps:
|
|
|
429
409
|
\u{1F512} Security Note:
|
|
430
410
|
Your service now uses layered Riptide security! The Dockerfile injects a controlled
|
|
431
411
|
Riptide runtime on top of your base image, ensuring integrity and preventing tampering.
|
|
432
|
-
`)}async checkIfInWorkspace(e){let t=l.resolve(e),r=l.parse(t).root;for(;t!==r;){try{return await h.access(l.join(t,"pnpm-workspace.yaml")),!0}catch{}t=l.dirname(t)}return!1}async getRiptideVersion(){try{let e=[l.join(__dirname,"..","package.json"),l.join(__dirname,"..","..","package.json"),l.join(__dirname,"..","..","..","@deeep-network","riptide","package.json"),l.join(__dirname,"..","..","node_modules","@deeep-network","riptide","package.json")];for(let t of e)try{let r=await h.readFile(t,"utf-8"),i=JSON.parse(r);if(i.name==="@deeep-network/riptide")return`^${i.version}`}catch{continue}try{return`^${require("@deeep-network/riptide/package.json").version}`}catch{}return this.logger.warn("Could not read riptide package.json, falling back to default version"),"^0.1.3"}catch{return this.logger.warn("Could not read riptide package.json, falling back to default version"),"^0.1.3"}}};async function T(o,e,t={}){await new C(o).scaffold({serviceName:e,...t})}n(T,"initService");var u=(0,D.default)({level:process.env.LOG_LEVEL||"info",transport:{target:"pino-pretty"}});async function
|
|
412
|
+
`)}async checkIfInWorkspace(e){let t=l.resolve(e),r=l.parse(t).root;for(;t!==r;){try{return await h.access(l.join(t,"pnpm-workspace.yaml")),!0}catch{}t=l.dirname(t)}return!1}async getRiptideVersion(){try{let e=[l.join(__dirname,"..","package.json"),l.join(__dirname,"..","..","package.json"),l.join(__dirname,"..","..","..","@deeep-network","riptide","package.json"),l.join(__dirname,"..","..","node_modules","@deeep-network","riptide","package.json")];for(let t of e)try{let r=await h.readFile(t,"utf-8"),i=JSON.parse(r);if(i.name==="@deeep-network/riptide")return`^${i.version}`}catch{continue}try{return`^${require("@deeep-network/riptide/package.json").version}`}catch{}return this.logger.warn("Could not read riptide package.json, falling back to default version"),"^0.1.3"}catch{return this.logger.warn("Could not read riptide package.json, falling back to default version"),"^0.1.3"}}};async function T(o,e,t={}){await new C(o).scaffold({serviceName:e,...t})}n(T,"initService");var u=(0,D.default)({level:process.env.LOG_LEVEL||"info",transport:{target:"pino-pretty"}});async function J(){try{let o=process.argv[2];if(o==="--help"||o==="-h"||o==="help"){j();return}if(o==="--version"||o==="-v"||o==="version"){await U();return}let e=v("--config")||v("-c")||"./riptide.config.json",t=v("--hooks")||v("-h")||"./hooks.js";switch(o){case"init":await z();break;case"start":await $(e,t);break;case"validate":await K(e,t);break;case"health":await G();break;case"status":await B();break;case"verify":break;default:o?(u.error(`Unknown command: ${o}`),j(),process.exit(1)):await $(e,t)}}catch(o){u.error({error:o},"CLI command failed"),process.exit(1)}}n(J,"main");function v(o){let e=process.argv.indexOf(o);if(e>=0&&e+1<process.argv.length)return process.argv[e+1]}n(v,"getArgValue");function j(){console.log(`
|
|
433
413
|
Riptide - Self-contained service lifecycle management
|
|
434
414
|
|
|
435
415
|
USAGE:
|
|
@@ -460,4 +440,4 @@ EXAMPLES:
|
|
|
460
440
|
npx riptide start # Start service
|
|
461
441
|
npx riptide validate # Validate config and hooks
|
|
462
442
|
npx riptide health # Check service health
|
|
463
|
-
`)}n(j,"showHelp");async function
|
|
443
|
+
`)}n(j,"showHelp");async function U(){try{let o=await import("fs/promises"),e=await import("path"),t=[e.resolve(__dirname,"../package.json"),e.resolve(__dirname,"./package.json"),"/riptide-runtime/package.json",e.resolve(process.cwd(),"package.json")];for(let r of t)try{let i=await o.readFile(r,"utf-8"),s=JSON.parse(i);if(s.name==="@deeep-network/riptide"){console.log(`@deeep-network/riptide v${s.version}`);return}}catch{continue}console.log("@deeep-network/riptide (version unknown)")}catch{console.log("@deeep-network/riptide (version unknown)")}}n(U,"showVersion");async function $(o,e){try{let t=await import("fs/promises"),r=await import("path"),i=await t.readFile(o,"utf-8"),s=JSON.parse(i),c=await import(r.resolve(process.cwd(),e)),d=c.default||c;await new S(d,s,{}).start()}catch(t){u.error({error:t},"Failed to start service"),process.exit(1)}}n($,"startService");async function K(o,e){u.info("Validating riptide configuration and hooks...");try{let t=await import("fs/promises"),r=await import("path"),i=await t.readFile(o,"utf-8"),s=JSON.parse(i);u.info({config:s},"Configuration loaded successfully");let c=await import(r.resolve(process.cwd(),e)),d=c.default||c;u.info({availableHooks:Object.keys(d)},"Hooks loaded successfully"),u.info("\u2705 Validation completed successfully"),process.exit(0)}catch(t){u.error({error:t},"\u274C Validation failed"),process.exit(1)}}n(K,"validateService");async function G(){try{let e=await(await fetch("http://localhost:3000/health")).json();console.log("Health Status:",JSON.stringify(e,null,2)),e.status==="healthy"?process.exit(0):process.exit(1)}catch(o){u.error({error:o},"Failed to check health"),process.exit(1)}}n(G,"checkHealth");async function B(){try{let e=await(await fetch("http://localhost:3000/status")).json();console.log("Service Status:",JSON.stringify(e,null,2))}catch(o){u.error({error:o},"Failed to get status"),process.exit(1)}}n(B,"showStatus");async function z(){let o=process.argv[3];o||(u.error("Service name is required"),console.log("Usage: npx riptide init <service-name>"),process.exit(1)),/^[a-z0-9-]+$/.test(o)||(u.error("Service name must contain only lowercase letters, numbers, and hyphens"),process.exit(1));let e=v("--template")||"basic",t=v("--path")||"services/",r=v("--description"),i=["basic","with-secrets","with-process","with-metrics"];i.includes(e)||(u.error(`Invalid template: ${e}`),console.log(`Valid templates: ${i.join(", ")}`),process.exit(1));try{await T(u,o,{targetPath:t,template:e,description:r}),process.exit(0)}catch(s){u.error({error:s},"Failed to initialize service"),process.exit(1)}}n(z,"initServiceCommand");J();
|
package/dist/index.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
"use strict";var Q=Object.create;var v=Object.defineProperty;var Z=Object.getOwnPropertyDescriptor;var ee=Object.getOwnPropertyNames;var te=Object.getPrototypeOf,re=Object.prototype.hasOwnProperty;var a=(r,e)=>v(r,"name",{value:e,configurable:!0});var oe=(r,e)=>{for(var t in e)v(r,t,{get:e[t],enumerable:!0})},
|
|
1
|
+
"use strict";var Q=Object.create;var v=Object.defineProperty;var Z=Object.getOwnPropertyDescriptor;var ee=Object.getOwnPropertyNames;var te=Object.getPrototypeOf,re=Object.prototype.hasOwnProperty;var a=(r,e)=>v(r,"name",{value:e,configurable:!0});var oe=(r,e)=>{for(var t in e)v(r,t,{get:e[t],enumerable:!0})},D=(r,e,t,o)=>{if(e&&typeof e=="object"||typeof e=="function")for(let i of ee(e))!re.call(r,i)&&i!==t&&v(r,i,{get:()=>e[i],enumerable:!(o=Z(e,i))||o.enumerable});return r};var m=(r,e,t)=>(t=r!=null?Q(te(r)):{},D(e||!r||!r.__esModule?v(t,"default",{value:r,enumerable:!0}):t,r)),ie=r=>D(v({},"__esModule",{value:!0}),r);var pe={};oe(pe,{AlreadyRunningError:()=>x,DiagnoseRequiredError:()=>w,InvalidSecretError:()=>C,MissingSecretError:()=>S,RiptideEntrypoint:()=>H,RiptideError:()=>h,ServiceScaffolder:()=>k,createChildLogger:()=>A,createLogger:()=>b,createUtilityContext:()=>P,expandEnvironmentVariables:()=>R,getDefaultConfig:()=>G,initService:()=>z,isAlreadyRunningError:()=>N,isDiagnoseRequiredError:()=>M,isInvalidSecretError:()=>_,isMissingSecretError:()=>j,isRiptideError:()=>y,loadConfig:()=>V,loadHooks:()=>K,mergeConfigs:()=>B,parseEnvironmentVariables:()=>L,redactSecret:()=>q,validateConfig:()=>I});module.exports=ie(pe);var h=class r extends Error{static{a(this,"RiptideError")}constructor(e){super(e),Object.setPrototypeOf(this,r.prototype)}},w=class r extends h{static{a(this,"DiagnoseRequiredError")}exitCode=3;constructor(e){super(e),this.name="DiagnoseRequiredError",Object.setPrototypeOf(this,r.prototype)}},S=class r extends h{static{a(this,"MissingSecretError")}exitCode=4;constructor(e){super(e),this.name="MissingSecretError",Object.setPrototypeOf(this,r.prototype)}},C=class r extends h{static{a(this,"InvalidSecretError")}exitCode=5;constructor(e){super(e),this.name="InvalidSecretError",Object.setPrototypeOf(this,r.prototype)}},x=class r extends h{static{a(this,"AlreadyRunningError")}exitCode=6;constructor(e){super(e),this.name="AlreadyRunningError",Object.setPrototypeOf(this,r.prototype)}};function y(r){return r instanceof Error&&"exitCode"in r&&typeof r.exitCode=="number"}a(y,"isRiptideError");function M(r){return r instanceof Error&&r.name==="DiagnoseRequiredError"&&r.exitCode===3}a(M,"isDiagnoseRequiredError");function j(r){return r instanceof Error&&r.name==="MissingSecretError"&&r.exitCode===4}a(j,"isMissingSecretError");function _(r){return r instanceof Error&&r.name==="InvalidSecretError"&&r.exitCode===5}a(_,"isInvalidSecretError");function N(r){return r instanceof Error&&r.name==="AlreadyRunningError"&&r.exitCode===6}a(N,"isAlreadyRunningError");var E=m(require("pino"));function b(r){let{level:e="info",format:t="pretty",serviceName:o}=r,i={level:e,timestamp:E.default.stdTimeFunctions.isoTime,formatters:{log:a(s=>({service:o,...s}),"log"),bindings:a(s=>{let{pid:n,hostname:c,...u}=s;return u},"bindings")}};return t==="pretty"&&!process.env.NODE_ENV?.includes("prod")?(0,E.default)({...i,transport:{target:"pino-pretty",options:{colorize:!0,translateTime:"SYS:standard",ignore:"pid,hostname"}}}):(0,E.default)(i)}a(b,"createLogger");function A(r,e){return r.child(e)}a(A,"createChildLogger");var F=require("child_process"),g=require("fs"),se=m(require("http")),ne=m(require("https")),T=m(require("path")),Y=require("util");var ae=(0,Y.promisify)(F.exec);function P(){return{sleep:a(r=>new Promise(e=>setTimeout(e,r)),"sleep"),retry:a(async(r,e={})=>{let{maxAttempts:t=3,delay:o=1e3,backoffMultiplier:i=2,maxDelay:s=3e4}=e,n,c=o;for(let u=1;u<=t;u++)try{return await r()}catch(l){if(n=l instanceof Error?l:new Error(String(l)),u===t)throw n;await new Promise(f=>setTimeout(f,Math.min(c,s))),c*=i}throw n},"retry"),execCommand:a(async(r,e={})=>{let{timeout:t=3e4,cwd:o=process.cwd(),env:i=process.env}=e;try{let{stdout:s,stderr:n}=await ae(r,{timeout:t,cwd:o,env:{...process.env,...i}});return{stdout:s.trim(),stderr:n.trim(),exitCode:0}}catch(s){if(s.killed&&s.signal==="SIGTERM"){let c=`Command timed out after ${t/1e3}s. Consider increasing timeout if command needs more time to complete: utils.execCommand('${r}', { timeout: ${t*2} })`,u=s.stderr?.trim()||"",l=u?`${u}
|
|
2
2
|
|
|
3
3
|
${c}`:c,f=[];s.stdout?.trim()&&f.push(`STDOUT: ${s.stdout.trim()}`),l&&f.push(`STDERR: ${l}`);let X=[`Command execution timed out: ${r}`,...f].join(`
|
|
4
4
|
|
|
5
|
-
`),$=new Error(X);throw $.name="CommandTimeoutError",$}return{stdout:s.stdout?.trim()||"",stderr:s.stderr?.trim()||s.message,exitCode:s.code||1}}},"execCommand"),writeFile:a(async(r,e,t={})=>{let{mode:o="0644",encoding:i="utf8"}=t;await g.promises.mkdir(T.dirname(r),{recursive:!0}),await g.promises.writeFile(r,e,{encoding:i}),await g.promises.chmod(r,o)},"writeFile"),readFile:a(async r=>await g.promises.readFile(r,"utf8"),"readFile"),fileExists:a(async r=>{try{return await g.promises.access(r),!0}catch{return!1}},"fileExists"),downloadFile:a(async function r(e,t){return new Promise((o,i)=>{let s=T.dirname(t);g.promises.mkdir(s,{recursive:!0}).then(()=>{let n=(0,g.createWriteStream)(t);(e.startsWith("https:")?ne:se).get(e,l=>{if(l.statusCode===200)l.pipe(n),n.on("finish",()=>{n.close(),o(t)});else if(l.statusCode===301||l.statusCode===302){let f=l.headers.location;f?o(r(f,t)):i(new Error(`Redirect without location header: ${l.statusCode}`))}else i(new Error(`Failed to download: ${l.statusCode} ${l.statusMessage}`))}).on("error",l=>{g.promises.unlink(t).catch(()=>{}),i(l)}),n.on("error",l=>{g.promises.unlink(t).catch(()=>{}),i(l)})}).catch(i)})},"downloadFile")}}a(
|
|
6
|
-
`);for(let o of t){let i=o.trim();if(i&&!i.startsWith("#")){let[s,...n]=i.split("=");s&&n.length>0&&(e[s.trim()]=n.join("=").trim())}}return e}a(U,"parseEnvironmentVariables");function P(r,e=process.env){return r.replace(/\$\{([^}]+)\}/g,(t,o)=>e[o]||t)}a(P,"expandEnvironmentVariables");var q=require("http");var O=class{constructor(e,t,o,i,s){this.getStatus=o;this.executeHealthCheck=i;this.getMetrics=s;this.port=e,this.logger=t.child({component:"web-server"})}static{a(this,"WebServer")}server=null;port;logger;async start(){if(this.server){this.logger.warn("Web server already running");return}return this.server=(0,q.createServer)(async(e,t)=>{if(e.method!=="GET"){t.writeHead(405,{"Content-Type":"application/json"}),t.end(JSON.stringify({error:"Method not allowed"}));return}if(e.url==="/health")try{let o=await this.executeHealthCheck(),i=this.getStatus(),s={healthy:o,status:i.status,uptime:i.uptime,message:i.message};o?t.writeHead(200,{"Content-Type":"application/json"}):t.writeHead(503,{"Content-Type":"application/json"}),t.end(JSON.stringify(s))}catch(o){this.logger.error({error:o},"Health check endpoint error"),t.writeHead(500,{"Content-Type":"application/json"}),t.end(JSON.stringify({healthy:!1,error:o instanceof Error?o.message:"Internal server error"}))}else if(e.url==="/metrics")try{let o=await this.getMetrics();t.writeHead(200,{"Content-Type":"text/plain; version=0.0.4"}),t.end(o)}catch(o){this.logger.error({error:o},"Metrics endpoint error"),t.writeHead(500,{"Content-Type":"application/json"}),t.end(JSON.stringify({error:o instanceof Error?o.message:"Internal server error"}))}else t.writeHead(404,{"Content-Type":"application/json"}),t.end(JSON.stringify({error:"Not found"}))}),new Promise((e,t)=>{this.server.once("error",o=>{this.logger.error({error:o,port:this.port},"Failed to start web server"),t(o)}),this.server.listen(this.port,()=>{e()})})}async stop(){if(this.server)return new Promise(e=>{this.server.close(()=>{this.logger.info("Web server stopped"),this.server=null,e()})})}};var H=class{static{a(this,"RiptideEntrypoint")}logger;hooks;config;isShuttingDown=!1;status={status:"starting"};startTime;heartbeatInterval;webServer;constructor(e,t,o={}){this.hooks=e,this.config=t,this.startTime=Date.now(),this.logger=b({serviceName:t.service.name,level:o.logLevel||t.logging?.level||"info",format:process.env.NODE_ENV==="production"?"json":t.logging?.format||"pretty"}),this.setupGlobalErrorHandlers(),this.setupSignalHandlers()}async start(){try{let e=this.config.service.version||"unknown",t=process.env.NODE_ENV||"production";this.logger.info({version:e,environment:t},`Starting ${this.config.service.name}`),await this.processSecrets(),await this.executeHook("start"),await this.startWebServer(this.config.health?.port||3e3),this.startHeartbeat(),this.status={status:"healthy",uptime:Date.now()-this.startTime,message:"Service started successfully"},this.logger.info({service:this.config.service.name},`${this.config.service.name} service is ready`),await this.waitForShutdown()}catch(e){this.logger.error({error:e instanceof Error?e.message:String(e),stack:e instanceof Error?e.stack:void 0},`Failed to start ${this.config.service.name} service`),this.status={status:"unhealthy",message:e instanceof Error?e.message:String(e)},y(e)?(this.logger.error(`Exiting with code ${e.exitCode} (${e.name})`),process.exit(e.exitCode)):process.exit(1)}}async processSecrets(){if(!this.hooks.installSecrets){this.logger.info("No installSecrets hook, continuing...");return}this.logger.info("Installing secrets...");try{await this.executeHook("installSecrets")}catch(e){throw this.logger.error({error:e instanceof Error?e.message:String(e),stack:e instanceof Error?e.stack:void 0,hookName:"installSecrets"},"Failed to install secrets"),e}}async executeHook(e){let t=this.hooks[e];if(typeof t!="function")throw new Error(`Required hook '${e}' not found`);let o=this.createHookContext(),i=Date.now();try{let s=await t(o),n=Date.now()-i;return this.logger.debug(`Hook ${e} completed in ${n}ms`),s}catch(s){let n=Date.now()-i;throw this.logger.error({hookName:e,duration:n,error:s instanceof Error?s.message:String(s),stack:s instanceof Error?s.stack:void 0},"Hook threw an exception, will not continue"),s}}async executeHookSafely(e){if(typeof this.hooks[e]!="function")return this.logger.debug(`Optional hook '${e}' not found, skipping`),null;try{return await this.executeHook(e)}catch(o){return this.logger.warn({hookName:e,error:o instanceof Error?o.message:String(o)},"Optional hook execution failed but continuing"),null}}createHookContext(){return{config:this.config,logger:this.logger,env:process.env,utils:R()}}setupGlobalErrorHandlers(){process.on("uncaughtException",e=>{this.logger.error({error:e.message,stack:e.stack},"Uncaught exception"),y(e)?(this.logger.info(`Exiting with code ${e.exitCode} (${e.name}) from unhandled exception`),process.exit(e.exitCode)):(this.logger.error("Exiting with code 1 due to unhandled exception"),process.exit(1))}),process.on("unhandledRejection",(e,t)=>{this.logger.error({reason:e,promise:t},"Unhandled promise rejection"),y(e)?(this.logger.info(`Exiting with code ${e.exitCode} (${e.name}) from unhandled rejection`),process.exit(e.exitCode)):(this.logger.error("Exiting with code 1 due to unhandled promise rejection"),process.exit(1))})}setupSignalHandlers(){let e=a(async t=>{this.isShuttingDown&&(this.logger.warn("Force shutdown signal received"),process.exit(1)),this.isShuttingDown=!0,this.status={status:"stopping",message:`Received ${t} signal`},this.logger.info(`${t} signal received, starting graceful shutdown...`);try{this.heartbeatInterval&&clearInterval(this.heartbeatInterval),this.webServer&&await this.webServer.stop(),await this.executeHookSafely("stop"),this.status={status:"stopped",message:"Graceful shutdown completed"},this.logger.info("Graceful shutdown completed"),process.exit(0)}catch(o){this.logger.error({error:o instanceof Error?o.message:String(o)},"Error during graceful shutdown"),process.exit(1)}},"gracefulShutdown");process.on("SIGTERM",()=>e("SIGTERM")),process.on("SIGINT",()=>e("SIGINT"))}startHeartbeat(){if(!this.hooks.heartbeat){this.logger.info("No heartbeat hook defined, skipping heartbeat");return}if(!this.config.heartbeat?.enabled){this.logger.info("Heartbeat disabled in config, skipping...");return}let e=this.config.heartbeat?.interval||6e4;this.logger.info({interval:e},"Starting heartbeat");let t=!1;this.heartbeatInterval=setInterval(async()=>{if(t){this.logger.info("Heartbeat still executing, skipping this interval");return}t=!0;try{let o=await this.executeHookSafely("heartbeat");if(o===null){this.logger.info("Heartbeat hook returned null, skipping heartbeat");return}await this.pingSonar(o)}catch(o){this.logger.warn({error:o instanceof Error?o.message:String(o)},"Heartbeat execution failed")}finally{t=!1}},e)}async pingSonar(e){let t=process.env.SONAR_API_URL,o=process.env.SONAR_API_KEY,i=process.env.NOMAD_JOB_NAME;if(!t||!o||!i){this.logger.info({hasUrl:!!t,hasKey:!!o,hasJobId:!!i},"Sonar API configuration incomplete, skipping heartbeat send");return}let s=Math.floor(Date.now()/1e3),n={entity_id:i,client_timestamp:s,metadata:e};try{this.logger.info({payload:n,sonarUrl:t,sonarApiKey:o,nomadJobId:i},"Sending heartbeat to Sonar API");let c=await fetch(`${t}/api/v1/heartbeat`,{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${o}`},body:JSON.stringify(n)});if(!c.ok){let u=await c.text();this.logger.warn({status:c.status,error:u,entity_id:i},"Sonar API heartbeat failed")}}catch(c){this.logger.error({error:c instanceof Error?c.message:String(c),errorType:c?.constructor?.name,errorCause:c instanceof Error&&"cause"in c?c.cause:void 0,stack:c instanceof Error?c.stack:void 0,url:`${t}/api/v1/heartbeat`,entity_id:i},"Failed to send heartbeat to Sonar API")}}async startWebServer(e){this.webServer=new O(e,this.logger,()=>({...this.status,uptime:Date.now()-this.startTime}),async()=>await this.executeHookSafely("health")===!0,async()=>await this.getMetrics());try{await this.webServer.start()}catch(t){this.logger.error({error:t,port:e},"Failed to start web server")}}async waitForShutdown(){return new Promise(e=>{let t=a(()=>{this.isShuttingDown?e():setTimeout(t,100)},"checkShutdown");t()})}async getMetrics(){if(!this.hooks.metrics)return{uptime:Date.now()-this.startTime,status:this.status.status};try{return await this.executeHookSafely("metrics")}catch{return{uptime:Date.now()-this.startTime,status:this.status.status}}}};var W=require("fs"),V=m(require("path"));async function J(r){try{let e=await W.promises.readFile(r,"utf8"),t=JSON.parse(e),o=ce(t);return I(o),o}catch(e){throw new Error(`Failed to load config from ${r}: ${e instanceof Error?e.message:String(e)}`)}}a(J,"loadConfig");async function K(r){try{let e=V.resolve(r);delete require.cache[e];let t=require(e);if(typeof t.start!="function")throw new Error('Hooks module must export a "start" function');return t}catch(e){throw new Error(`Failed to load hooks from ${r}: ${e instanceof Error?e.message:String(e)}`)}}a(K,"loadHooks");function I(r){if(!r.service?.name)throw new Error("Config must have service.name");if(r.health,r.heartbeat&&r.heartbeat.interval&&r.heartbeat.interval<1e3)throw new Error("heartbeat.interval must be at least 1000ms")}a(I,"validateConfig");function ce(r,e=process.env){let t=JSON.parse(JSON.stringify(r));function o(i){if(typeof i=="string")return P(i,e);if(Array.isArray(i))return i.map(o);if(i&&typeof i=="object"){let s={};for(let[n,c]of Object.entries(i))s[n]=o(c);return s}return i}return a(o,"expandObject"),o(t)}a(ce,"expandConfigVariables");function G(){return{health:{port:3e3},heartbeat:{interval:6e4,enabled:!1},logging:{level:"info",format:"pretty"}}}a(G,"getDefaultConfig");function B(r,e){function t(i,s){if(s&&typeof s=="object"&&!Array.isArray(s))for(let n in s)s.hasOwnProperty(n)&&(i[n]&&typeof i[n]=="object"&&!Array.isArray(i[n])?i[n]=t(i[n],s[n]):i[n]=s[n]);return i}a(t,"deepMerge");let o=t({...r},e);return I(o),o}a(B,"mergeConfigs");var d=m(require("fs/promises")),p=m(require("path"));var k=class{constructor(e){this.logger=e}static{a(this,"ServiceScaffolder")}async scaffold(e){let{serviceName:t,targetPath:o=".",template:i="basic",description:s}=e,n=p.join(o,t);this.logger.info(`Creating new Coral Reef service: ${t}`),this.logger.info(`Template: ${i}`),this.logger.info(`Location: ${n}`),await this.createDirectoryStructure(n),await this.createPackageJson(n,t,s),await this.createRiptideConfig(n,t,s),await this.createTsConfig(n),await this.createTsupConfig(n),await this.createHooks(n,i),await this.createDockerfile(n,t),this.logger.info("\u2705 Service scaffolding complete!"),this.showNextSteps(t,n)}async createDirectoryStructure(e){await d.mkdir(p.join(e,"src"),{recursive:!0})}async createPackageJson(e,t,o){let i=await this.checkIfInWorkspace(e),s=i?"workspace:*":await this.getRiptideVersion(),n=i?`cd ../.. && docker build --platform \${DOCKER_PLATFORM:-linux/amd64} --progress=plain -t reef-${t} -f reef/${t}/Dockerfile .`:`docker build --platform \${DOCKER_PLATFORM:-linux/amd64} --progress=plain -t reef-${t} .`,c={name:`reef-${t}`,version:"1.0.0",description:o||`${t} service for Coral Reef`,main:"dist/hooks.js",scripts:{build:"tsc --noEmit && tsup","build:docker":n,clean:"rm -rf dist",start:"npx @deeep-network/riptide start --hooks dist/hooks.js",validate:"pnpm run build && npx @deeep-network/riptide validate --hooks dist/hooks.js","type-check":"tsc --noEmit"},dependencies:{"@deeep-network/riptide":s},devDependencies:{typescript:"^5.8.3",tsup:"^8.5.0","@types/node":"^20.0.0"},engines:{node:">=22.0.0"}};await d.writeFile(p.join(e,"package.json"),JSON.stringify(c,null,2))}async createRiptideConfig(e,t,o){let i={service:{name:t,version:"1.0.0",description:o||`${t} service`},logging:{level:"info"}};await d.writeFile(p.join(e,"riptide.config.json"),JSON.stringify(i,null,2))}async createTsConfig(e){let o=await this.checkIfInWorkspace(e)?{extends:"../../tsconfig.json",compilerOptions:{outDir:"./dist",rootDir:"./src",declaration:!0,declarationMap:!0,sourceMap:!0},include:["src/**/*"],exclude:["dist","node_modules"]}:{compilerOptions:{target:"ES2022",module:"commonjs",lib:["ES2022"],outDir:"./dist",rootDir:"./src",strict:!0,esModuleInterop:!0,skipLibCheck:!0,forceConsistentCasingInFileNames:!0,declaration:!0,declarationMap:!0,sourceMap:!0,moduleResolution:"node"},include:["src/**/*"],exclude:["dist","node_modules"]};await d.writeFile(p.join(e,"tsconfig.json"),JSON.stringify(o,null,2))}async createTsupConfig(e){await d.writeFile(p.join(e,"tsup.config.ts"),`import { defineConfig } from 'tsup'
|
|
5
|
+
`),$=new Error(X);throw $.name="CommandTimeoutError",$}return{stdout:s.stdout?.trim()||"",stderr:s.stderr?.trim()||s.message,exitCode:s.code||1}}},"execCommand"),writeFile:a(async(r,e,t={})=>{let{mode:o="0644",encoding:i="utf8"}=t;await g.promises.mkdir(T.dirname(r),{recursive:!0}),await g.promises.writeFile(r,e,{encoding:i}),await g.promises.chmod(r,o)},"writeFile"),readFile:a(async r=>await g.promises.readFile(r,"utf8"),"readFile"),fileExists:a(async r=>{try{return await g.promises.access(r),!0}catch{return!1}},"fileExists"),downloadFile:a(async function r(e,t){return new Promise((o,i)=>{let s=T.dirname(t);g.promises.mkdir(s,{recursive:!0}).then(()=>{let n=(0,g.createWriteStream)(t);(e.startsWith("https:")?ne:se).get(e,l=>{if(l.statusCode===200)l.pipe(n),n.on("finish",()=>{n.close(),o(t)});else if(l.statusCode===301||l.statusCode===302){let f=l.headers.location;f?o(r(f,t)):i(new Error(`Redirect without location header: ${l.statusCode}`))}else i(new Error(`Failed to download: ${l.statusCode} ${l.statusMessage}`))}).on("error",l=>{g.promises.unlink(t).catch(()=>{}),i(l)}),n.on("error",l=>{g.promises.unlink(t).catch(()=>{}),i(l)})}).catch(i)})},"downloadFile")}}a(P,"createUtilityContext");function q(r){return!r||r.length<=10?"[REDACTED]":`${r.slice(0,4)}...${r.slice(-4)}`}a(q,"redactSecret");function L(r){let e={},t=r.split(`
|
|
6
|
+
`);for(let o of t){let i=o.trim();if(i&&!i.startsWith("#")){let[s,...n]=i.split("=");s&&n.length>0&&(e[s.trim()]=n.join("=").trim())}}return e}a(L,"parseEnvironmentVariables");function R(r,e=process.env){return r.replace(/\$\{([^}]+)\}/g,(t,o)=>e[o]||t)}a(R,"expandEnvironmentVariables");var W=require("http");var O=class{constructor(e,t,o,i,s){this.getStatus=o;this.executeHealthCheck=i;this.getMetrics=s;this.port=e,this.logger=t.child({component:"web-server"})}static{a(this,"WebServer")}server=null;port;logger;async start(){if(this.server){this.logger.warn("Web server already running");return}return this.server=(0,W.createServer)(async(e,t)=>{if(e.method!=="GET"){t.writeHead(405,{"Content-Type":"application/json"}),t.end(JSON.stringify({error:"Method not allowed"}));return}if(e.url==="/health")try{let o=await this.executeHealthCheck(),i=this.getStatus(),s={healthy:o,status:i.status,uptime:i.uptime,message:i.message};o?t.writeHead(200,{"Content-Type":"application/json"}):t.writeHead(503,{"Content-Type":"application/json"}),t.end(JSON.stringify(s))}catch(o){this.logger.error({error:o},"Health check endpoint error"),t.writeHead(500,{"Content-Type":"application/json"}),t.end(JSON.stringify({healthy:!1,error:o instanceof Error?o.message:"Internal server error"}))}else if(e.url==="/metrics")try{let o=await this.getMetrics();t.writeHead(200,{"Content-Type":"text/plain; version=0.0.4"}),t.end(o)}catch(o){this.logger.error({error:o},"Metrics endpoint error"),t.writeHead(500,{"Content-Type":"application/json"}),t.end(JSON.stringify({error:o instanceof Error?o.message:"Internal server error"}))}else t.writeHead(404,{"Content-Type":"application/json"}),t.end(JSON.stringify({error:"Not found"}))}),new Promise((e,t)=>{this.server.once("error",o=>{this.logger.error({error:o,port:this.port},"Failed to start web server"),t(o)}),this.server.listen(this.port,()=>{e()})})}async stop(){if(this.server)return new Promise(e=>{this.server.close(()=>{this.logger.info("Web server stopped"),this.server=null,e()})})}};var H=class{static{a(this,"RiptideEntrypoint")}logger;hooks;config;isShuttingDown=!1;status={status:"starting"};startTime;heartbeatInterval;webServer;constructor(e,t,o={}){this.hooks=e,this.config=t,this.startTime=Date.now(),this.logger=b({serviceName:t.service.name,level:o.logLevel||t.logging?.level||"info",format:process.env.NODE_ENV==="production"?"json":t.logging?.format||"pretty"}),this.setupGlobalErrorHandlers(),this.setupSignalHandlers()}async start(){try{let e=this.config.service.version||"unknown",t=process.env.NODE_ENV||"production";this.logger.info({version:e,environment:t},`Starting ${this.config.service.name}`),await this.processSecrets(),await this.executeHook("start"),await this.startWebServer(this.config.health?.port||3e3),this.startHeartbeat(),this.status={status:"healthy",uptime:Date.now()-this.startTime,message:"Service started successfully"},this.logger.info({service:this.config.service.name},`${this.config.service.name} service is ready`),await this.waitForShutdown()}catch(e){this.logger.error({error:e instanceof Error?e.message:String(e),stack:e instanceof Error?e.stack:void 0},`Failed to start ${this.config.service.name} service`),this.status={status:"unhealthy",message:e instanceof Error?e.message:String(e)},y(e)?(this.logger.error(`Exiting with code ${e.exitCode} (${e.name})`),process.exit(e.exitCode)):process.exit(1)}}async processSecrets(){if(!this.hooks.installSecrets){this.logger.info("No installSecrets hook, continuing...");return}this.logger.info("Processing secrets...");try{await this.executeHook("installSecrets")}catch(e){throw this.logger.error({error:e instanceof Error?e.message:String(e),stack:e instanceof Error?e.stack:void 0,hookName:"installSecrets"},"Failed to install secrets"),e}}async executeHook(e){let t=this.hooks[e];if(typeof t!="function")throw new Error(`Required hook '${e}' not found`);let o=this.createHookContext(),i=Date.now();try{let s=await t(o),n=Date.now()-i;return this.logger.debug(`Hook ${e} completed in ${n}ms`),s}catch(s){let n=Date.now()-i;throw this.logger.error({hookName:e,duration:n,error:s instanceof Error?s.message:String(s),stack:s instanceof Error?s.stack:void 0},"Hook threw an exception, will not continue"),s}}async executeHookSafely(e){if(typeof this.hooks[e]!="function")return this.logger.debug(`Optional hook '${e}' not found, skipping`),null;try{return await this.executeHook(e)}catch(o){return this.logger.warn({hookName:e,error:o instanceof Error?o.message:String(o)},"Optional hook execution failed but continuing"),null}}createHookContext(){return{config:this.config,logger:this.logger,env:process.env,utils:P()}}setupGlobalErrorHandlers(){process.on("uncaughtException",e=>{this.logger.error({error:e.message,stack:e.stack},"Uncaught exception"),y(e)?(this.logger.info(`Exiting with code ${e.exitCode} (${e.name}) from unhandled exception`),process.exit(e.exitCode)):(this.logger.error("Exiting with code 1 due to unhandled exception"),process.exit(1))}),process.on("unhandledRejection",(e,t)=>{this.logger.error({reason:e,promise:t},"Unhandled promise rejection"),y(e)?(this.logger.info(`Exiting with code ${e.exitCode} (${e.name}) from unhandled rejection`),process.exit(e.exitCode)):(this.logger.error("Exiting with code 1 due to unhandled promise rejection"),process.exit(1))})}setupSignalHandlers(){let e=a(async t=>{this.isShuttingDown&&(this.logger.warn("Force shutdown signal received"),process.exit(1)),this.isShuttingDown=!0,this.status={status:"stopping",message:`Received ${t} signal`},this.logger.info(`${t} signal received, starting graceful shutdown...`);try{this.heartbeatInterval&&clearInterval(this.heartbeatInterval),this.webServer&&await this.webServer.stop(),await this.executeHookSafely("stop"),this.status={status:"stopped",message:"Graceful shutdown completed"},this.logger.info("Graceful shutdown completed"),process.exit(0)}catch(o){this.logger.error({error:o instanceof Error?o.message:String(o)},"Error during graceful shutdown"),process.exit(1)}},"gracefulShutdown");process.on("SIGTERM",()=>e("SIGTERM")),process.on("SIGINT",()=>e("SIGINT"))}startHeartbeat(){if(!this.hooks.heartbeat){this.logger.info("No heartbeat hook defined, skipping heartbeat");return}if(!this.config.heartbeat?.enabled){this.logger.info("Heartbeat disabled in config, skipping...");return}let e=this.config.heartbeat?.interval||6e4;this.logger.info({interval:e},"Starting heartbeat");let t=!1;this.heartbeatInterval=setInterval(async()=>{if(t){this.logger.info("Heartbeat still executing, skipping this interval");return}t=!0;try{let o=await this.executeHookSafely("heartbeat");if(o===null){this.logger.info("Heartbeat hook returned null, skipping heartbeat");return}await this.pingSonar(o)}catch(o){this.logger.warn({error:o instanceof Error?o.message:String(o)},"Heartbeat execution failed")}finally{t=!1}},e)}async pingSonar(e){let t=process.env.SONAR_API_URL,o=process.env.SONAR_API_KEY,i=process.env.NOMAD_JOB_NAME;if(!t||!o||!i){this.logger.info({hasUrl:!!t,hasKey:!!o,hasJobId:!!i},"Sonar API configuration incomplete, skipping heartbeat send");return}let s=Math.floor(Date.now()/1e3),n={entity_id:i,client_timestamp:s,metadata:e};try{this.logger.info({payload:n,sonarUrl:t,sonarApiKey:o,nomadJobId:i},"Sending heartbeat to Sonar API");let c=await fetch(`${t}/api/v1/heartbeat`,{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${o}`},body:JSON.stringify(n)});if(!c.ok){let u=await c.text();this.logger.warn({status:c.status,error:u,entity_id:i},"Sonar API heartbeat failed")}}catch(c){this.logger.error({error:c instanceof Error?c.message:String(c),errorType:c?.constructor?.name,errorCause:c instanceof Error&&"cause"in c?c.cause:void 0,stack:c instanceof Error?c.stack:void 0,url:`${t}/api/v1/heartbeat`,entity_id:i},"Failed to send heartbeat to Sonar API")}}async startWebServer(e){this.webServer=new O(e,this.logger,()=>({...this.status,uptime:Date.now()-this.startTime}),async()=>await this.executeHookSafely("health")===!0,async()=>await this.getMetrics());try{await this.webServer.start()}catch(t){this.logger.error({error:t,port:e},"Failed to start web server")}}async waitForShutdown(){return new Promise(e=>{let t=a(()=>{this.isShuttingDown?e():setTimeout(t,100)},"checkShutdown");t()})}async getMetrics(){if(!this.hooks.metrics)return{uptime:Date.now()-this.startTime,status:this.status.status};try{return await this.executeHookSafely("metrics")}catch{return{uptime:Date.now()-this.startTime,status:this.status.status}}}};var U=require("fs"),J=m(require("path"));async function V(r){try{let e=await U.promises.readFile(r,"utf8"),t=JSON.parse(e),o=ce(t);return I(o),o}catch(e){throw new Error(`Failed to load config from ${r}: ${e instanceof Error?e.message:String(e)}`)}}a(V,"loadConfig");async function K(r){try{let e=J.resolve(r);delete require.cache[e];let t=require(e);if(typeof t.start!="function")throw new Error('Hooks module must export a "start" function');return t}catch(e){throw new Error(`Failed to load hooks from ${r}: ${e instanceof Error?e.message:String(e)}`)}}a(K,"loadHooks");function I(r){if(!r.service?.name)throw new Error("Config must have service.name");if(r.health,r.heartbeat&&r.heartbeat.interval&&r.heartbeat.interval<1e3)throw new Error("heartbeat.interval must be at least 1000ms")}a(I,"validateConfig");function ce(r,e=process.env){let t=JSON.parse(JSON.stringify(r));function o(i){if(typeof i=="string")return R(i,e);if(Array.isArray(i))return i.map(o);if(i&&typeof i=="object"){let s={};for(let[n,c]of Object.entries(i))s[n]=o(c);return s}return i}return a(o,"expandObject"),o(t)}a(ce,"expandConfigVariables");function G(){return{health:{port:3e3},heartbeat:{interval:6e4,enabled:!1},logging:{level:"info",format:"pretty"}}}a(G,"getDefaultConfig");function B(r,e){function t(i,s){if(s&&typeof s=="object"&&!Array.isArray(s))for(let n in s)s.hasOwnProperty(n)&&(i[n]&&typeof i[n]=="object"&&!Array.isArray(i[n])?i[n]=t(i[n],s[n]):i[n]=s[n]);return i}a(t,"deepMerge");let o=t({...r},e);return I(o),o}a(B,"mergeConfigs");var d=m(require("fs/promises")),p=m(require("path"));var k=class{constructor(e){this.logger=e}static{a(this,"ServiceScaffolder")}async scaffold(e){let{serviceName:t,targetPath:o=".",template:i="basic",description:s}=e,n=p.join(o,t);this.logger.info(`Creating new Coral Reef service: ${t}`),this.logger.info(`Template: ${i}`),this.logger.info(`Location: ${n}`),await this.createDirectoryStructure(n),await this.createPackageJson(n,t,s),await this.createRiptideConfig(n,t,s),await this.createTsConfig(n),await this.createTsupConfig(n),await this.createHooks(n,i),await this.createDockerfile(n,t),this.logger.info("\u2705 Service scaffolding complete!"),this.showNextSteps(t,n)}async createDirectoryStructure(e){await d.mkdir(p.join(e,"src"),{recursive:!0})}async createPackageJson(e,t,o){let i=await this.checkIfInWorkspace(e),s=i?"workspace:*":await this.getRiptideVersion(),n=i?`cd ../.. && docker build --platform \${DOCKER_PLATFORM:-linux/amd64} --progress=plain -t reef-${t} -f services/${t}/Dockerfile .`:`docker build --platform \${DOCKER_PLATFORM:-linux/amd64} --progress=plain -t reef-${t} .`,c={name:`reef-${t}`,version:"1.0.0",description:o||`${t} service for Coral Reef`,main:"dist/hooks.js",scripts:{build:"tsc --noEmit && tsup","build:docker":n,clean:"rm -rf dist",start:"npx @deeep-network/riptide start --hooks dist/hooks.js",validate:"pnpm run build && npx @deeep-network/riptide validate --hooks dist/hooks.js","type-check":"tsc --noEmit"},dependencies:{"@deeep-network/riptide":s},devDependencies:{typescript:"^5.8.3",tsup:"^8.5.0","@types/node":"^20.0.0"},engines:{node:">=22.0.0"}};await d.writeFile(p.join(e,"package.json"),JSON.stringify(c,null,2))}async createRiptideConfig(e,t,o){let i={service:{name:t,version:"1.0.0",description:o||`${t} service`},logging:{level:"info"}};await d.writeFile(p.join(e,"riptide.config.json"),JSON.stringify(i,null,2))}async createTsConfig(e){let o=await this.checkIfInWorkspace(e)?{extends:"../../tsconfig.json",compilerOptions:{outDir:"./dist",rootDir:"./src",declaration:!0,declarationMap:!0,sourceMap:!0},include:["src/**/*"],exclude:["dist","node_modules"]}:{compilerOptions:{target:"ES2022",module:"commonjs",lib:["ES2022"],outDir:"./dist",rootDir:"./src",strict:!0,esModuleInterop:!0,skipLibCheck:!0,forceConsistentCasingInFileNames:!0,declaration:!0,declarationMap:!0,sourceMap:!0,moduleResolution:"node"},include:["src/**/*"],exclude:["dist","node_modules"]};await d.writeFile(p.join(e,"tsconfig.json"),JSON.stringify(o,null,2))}async createTsupConfig(e){await d.writeFile(p.join(e,"tsup.config.ts"),`import { defineConfig } from 'tsup'
|
|
7
7
|
|
|
8
8
|
export default defineConfig({
|
|
9
9
|
entry: ['src/hooks.ts'],
|
|
@@ -246,7 +246,7 @@ module.exports = {
|
|
|
246
246
|
logger.info('Service stopping')
|
|
247
247
|
}
|
|
248
248
|
}
|
|
249
|
-
`}async createDockerfile(e,t){let i=await this.checkIfInWorkspace(e)?`# ${t}
|
|
249
|
+
`}async createDockerfile(e,t){let i=await this.checkIfInWorkspace(e)?`# DeEEP Network Service for ${t}
|
|
250
250
|
|
|
251
251
|
# ----------------------------------------
|
|
252
252
|
# Base
|
|
@@ -278,7 +278,7 @@ RUN pnpm turbo run build --filter=reef-${t}...
|
|
|
278
278
|
RUN pnpm deploy --prod --filter reef-${t} deploy
|
|
279
279
|
|
|
280
280
|
# ----------------------------------------
|
|
281
|
-
# Third-party Base
|
|
281
|
+
# Third-party Base
|
|
282
282
|
# ----------------------------------------
|
|
283
283
|
FROM node:22-alpine AS third-party-base
|
|
284
284
|
|
|
@@ -290,40 +290,30 @@ RUN apk add --no-cache \\
|
|
|
290
290
|
ca-certificates
|
|
291
291
|
|
|
292
292
|
# ----------------------------------------
|
|
293
|
-
# Final Layer:
|
|
293
|
+
# Final Layer: Riptide Runtime
|
|
294
294
|
# ----------------------------------------
|
|
295
295
|
FROM third-party-base
|
|
296
|
-
|
|
297
|
-
# \u{1F512} SECURITY LAYER: Inject controlled Riptide runtime
|
|
298
|
-
# This layer comes from your signed, verified base image
|
|
299
296
|
COPY --from=quay.io/nerdnode/riptide:latest /usr/local/bin/riptide /usr/local/bin/riptide
|
|
300
297
|
COPY --from=quay.io/nerdnode/riptide:latest /riptide-runtime/ /riptide-runtime/
|
|
301
298
|
|
|
302
|
-
# Verify Riptide binary permissions and ownership
|
|
303
299
|
RUN chmod +x /usr/local/bin/riptide && \\
|
|
304
300
|
chown root:root /usr/local/bin/riptide
|
|
305
|
-
|
|
306
|
-
# Set up environment
|
|
307
301
|
ENV NODE_ENV=production
|
|
308
302
|
ENV PATH="/usr/local/bin:$PATH"
|
|
309
303
|
|
|
310
304
|
# Copy the built application (your hooks that manage the service)
|
|
311
|
-
WORKDIR /riptide/
|
|
305
|
+
WORKDIR /riptide/services/${t}
|
|
312
306
|
COPY --from=builder /app/deploy/dist ./dist
|
|
313
307
|
COPY --from=builder /app/deploy/node_modules ./node_modules
|
|
314
308
|
COPY --from=builder /app/deploy/package.json ./package.json
|
|
309
|
+
COPY services/${t}/riptide.config.json ./riptide.config.json
|
|
315
310
|
|
|
316
|
-
#
|
|
317
|
-
COPY reef/${t}/riptide.config.json ./riptide.config.json
|
|
318
|
-
|
|
319
|
-
# Expose health check port
|
|
311
|
+
# Expose riptide health check port
|
|
320
312
|
EXPOSE 3000
|
|
321
313
|
|
|
322
|
-
# \u{1F6E1}\uFE0F SECURITY: Use controlled Riptide binary directly (bypasses npm)
|
|
323
|
-
# This ensures we use YOUR verified Riptide, not workspace dependencies
|
|
324
314
|
ENTRYPOINT ["/usr/local/bin/riptide"]
|
|
325
|
-
CMD ["start", "--config", "/riptide/
|
|
326
|
-
`:`# ${t}
|
|
315
|
+
CMD ["start", "--config", "/riptide/services/${t}/riptide.config.json", "--hooks", "/riptide/services/${t}/dist/hooks.js"]
|
|
316
|
+
`:`# DeEEP Network Service for ${t}
|
|
327
317
|
|
|
328
318
|
# ----------------------------------------
|
|
329
319
|
# Builder Stage
|
|
@@ -353,7 +343,7 @@ RUN pnpm run build
|
|
|
353
343
|
RUN pnpm prune --prod
|
|
354
344
|
|
|
355
345
|
# ----------------------------------------
|
|
356
|
-
# Third-party Base
|
|
346
|
+
# Third-party Base
|
|
357
347
|
# ----------------------------------------
|
|
358
348
|
FROM node:22-alpine AS third-party-base
|
|
359
349
|
|
|
@@ -365,20 +355,14 @@ RUN apk add --no-cache \\
|
|
|
365
355
|
ca-certificates
|
|
366
356
|
|
|
367
357
|
# ----------------------------------------
|
|
368
|
-
# Final Layer:
|
|
358
|
+
# Final Layer: Riptide Runtime
|
|
369
359
|
# ----------------------------------------
|
|
370
360
|
FROM third-party-base
|
|
371
|
-
|
|
372
|
-
# \u{1F512} SECURITY LAYER: Inject controlled Riptide runtime
|
|
373
|
-
# This layer comes from your signed, verified base image
|
|
374
361
|
COPY --from=quay.io/nerdnode/riptide:latest /usr/local/bin/riptide /usr/local/bin/riptide
|
|
375
362
|
COPY --from=quay.io/nerdnode/riptide:latest /riptide-runtime/ /riptide-runtime/
|
|
376
363
|
|
|
377
|
-
# Verify Riptide binary permissions and ownership
|
|
378
364
|
RUN chmod +x /usr/local/bin/riptide && \\
|
|
379
365
|
chown root:root /usr/local/bin/riptide
|
|
380
|
-
|
|
381
|
-
# Set up environment
|
|
382
366
|
ENV NODE_ENV=production
|
|
383
367
|
ENV PATH="/usr/local/bin:$PATH"
|
|
384
368
|
|
|
@@ -387,15 +371,11 @@ WORKDIR /app
|
|
|
387
371
|
COPY --from=builder /app/dist ./dist
|
|
388
372
|
COPY --from=builder /app/node_modules ./node_modules
|
|
389
373
|
COPY --from=builder /app/package.json ./package.json
|
|
390
|
-
|
|
391
|
-
# Copy Riptide configuration directly from source
|
|
392
374
|
COPY riptide.config.json ./riptide.config.json
|
|
393
375
|
|
|
394
|
-
# Expose health check port
|
|
376
|
+
# Expose riptide health check port
|
|
395
377
|
EXPOSE 3000
|
|
396
378
|
|
|
397
|
-
# \u{1F6E1}\uFE0F SECURITY: Use controlled Riptide binary directly (bypasses npm)
|
|
398
|
-
# This ensures we use YOUR verified Riptide, not workspace dependencies
|
|
399
379
|
ENTRYPOINT ["/usr/local/bin/riptide"]
|
|
400
380
|
CMD ["start", "--config", "/app/riptide.config.json", "--hooks", "/app/dist/hooks.js"]
|
|
401
381
|
`;await d.writeFile(p.join(e,"Dockerfile"),i)}showNextSteps(e,t){console.log(`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@deeep-network/riptide",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
4
4
|
"description": "Universal Riptide Runtime for the DeEEP Network",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -14,6 +14,10 @@
|
|
|
14
14
|
"deploy:docker": "./scripts/deploy-docker.sh",
|
|
15
15
|
"deploy:npm": "./scripts/deploy-npm.sh",
|
|
16
16
|
"test:universal": "./tests/test-universal.sh",
|
|
17
|
+
"release": "./scripts/release.sh",
|
|
18
|
+
"release:minor": "./scripts/release.sh --type minor",
|
|
19
|
+
"release:major": "./scripts/release.sh --type major",
|
|
20
|
+
"release:dry-run": "./scripts/release.sh --dry-run",
|
|
17
21
|
"dev": "tsup --watch",
|
|
18
22
|
"clean": "rm -rf dist"
|
|
19
23
|
},
|