@deeep-network/riptide 0.1.1
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 +57 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +361 -0
- package/dist/index.d.ts +207 -0
- package/dist/index.js +331 -0
- package/package.json +53 -0
package/README.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# @deeep-network/riptide
|
|
2
|
+
|
|
3
|
+
Self-contained container orchestration library with lifecycle hooks for Coral Reef services.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @deeep-network/riptide
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
### Create a New Service
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npx @deeep-network/riptide init my-service
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
### With Options
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
# Create with API key validation
|
|
23
|
+
npx @deeep-network/riptide init my-api --template with-secrets
|
|
24
|
+
|
|
25
|
+
# Create with subprocess management
|
|
26
|
+
npx @deeep-network/riptide init my-worker --template with-process
|
|
27
|
+
|
|
28
|
+
# Create with Prometheus metrics
|
|
29
|
+
npx @deeep-network/riptide init my-monitored --template with-metrics
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Templates
|
|
33
|
+
|
|
34
|
+
- **basic** - Simple healthy service with minimal hooks
|
|
35
|
+
- **with-secrets** - Includes API key validation and error handling
|
|
36
|
+
- **with-process** - Manages subprocess lifecycle
|
|
37
|
+
- **with-metrics** - Includes Prometheus metrics endpoint
|
|
38
|
+
|
|
39
|
+
## CLI Commands
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
# Initialize a new service
|
|
43
|
+
npx @deeep-network/riptide init <name> [options]
|
|
44
|
+
|
|
45
|
+
# Start a service
|
|
46
|
+
npx @deeep-network/riptide start
|
|
47
|
+
|
|
48
|
+
# Validate hooks
|
|
49
|
+
npx @deeep-network/riptide validate
|
|
50
|
+
|
|
51
|
+
# Check health
|
|
52
|
+
npx @deeep-network/riptide health
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## License
|
|
56
|
+
|
|
57
|
+
MIT
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";var N=Object.create;var x=Object.defineProperty;var _=Object.getOwnPropertyDescriptor;var A=Object.getOwnPropertyNames;var F=Object.getPrototypeOf,L=Object.prototype.hasOwnProperty;var n=(o,e)=>x(o,"name",{value:e,configurable:!0});var J=(o,e,t,r)=>{if(e&&typeof e=="object"||typeof e=="function")for(let i of A(e))!L.call(o,i)&&i!==t&&x(o,i,{get:()=>e[i],enumerable:!(r=_(e,i))||r.enumerable});return o};var u=(o,e,t)=>(t=o!=null?N(F(o)):{},J(e||!o||!o.__esModule?x(t,"default",{value:o,enumerable:!0}):t,o));var M=u(require("pino"));function y(o){return o instanceof Error&&"exitCode"in o&&typeof o.exitCode=="number"}n(y,"isRiptideError");var w=u(require("pino"));function P(o){let{level:e="info",format:t="pretty",serviceName:r}=o,i={level:e,timestamp:w.default.stdTimeFunctions.isoTime,formatters:{log:n(s=>({service:r,...s}),"log"),bindings:n(s=>{let{pid:a,hostname:c,...p}=s;return p},"bindings")}};return t==="pretty"&&!process.env.NODE_ENV?.includes("prod")?(0,w.default)({...i,transport:{target:"pino-pretty",options:{colorize:!0,translateTime:"SYS:standard",ignore:"pid,hostname"}}}):(0,w.default)(i)}n(P,"createLogger");var H=require("child_process"),d=require("fs"),U=u(require("http")),V=u(require("https")),C=u(require("path")),O=require("util");var q=(0,O.promisify)(H.exec);function R(){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 p=1;p<=t;p++)try{return await o()}catch(l){if(a=l instanceof Error?l:new Error(String(l)),p===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} })`,p=s.stderr?.trim()||"",l=p?`${p}
|
|
3
|
+
|
|
4
|
+
${c}`:c,m=[];s.stdout?.trim()&&m.push(`STDOUT: ${s.stdout.trim()}`),l&&m.push(`STDERR: ${l}`);let j=[`Command execution timed out: ${o}`,...m].join(`
|
|
5
|
+
|
|
6
|
+
`),b=new Error(j);throw b.name="CommandTimeoutError",b}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 d.promises.mkdir(C.dirname(o),{recursive:!0}),await d.promises.writeFile(o,e,{encoding:i}),await d.promises.chmod(o,r)},"writeFile"),readFile:n(async o=>await d.promises.readFile(o,"utf8"),"readFile"),fileExists:n(async o=>{try{return await d.promises.access(o),!0}catch{return!1}},"fileExists"),downloadFile:n(async function o(e,t){return new Promise((r,i)=>{let s=C.dirname(t);d.promises.mkdir(s,{recursive:!0}).then(()=>{let a=(0,d.createWriteStream)(t);(e.startsWith("https:")?V:U).get(e,l=>{if(l.statusCode===200)l.pipe(a),a.on("finish",()=>{a.close(),r(t)});else if(l.statusCode===301||l.statusCode===302){let m=l.headers.location;m?r(o(m,t)):i(new Error(`Redirect without location header: ${l.statusCode}`))}else i(new Error(`Failed to download: ${l.statusCode} ${l.statusMessage}`))}).on("error",l=>{d.promises.unlink(t).catch(()=>{}),i(l)}),a.on("error",l=>{d.promises.unlink(t).catch(()=>{}),i(l)})}).catch(i)})},"downloadFile")}}n(R,"createUtilityContext");var T=require("http");var k=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,T.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("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: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=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 p=await c.text();this.logger.warn({status:c.status,error:p,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 k(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 f=u(require("fs/promises")),h=u(require("path"));var E=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=h.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 f.mkdir(h.join(e,"src"),{recursive:!0})}async createPackageJson(e,t,r){let i={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":`cd ../../../.. && docker build --platform \${DOCKER_PLATFORM:-linux/amd64} --progress=plain -t reef-${t} -f apps/coral-reef/services/${t}/Dockerfile .`,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":"workspace:*"},devDependencies:{typescript:"^5.8.3",tsup:"^8.5.0","@types/node":"^20.0.0"},engines:{node:">=22.0.0"}};await f.writeFile(h.join(e,"package.json"),JSON.stringify(i,null,2))}async createRiptideConfig(e,t,r){let i={service:{name:t,version:"1.0.0",description:r||`${t} service`},logging:{level:"info"}};await f.writeFile(h.join(e,"riptide.config.json"),JSON.stringify(i,null,2))}async createTsConfig(e){let t={extends:"../../../../tsconfig.json",compilerOptions:{outDir:"./dist",rootDir:"./src",declaration:!0,declarationMap:!0,sourceMap:!0},include:["src/**/*"],exclude:["dist","node_modules"]};await f.writeFile(h.join(e,"tsconfig.json"),JSON.stringify(t,null,2))}async createTsupConfig(e){await f.writeFile(h.join(e,"tsup.config.ts"),`import { defineConfig } from 'tsup'
|
|
7
|
+
|
|
8
|
+
export default defineConfig({
|
|
9
|
+
entry: ['src/hooks.ts'],
|
|
10
|
+
format: ['cjs'],
|
|
11
|
+
target: 'node22',
|
|
12
|
+
outDir: 'dist',
|
|
13
|
+
clean: true,
|
|
14
|
+
minify: false,
|
|
15
|
+
sourcemap: true
|
|
16
|
+
})
|
|
17
|
+
`)}async createHooks(e,t){let r="";switch(t){case"with-secrets":r=this.getHooksWithSecrets();break;case"with-process":r=this.getHooksWithProcess();break;case"with-metrics":r=this.getHooksWithMetrics();break;default:r=this.getBasicHooks()}await f.writeFile(h.join(e,"src","hooks.ts"),r)}getBasicHooks(){return`import type { HookContext } from '@deeep-network/riptide'
|
|
18
|
+
|
|
19
|
+
module.exports = {
|
|
20
|
+
installSecrets: async ({ logger }: HookContext) => {
|
|
21
|
+
logger.info('Installing secrets')
|
|
22
|
+
return { success: true }
|
|
23
|
+
},
|
|
24
|
+
|
|
25
|
+
start: async ({ logger }: HookContext) => {
|
|
26
|
+
logger.info('Service starting')
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
health: async ({ logger }: HookContext) => {
|
|
30
|
+
logger.debug('Health check')
|
|
31
|
+
return true
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
stop: async ({ logger }: HookContext) => {
|
|
35
|
+
logger.info('Service stopping')
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
`}getHooksWithSecrets(){return`import type { HookContext } from '@deeep-network/riptide'
|
|
39
|
+
import {
|
|
40
|
+
MissingSecretError,
|
|
41
|
+
InvalidSecretError,
|
|
42
|
+
DiagnoseRequiredError
|
|
43
|
+
} from '@deeep-network/riptide'
|
|
44
|
+
|
|
45
|
+
module.exports = {
|
|
46
|
+
installSecrets: async ({ env, logger }: HookContext) => {
|
|
47
|
+
logger.info('Validating secrets')
|
|
48
|
+
|
|
49
|
+
const apiKey = env.API_KEY
|
|
50
|
+
if (!apiKey) {
|
|
51
|
+
throw new MissingSecretError('API_KEY is required')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const response = await fetch('https://api.example.com/validate', {
|
|
56
|
+
headers: { 'x-api-key': apiKey }
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
if (response.status === 401) {
|
|
60
|
+
throw new InvalidSecretError('Invalid API key')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (!response.ok) {
|
|
64
|
+
throw new DiagnoseRequiredError(\`API returned \${response.status}\`)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
logger.info('Secrets validated successfully')
|
|
68
|
+
return { success: true }
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (error instanceof Error && 'code' in error) {
|
|
71
|
+
throw new DiagnoseRequiredError(\`Network error: \${error.message}\`)
|
|
72
|
+
}
|
|
73
|
+
throw error
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
start: async ({ env, logger }: HookContext) => {
|
|
78
|
+
const apiKey = env.API_KEY
|
|
79
|
+
if (!apiKey) {
|
|
80
|
+
throw new MissingSecretError('API_KEY is required')
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
logger.info('Service starting with validated API key')
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
health: async ({ logger }: HookContext) => {
|
|
87
|
+
logger.debug('Health check')
|
|
88
|
+
return true
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
heartbeat: async ({ logger }: HookContext) => {
|
|
92
|
+
return {
|
|
93
|
+
service: 'your-service',
|
|
94
|
+
status: 'healthy',
|
|
95
|
+
timestamp: new Date().toISOString()
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
stop: async ({ logger }: HookContext) => {
|
|
100
|
+
logger.info('Service stopping')
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
`}getHooksWithProcess(){return`import type { HookContext } from '@deeep-network/riptide'
|
|
104
|
+
import { spawn, ChildProcess } from 'child_process'
|
|
105
|
+
import { MissingSecretError } from '@deeep-network/riptide'
|
|
106
|
+
|
|
107
|
+
let serviceProcess: ChildProcess | null = null
|
|
108
|
+
|
|
109
|
+
module.exports = {
|
|
110
|
+
installSecrets: async ({ env, logger }: HookContext) => {
|
|
111
|
+
logger.info('Checking required configuration')
|
|
112
|
+
|
|
113
|
+
if (!env.SERVICE_CONFIG) {
|
|
114
|
+
throw new MissingSecretError('SERVICE_CONFIG is required')
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return { success: true }
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
start: async ({ env, logger }: HookContext) => {
|
|
121
|
+
logger.info('Starting service process')
|
|
122
|
+
|
|
123
|
+
return new Promise((resolve, reject) => {
|
|
124
|
+
serviceProcess = spawn('/path/to/binary', [env.SERVICE_CONFIG || ''], {
|
|
125
|
+
detached: true,
|
|
126
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
serviceProcess.on('spawn', () => {
|
|
130
|
+
logger.info(\`Service process started with PID: \${serviceProcess?.pid}\`)
|
|
131
|
+
resolve()
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
serviceProcess.on('error', (error) => {
|
|
135
|
+
logger.error(\`Failed to start service process: \${error.message}\`)
|
|
136
|
+
reject(error)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
serviceProcess.stdout?.on('data', (data) => {
|
|
140
|
+
logger.info(\`[SERVICE] \${data.toString().trim()}\`)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
serviceProcess.stderr?.on('data', (data) => {
|
|
144
|
+
logger.error(\`[SERVICE ERROR] \${data.toString().trim()}\`)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
serviceProcess.unref()
|
|
148
|
+
})
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
health: async ({ logger, utils }: HookContext) => {
|
|
152
|
+
logger.debug('Checking service health')
|
|
153
|
+
|
|
154
|
+
if (!serviceProcess || !serviceProcess.pid) {
|
|
155
|
+
return false
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
process.kill(serviceProcess.pid, 0)
|
|
160
|
+
return true
|
|
161
|
+
} catch {
|
|
162
|
+
return false
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
|
|
166
|
+
stop: async ({ logger, utils }: HookContext) => {
|
|
167
|
+
logger.info('Stopping service process')
|
|
168
|
+
|
|
169
|
+
if (serviceProcess && serviceProcess.pid) {
|
|
170
|
+
try {
|
|
171
|
+
process.kill(serviceProcess.pid, 'SIGTERM')
|
|
172
|
+
await utils.sleep(2000)
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
process.kill(serviceProcess.pid, 0)
|
|
176
|
+
process.kill(serviceProcess.pid, 'SIGKILL')
|
|
177
|
+
logger.warn('Had to force kill service process')
|
|
178
|
+
} catch {
|
|
179
|
+
logger.info('Service process stopped gracefully')
|
|
180
|
+
}
|
|
181
|
+
} catch (error) {
|
|
182
|
+
logger.debug('Service process already stopped')
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
serviceProcess = null
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
`}getHooksWithMetrics(){return`import type { HookContext } from '@deeep-network/riptide'
|
|
190
|
+
|
|
191
|
+
let requestCount = 0
|
|
192
|
+
let errorCount = 0
|
|
193
|
+
let serviceStartTime = Date.now()
|
|
194
|
+
|
|
195
|
+
module.exports = {
|
|
196
|
+
installSecrets: async ({ logger }: HookContext) => {
|
|
197
|
+
logger.info('Installing secrets')
|
|
198
|
+
return { success: true }
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
start: async ({ logger }: HookContext) => {
|
|
202
|
+
logger.info('Service starting')
|
|
203
|
+
serviceStartTime = Date.now()
|
|
204
|
+
|
|
205
|
+
setInterval(() => {
|
|
206
|
+
requestCount += Math.floor(Math.random() * 10)
|
|
207
|
+
if (Math.random() < 0.1) errorCount++
|
|
208
|
+
}, 5000)
|
|
209
|
+
},
|
|
210
|
+
|
|
211
|
+
health: async ({ logger }: HookContext) => {
|
|
212
|
+
logger.debug('Health check')
|
|
213
|
+
return true
|
|
214
|
+
},
|
|
215
|
+
|
|
216
|
+
heartbeat: async () => {
|
|
217
|
+
return {
|
|
218
|
+
service: 'your-service',
|
|
219
|
+
status: 'healthy',
|
|
220
|
+
uptime_seconds: Math.floor((Date.now() - serviceStartTime) / 1000),
|
|
221
|
+
request_count: requestCount,
|
|
222
|
+
error_count: errorCount
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
|
|
226
|
+
metrics: async () => {
|
|
227
|
+
const uptime = Math.floor((Date.now() - serviceStartTime) / 1000)
|
|
228
|
+
|
|
229
|
+
let output = ''
|
|
230
|
+
output += '# HELP service_uptime_seconds Time since service started\\n'
|
|
231
|
+
output += '# TYPE service_uptime_seconds gauge\\n'
|
|
232
|
+
output += \`service_uptime_seconds \${uptime}\\n\\n\`
|
|
233
|
+
|
|
234
|
+
output += '# HELP service_requests_total Total requests processed\\n'
|
|
235
|
+
output += '# TYPE service_requests_total counter\\n'
|
|
236
|
+
output += \`service_requests_total \${requestCount}\\n\\n\`
|
|
237
|
+
|
|
238
|
+
output += '# HELP service_errors_total Total errors encountered\\n'
|
|
239
|
+
output += '# TYPE service_errors_total counter\\n'
|
|
240
|
+
output += \`service_errors_total \${errorCount}\\n\`
|
|
241
|
+
|
|
242
|
+
return output
|
|
243
|
+
},
|
|
244
|
+
|
|
245
|
+
stop: async ({ logger }: HookContext) => {
|
|
246
|
+
logger.info('Service stopping')
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
`}async createDockerfile(e,t){let r=`# ${t} Service
|
|
250
|
+
|
|
251
|
+
# ----------------------------------------
|
|
252
|
+
# Base
|
|
253
|
+
# ----------------------------------------
|
|
254
|
+
FROM node:22-alpine AS base
|
|
255
|
+
RUN apk add --no-cache libc6-compat
|
|
256
|
+
RUN npm install -g pnpm@10.8.0 turbo@2.5.0
|
|
257
|
+
|
|
258
|
+
# ----------------------------------------
|
|
259
|
+
# Turbo Pruner
|
|
260
|
+
# ----------------------------------------
|
|
261
|
+
FROM base AS pruner
|
|
262
|
+
WORKDIR /app
|
|
263
|
+
COPY . .
|
|
264
|
+
RUN turbo prune reef-${t} --docker
|
|
265
|
+
|
|
266
|
+
# ----------------------------------------
|
|
267
|
+
# Turbo Builder and Pnpm Deployment
|
|
268
|
+
# ----------------------------------------
|
|
269
|
+
FROM base AS builder
|
|
270
|
+
WORKDIR /app
|
|
271
|
+
COPY --from=pruner /app/out/full/ .
|
|
272
|
+
RUN pnpm install
|
|
273
|
+
COPY turbo.json tsconfig.json .
|
|
274
|
+
RUN pnpm turbo run build --filter=reef-${t}...
|
|
275
|
+
|
|
276
|
+
# Create pnpm deployment
|
|
277
|
+
RUN pnpm deploy --prod --filter reef-${t} deploy
|
|
278
|
+
|
|
279
|
+
# ----------------------------------------
|
|
280
|
+
# Minimal Runner
|
|
281
|
+
# ----------------------------------------
|
|
282
|
+
FROM node:22-alpine AS runner
|
|
283
|
+
|
|
284
|
+
# Create application user
|
|
285
|
+
RUN addgroup -g 1001 app && \\
|
|
286
|
+
adduser -u 1001 -G app -D app
|
|
287
|
+
|
|
288
|
+
WORKDIR /app
|
|
289
|
+
|
|
290
|
+
# Copy deployment artifacts
|
|
291
|
+
COPY --from=builder --chown=app:app /app/deploy/dist ./dist
|
|
292
|
+
COPY --from=builder --chown=app:app /app/deploy/node_modules ./node_modules
|
|
293
|
+
COPY --from=builder --chown=app:app /app/deploy/package.json ./package.json
|
|
294
|
+
COPY --from=builder --chown=app:app /app/deploy/riptide.config.json ./riptide.config.json
|
|
295
|
+
|
|
296
|
+
# Create necessary directories
|
|
297
|
+
RUN mkdir -p logs data tmp && chown -R app:app logs data tmp
|
|
298
|
+
|
|
299
|
+
# Switch to non-root user
|
|
300
|
+
USER app
|
|
301
|
+
|
|
302
|
+
# Set environment
|
|
303
|
+
ENV NODE_ENV=production
|
|
304
|
+
|
|
305
|
+
# Start command
|
|
306
|
+
CMD ["npm", "start"]
|
|
307
|
+
`;await f.writeFile(h.join(e,"Dockerfile"),r)}showNextSteps(e,t){console.log(`
|
|
308
|
+
Next steps:
|
|
309
|
+
-----------
|
|
310
|
+
1. Navigate to your service:
|
|
311
|
+
cd ${t}
|
|
312
|
+
|
|
313
|
+
2. Install dependencies:
|
|
314
|
+
pnpm install
|
|
315
|
+
|
|
316
|
+
3. Build the service:
|
|
317
|
+
pnpm run build
|
|
318
|
+
|
|
319
|
+
4. Validate the hooks:
|
|
320
|
+
pnpm run validate
|
|
321
|
+
|
|
322
|
+
5. Build Docker image:
|
|
323
|
+
pnpm run build:docker
|
|
324
|
+
|
|
325
|
+
6. Run locally:
|
|
326
|
+
docker run -e API_KEY=your-key reef-${e}
|
|
327
|
+
|
|
328
|
+
7. Add to turbo.json if needed for monorepo builds
|
|
329
|
+
|
|
330
|
+
8. Customize the hooks in src/hooks.ts for your specific requirements
|
|
331
|
+
`)}};async function I(o,e,t={}){await new E(o).scaffold({serviceName:e,...t})}n(I,"initService");var g=(0,M.default)({level:process.env.LOG_LEVEL||"info",transport:{target:"pino-pretty"}});async function W(){try{let o=process.argv[2];if(o==="--help"||o==="-h"||o==="help"){D();return}if(o==="--version"||o==="-v"||o==="version"){await G();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 Y();break;case"status":await B();break;default:o?(g.error(`Unknown command: ${o}`),D(),process.exit(1)):await $(e,t)}}catch(o){g.error({error:o},"CLI command failed"),process.exit(1)}}n(W,"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 D(){console.log(`
|
|
332
|
+
Riptide - Self-contained service lifecycle management
|
|
333
|
+
|
|
334
|
+
USAGE:
|
|
335
|
+
npx riptide [COMMAND] [OPTIONS]
|
|
336
|
+
|
|
337
|
+
COMMANDS:
|
|
338
|
+
init <name> Create a new Coral Reef service
|
|
339
|
+
start Start the service (default)
|
|
340
|
+
validate Validate configuration and hooks
|
|
341
|
+
health Check service health
|
|
342
|
+
status Show service status
|
|
343
|
+
help, --help, -h Show this help message
|
|
344
|
+
version, --version Show version information
|
|
345
|
+
|
|
346
|
+
INIT OPTIONS:
|
|
347
|
+
--template TYPE Template to use: basic, with-secrets, with-process, with-metrics (default: basic)
|
|
348
|
+
--path PATH Directory to create service in (default: current directory)
|
|
349
|
+
--description DESC Service description
|
|
350
|
+
|
|
351
|
+
OPTIONS:
|
|
352
|
+
--config, -c PATH Path to configuration file (default: ./riptide.config.json)
|
|
353
|
+
--hooks PATH Path to hooks file (default: ./hooks.js)
|
|
354
|
+
|
|
355
|
+
EXAMPLES:
|
|
356
|
+
npx riptide init my-service # Create new service
|
|
357
|
+
npx riptide init my-service --template with-secrets
|
|
358
|
+
npx riptide start # Start service
|
|
359
|
+
npx riptide validate # Validate config and hooks
|
|
360
|
+
npx riptide health # Check service health
|
|
361
|
+
`)}n(D,"showHelp");async function G(){let o=await import("fs/promises"),t=(await import("path")).resolve(__dirname,"../package.json"),r=await o.readFile(t,"utf-8"),i=JSON.parse(r);console.log(`@deeep-network/riptide v${i.version}`)}n(G,"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)),p=c.default||c;await new S(p,s,{}).start()}catch(t){g.error({error:t},"Failed to start service"),process.exit(1)}}n($,"startService");async function K(o,e){g.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);g.info({config:s},"Configuration loaded successfully");let c=await import(r.resolve(process.cwd(),e)),p=c.default||c;g.info({availableHooks:Object.keys(p)},"Hooks loaded successfully"),g.info("\u2705 Validation completed successfully"),process.exit(0)}catch(t){g.error({error:t},"\u274C Validation failed"),process.exit(1)}}n(K,"validateService");async function Y(){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){g.error({error:o},"Failed to check health"),process.exit(1)}}n(Y,"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){g.error({error:o},"Failed to get status"),process.exit(1)}}n(B,"showStatus");async function z(){let o=process.argv[3];o||(g.error("Service name is required"),console.log("Usage: npx riptide init <service-name>"),process.exit(1)),/^[a-z0-9-]+$/.test(o)||(g.error("Service name must contain only lowercase letters, numbers, and hyphens"),process.exit(1));let e=v("--template")||"basic",t=v("--path")||"apps/coral-reef/services",r=v("--description"),i=["basic","with-secrets","with-process","with-metrics"];i.includes(e)||(g.error(`Invalid template: ${e}`),console.log(`Valid templates: ${i.join(", ")}`),process.exit(1));try{await I(g,o,{targetPath:t,template:e,description:r}),process.exit(0)}catch(s){g.error({error:s},"Failed to initialize service"),process.exit(1)}}n(z,"initServiceCommand");W();
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { Logger } from 'pino';
|
|
2
|
+
|
|
3
|
+
interface HookContext {
|
|
4
|
+
config: ServiceConfig;
|
|
5
|
+
logger: Logger;
|
|
6
|
+
env: NodeJS.ProcessEnv;
|
|
7
|
+
utils: UtilityContext;
|
|
8
|
+
[key: string]: any;
|
|
9
|
+
}
|
|
10
|
+
interface UtilityContext {
|
|
11
|
+
sleep: (ms: number) => Promise<void>;
|
|
12
|
+
retry: <T>(fn: () => Promise<T>, options?: RetryOptions) => Promise<T>;
|
|
13
|
+
execCommand: (command: string, options?: ExecOptions) => Promise<ExecResult>;
|
|
14
|
+
writeFile: (path: string, content: string, options?: WriteFileOptions) => Promise<void>;
|
|
15
|
+
readFile: (path: string) => Promise<string>;
|
|
16
|
+
fileExists: (path: string) => Promise<boolean>;
|
|
17
|
+
downloadFile: (url: string, destination: string) => Promise<string>;
|
|
18
|
+
}
|
|
19
|
+
interface RetryOptions {
|
|
20
|
+
maxAttempts?: number;
|
|
21
|
+
delay?: number;
|
|
22
|
+
backoffMultiplier?: number;
|
|
23
|
+
maxDelay?: number;
|
|
24
|
+
}
|
|
25
|
+
interface ExecOptions {
|
|
26
|
+
timeout?: number;
|
|
27
|
+
user?: string;
|
|
28
|
+
cwd?: string;
|
|
29
|
+
env?: Record<string, string>;
|
|
30
|
+
}
|
|
31
|
+
interface ExecResult {
|
|
32
|
+
stdout: string;
|
|
33
|
+
stderr: string;
|
|
34
|
+
exitCode: number;
|
|
35
|
+
}
|
|
36
|
+
interface WriteFileOptions {
|
|
37
|
+
mode?: string;
|
|
38
|
+
encoding?: BufferEncoding;
|
|
39
|
+
}
|
|
40
|
+
interface HookModule {
|
|
41
|
+
installSecrets?: (context: HookContext) => Promise<{
|
|
42
|
+
success: boolean;
|
|
43
|
+
transformedSecrets?: Record<string, string>;
|
|
44
|
+
}>;
|
|
45
|
+
start: (context: HookContext) => Promise<void>;
|
|
46
|
+
stop?: (context: HookContext) => Promise<void>;
|
|
47
|
+
health: (context: HookContext) => Promise<boolean>;
|
|
48
|
+
heartbeat?: (context: HookContext) => Promise<Record<string, any> | null>;
|
|
49
|
+
status?: (context: HookContext) => Promise<ServiceStatus>;
|
|
50
|
+
metrics?: (context: HookContext) => Promise<ServiceMetrics>;
|
|
51
|
+
}
|
|
52
|
+
interface ServiceConfig {
|
|
53
|
+
service: {
|
|
54
|
+
name: string;
|
|
55
|
+
version?: string;
|
|
56
|
+
description?: string;
|
|
57
|
+
};
|
|
58
|
+
health?: {
|
|
59
|
+
port?: number;
|
|
60
|
+
};
|
|
61
|
+
heartbeat?: {
|
|
62
|
+
interval?: number;
|
|
63
|
+
enabled?: boolean;
|
|
64
|
+
};
|
|
65
|
+
logging?: {
|
|
66
|
+
level?: 'debug' | 'info' | 'warn' | 'error';
|
|
67
|
+
format?: 'json' | 'pretty';
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
interface ServiceStatus {
|
|
71
|
+
status: 'starting' | 'healthy' | 'unhealthy' | 'stopping' | 'stopped';
|
|
72
|
+
uptime?: number;
|
|
73
|
+
message?: string;
|
|
74
|
+
details?: Record<string, any>;
|
|
75
|
+
}
|
|
76
|
+
interface ServiceMetrics {
|
|
77
|
+
[key: string]: number | string | boolean;
|
|
78
|
+
}
|
|
79
|
+
interface RiptideEntrypointOptions {
|
|
80
|
+
configPath?: string;
|
|
81
|
+
hooksPath?: string;
|
|
82
|
+
logLevel?: 'debug' | 'info' | 'warn' | 'error';
|
|
83
|
+
dryRun?: boolean;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
declare class RiptideEntrypoint {
|
|
87
|
+
private logger;
|
|
88
|
+
private hooks;
|
|
89
|
+
private config;
|
|
90
|
+
private isShuttingDown;
|
|
91
|
+
private status;
|
|
92
|
+
private startTime;
|
|
93
|
+
private heartbeatInterval?;
|
|
94
|
+
private webServer?;
|
|
95
|
+
constructor(hooks: HookModule, config: ServiceConfig, options?: RiptideEntrypointOptions);
|
|
96
|
+
start(): Promise<void>;
|
|
97
|
+
private processSecrets;
|
|
98
|
+
private executeHook;
|
|
99
|
+
private executeHookSafely;
|
|
100
|
+
private createHookContext;
|
|
101
|
+
private setupGlobalErrorHandlers;
|
|
102
|
+
private setupSignalHandlers;
|
|
103
|
+
private startHeartbeat;
|
|
104
|
+
private pingSonar;
|
|
105
|
+
private startWebServer;
|
|
106
|
+
private waitForShutdown;
|
|
107
|
+
getMetrics(): Promise<any>;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
declare function loadConfig(configPath: string): Promise<ServiceConfig>;
|
|
111
|
+
declare function loadHooks(hooksPath: string): Promise<HookModule>;
|
|
112
|
+
declare function validateConfig(config: ServiceConfig): void;
|
|
113
|
+
declare function getDefaultConfig(): Partial<ServiceConfig>;
|
|
114
|
+
declare function mergeConfigs(base: Partial<ServiceConfig>, override: Partial<ServiceConfig>): ServiceConfig;
|
|
115
|
+
|
|
116
|
+
interface LoggerOptions {
|
|
117
|
+
level?: 'debug' | 'info' | 'warn' | 'error';
|
|
118
|
+
format?: 'json' | 'pretty';
|
|
119
|
+
serviceName: string;
|
|
120
|
+
}
|
|
121
|
+
declare function createLogger(options: LoggerOptions): Logger;
|
|
122
|
+
declare function createChildLogger(parentLogger: Logger, context: Record<string, any>): Logger;
|
|
123
|
+
|
|
124
|
+
declare function createUtilityContext(): UtilityContext;
|
|
125
|
+
declare function redactSecret(secret: string): string;
|
|
126
|
+
declare function parseEnvironmentVariables(input: string): Record<string, string>;
|
|
127
|
+
declare function expandEnvironmentVariables(input: string, env?: Record<string, string | undefined>): string;
|
|
128
|
+
|
|
129
|
+
interface ScaffoldOptions {
|
|
130
|
+
serviceName: string;
|
|
131
|
+
targetPath?: string;
|
|
132
|
+
template?: 'basic' | 'with-secrets' | 'with-process' | 'with-metrics';
|
|
133
|
+
description?: string;
|
|
134
|
+
}
|
|
135
|
+
declare class ServiceScaffolder {
|
|
136
|
+
private logger;
|
|
137
|
+
constructor(logger: Logger);
|
|
138
|
+
scaffold(options: ScaffoldOptions): Promise<void>;
|
|
139
|
+
private createDirectoryStructure;
|
|
140
|
+
private createPackageJson;
|
|
141
|
+
private createRiptideConfig;
|
|
142
|
+
private createTsConfig;
|
|
143
|
+
private createTsupConfig;
|
|
144
|
+
private createHooks;
|
|
145
|
+
private getBasicHooks;
|
|
146
|
+
private getHooksWithSecrets;
|
|
147
|
+
private getHooksWithProcess;
|
|
148
|
+
private getHooksWithMetrics;
|
|
149
|
+
private createDockerfile;
|
|
150
|
+
private showNextSteps;
|
|
151
|
+
}
|
|
152
|
+
declare function initService(logger: Logger, serviceName: string, options?: Partial<ScaffoldOptions>): Promise<void>;
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Custom error classes for Riptide Entrypoint
|
|
156
|
+
* These errors map to specific exit codes for container orchestration
|
|
157
|
+
*/
|
|
158
|
+
/**
|
|
159
|
+
* Base class for all Riptide custom errors
|
|
160
|
+
* Provides a reliable way to identify riptide-specific errors
|
|
161
|
+
*/
|
|
162
|
+
declare abstract class RiptideError extends Error {
|
|
163
|
+
abstract readonly exitCode: number;
|
|
164
|
+
constructor(message: string);
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Error indicating that manual intervention is required
|
|
168
|
+
* Exit code: 3
|
|
169
|
+
*/
|
|
170
|
+
declare class DiagnoseRequiredError extends RiptideError {
|
|
171
|
+
readonly exitCode = 3;
|
|
172
|
+
constructor(message: string);
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Error indicating that a required secret is missing
|
|
176
|
+
* Exit code: 4
|
|
177
|
+
*/
|
|
178
|
+
declare class MissingSecretError extends RiptideError {
|
|
179
|
+
readonly exitCode = 4;
|
|
180
|
+
constructor(message: string);
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Error indicating that a secret is invalid or malformed
|
|
184
|
+
* Exit code: 5
|
|
185
|
+
*/
|
|
186
|
+
declare class InvalidSecretError extends RiptideError {
|
|
187
|
+
readonly exitCode = 5;
|
|
188
|
+
constructor(message: string);
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Error indicating that the service is already running somewhere and we can't start it again
|
|
192
|
+
* Exit code: 6
|
|
193
|
+
*/
|
|
194
|
+
declare class AlreadyRunningError extends RiptideError {
|
|
195
|
+
readonly exitCode = 6;
|
|
196
|
+
constructor(message: string);
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Type guards for Riptide errors that work across module boundaries
|
|
200
|
+
*/
|
|
201
|
+
declare function isRiptideError(error: unknown): error is RiptideError;
|
|
202
|
+
declare function isDiagnoseRequiredError(error: unknown): error is DiagnoseRequiredError;
|
|
203
|
+
declare function isMissingSecretError(error: unknown): error is MissingSecretError;
|
|
204
|
+
declare function isInvalidSecretError(error: unknown): error is InvalidSecretError;
|
|
205
|
+
declare function isAlreadyRunningError(error: unknown): error is AlreadyRunningError;
|
|
206
|
+
|
|
207
|
+
export { AlreadyRunningError, DiagnoseRequiredError, type ExecOptions, type ExecResult, type HookContext, type HookModule, InvalidSecretError, MissingSecretError, type RetryOptions, RiptideEntrypoint, type RiptideEntrypointOptions, RiptideError, type ScaffoldOptions, type ServiceConfig, type ServiceMetrics, ServiceScaffolder, type ServiceStatus, type UtilityContext, type WriteFileOptions, createChildLogger, createLogger, createUtilityContext, expandEnvironmentVariables, getDefaultConfig, initService, isAlreadyRunningError, isDiagnoseRequiredError, isInvalidSecretError, isMissingSecretError, isRiptideError, loadConfig, loadHooks, mergeConfigs, parseEnvironmentVariables, redactSecret, validateConfig };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"use strict";var X=Object.create;var y=Object.defineProperty;var Z=Object.getOwnPropertyDescriptor;var ee=Object.getOwnPropertyNames;var te=Object.getPrototypeOf,re=Object.prototype.hasOwnProperty;var n=(r,e)=>y(r,"name",{value:e,configurable:!0});var oe=(r,e)=>{for(var t in e)y(r,t,{get:e[t],enumerable:!0})},T=(r,e,t,o)=>{if(e&&typeof e=="object"||typeof e=="function")for(let i of ee(e))!re.call(r,i)&&i!==t&&y(r,i,{get:()=>e[i],enumerable:!(o=Z(e,i))||o.enumerable});return r};var m=(r,e,t)=>(t=r!=null?X(te(r)):{},T(e||!r||!r.__esModule?y(t,"default",{value:r,enumerable:!0}):t,r)),ie=r=>T(y({},"__esModule",{value:!0}),r);var pe={};oe(pe,{AlreadyRunningError:()=>C,DiagnoseRequiredError:()=>S,InvalidSecretError:()=>x,MissingSecretError:()=>k,RiptideEntrypoint:()=>O,RiptideError:()=>h,ServiceScaffolder:()=>w,createChildLogger:()=>j,createLogger:()=>b,createUtilityContext:()=>P,expandEnvironmentVariables:()=>H,getDefaultConfig:()=>G,initService:()=>z,isAlreadyRunningError:()=>A,isDiagnoseRequiredError:()=>M,isInvalidSecretError:()=>N,isMissingSecretError:()=>_,isRiptideError:()=>v,loadConfig:()=>V,loadHooks:()=>Y,mergeConfigs:()=>B,parseEnvironmentVariables:()=>U,redactSecret:()=>q,validateConfig:()=>I});module.exports=ie(pe);var h=class r extends Error{static{n(this,"RiptideError")}constructor(e){super(e),Object.setPrototypeOf(this,r.prototype)}},S=class r extends h{static{n(this,"DiagnoseRequiredError")}exitCode=3;constructor(e){super(e),this.name="DiagnoseRequiredError",Object.setPrototypeOf(this,r.prototype)}},k=class r extends h{static{n(this,"MissingSecretError")}exitCode=4;constructor(e){super(e),this.name="MissingSecretError",Object.setPrototypeOf(this,r.prototype)}},x=class r extends h{static{n(this,"InvalidSecretError")}exitCode=5;constructor(e){super(e),this.name="InvalidSecretError",Object.setPrototypeOf(this,r.prototype)}},C=class r extends h{static{n(this,"AlreadyRunningError")}exitCode=6;constructor(e){super(e),this.name="AlreadyRunningError",Object.setPrototypeOf(this,r.prototype)}};function v(r){return r instanceof Error&&"exitCode"in r&&typeof r.exitCode=="number"}n(v,"isRiptideError");function M(r){return r instanceof Error&&r.name==="DiagnoseRequiredError"&&r.exitCode===3}n(M,"isDiagnoseRequiredError");function _(r){return r instanceof Error&&r.name==="MissingSecretError"&&r.exitCode===4}n(_,"isMissingSecretError");function N(r){return r instanceof Error&&r.name==="InvalidSecretError"&&r.exitCode===5}n(N,"isInvalidSecretError");function A(r){return r instanceof Error&&r.name==="AlreadyRunningError"&&r.exitCode===6}n(A,"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:n(s=>({service:o,...s}),"log"),bindings:n(s=>{let{pid:a,hostname:c,...l}=s;return l},"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)}n(b,"createLogger");function j(r,e){return r.child(e)}n(j,"createChildLogger");var F=require("child_process"),g=require("fs"),se=m(require("http")),ne=m(require("https")),$=m(require("path")),L=require("util");var ae=(0,L.promisify)(F.exec);function P(){return{sleep:n(r=>new Promise(e=>setTimeout(e,r)),"sleep"),retry:n(async(r,e={})=>{let{maxAttempts:t=3,delay:o=1e3,backoffMultiplier:i=2,maxDelay:s=3e4}=e,a,c=o;for(let l=1;l<=t;l++)try{return await r()}catch(p){if(a=p instanceof Error?p:new Error(String(p)),l===t)throw a;await new Promise(f=>setTimeout(f,Math.min(c,s))),c*=i}throw a},"retry"),execCommand:n(async(r,e={})=>{let{timeout:t=3e4,cwd:o=process.cwd(),env:i=process.env}=e;try{let{stdout:s,stderr:a}=await ae(r,{timeout:t,cwd:o,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('${r}', { timeout: ${t*2} })`,l=s.stderr?.trim()||"",p=l?`${l}
|
|
2
|
+
|
|
3
|
+
${c}`:c,f=[];s.stdout?.trim()&&f.push(`STDOUT: ${s.stdout.trim()}`),p&&f.push(`STDERR: ${p}`);let Q=[`Command execution timed out: ${r}`,...f].join(`
|
|
4
|
+
|
|
5
|
+
`),D=new Error(Q);throw D.name="CommandTimeoutError",D}return{stdout:s.stdout?.trim()||"",stderr:s.stderr?.trim()||s.message,exitCode:s.code||1}}},"execCommand"),writeFile:n(async(r,e,t={})=>{let{mode:o="0644",encoding:i="utf8"}=t;await g.promises.mkdir($.dirname(r),{recursive:!0}),await g.promises.writeFile(r,e,{encoding:i}),await g.promises.chmod(r,o)},"writeFile"),readFile:n(async r=>await g.promises.readFile(r,"utf8"),"readFile"),fileExists:n(async r=>{try{return await g.promises.access(r),!0}catch{return!1}},"fileExists"),downloadFile:n(async function r(e,t){return new Promise((o,i)=>{let s=$.dirname(t);g.promises.mkdir(s,{recursive:!0}).then(()=>{let a=(0,g.createWriteStream)(t);(e.startsWith("https:")?ne:se).get(e,p=>{if(p.statusCode===200)p.pipe(a),a.on("finish",()=>{a.close(),o(t)});else if(p.statusCode===301||p.statusCode===302){let f=p.headers.location;f?o(r(f,t)):i(new Error(`Redirect without location header: ${p.statusCode}`))}else i(new Error(`Failed to download: ${p.statusCode} ${p.statusMessage}`))}).on("error",p=>{g.promises.unlink(t).catch(()=>{}),i(p)}),a.on("error",p=>{g.promises.unlink(t).catch(()=>{}),i(p)})}).catch(i)})},"downloadFile")}}n(P,"createUtilityContext");function q(r){return!r||r.length<=10?"[REDACTED]":`${r.slice(0,4)}...${r.slice(-4)}`}n(q,"redactSecret");function U(r){let e={},t=r.split(`
|
|
6
|
+
`);for(let o of t){let i=o.trim();if(i&&!i.startsWith("#")){let[s,...a]=i.split("=");s&&a.length>0&&(e[s.trim()]=a.join("=").trim())}}return e}n(U,"parseEnvironmentVariables");function H(r,e=process.env){return r.replace(/\$\{([^}]+)\}/g,(t,o)=>e[o]||t)}n(H,"expandEnvironmentVariables");var J=require("http");var R=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{n(this,"WebServer")}server=null;port;logger;async start(){if(this.server){this.logger.warn("Web server already running");return}return this.server=(0,J.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 O=class{static{n(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)},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 o=this.createHookContext(),i=Date.now();try{let s=await t(o),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(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"),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(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),a={entity_id:i,client_timestamp:s,metadata:e};try{this.logger.info({payload:a,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(a)});if(!c.ok){let l=await c.text();this.logger.warn({status:c.status,error:l,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 R(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 W=require("fs"),K=m(require("path"));async function V(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)}`)}}n(V,"loadConfig");async function Y(r){try{let e=K.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)}`)}}n(Y,"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")}n(I,"validateConfig");function ce(r,e=process.env){let t=JSON.parse(JSON.stringify(r));function o(i){if(typeof i=="string")return H(i,e);if(Array.isArray(i))return i.map(o);if(i&&typeof i=="object"){let s={};for(let[a,c]of Object.entries(i))s[a]=o(c);return s}return i}return n(o,"expandObject"),o(t)}n(ce,"expandConfigVariables");function G(){return{health:{port:3e3},heartbeat:{interval:6e4,enabled:!1},logging:{level:"info",format:"pretty"}}}n(G,"getDefaultConfig");function B(r,e){function t(i,s){if(s&&typeof s=="object"&&!Array.isArray(s))for(let a in s)s.hasOwnProperty(a)&&(i[a]&&typeof i[a]=="object"&&!Array.isArray(i[a])?i[a]=t(i[a],s[a]):i[a]=s[a]);return i}n(t,"deepMerge");let o=t({...r},e);return I(o),o}n(B,"mergeConfigs");var d=m(require("fs/promises")),u=m(require("path"));var w=class{constructor(e){this.logger=e}static{n(this,"ServiceScaffolder")}async scaffold(e){let{serviceName:t,targetPath:o=".",template:i="basic",description:s}=e,a=u.join(o,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 d.mkdir(u.join(e,"src"),{recursive:!0})}async createPackageJson(e,t,o){let i={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":`cd ../../../.. && docker build --platform \${DOCKER_PLATFORM:-linux/amd64} --progress=plain -t reef-${t} -f apps/coral-reef/services/${t}/Dockerfile .`,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":"workspace:*"},devDependencies:{typescript:"^5.8.3",tsup:"^8.5.0","@types/node":"^20.0.0"},engines:{node:">=22.0.0"}};await d.writeFile(u.join(e,"package.json"),JSON.stringify(i,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(u.join(e,"riptide.config.json"),JSON.stringify(i,null,2))}async createTsConfig(e){let t={extends:"../../../../tsconfig.json",compilerOptions:{outDir:"./dist",rootDir:"./src",declaration:!0,declarationMap:!0,sourceMap:!0},include:["src/**/*"],exclude:["dist","node_modules"]};await d.writeFile(u.join(e,"tsconfig.json"),JSON.stringify(t,null,2))}async createTsupConfig(e){await d.writeFile(u.join(e,"tsup.config.ts"),`import { defineConfig } from 'tsup'
|
|
7
|
+
|
|
8
|
+
export default defineConfig({
|
|
9
|
+
entry: ['src/hooks.ts'],
|
|
10
|
+
format: ['cjs'],
|
|
11
|
+
target: 'node22',
|
|
12
|
+
outDir: 'dist',
|
|
13
|
+
clean: true,
|
|
14
|
+
minify: false,
|
|
15
|
+
sourcemap: true
|
|
16
|
+
})
|
|
17
|
+
`)}async createHooks(e,t){let o="";switch(t){case"with-secrets":o=this.getHooksWithSecrets();break;case"with-process":o=this.getHooksWithProcess();break;case"with-metrics":o=this.getHooksWithMetrics();break;default:o=this.getBasicHooks()}await d.writeFile(u.join(e,"src","hooks.ts"),o)}getBasicHooks(){return`import type { HookContext } from '@deeep-network/riptide'
|
|
18
|
+
|
|
19
|
+
module.exports = {
|
|
20
|
+
installSecrets: async ({ logger }: HookContext) => {
|
|
21
|
+
logger.info('Installing secrets')
|
|
22
|
+
return { success: true }
|
|
23
|
+
},
|
|
24
|
+
|
|
25
|
+
start: async ({ logger }: HookContext) => {
|
|
26
|
+
logger.info('Service starting')
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
health: async ({ logger }: HookContext) => {
|
|
30
|
+
logger.debug('Health check')
|
|
31
|
+
return true
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
stop: async ({ logger }: HookContext) => {
|
|
35
|
+
logger.info('Service stopping')
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
`}getHooksWithSecrets(){return`import type { HookContext } from '@deeep-network/riptide'
|
|
39
|
+
import {
|
|
40
|
+
MissingSecretError,
|
|
41
|
+
InvalidSecretError,
|
|
42
|
+
DiagnoseRequiredError
|
|
43
|
+
} from '@deeep-network/riptide'
|
|
44
|
+
|
|
45
|
+
module.exports = {
|
|
46
|
+
installSecrets: async ({ env, logger }: HookContext) => {
|
|
47
|
+
logger.info('Validating secrets')
|
|
48
|
+
|
|
49
|
+
const apiKey = env.API_KEY
|
|
50
|
+
if (!apiKey) {
|
|
51
|
+
throw new MissingSecretError('API_KEY is required')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const response = await fetch('https://api.example.com/validate', {
|
|
56
|
+
headers: { 'x-api-key': apiKey }
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
if (response.status === 401) {
|
|
60
|
+
throw new InvalidSecretError('Invalid API key')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (!response.ok) {
|
|
64
|
+
throw new DiagnoseRequiredError(\`API returned \${response.status}\`)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
logger.info('Secrets validated successfully')
|
|
68
|
+
return { success: true }
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (error instanceof Error && 'code' in error) {
|
|
71
|
+
throw new DiagnoseRequiredError(\`Network error: \${error.message}\`)
|
|
72
|
+
}
|
|
73
|
+
throw error
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
start: async ({ env, logger }: HookContext) => {
|
|
78
|
+
const apiKey = env.API_KEY
|
|
79
|
+
if (!apiKey) {
|
|
80
|
+
throw new MissingSecretError('API_KEY is required')
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
logger.info('Service starting with validated API key')
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
health: async ({ logger }: HookContext) => {
|
|
87
|
+
logger.debug('Health check')
|
|
88
|
+
return true
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
heartbeat: async ({ logger }: HookContext) => {
|
|
92
|
+
return {
|
|
93
|
+
service: 'your-service',
|
|
94
|
+
status: 'healthy',
|
|
95
|
+
timestamp: new Date().toISOString()
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
|
|
99
|
+
stop: async ({ logger }: HookContext) => {
|
|
100
|
+
logger.info('Service stopping')
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
`}getHooksWithProcess(){return`import type { HookContext } from '@deeep-network/riptide'
|
|
104
|
+
import { spawn, ChildProcess } from 'child_process'
|
|
105
|
+
import { MissingSecretError } from '@deeep-network/riptide'
|
|
106
|
+
|
|
107
|
+
let serviceProcess: ChildProcess | null = null
|
|
108
|
+
|
|
109
|
+
module.exports = {
|
|
110
|
+
installSecrets: async ({ env, logger }: HookContext) => {
|
|
111
|
+
logger.info('Checking required configuration')
|
|
112
|
+
|
|
113
|
+
if (!env.SERVICE_CONFIG) {
|
|
114
|
+
throw new MissingSecretError('SERVICE_CONFIG is required')
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return { success: true }
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
start: async ({ env, logger }: HookContext) => {
|
|
121
|
+
logger.info('Starting service process')
|
|
122
|
+
|
|
123
|
+
return new Promise((resolve, reject) => {
|
|
124
|
+
serviceProcess = spawn('/path/to/binary', [env.SERVICE_CONFIG || ''], {
|
|
125
|
+
detached: true,
|
|
126
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
serviceProcess.on('spawn', () => {
|
|
130
|
+
logger.info(\`Service process started with PID: \${serviceProcess?.pid}\`)
|
|
131
|
+
resolve()
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
serviceProcess.on('error', (error) => {
|
|
135
|
+
logger.error(\`Failed to start service process: \${error.message}\`)
|
|
136
|
+
reject(error)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
serviceProcess.stdout?.on('data', (data) => {
|
|
140
|
+
logger.info(\`[SERVICE] \${data.toString().trim()}\`)
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
serviceProcess.stderr?.on('data', (data) => {
|
|
144
|
+
logger.error(\`[SERVICE ERROR] \${data.toString().trim()}\`)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
serviceProcess.unref()
|
|
148
|
+
})
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
health: async ({ logger, utils }: HookContext) => {
|
|
152
|
+
logger.debug('Checking service health')
|
|
153
|
+
|
|
154
|
+
if (!serviceProcess || !serviceProcess.pid) {
|
|
155
|
+
return false
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
process.kill(serviceProcess.pid, 0)
|
|
160
|
+
return true
|
|
161
|
+
} catch {
|
|
162
|
+
return false
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
|
|
166
|
+
stop: async ({ logger, utils }: HookContext) => {
|
|
167
|
+
logger.info('Stopping service process')
|
|
168
|
+
|
|
169
|
+
if (serviceProcess && serviceProcess.pid) {
|
|
170
|
+
try {
|
|
171
|
+
process.kill(serviceProcess.pid, 'SIGTERM')
|
|
172
|
+
await utils.sleep(2000)
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
process.kill(serviceProcess.pid, 0)
|
|
176
|
+
process.kill(serviceProcess.pid, 'SIGKILL')
|
|
177
|
+
logger.warn('Had to force kill service process')
|
|
178
|
+
} catch {
|
|
179
|
+
logger.info('Service process stopped gracefully')
|
|
180
|
+
}
|
|
181
|
+
} catch (error) {
|
|
182
|
+
logger.debug('Service process already stopped')
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
serviceProcess = null
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
`}getHooksWithMetrics(){return`import type { HookContext } from '@deeep-network/riptide'
|
|
190
|
+
|
|
191
|
+
let requestCount = 0
|
|
192
|
+
let errorCount = 0
|
|
193
|
+
let serviceStartTime = Date.now()
|
|
194
|
+
|
|
195
|
+
module.exports = {
|
|
196
|
+
installSecrets: async ({ logger }: HookContext) => {
|
|
197
|
+
logger.info('Installing secrets')
|
|
198
|
+
return { success: true }
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
start: async ({ logger }: HookContext) => {
|
|
202
|
+
logger.info('Service starting')
|
|
203
|
+
serviceStartTime = Date.now()
|
|
204
|
+
|
|
205
|
+
setInterval(() => {
|
|
206
|
+
requestCount += Math.floor(Math.random() * 10)
|
|
207
|
+
if (Math.random() < 0.1) errorCount++
|
|
208
|
+
}, 5000)
|
|
209
|
+
},
|
|
210
|
+
|
|
211
|
+
health: async ({ logger }: HookContext) => {
|
|
212
|
+
logger.debug('Health check')
|
|
213
|
+
return true
|
|
214
|
+
},
|
|
215
|
+
|
|
216
|
+
heartbeat: async () => {
|
|
217
|
+
return {
|
|
218
|
+
service: 'your-service',
|
|
219
|
+
status: 'healthy',
|
|
220
|
+
uptime_seconds: Math.floor((Date.now() - serviceStartTime) / 1000),
|
|
221
|
+
request_count: requestCount,
|
|
222
|
+
error_count: errorCount
|
|
223
|
+
}
|
|
224
|
+
},
|
|
225
|
+
|
|
226
|
+
metrics: async () => {
|
|
227
|
+
const uptime = Math.floor((Date.now() - serviceStartTime) / 1000)
|
|
228
|
+
|
|
229
|
+
let output = ''
|
|
230
|
+
output += '# HELP service_uptime_seconds Time since service started\\n'
|
|
231
|
+
output += '# TYPE service_uptime_seconds gauge\\n'
|
|
232
|
+
output += \`service_uptime_seconds \${uptime}\\n\\n\`
|
|
233
|
+
|
|
234
|
+
output += '# HELP service_requests_total Total requests processed\\n'
|
|
235
|
+
output += '# TYPE service_requests_total counter\\n'
|
|
236
|
+
output += \`service_requests_total \${requestCount}\\n\\n\`
|
|
237
|
+
|
|
238
|
+
output += '# HELP service_errors_total Total errors encountered\\n'
|
|
239
|
+
output += '# TYPE service_errors_total counter\\n'
|
|
240
|
+
output += \`service_errors_total \${errorCount}\\n\`
|
|
241
|
+
|
|
242
|
+
return output
|
|
243
|
+
},
|
|
244
|
+
|
|
245
|
+
stop: async ({ logger }: HookContext) => {
|
|
246
|
+
logger.info('Service stopping')
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
`}async createDockerfile(e,t){let o=`# ${t} Service
|
|
250
|
+
|
|
251
|
+
# ----------------------------------------
|
|
252
|
+
# Base
|
|
253
|
+
# ----------------------------------------
|
|
254
|
+
FROM node:22-alpine AS base
|
|
255
|
+
RUN apk add --no-cache libc6-compat
|
|
256
|
+
RUN npm install -g pnpm@10.8.0 turbo@2.5.0
|
|
257
|
+
|
|
258
|
+
# ----------------------------------------
|
|
259
|
+
# Turbo Pruner
|
|
260
|
+
# ----------------------------------------
|
|
261
|
+
FROM base AS pruner
|
|
262
|
+
WORKDIR /app
|
|
263
|
+
COPY . .
|
|
264
|
+
RUN turbo prune reef-${t} --docker
|
|
265
|
+
|
|
266
|
+
# ----------------------------------------
|
|
267
|
+
# Turbo Builder and Pnpm Deployment
|
|
268
|
+
# ----------------------------------------
|
|
269
|
+
FROM base AS builder
|
|
270
|
+
WORKDIR /app
|
|
271
|
+
COPY --from=pruner /app/out/full/ .
|
|
272
|
+
RUN pnpm install
|
|
273
|
+
COPY turbo.json tsconfig.json .
|
|
274
|
+
RUN pnpm turbo run build --filter=reef-${t}...
|
|
275
|
+
|
|
276
|
+
# Create pnpm deployment
|
|
277
|
+
RUN pnpm deploy --prod --filter reef-${t} deploy
|
|
278
|
+
|
|
279
|
+
# ----------------------------------------
|
|
280
|
+
# Minimal Runner
|
|
281
|
+
# ----------------------------------------
|
|
282
|
+
FROM node:22-alpine AS runner
|
|
283
|
+
|
|
284
|
+
# Create application user
|
|
285
|
+
RUN addgroup -g 1001 app && \\
|
|
286
|
+
adduser -u 1001 -G app -D app
|
|
287
|
+
|
|
288
|
+
WORKDIR /app
|
|
289
|
+
|
|
290
|
+
# Copy deployment artifacts
|
|
291
|
+
COPY --from=builder --chown=app:app /app/deploy/dist ./dist
|
|
292
|
+
COPY --from=builder --chown=app:app /app/deploy/node_modules ./node_modules
|
|
293
|
+
COPY --from=builder --chown=app:app /app/deploy/package.json ./package.json
|
|
294
|
+
COPY --from=builder --chown=app:app /app/deploy/riptide.config.json ./riptide.config.json
|
|
295
|
+
|
|
296
|
+
# Create necessary directories
|
|
297
|
+
RUN mkdir -p logs data tmp && chown -R app:app logs data tmp
|
|
298
|
+
|
|
299
|
+
# Switch to non-root user
|
|
300
|
+
USER app
|
|
301
|
+
|
|
302
|
+
# Set environment
|
|
303
|
+
ENV NODE_ENV=production
|
|
304
|
+
|
|
305
|
+
# Start command
|
|
306
|
+
CMD ["npm", "start"]
|
|
307
|
+
`;await d.writeFile(u.join(e,"Dockerfile"),o)}showNextSteps(e,t){console.log(`
|
|
308
|
+
Next steps:
|
|
309
|
+
-----------
|
|
310
|
+
1. Navigate to your service:
|
|
311
|
+
cd ${t}
|
|
312
|
+
|
|
313
|
+
2. Install dependencies:
|
|
314
|
+
pnpm install
|
|
315
|
+
|
|
316
|
+
3. Build the service:
|
|
317
|
+
pnpm run build
|
|
318
|
+
|
|
319
|
+
4. Validate the hooks:
|
|
320
|
+
pnpm run validate
|
|
321
|
+
|
|
322
|
+
5. Build Docker image:
|
|
323
|
+
pnpm run build:docker
|
|
324
|
+
|
|
325
|
+
6. Run locally:
|
|
326
|
+
docker run -e API_KEY=your-key reef-${e}
|
|
327
|
+
|
|
328
|
+
7. Add to turbo.json if needed for monorepo builds
|
|
329
|
+
|
|
330
|
+
8. Customize the hooks in src/hooks.ts for your specific requirements
|
|
331
|
+
`)}};async function z(r,e,t={}){await new w(r).scaffold({serviceName:e,...t})}n(z,"initService");0&&(module.exports={AlreadyRunningError,DiagnoseRequiredError,InvalidSecretError,MissingSecretError,RiptideEntrypoint,RiptideError,ServiceScaffolder,createChildLogger,createLogger,createUtilityContext,expandEnvironmentVariables,getDefaultConfig,initService,isAlreadyRunningError,isDiagnoseRequiredError,isInvalidSecretError,isMissingSecretError,isRiptideError,loadConfig,loadHooks,mergeConfigs,parseEnvironmentVariables,redactSecret,validateConfig});
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@deeep-network/riptide",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Self-contained container orchestration library with lifecycle hooks",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"types": "dist/index.d.ts",
|
|
7
|
+
"bin": {
|
|
8
|
+
"riptide": "dist/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsc --noEmit && tsup",
|
|
12
|
+
"build:publish": "tsc --noEmit && tsup --minify",
|
|
13
|
+
"dev": "tsup --watch",
|
|
14
|
+
"clean": "rm -rf dist",
|
|
15
|
+
"prepublishOnly": "npm run clean && npm run build:publish"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"pino": "^9.6.0",
|
|
19
|
+
"pino-pretty": "^13.0.0",
|
|
20
|
+
"dotenv": "^16.3.1"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"typescript": "^5.8.3",
|
|
24
|
+
"@types/node": "^22.14.0",
|
|
25
|
+
"tsup": "^8.5.0"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"dist",
|
|
29
|
+
"README.md",
|
|
30
|
+
"package.json"
|
|
31
|
+
],
|
|
32
|
+
"publishConfig": {
|
|
33
|
+
"access": "public",
|
|
34
|
+
"registry": "https://registry.npmjs.org/"
|
|
35
|
+
},
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "git+https://github.com/deeep-network/riptide.git"
|
|
39
|
+
},
|
|
40
|
+
"keywords": [
|
|
41
|
+
"container",
|
|
42
|
+
"entrypoint",
|
|
43
|
+
"lifecycle",
|
|
44
|
+
"hooks",
|
|
45
|
+
"coral-reef",
|
|
46
|
+
"riptide"
|
|
47
|
+
],
|
|
48
|
+
"author": "DEEEP Network",
|
|
49
|
+
"license": "MIT",
|
|
50
|
+
"engines": {
|
|
51
|
+
"node": ">=22.0.0"
|
|
52
|
+
}
|
|
53
|
+
}
|